` web frontend.
+- **[`tile57`](https://github.com/beetlebugorg/tile57)** (Zig) — the chart
+ engine. It builds **libtile57**, a native static library that does *all* of the
+ chart work: S-57 decoding, S-101 portrayal, web-Mercator tiling, MLT/MVT
+ encoding, and generating the MapLibre style and client assets (sprites, color
+ tables, line styles, patterns).
+
+**Naming, once:** *libtile57* is the native engine library, built from the
+*tile57* repo and statically linked into the Go binary via CGO. The Go code is
+the hub around it; the browser only renders what the engine baked.
## 🎯 Goal
-Implement the IHO chart standards — **S-57** (ENC data), **S-101** portrayal (the
-successor to S-52), and the wider **S-100 / S-102** family — in **pure Go**, with
-**minimal dependencies and no CGO**, so the whole thing **cross-compiles to a single
-static binary** for any platform with `GOOS`/`GOARCH` and nothing else to install.
+Implement the IHO chart standards — **S-57** (ENC data) and **S-101** portrayal
+(the successor to S-52), with the wider **S-100 / S-102** family planned — as a
+fast, low-memory native engine (tile57, in Zig) wrapped by a small Go server, so
+one locally-built binary bakes and serves real charts on anything from a laptop
+to a Raspberry Pi on a boat.
## ✨ Features
-- **A complete chart pipeline.** chartplotter does every step: ISO 8211 decode, the
- S-57 feature model, S-101 portrayal, web-Mercator tiling, vector-tile encode, and a
- streaming PMTiles writer.
-- **Works offline.** Generate one `.pmtiles` archive for a region, then serve or ship
- it. You do not need a tile server to view it.
-- **Adjust the chart live.** Switch Day, Dusk, and Night palettes and toggle mariner
- settings — depth shading, soundings, contours, safety-depth danger highlighting — and
- the map restyles at once. Colors are stored as S-101 names and settings ride along as
- tile attributes, so the viewer applies your changes without regenerating the tiles.
-- **Ships as one binary.** The S-101 catalogue *and* the web frontend build into the
- program. A self-contained `chartplotter serve` needs no files on disk — you supply
- only the ENC cells.
-- **Runs a server.** The built-in HTTP server downloads NOAA cells, generates tiles in
- the background, and serves the frontend with byte-range support.
-- **Live position and AIS (early).** Point a **NMEA 0183** feed at the server (over
- TCP) and it shows your **own ship** and **basic AIS targets** on the chart. A
- built-in `simulate` command generates traffic for testing.
-- **Draws the whole symbol set.** It renders the complete S-52 Presentation Library
- **ECDIS "Chart 1"** reference sheet — every symbol, line style, area fill, and
- colour — drawn by the same pipeline that bakes real NOAA charts and diffed against
- the spec's own plots. [See the rendered sheet →](https://beetlebugorg.github.io/chartplotter/chart1)
+- **A complete chart pipeline.** libtile57 does every step: ISO 8211 decode, the
+ S-57 feature model, S-101 portrayal, tiling, MLT/MVT encode, PMTiles output,
+ and the matching MapLibre style + symbol assets.
+- **Works offline.** Bake a region once, then serve or ship it. You do not need
+ an internet connection to view it.
+- **Adjust the chart live.** Switch Day, Dusk, and Night palettes and toggle
+ mariner settings — depth shading, soundings, contours, safety-depth danger
+ highlighting — and the map restyles at once, without regenerating tiles.
+- **Ships as one self-contained binary.** The S-101 catalogue is compiled into
+ libtile57 and the web frontend is embedded in the Go binary, so `chartplotter`
+ runs from a single file — you supply only the ENC cells. Download a per-platform
+ build from the releases page, or build it yourself (see below).
+- **Runs a server.** The built-in HTTP server downloads NOAA cells, bakes tiles
+ in the background, and serves the frontend with byte-range support.
+- **Live position and AIS (early).** Point a **NMEA 0183** feed at the server
+ (over TCP) and it shows your **own ship** and **basic AIS targets** on the
+ chart. A built-in `simulate` command generates traffic for testing.
+- **Draws the whole symbol set.** It renders the complete S-52 Presentation
+ Library **ECDIS "Chart 1"** reference sheet — every symbol, line style, area
+ fill, and colour — drawn by the same engine that bakes real NOAA charts and
+ diffed against the spec's own plots.
+ [See the rendered sheet →](https://beetlebugorg.github.io/chartplotter/chart1)
@@ -98,61 +117,113 @@ of the chart — **instrument gauges**, custom overlays, routes, and more — wi
forking the core. NMEA 0183 own-ship and AIS are the first slice of that; expect
the surface to grow and change.
-## 📦 Install
+## 🐳 Run with Docker
-### Pre-built binaries
+The simplest way to run chartplotter — and the primary path for the
+**server-hub-on-a-boat** model (a Raspberry Pi, laptop, or mini PC that holds all
+chart state while every screen just points a browser at it) — is the published
+container image:
-Download an archive for your platform from the
-[**Releases**](https://github.com/beetlebugorg/chartplotter/releases) page, extract it,
-and put `chartplotter` on your `PATH`. Each platform ships two builds:
-
-- **`…_s101`** — self-contained: embeds the S-101 catalogue, runs with no extra
- files. (That catalogue is IHO material; see
- [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).)
-- **plain** — needs `--s101 ` at runtime, pointing at your
- own copy of the catalogue.
-
-### With go install
+```sh
+docker run -p 8080:8080 -v chartplotter-data:/data \
+ ghcr.io/beetlebugorg/chartplotter
+# open http://localhost:8080
+```
-Requires **Go 1.26+**.
+Or with Docker Compose ([`compose.yaml`](compose.yaml)):
```sh
-go install github.com/beetlebugorg/chartplotter/cmd/chartplotter@latest
+docker compose up -d
```
-### From source
+The image is **multi-arch** (`linux/amd64` + `linux/arm64`), so the same command
+runs on a Raspberry Pi and on an amd64 box. It's built `FROM scratch` around a
+**fully-static musl binary**, so it's tiny — essentially just the ~26 MB binary
+plus a CA bundle. The named `/data` volume holds the ENC source, baked tiles, and
+settings, and survives image upgrades. **macOS / Windows** users run the same
+image via **Docker Desktop** — no native Mac/Windows binary needed.
+
+Native binaries (below) remain available as a secondary option for bare-metal
+installs.
+
+## 📦 Install & build
+
+**Download a binary.** Every tagged release publishes a self-contained
+`chartplotter` for **linux and windows** (amd64 + arm64) on the
+[releases page](https://github.com/beetlebugorg/chartplotter/releases): unpack
+the archive for your platform and run it — the S-101 catalogue and web frontend
+are baked in, so you supply only the ENC cells. **macOS** is not shipped as a
+prebuilt binary (the engine links Apple frameworks Zig can't cross-compile) — Mac
+users run the [Docker image](#-run-with-docker) via Docker Desktop, or build from
+source below.
+
+Those published binaries embed the **IHO S-101 Portrayal and Feature Catalogues**
+(compiled into libtile57 from the IHO's own GitHub repositories, which declare no
+license); the project distributes them as an accepted position — see
+[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
+
+**Build it yourself.** `go install …@latest` does not work — the build links a
+native library and uses a local `replace` directive — so you clone two repos and
+build locally.
+
+### Requirements
+
+- **Go 1.26+**
+- **Zig 0.16** (builds libtile57, and serves as the C cross-toolchain)
+- **git** (the engine's submodules fetch the IHO catalogues)
+
+### Recipe
+
+The engine is the [`tile57`](https://github.com/beetlebugorg/tile57) git submodule
+at `./tile57` — this repo's `go.mod` points at `./tile57/bindings/go`, and the
+Makefile builds `./tile57/zig-out/lib/libtile57.a` on demand. Clone with
+`--recurse-submodules` (or just run `make build`, which fetches the submodule and
+its nested IHO catalogues on first run).
```sh
-git clone https://github.com/beetlebugorg/chartplotter.git
+git clone --recurse-submodules https://github.com/beetlebugorg/chartplotter.git
cd chartplotter
-make build # -> bin/chartplotter (embeds the catalogue if it is available)
+make build # fetches the tile57 submodule if needed, zig-builds libtile57,
+ # then a CGO go build → bin/chartplotter
bin/chartplotter version
```
+`make build` is the ground truth for how the binary is produced (CGO enabled,
+statically linking libtile57); [CLAUDE.md](CLAUDE.md) and the
+[Makefile](Makefile) describe the build contract.
+
## 🚀 Get started
-The frontend is built into the binary, so one file is all you need. Start the server
-and open the viewer:
+The frontend is built into the binary, so one file is all you need. Start the
+server and open the viewer:
```sh
-chartplotter serve
+bin/chartplotter serve
# open http://127.0.0.1:8080 → pick a region → it downloads and builds tiles → the chart appears
```
The server writes everything it generates to your cache directory
(`~/.cache/chartplotter`), never into the binary's assets.
-You can also build a standalone archive yourself with the `bake` command:
+You can also bake charts yourself with the `bake` command:
```sh
-# Generate one archive from cells, a directory, or a NOAA ENC zip.
-chartplotter bake -o charts.pmtiles US4MD81M.000
+# Bake cells, a directory, or a NOAA ENC zip into a self-contained chart bundle
+# (charts/tiles/chart.pmtiles + per-scheme styles + assets + manifest).
+chartplotter bake -o charts US4MD81M.000
-# Generate one archive per navigational band (best-available display).
+# Or write one gap-clipped PMTiles archive per navigational band
+# (best-available display), as the static demo/widget workflows use.
chartplotter bake --bands -o charts.pmtiles US5MD_ENCs.zip
```
-To develop the frontend, serve the assets from disk instead of the embedded bundle:
+Tiles are encoded as **MLT (MapLibre Tiles)** by default, which needs MapLibre
+GL JS **5.12 or newer** to decode (the bundled viewer vendors 5.24.0, so nothing
+to do there). If you want tiles for a consumer without an MLT decoder, bake with
+`--format mvt`.
+
+To develop the frontend, serve the assets from disk instead of the embedded
+bundle:
```sh
chartplotter serve --assets web
@@ -162,11 +233,11 @@ chartplotter serve --assets web
| Command | What it does |
| --- | --- |
-| `version` | Print the version and whether the S-101 catalogue is embedded. |
+| `version` | Print the chartplotter and libtile57 versions. |
| `emit-assets DIR` | Write the S-101 client assets (color tables, sprites, line styles, patterns) to a directory. |
| `catalog-json IN.xml OUT.json` | Distil NOAA `ENCProdCat.xml` into a compact `catalog.json`. |
-| `bake -o OUT.pmtiles IN…` | Generate a PMTiles archive from S-57 cells, directories, or NOAA ENC zips. |
-| `serve [--host] [--port] [--assets DIR]` | Serve the web frontend, the baking API, and the NOAA cell proxy. |
+| `bake -o OUT IN…` | Bake S-57 cells, directories, or NOAA ENC zips into a chart bundle (or per-band PMTiles with `--bands`). |
+| `serve [--host] [--port] [--assets DIR]` | Serve the web frontend, the baking API, and the NOAA cell proxy (baked tiles only). |
| `simulate` | Run an NMEA 0183 traffic generator over TCP (own-ship + AIS targets) for testing. |
Run `chartplotter --help` for the full flags.
@@ -174,19 +245,23 @@ Run `chartplotter --help` for the full flags.
## 🧭 How it works
```
-S-57 ENC cell (.000)
- │ ISO 8211 decode pkg/iso8211
+S-57 ENC cells (.000 + .001… updates)
+ │
▼
-S-57 feature + geometry model pkg/s57
- │ S-101 portrayal pkg/s100, internal/engine/s101
+libtile57 — the native engine (Zig, ./tile57 submodule, linked via CGO)
+ │ ISO 8211 decode → S-57 model → S-101 portrayal →
+ │ web-Mercator tiling → MLT/MVT encode →
+ │ MapLibre style + sprites/colors/line styles
▼
-Primitive drawing list (lat/lon) internal/engine/portrayal
- │ project + clip internal/engine/tile
+Chart bundles: PMTiles + style-{day,dusk,night}.json + assets
+ │
▼
-Mapbox Vector Tiles internal/engine/mvt
- │ dedup + streaming write internal/engine/pmtiles
+Go server (this repo) — the hub
+ │ chart library & background bakes, /tiles + /api,
+ │ settings, NMEA 0183 / AIS, aux attachments, plugins
▼
-charts.pmtiles ───────────────▶ / MapLibre GL JS (web/)
+ web component (web/) — MapLibre GL JS
+ renders the pre-baked tiles; no portrayal in the browser
```
Read the [**Architecture**](https://beetlebugorg.github.io/chartplotter/architecture)
@@ -197,16 +272,56 @@ layer and field contract the frontend depends on.
## 🛠️ Development
```sh
-make build # build bin/chartplotter
+make build # zig-build libtile57 + CGO go build → bin/chartplotter
make test # go test ./...
make vet # go vet ./...
make fmt # gofmt -w .
make serve # build + serve web/ on :8080
+make xbuild # cross-compile with `zig cc` (linux + windows, amd64/arm64)
+make musl # fully-static musl binaries (linux amd64+arm64) for the Docker image
+```
+
+The container image is built `FROM scratch` around the static musl binary (`make
+musl` / the [`Dockerfile`](Dockerfile)); the engine is git-cloned inside the
+builder, so no sibling checkout is needed to `docker build`. `zig cc`
+cross-compiles both arches from one native builder — no QEMU — and
+[`.github/workflows/docker.yml`](.github/workflows/docker.yml) pushes the
+multi-arch image to GHCR on each `v*` tag.
+
+CGO is required — libtile57 is the sole tile/portrayal engine, so
+`CGO_ENABLED=0` does not build. Cross-compilation still works with **Zig as the
+C toolchain** (`make xbuild` covers linux and windows; darwin must be built
+natively on a Mac, because Go's `crypto/x509` links Apple frameworks Zig doesn't
+bundle).
+
+### Developing the engine
+
+The engine is the `./tile57` git submodule, and both halves of the build point at
+it: `go.mod` replaces the Go binding at `./tile57/bindings/go`, and the Makefile
+defaults `TILE57 ?= tile57`. Day-to-day engine hacking works right inside the
+submodule — `cd tile57`, check out a branch (a fresh submodule lands detached, so
+`git checkout main` first), edit, commit, then `make build` picks the working tree
+up as usual. CI builds against the engine's latest `main`; the committed submodule
+pin is just last-known-good, bumped with a normal `git add tile57` when you want it.
+
+`go.work` is **optional** here: you only need it to build against a *different*
+engine checkout (not the `./tile57` submodule — e.g. a separate sibling clone you
+develop in). To do that, redirect both halves — the Go binding via a gitignored
+`go.work`, and the Makefile's zig build via `TILE57=`:
+
+```sh
+cat > go.work <<'EOF'
+go 1.26.0
+
+use .
+
+replace github.com/beetlebugorg/tile57/bindings/go => /path/to/other/tile57/bindings/go
+EOF
+make TILE57=/path/to/other/tile57 build
```
-CI runs `gofmt`, `go vet`, `go test`, and `go build` on every push. When you push a
-`v*` tag, [GoReleaser](https://goreleaser.com/) cuts a release with binaries for Linux,
-macOS, and Windows on amd64 and arm64.
+`go.work`/`go.work.sum` are gitignored, so the override never leaks into a commit;
+delete `go.work` to fall back to the `./tile57` submodule.
## 📚 Documentation
@@ -216,14 +331,17 @@ install, the CLI reference, the chart pipeline, and the vector-tile schema.
## 📄 License
-chartplotter's own code is [MIT](LICENSE) © Jeremy Collins.
+chartplotter's own code is [MIT](LICENSE) © Jeremy Collins, and so is the
+[tile57](https://github.com/beetlebugorg/tile57) engine it links.
-It bundles third-party software and data under their own licenses — all Go
+It bundles third-party software and data under their own licenses — the Go
dependencies are permissive (MIT / BSD-3-Clause), plus MapLibre GL JS (BSD),
-Noto Sans (OFL), OpenBridge icons (CC BY 4.0), and a GSHHG coastline basemap
+Noto Sans (OFL 1.1), OpenBridge icons (CC BY 4.0), and a GSHHG coastline basemap
(LGPL). NOAA ENC charts are U.S. public domain and **not for navigation**.
-The **IHO S-101 Portrayal & Feature Catalogue** is © IHO and is *not* included in
-this repository; a draft copy is embedded only in opt-in `_s101` builds, and its
-redistribution terms are still to be confirmed. See
+The **IHO S-101 Portrayal & Feature Catalogues** are © IHO and are *not* in
+either repository; the build fetches them from the IHO's own repositories via git
+submodules and compiles them into libtile57 — and therefore into both
+locally-built and published binaries. The IHO declares no license; the project
+distributes the resulting binaries as an accepted position. See
[**THIRD-PARTY-NOTICES.md**](THIRD-PARTY-NOTICES.md) for the full inventory.
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 47a74b6..51dc1b2 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -2,11 +2,25 @@
chartplotter is licensed under the [MIT License](LICENSE), © 2026 Jeremy Collins.
-The program bundles, embeds, or builds on the third-party software and data
-listed below. Each remains under its own license. This file is informational; the
-upstream license text governs.
+The program bundles, embeds, links, or builds on the third-party software and
+data listed below. Each remains under its own license. This file is
+informational; the upstream license text governs.
-## Go dependencies (linked into the binary)
+## libtile57 (the native chart engine)
+
+The binary statically links **libtile57**, the native S-57/S-101 chart engine
+built from the sibling [tile57](https://github.com/beetlebugorg/tile57)
+repository. tile57 is **MIT**-licensed, © 2026 Jeremy Collins.
+
+tile57 carries its own third-party inventory in its
+[`THIRD_PARTY_LICENSES.md`](https://github.com/beetlebugorg/tile57/blob/main/THIRD_PARTY_LICENSES.md),
+which covers the components compiled into libtile57 — and therefore into every
+chartplotter binary — including vendored **Lua** (MIT), **nanosvg** (zlib),
+**stb_image_write** (public domain / MIT), an embedded **Noto Sans** face (SIL
+OFL 1.1), ported algorithms, and the **IHO S-101 catalogues** (see the IHO
+section below).
+
+## Go dependencies
All are permissive (MIT or BSD-3-Clause). None are copyleft.
@@ -16,29 +30,28 @@ All are permissive (MIT or BSD-3-Clause). None are copyleft.
| github.com/adrianmo/go-nmea | MIT |
| github.com/alecthomas/kong | MIT |
| github.com/dustin/go-humanize | MIT |
-| github.com/yuin/gopher-lua | MIT |
-| github.com/dhconnelly/rtreego | BSD-3-Clause |
-| github.com/srwiley/oksvg | BSD-3-Clause |
-| github.com/srwiley/rasterx | BSD-3-Clause |
+| github.com/mattn/go-isatty | MIT |
+| github.com/ncruces/go-strftime | MIT |
| github.com/google/uuid | BSD-3-Clause |
| github.com/remyoudompheng/bigfft | BSD-3-Clause |
| golang.org/x/image | BSD-3-Clause |
-| golang.org/x/net | BSD-3-Clause |
+| golang.org/x/sync | BSD-3-Clause |
| golang.org/x/sys | BSD-3-Clause |
-| golang.org/x/text | BSD-3-Clause |
| modernc.org/libc | BSD-3-Clause |
| modernc.org/mathutil | BSD-3-Clause |
| modernc.org/memory | BSD-3-Clause |
| modernc.org/sqlite | BSD-3-Clause |
-Regenerate this list from a built binary with `go version -m bin/chartplotter`.
+Regenerate this list from a built binary with `go version -m bin/chartplotter`
+(the module graph in `go.mod` includes a few indirect and test-only modules that
+are not linked into the binary).
## Bundled web assets
| Asset | Where | License |
| --- | --- | --- |
| MapLibre GL JS v5.24.0 | `web/vendor/maplibre-gl.js` | BSD-3-Clause |
-| Noto Sans glyphs | `web/glyphs/` | SIL Open Font License 1.1 |
+| Noto Sans glyphs | `web/glyphs/` | SIL Open Font License 1.1 (full text: [`web/fonts/OFL.txt`](web/fonts/OFL.txt)) |
| OpenBridge icons | `web/src/lib/openbridge-icons.mjs` | Artwork CC BY 4.0; code Apache-2.0 |
OpenBridge attribution, as required by CC BY 4.0: *"Icons from the OpenBridge Icon
@@ -48,10 +61,12 @@ Pack by the Ocean Industries Concept Lab, CC BY 4.0."*
### GSHHG coastline basemap
-`web/basemap/coastline.geojson` and `coastline.pmtiles` are derived from the
-**GSHHG** shoreline data set (A Global Self-consistent, Hierarchical,
-High-resolution Geography Database) by Paul Wessel and Walter H. F. Smith,
-distributed under the **GNU LGPL**. The offline basemap underlay is optional.
+`web/basemap/coastline.geojson` is derived from the **GSHHG** shoreline data set
+(A Global Self-consistent, Hierarchical, High-resolution Geography Database) by
+Paul Wessel and Walter H. F. Smith, distributed under the **GNU LGPL**. Only the
+GeoJSON is tracked in this repository; the viewer can optionally load a tiled
+`basemap/coastline.pmtiles` derived from the same data if you provide one. The
+offline basemap underlay is optional.
### NOAA ENC data and catalog
@@ -60,6 +75,10 @@ chartplotter reads NOAA S-57 ENC cells and ships a distilled product catalog
the U.S. Government and are in the **public domain**. They carry NOAA's standard
disclaimer: the data is **not to be used for navigation**.
+Two NOAA cells **are tracked in this repository** as test fixtures:
+`testdata/US4MD81M.000` (with its `.001`–`.003` update files) and
+`testdata/US5MD1MC.000`.
+
### S-57 attribute table (from GDAL)
`internal/s57/parser/s57attributes.csv` is the S-57 attribute catalogue from the
@@ -68,33 +87,42 @@ it contains originate from the IHO S-57 Object and Attribute Catalogue.
## IHO S-101 Portrayal Catalogue and Feature Catalogue
-> **License status: to be confirmed.** Treat this section as a known open item,
-> not a cleared right to redistribute.
+> **License status: undeclared.** The IHO publishes these catalogues with no
+> license statement, so redistribution rights are not formally cleared — treat
+> this as a known open item. The project nonetheless **distributes binaries that
+> embed the catalogue** as an accepted position; if the IHO clarifies or objects
+> to its terms, this section and that decision must be revisited.
chartplotter portrays charts using the **IHO S-101 Portrayal Catalogue** and
**Feature Catalogue** (the symbols, color profiles, drawing rules, and feature
-definitions). The embedded copy is a **draft** — `S-101 2.1.0-DRAFT`, built on
-S-100 Edition 5.2 — sourced from the IHO working-group repositories. These
-materials are **© the International Hydrographic Organization (IHO)**.
+definitions). These materials are **© the International Hydrographic
+Organization (IHO)**.
-Source repositories (public, but **no license declared** — `license: null`, i.e.
-all rights reserved):
+Source repositories (public, but **no license declared** — `license: null`,
+i.e. all rights reserved):
- Portrayal Catalogue —
- Feature Catalogue —
-How chartplotter handles them:
-
-- **Not in this source repository.** The catalogue is `.gitignore`d
- (`internal/engine/s101catalog/catalog/`) and synced from an external copy at
- build time (`make sync-s101`), so the repository itself does not redistribute
- IHO material.
-- **Embedded only in the `_s101` release binaries.** Each release ships two builds
- per platform: a plain binary (no catalogue; needs `--s101 ` at runtime) and
- an `_s101` binary built with `-tags embed_s101`. The release workflow clones the
- two IHO repos above so the `_s101` build embeds the catalogue — only that variant
- **redistributes** IHO material.
-
-Because the IHO repositories declare no license, the right to redistribute this
-material in release binaries is **unconfirmed**. The IHO copyright and
-reproduction policy is at .
+How the project handles them — **fetch from the IHO at build, embed, and
+distribute the built binaries**:
+
+- **Not in either repository.** Neither this repo nor
+ [tile57](https://github.com/beetlebugorg/tile57) commits any IHO catalogue
+ content. The tile57 repo references the two IHO repositories above as **git
+ submodules** (`vendor/S-101_Portrayal-Catalogue`,
+ `vendor/S-101-Documentation-and-FC`), so
+ `git submodule update --init --recursive` fetches the material **directly
+ from the IHO's own repositories** onto the builder's machine.
+- **Embedded in every binary.** Building libtile57 compiles the fetched
+ catalogue into the library, and every `chartplotter` binary links libtile57 —
+ so **any built binary embeds IHO material**, whether built locally or on CI.
+- **Distributed in published binaries.** Tagged releases publish per-platform
+ `chartplotter` binaries built on CI, where the runner obtains the catalogue
+ from the IHO's own repositories at build time. Those released binaries
+ therefore embed IHO material, and the project distributes them as an accepted
+ position despite the undeclared license.
+
+The IHO copyright and reproduction policy is at . If the IHO
+clarifies (or objects to) the catalogues' redistribution terms, this section —
+and the decision to distribute binaries — must be revisited.
diff --git a/cmd/chartplotter/auxfiles.go b/cmd/chartplotter/auxfiles.go
index 7599372..e7bce50 100644
--- a/cmd/chartplotter/auxfiles.go
+++ b/cmd/chartplotter/auxfiles.go
@@ -1,24 +1,23 @@
package main
import (
- "archive/zip"
- "bytes"
- "encoding/json"
"fmt"
- "image/png"
"os"
"path/filepath"
"strings"
- "golang.org/x/image/tiff"
+ "github.com/beetlebugorg/chartplotter/internal/engine/auxfiles"
)
// Aux ("auxiliary") files are the external resources an ENC feature points at by
// filename rather than carrying inline: TXTDSC/NTXTDS textual descriptions (.TXT)
// and PICREP pictures (.TIF/.JPG). They ship in the exchange set alongside the
// .000 cells. The baked tiles carry only the *filename* (in the s57 blob), so we
-// bundle the referenced files into a single companion archive — "-aux.zip"
-// — that the client fetches once and reads by filename for the pick report.
+// lay the referenced files out as loose static files under a companion "-aux/"
+// dir with an index.json — exactly what the server's import/bake writes (both go
+// through internal/engine/auxfiles), so the pick report resolves attachments the
+// same way whether the bundle came from the CLI or the C-ABI server bake, and it
+// works OFFLINE as plain files with no zip and no server.
// isAuxContent reports whether a non-cell file is aux *content* we ship. It keys
// off the content extensions (text + pictures) and excludes the exchange-set
@@ -35,116 +34,45 @@ func isAuxContent(name string) bool {
return false
}
-// auxKey normalises an aux filename to the form features reference it by: the
-// bare basename, upper-cased (S-57 stores TXTDSC/PICREP values upper-cased, and
-// exchange sets are case-inconsistent across platforms).
-func auxKey(name string) string { return strings.ToUpper(filepath.Base(name)) }
+// auxKey normalises an aux filename to the form features reference it by (the bare
+// UPPER basename) — the shared key the pick report looks up by.
+func auxKey(name string) string { return auxfiles.Key(name) }
-// auxEntry is one file's record in the companion zip's index.json.
-type auxEntry struct {
- Stored string `json:"stored"` // entry name inside the zip
- Type string `json:"type"` // MIME type the client should make a Blob with
- From string `json:"from,omitempty"` // original filename, when transcoded (TIFF→PNG)
-}
-
-// contentType maps a stored filename to the MIME type the pick report renders.
-func contentType(name string) string {
- switch strings.ToLower(filepath.Ext(name)) {
- case ".txt":
- return "text/plain"
- case ".png":
- return "image/png"
- case ".jpg", ".jpeg":
- return "image/jpeg"
- case ".tif", ".tiff":
- return "image/tiff"
- }
- return "application/octet-stream"
-}
-
-// writeAuxZip packages the collected aux files into "-aux.zip" next to the
-// baked archive(s), transcoding TIFF pictures to PNG (browsers can't render TIFF)
-// and writing an index.json mapping each referenced filename to its stored entry.
-// Returns the zip's basename (for the manifest), or "" when there's nothing to ship.
-func writeAuxZip(stem string, aux map[string][]byte) (string, error) {
+// writeAuxDir lays the collected aux files out as loose static files under a
+// companion "-aux/" dir (with an index.json), transcoding TIFF pictures to
+// PNG. Returns the dir's manifest path RELATIVE to the bundle (for the charts-index
+// "aux" field), or "" when there's nothing to ship.
+func writeAuxDir(stem string, aux map[string][]byte) (string, error) {
if len(aux) == 0 {
return "", nil
}
- out := stem + "-aux.zip"
- f, err := os.Create(out)
+ dir := stem + "-aux"
+ n, err := auxfiles.WriteDir(dir, aux)
if err != nil {
return "", err
}
- zw := zip.NewWriter(f)
-
- index := map[string]auxEntry{}
- for key, data := range aux {
- stored := filepath.Base(key)
- typ := contentType(stored)
- from := ""
-
- // Browsers have no TIFF decoder; transcode to PNG so the picture renders
- // inline. On failure, ship the original and let the client offer a download.
- if ext := strings.ToLower(filepath.Ext(stored)); ext == ".tif" || ext == ".tiff" {
- if png, e := tiffToPNG(data); e == nil {
- from = stored
- stored = strings.TrimSuffix(stored, filepath.Ext(stored)) + ".png"
- typ = "image/png"
- data = png
- } else {
- fmt.Fprintf(os.Stderr, " aux: keeping %s as TIFF (transcode failed: %v)\n", stored, e)
- }
- }
+ fmt.Printf("wrote %d aux file(s) → %s/\n", n, dir)
+ // The client resolves this relative to the manifest URL, so ship a relative path
+ // (dir basename + index.json), not the absolute on-disk path.
+ return filepath.Base(dir) + "/" + auxfiles.IndexName, nil
+}
- w, err := zw.Create(stored)
- if err != nil {
- zw.Close()
- f.Close()
- return "", err
+// collectAuxDir walks a directory tree for referenced aux content (TXTDSC /
+// PICREP text + pictures) and returns it keyed like collectCells' aux map
+// (auxKey — UPPER basename, first occurrence wins). Lets the streaming
+// flat-archive bake ship the aux dir without reading any cell into memory.
+func collectAuxDir(dir string) map[string][]byte {
+ aux := map[string][]byte{}
+ _ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !isAuxContent(p) {
+ return nil
}
- if _, err := w.Write(data); err != nil {
- zw.Close()
- f.Close()
- return "", err
+ if k := auxKey(p); aux[k] == nil {
+ if b, e := os.ReadFile(p); e == nil {
+ aux[k] = b
+ }
}
- index[key] = auxEntry{Stored: stored, Type: typ, From: from}
- }
-
- iw, err := zw.Create("index.json")
- if err != nil {
- zw.Close()
- f.Close()
- return "", err
- }
- enc := json.NewEncoder(iw)
- enc.SetIndent("", " ")
- if err := enc.Encode(map[string]any{"version": 1, "files": index}); err != nil {
- zw.Close()
- f.Close()
- return "", err
- }
-
- if err := zw.Close(); err != nil {
- f.Close()
- return "", err
- }
- if err := f.Close(); err != nil {
- return "", err
- }
- st, _ := os.Stat(out)
- fmt.Printf("wrote %d aux file(s) → %s (%.1f KB)\n", len(index), out, float64(st.Size())/1024)
- return filepath.Base(out), nil
-}
-
-// tiffToPNG decodes a TIFF image and re-encodes it as PNG.
-func tiffToPNG(data []byte) ([]byte, error) {
- img, err := tiff.Decode(bytes.NewReader(data))
- if err != nil {
- return nil, err
- }
- var buf bytes.Buffer
- if err := png.Encode(&buf, img); err != nil {
- return nil, err
- }
- return buf.Bytes(), nil
+ return nil
+ })
+ return aux
}
diff --git a/cmd/chartplotter/bake.go b/cmd/chartplotter/bake.go
index 31270ac..87986a6 100644
--- a/cmd/chartplotter/bake.go
+++ b/cmd/chartplotter/bake.go
@@ -11,7 +11,6 @@ import (
"strings"
"github.com/beetlebugorg/chartplotter/internal/engine/baker"
- "github.com/beetlebugorg/chartplotter/internal/engine/pmtiles"
)
// bakeCmd bakes S-57 ENC base cells into a PMTiles archive of MVT tiles, for
@@ -22,203 +21,149 @@ type bakeCmd struct {
Out string `short:"o" type:"path" default:"charts.pmtiles" help:"Output PMTiles archive."`
Manifest string `help:"Also write a charts-index.json manifest (for the app's catalog=… option)."`
BaseURL string `name:"base-url" help:"URL/prefix for the archive in the manifest (default: the archive's basename)."`
- Overzoom bool `help:"Overzoom all bands DOWN to the world view, so a standalone large-scale set (e.g. an IENC bundle with no overview cells) stays visible when zoomed out."`
MaxZoom int `name:"max-zoom" help:"Cap the highest baked zoom (0 = each cell's native band max). Large-scale cells over a wide area (e.g. IENC at 1:5000) emit tens of millions of z17–18 tiles; cap the bake and let the client overzoom the vector tiles."`
- Bands bool `help:"Write one gap-clipped archive PER navigational band (-.pmtiles) instead of one merged archive, so the client reproduces the realtime best-available display: each band's source client-overzooms its own data, coarser bands fill finer gaps, none bleed."`
+ Format string `enum:"mlt,mvt," default:"" help:"Tile encoding: mlt (MapLibre Tile, the engine default) or mvt (Mapbox Vector Tile, for consumers without an MLT decoder). Empty = the engine default (mlt)."`
S101 string `name:"s101" type:"existingdir" help:"Override the embedded catalogue with an external S-101 PortrayalCatalog directory (for iterating on rules). Requires --s101-fc."`
S101FC string `name:"s101-fc" type:"existingfile" help:"S-101 FeatureCatalogue.xml path (with --s101)."`
}
func (c bakeCmd) Run() error {
- if c.S101 != "" {
- if c.S101FC == "" {
- return fmt.Errorf("--s101 requires --s101-fc")
- }
- if err := baker.UseS101Catalog(c.S101, c.S101FC); err != nil {
- return fmt.Errorf("load S-101 catalogue: %w", err)
- }
- fmt.Fprintln(os.Stderr, "portrayal: S-101 rule engine")
+ // libtile57 is the sole bake engine. A *.pmtiles/-mbtiles -o writes ONE flat
+ // merged archive (+ optional --manifest / aux.zip) — the coverage-clipped
+ // composite resolves best-available inside the single archive, so there are
+ // no per-band archives any more (the retired --bands). Any other -o is a
+ // self-contained bundle directory (tiles/chart.pmtiles + per-scheme style +
+ // assets + manifest.json).
+ switch strings.ToLower(filepath.Ext(c.Out)) {
+ case ".pmtiles", ".mbtiles":
+ return c.runTile57Archive()
}
- cells, aux, err := collectCells(c.In)
+ return c.runTile57Bundle()
+}
+
+// runTile57Bundle bakes the ENC inputs with the native libtile57 engine into a
+// self-contained chart bundle (tiles/chart.pmtiles + per-scheme SCAMIN-bucketed
+// style-*.json + assets + manifest.json) under the output directory. The engine
+// reads the ENC from disk, so a lone directory or .000 is handed over directly and
+// only zips / multiple inputs are staged into a temp directory of cells first.
+func (c bakeCmd) runTile57Bundle() error {
+ input, cleanup, err := c.tile57Input()
if err != nil {
return err
}
- if len(cells) == 0 {
- return fmt.Errorf("no .000 base cells found in: %s", strings.Join(c.In, ", "))
- }
- nUpd := 0
- for _, cd := range cells {
- nUpd += len(cd.Updates)
- }
- fmt.Fprintf(os.Stderr, "baking %d cell(s) (%d update file(s) applied)…\n", len(cells), nUpd)
+ defer cleanup()
- // Per-band streaming holds only one band's geometry at a time, so it skips the
- // all-cells BuildBakerWithUpdates entirely.
- if c.Bands {
- return c.runBands(cells, aux)
+ outDir := bundleOutDir(c.Out)
+ if err := os.MkdirAll(outDir, 0o755); err != nil {
+ return err
}
-
- b, ok, err := baker.BuildBakerWithUpdates(cells, c.Overzoom, func(name string, err error) {
- fmt.Fprintf(os.Stderr, " skip %s: %v\n", name, err)
- })
+ // nil progress → the lib's built-in per-band console progress (good CLI output).
+ n, bbox, err := bakeTile57Bundle(input, outDir, c.MaxZoom, c.Format, nil)
if err != nil {
return err
}
- if len(ok) == 0 {
- return fmt.Errorf("no cells parsed successfully")
- }
- if c.MaxZoom > 0 {
- b.MaxBakeZoom = uint32(c.MaxZoom)
- }
+ fmt.Printf("baked %d cell(s) → %s/ via libtile57 — bundle: tiles/chart.pmtiles + assets/style-{day,dusk,night}.json + manifest.json (bbox %.4f,%.4f,%.4f,%.4f)\n",
+ n, outDir, bbox[0], bbox[1], bbox[2], bbox[3])
+ return nil
+}
- lastPct := -1
- pb := baker.BakeToPMTiles(b, func(done, total int) {
- if total == 0 {
- return
- }
- if pct := done * 100 / total; pct != lastPct && pct%5 == 0 {
- lastPct = pct
- fmt.Fprintf(os.Stderr, "\r tiles %d/%d (%d%%)", done, total, pct)
+// tile57Input resolves the ENC inputs to a single on-disk path for the bundle
+// baker. A lone existing directory or .000 file is used as-is (no cleanup). Zips,
+// multiple inputs, or anything else are gathered with collectCells and written to a
+// temp directory of cells (returned with a cleanup that removes it).
+func (c bakeCmd) tile57Input() (path string, cleanup func(), err error) {
+ noop := func() {}
+ if len(c.In) == 1 {
+ if fi, e := os.Stat(c.In[0]); e == nil {
+ // A lone .000, or a directory that ALREADY holds extracted .000 cells
+ // (a real ENC_ROOT), is handed to the engine as-is. A directory of only
+ // exchange-set .zip bundles (the demo cache, an IENC download) has no
+ // .000 for the engine to read — it must be unpacked via collectCells
+ // below, else the bake covers nothing.
+ if encExt(c.In[0]) == ".000" || (fi.IsDir() && dirHasBaseCell(c.In[0])) {
+ return c.In[0], noop, nil
+ }
}
- })
- fmt.Fprintln(os.Stderr)
-
- f, err := os.Create(c.Out)
- if err != nil {
- return err
}
- if err := pb.WriteArchive(f); err != nil {
- f.Close()
- return err
+ cells, aux, err := collectCells(c.In)
+ if err != nil {
+ return "", noop, err
}
- if err := f.Close(); err != nil {
- return err
+ if len(cells) == 0 {
+ return "", noop, fmt.Errorf("no .000 base cells found in: %s", strings.Join(c.In, ", "))
}
- st, _ := os.Stat(c.Out)
- fmt.Printf("baked %d cell(s) → %s (%d tiles, %.1f MB)\n", len(ok), c.Out, pb.Count(), float64(st.Size())/(1<<20))
-
- stem := strings.TrimSuffix(c.Out, filepath.Ext(c.Out))
- auxFile, err := writeAuxZip(stem, aux)
+ dir, err := os.MkdirTemp("", "cp-tile57-enc-")
if err != nil {
- return err
+ return "", noop, err
}
-
- if c.Manifest != "" {
- file := c.BaseURL
- if file == "" {
- file = filepath.Base(c.Out)
- }
- bb := b.Bounds()
- man := map[string]any{
- "districts": []map[string]any{{
- "file": file,
- "band": "all",
- "bounds": []float64{bb.MinLon, bb.MinLat, bb.MaxLon, bb.MaxLat},
- }},
+ for name, cd := range cells { // name is ".000"
+ if err := os.WriteFile(filepath.Join(dir, name), cd.Base, 0o644); err != nil {
+ os.RemoveAll(dir)
+ return "", noop, err
}
- if auxFile != "" {
- man["aux"] = auxFile
- }
- mf, err := os.Create(c.Manifest)
- if err != nil {
- return err
+ for un, ub := range cd.Updates { // sequential .001+ alongside the base
+ if err := os.WriteFile(filepath.Join(dir, filepath.Base(un)), ub, 0o644); err != nil {
+ os.RemoveAll(dir)
+ return "", noop, err
+ }
}
- enc := json.NewEncoder(mf)
- enc.SetIndent("", " ")
- if err := enc.Encode(man); err != nil {
- mf.Close()
- return err
+ }
+ // Aux content (TXTDSC/PICREP text + pictures) rides beside the cells, exactly
+ // like a real ENC_ROOT — so the flat-archive path's aux.zip walk finds it for
+ // zip inputs too.
+ for name, data := range aux {
+ if err := os.WriteFile(filepath.Join(dir, filepath.Base(name)), data, 0o644); err != nil {
+ os.RemoveAll(dir)
+ return "", noop, err
}
- mf.Close()
- fmt.Printf("wrote manifest %s (file=%s)\n", c.Manifest, file)
}
- return nil
+ return dir, func() { os.RemoveAll(dir) }, nil
}
-// runBands writes one gap-clipped PMTiles archive per navigational band
-// (-.pmtiles) plus a manifest tagging each with its band slug, so
-// the frontend loads each into its own chart- source.
-func (c bakeCmd) runBands(cells map[string]baker.CellData, aux map[string][]byte) error {
- ext := filepath.Ext(c.Out)
- stem := strings.TrimSuffix(c.Out, ext)
- var entries []map[string]any
- lastPct := -1
-
- // Streaming: pass 1 derives coverage per cell; pass 2 re-parses + bakes one band
- // at a time, so only a single band's geometry + archive is ever resident.
- bb, nCells, err := baker.BakeToPMTilesBandsStreaming(cells, uint32(c.MaxZoom),
- func(name string, err error) {
- fmt.Fprintf(os.Stderr, " skip %s: %v\n", name, err)
- },
- func(stage string, done, total int, band string) {
- if total == 0 {
- return
- }
- if pct := done * 100 / total; pct != lastPct && pct%5 == 0 {
- lastPct = pct
- where := band
- if where == "" {
- where = "coverage"
- }
- fmt.Fprintf(os.Stderr, "\r %-9s %-8s %d/%d (%d%%) ", where, stage, done, total, pct)
- }
- },
- func(slug string, pb *pmtiles.Builder) error {
- out := stem + "-" + slug + ext
- f, err := os.Create(out)
- if err != nil {
- return err
- }
- if err := pb.WriteArchive(f); err != nil {
- f.Close()
- return err
- }
- if err := f.Close(); err != nil {
- return err
- }
- st, _ := os.Stat(out)
- fmt.Fprintf(os.Stderr, "\r")
- fmt.Printf(" %-9s → %s (%d tiles, %.1f MB)\n", slug, out, pb.Count(), float64(st.Size())/(1<<20))
- entries = append(entries, map[string]any{
- "file": filepath.Base(out),
- "band": slug,
- })
- return nil
- })
+// writeManifestJSON writes a charts-index.json manifest (indented) to path.
+func writeManifestJSON(path string, man map[string]any) error {
+ mf, err := os.Create(path)
if err != nil {
return err
}
- // District bounds (cell-union) are known only after both passes; stamp them
- // onto every band entry now.
- for _, e := range entries {
- e["bounds"] = []float64{bb.MinLon, bb.MinLat, bb.MaxLon, bb.MaxLat}
+ enc := json.NewEncoder(mf)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(man); err != nil {
+ mf.Close()
+ return err
}
- fmt.Printf("baked %d cell(s) → %d band archive(s)\n", nCells, len(entries))
+ return mf.Close()
+}
- auxFile, err := writeAuxZip(stem, aux)
- if err != nil {
- return err
+// bundleOutDir derives the bundle output DIRECTORY from the -o value: a *.pmtiles /
+// *.mbtiles path becomes its stem (charts.pmtiles → charts/), otherwise -o is used
+// as the directory verbatim.
+func bundleOutDir(out string) string {
+ switch strings.ToLower(filepath.Ext(out)) {
+ case ".pmtiles", ".mbtiles":
+ return strings.TrimSuffix(out, filepath.Ext(out))
+ default:
+ return out
}
+}
- if c.Manifest != "" {
- mf, err := os.Create(c.Manifest)
- if err != nil {
- return err
- }
- enc := json.NewEncoder(mf)
- enc.SetIndent("", " ")
- man := map[string]any{"districts": entries}
- if auxFile != "" {
- man["aux"] = auxFile
+// dirHasBaseCell reports whether dir already contains at least one extracted .000
+// base cell — i.e. it is a bakeable ENC_ROOT the engine can read directly, not a
+// directory of .zip exchange-set bundles (which must be unpacked via collectCells
+// first). Stops at the first hit.
+func dirHasBaseCell(dir string) bool {
+ found := false
+ _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return nil
}
- if err := enc.Encode(man); err != nil {
- mf.Close()
- return err
+ if encExt(path) == ".000" {
+ found = true
+ return fs.SkipAll
}
- mf.Close()
- fmt.Printf("wrote manifest %s\n", c.Manifest)
- }
- return nil
+ return nil
+ })
+ return found
}
// encExt reports the 3-digit S-57 cell extension (".000" base, ".001"+ updates)
diff --git a/cmd/chartplotter/main.go b/cmd/chartplotter/main.go
index fc11965..4b07038 100644
--- a/cmd/chartplotter/main.go
+++ b/cmd/chartplotter/main.go
@@ -9,23 +9,28 @@ package main
import (
"fmt"
- "io/fs"
"github.com/alecthomas/kong"
- "github.com/beetlebugorg/chartplotter/internal/engine/assets"
- "github.com/beetlebugorg/chartplotter/internal/engine/s101catalog"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
)
// version is overridden at build time via -ldflags "-X main.version=...".
var version = "dev"
+// engineCommit is the tile57 (libtile57) checkout's commit this binary was built
+// against, stamped via -ldflags "-X main.engineCommit=..." (Makefile
+// ENGINE_COMMIT; resolves the default sibling ../tile57 or a TILE57=… override).
+// Every bake records it beside the pack so the client can show which engine
+// commit produced the visible tiles. "unknown" for a bare `go build`.
+var engineCommit = "unknown"
+
type cli struct {
Version versionCmd `cmd:"" help:"Print version and embedded-asset info."`
EmitAssets emitAssetsCmd `cmd:"" name:"emit-assets" help:"Generate S-101 client assets (colortables.json, ...) into a directory."`
CatalogJSON catalogJSONCmd `cmd:"" name:"catalog-json" help:"Distil NOAA ENCProdCat.xml into a compact catalog.json."`
Bake bakeCmd `cmd:"" name:"bake" help:"Bake S-57 ENC cells (.zip/.000/dir) into a PMTiles archive for a prebaked deployment."`
- Serve serveCmd `cmd:"" name:"serve" help:"Serve the web frontend (embedded static + wasm) + the NOAA cell proxy."`
+ Serve serveCmd `cmd:"" name:"serve" help:"Serve the web frontend (embedded static) + the NOAA cell proxy."`
Simulate simulateCmd `cmd:"" name:"simulate" help:"Run a NMEA0183 traffic generator over TCP (own-ship + AIS targets) for testing."`
}
@@ -36,21 +41,9 @@ type emitAssetsCmd struct {
}
func (c emitAssetsCmd) Run() error {
- var (
- files []string
- err error
- )
- switch {
- case c.S101 != "":
- files, err = assets.EmitS101(c.S101, c.CSS, c.Dir)
- case s101catalog.Available():
- var fsys fs.FS
- if fsys, err = s101catalog.PortrayalFS(); err == nil {
- files, err = assets.EmitS101FS(fsys, c.CSS, c.Dir)
- }
- default:
- return fmt.Errorf("no S-101 catalogue (build with `make` or pass --s101)")
- }
+ // Emit the client assets via the native libtile57 asset baker: c.S101 "" uses
+ // libtile57's embedded S-101 catalogue, else an on-disk PortrayalCatalog dir.
+ files, err := emitS101Assets(c.S101, c.Dir)
if err != nil {
return err
}
@@ -64,7 +57,7 @@ type versionCmd struct{}
func (versionCmd) Run() error {
fmt.Printf("chartplotter %s\n", version)
- fmt.Printf("embedded S-101 catalogue: %v\n", s101catalog.Available())
+ fmt.Printf("libtile57 %s (engine commit %s, S-101 catalogue embedded)\n", tile57.Version(), engineCommit)
return nil
}
diff --git a/cmd/chartplotter/serve.go b/cmd/chartplotter/serve.go
index 860957d..9deff73 100644
--- a/cmd/chartplotter/serve.go
+++ b/cmd/chartplotter/serve.go
@@ -2,14 +2,10 @@ package main
import (
"fmt"
- "io/fs"
"net"
"net/http"
"os"
- "github.com/beetlebugorg/chartplotter/internal/engine/assets"
- "github.com/beetlebugorg/chartplotter/internal/engine/baker"
- "github.com/beetlebugorg/chartplotter/internal/engine/s101catalog"
"github.com/beetlebugorg/chartplotter/internal/engine/server"
)
@@ -29,47 +25,26 @@ type serveCmd struct {
}
func (c serveCmd) Run() error {
- // Portrayal is S-101. Pick the catalogue source: an explicit --s101 dir wins
- // (override / rule iteration); otherwise the build-time embedded catalogue (the
- // default — `make` builds it in). The baker defaults to the embedded portrayer
- // on its own (baker.applyPortrayer); here we emit the matching client assets
- // (colortables/sprite/patterns/linestyles) into a temp dir and serve them.
- var catalogFS fs.FS
- var s101AssetDir string // freshly-emitted S-101 client assets (temp dir), or ""
- switch {
- case c.S101 != "":
- if c.S101FC == "" {
- return fmt.Errorf("--s101 requires --s101-fc")
- }
- if err := baker.UseS101Catalog(c.S101, c.S101FC); err != nil {
- return fmt.Errorf("load S-101 catalogue: %w", err)
- }
- catalogFS = os.DirFS(c.S101)
- fmt.Printf("portrayal: S-101 (catalogue=%s)\n", c.S101)
- case s101catalog.Available():
- fsys, err := s101catalog.PortrayalFS()
- if err != nil {
- return fmt.Errorf("embedded S-101 catalogue: %w", err)
- }
- catalogFS = fsys
- fmt.Println("portrayal: S-101 (embedded catalogue)")
- default:
- fmt.Println("portrayal: none embedded — pass --s101 or build with `make` (-tags embed_s101)")
+ // Portrayal is S-101. Emit the client assets (colortables/linestyles/sprite/
+ // patterns) via libtile57's asset baker and serve them as a fallback: an explicit
+ // --s101 PortrayalCatalog dir overrides libtile57's embedded catalogue.
+ catalogDir := c.S101 // "" = libtile57's embedded catalogue
+ assetDir, err := os.MkdirTemp("", "cp-s101-assets-")
+ if err != nil {
+ return err
}
- if catalogFS != nil {
- assetDir, err := os.MkdirTemp("", "cp-s101-assets-")
- if err != nil {
- return err
- }
- if _, err := assets.EmitS101FS(catalogFS, "daySvgStyle.css", assetDir); err != nil {
- return fmt.Errorf("emit S-101 assets: %w", err)
- }
- // The emitted S-101 client assets (colortables/linestyles/sprite/patterns)
- // are a FALLBACK, not a replacement: an explicit --assets dir stays primary
- // (so a prebaked widget bundle serves its own index.html / charts-index.json /
- // .pmtiles), this temp dir fills in the generated S-101 files it lacks, and the
- // embedded bundle backs the rest. Registered on the Server below.
- s101AssetDir = assetDir
+ if _, err := emitS101Assets(catalogDir, assetDir); err != nil {
+ return fmt.Errorf("emit S-101 assets: %w", err)
+ }
+ // The emitted assets are a FALLBACK, not a replacement: an explicit --assets dir
+ // stays primary (a prebaked widget bundle serves its own index.html /
+ // charts-index.json / .pmtiles), this temp dir fills in the generated S-101 files
+ // it lacks, and the embedded bundle backs the rest. Registered on the Server below.
+ s101AssetDir := assetDir
+ if catalogDir != "" {
+ fmt.Printf("portrayal: S-101 (catalogue=%s)\n", catalogDir)
+ } else {
+ fmt.Println("portrayal: S-101 (libtile57 embedded catalogue)")
}
cacheDir := c.Cache
@@ -95,7 +70,8 @@ func (c serveCmd) Run() error {
srv := server.New(c.Assets, cacheDir, dataDir, allowRemote)
srv.SetAssetFallback(s101AssetDir) // emitted S-101 assets, searched after --assets, before embedded
srv.Version = version
- srv.ReportStaleCache() // loud warning if any served pack predates this binary
+ srv.EngineCommit = engineCommit // stamped onto every bake
+ srv.ReportStaleCache() // loud warning if any served pack predates this binary
addr := net.JoinHostPort(c.Host, fmt.Sprintf("%d", c.Port))
remoteNote := ""
diff --git a/cmd/chartplotter/simulate.go b/cmd/chartplotter/simulate.go
index ec774f1..009da96 100644
--- a/cmd/chartplotter/simulate.go
+++ b/cmd/chartplotter/simulate.go
@@ -12,9 +12,8 @@ import (
"sync"
"time"
- "github.com/beetlebugorg/chartplotter/internal/engine/baker"
"github.com/beetlebugorg/chartplotter/internal/engine/nmea/sim"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
)
// simulateCmd runs a NMEA0183 traffic generator over TCP: own-ship instruments
@@ -46,11 +45,11 @@ func (c simulateCmd) Run() error {
var water *sim.WaterMask
if c.Cell != "" {
- chart, err := loadCell(c.Cell)
+ feats, err := loadWaterFeatures(c.Cell)
if err != nil {
return fmt.Errorf("load cell %s: %w", c.Cell, err)
}
- if water = sim.NewWaterMask(chart, c.MinDepth); water == nil {
+ if water = sim.NewWaterMask(feats, c.MinDepth); water == nil {
fmt.Println("warning: no navigable depth areas (DEPARE ≥ min-depth) in cell; placing traffic unconstrained")
}
}
@@ -165,9 +164,25 @@ func (h *connHub) broadcast(lines []string) {
}
}
-// loadCell parses an S-57 cell from a .000 file or the first .000 inside an
-// exchange-set .zip.
-func loadCell(p string) (*s57.Chart, error) {
+// loadWaterFeatures opens an S-57 cell — a .000 file or the first .000 inside an
+// exchange-set .zip — with the native engine (base edition, no updates) and
+// returns its DEPARE/DRGARE features for the water mask.
+func loadWaterFeatures(p string) ([]tile57.Feature, error) {
+ data, err := readBaseCell(p)
+ if err != nil {
+ return nil, err
+ }
+ src, err := tile57.OpenChartBytes(data)
+ if err != nil {
+ return nil, err
+ }
+ defer src.Close()
+ return src.Features("DEPARE", "DRGARE")
+}
+
+// readBaseCell returns the raw base-cell bytes from a .000 file or the first
+// .000 inside an exchange-set .zip.
+func readBaseCell(p string) ([]byte, error) {
if strings.HasSuffix(strings.ToLower(p), ".zip") {
zr, err := zip.OpenReader(p)
if err != nil {
@@ -185,16 +200,12 @@ func loadCell(p string) (*s57.Chart, error) {
if err != nil {
return nil, err
}
- return baker.ParseCellBytes(filepath.Base(f.Name), data)
+ return data, nil
}
}
return nil, fmt.Errorf("no .000 cell found in %s", p)
}
- data, err := os.ReadFile(p)
- if err != nil {
- return nil, err
- }
- return baker.ParseCellBytes(filepath.Base(p), data)
+ return os.ReadFile(p)
}
func parseLatLon(s string) (float64, float64, error) {
diff --git a/cmd/chartplotter/tile57_assets.go b/cmd/chartplotter/tile57_assets.go
new file mode 100644
index 0000000..45a904e
--- /dev/null
+++ b/cmd/chartplotter/tile57_assets.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
+)
+
+// emitS101Assets writes the client asset files (colortables/linestyles/sprite/
+// patterns) into dir via the native libtile57 asset baker, so the served symbology
+// is produced by the SAME engine that renders the tiles. catalogDir "" uses
+// libtile57's embedded S-101 catalogue; a path emits from that on-disk
+// PortrayalCatalog instead. Returns the files written.
+func emitS101Assets(catalogDir, dir string) ([]string, error) {
+ a, err := tile57.BakeAssets(catalogDir)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, err
+ }
+ out := []struct {
+ name string
+ data []byte
+ }{
+ {"colortables.json", a.Colortables},
+ {"linestyles.json", a.Linestyles},
+ {"sprite.json", a.SpriteJSON},
+ {"sprite.png", a.SpritePNG},
+ {"patterns.json", a.PatternJSON},
+ {"patterns.png", a.PatternPNG},
+ }
+ var written []string
+ for _, f := range out {
+ if len(f.data) == 0 {
+ continue // an empty buffer (e.g. no area patterns) — skip
+ }
+ p := filepath.Join(dir, f.name)
+ if err := os.WriteFile(p, f.data, 0o644); err != nil {
+ return nil, err
+ }
+ written = append(written, p)
+ }
+ src := "libtile57 embedded catalogue"
+ if catalogDir != "" {
+ src = catalogDir
+ }
+ fmt.Printf("tile57: emitted %d S-101 client asset file(s) from %s\n", len(written), src)
+ return written, nil
+}
diff --git a/cmd/chartplotter/tile57_bake.go b/cmd/chartplotter/tile57_bake.go
new file mode 100644
index 0000000..902acb9
--- /dev/null
+++ b/cmd/chartplotter/tile57_bake.go
@@ -0,0 +1,118 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
+)
+
+// bakeTile57Bundle bakes an on-disk ENC input (a .000 cell or a directory of cells)
+// into a self-contained chart bundle under outDir via the native libtile57 engine.
+// maxZoom caps the highest baked zoom (0 = no cap). format selects the tile
+// encoding ("mlt"/"mvt"; "" = the engine default, MLT). progress nil uses the
+// lib's built-in console progress. Returns the cell count + bbox (w,s,e,n).
+func bakeTile57Bundle(input, outDir string, maxZoom int, format string, progress func(tile57.BakeProgress)) (int, [4]float64, error) {
+ // MaxZoom 24 = the ABI's "no clamp" (bake each cell's full native band); MaxZoom 0
+ // would clamp every band down to z0 — an EMPTY archive. Only narrow on --max-zoom.
+ opts := tile57.BakeOpts{MaxZoom: 24, Format: bakeFormat(format)}
+ if maxZoom > 0 && maxZoom < 24 {
+ opts.MaxZoom = uint8(maxZoom)
+ }
+ return tile57.BakeBundle(input, outDir, opts, progress)
+}
+
+// bakeFormat maps the --format flag to the engine's bake format. "" = the engine
+// default (MLT); "mvt" keeps the legacy Mapbox Vector Tile output.
+func bakeFormat(format string) tile57.TileFormat {
+ switch format {
+ case "mvt":
+ return tile57.FormatMVT
+ case "mlt":
+ return tile57.FormatMLT
+ }
+ return tile57.FormatDefault
+}
+
+// runTile57Archive bakes the ENC inputs into ONE flat merged archive at -o via
+// the STREAMING bundle driver — super-tile locality, geometry LRU, coarse
+// riders, capped overscale fill-up, TILE57_SUPER_DZ / TILE57_LRU_BUDGET
+// tuning, tiles streamed to disk instead of an in-memory whole-archive buffer.
+// BakeBundle bakes into a temp dir beside -o; the finished tiles/chart.pmtiles
+// then renames onto -o (same filesystem) and the bundle scaffolding (assets/
+// styles/manifest.json) is discarded. Honors --max-zoom; writes --manifest
+// (one band-less entry) + aux.zip (aux content collected from the input tree —
+// zip inputs stage theirs beside the cells).
+func (c bakeCmd) runTile57Archive() error {
+ input, cleanup, err := c.tile57Input()
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ outAbs, err := filepath.Abs(c.Out)
+ if err != nil {
+ return err
+ }
+ tmp, err := os.MkdirTemp(filepath.Dir(outAbs), ".bake-*")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmp)
+
+ n, bbox, err := bakeTile57Bundle(input, tmp, c.MaxZoom, c.Format, nil)
+ if err != nil {
+ return err
+ }
+ if err := os.Rename(filepath.Join(tmp, "tiles", "chart.pmtiles"), outAbs); err != nil {
+ return err
+ }
+ st, _ := os.Stat(outAbs)
+ fmt.Printf("baked %d cell(s) → %s (%.1f MB) via libtile57 (streamed)\n", n, c.Out, float64(st.Size())/(1<<20))
+
+ // Aux content walks the INPUT tree (for a lone .000, its directory).
+ auxRoot := input
+ if fi, e := os.Stat(input); e == nil && !fi.IsDir() {
+ auxRoot = filepath.Dir(input)
+ }
+ ext := filepath.Ext(c.Out)
+ stem := strings.TrimSuffix(c.Out, ext)
+ auxManifest, err := writeAuxDir(stem, collectAuxDir(auxRoot))
+ if err != nil {
+ return err
+ }
+
+ if c.Manifest != "" {
+ entry := map[string]any{"file": filepath.Base(c.Out), "bounds": bbox[:]}
+ man := map[string]any{"districts": []map[string]any{entry}}
+ if auxManifest != "" {
+ man["aux"] = auxManifest
+ }
+ if err := writeManifestJSON(c.Manifest, man); err != nil {
+ return err
+ }
+ fmt.Printf("wrote manifest %s\n", c.Manifest)
+ }
+ return nil
+}
+
+// orderedUpdates returns a cell's update bodies sorted by filename so libtile57
+// applies them in sequence (.001, .002, …).
+func orderedUpdates(m map[string][]byte) [][]byte {
+ if len(m) == 0 {
+ return nil
+ }
+ names := make([]string, 0, len(m))
+ for n := range m {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ out := make([][]byte, len(names))
+ for i, n := range names {
+ out[i] = m[n]
+ }
+ return out
+}
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..c9895ed
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,32 @@
+# chartplotter — the "server-hub on a boat" deployment (Pi / laptop / mini PC).
+# One long-running server holds all chart state; every screen (helm, tablet,
+# phone) points its browser at http://:8080/ and they stay in sync.
+#
+# docker compose up -d # pull ghcr.io image + run
+# docker compose up -d --build # build the image locally from ./Dockerfile
+#
+# The image is multi-arch (linux/amd64 + linux/arm64), so the SAME compose file
+# runs on a Raspberry Pi and on an amd64 box.
+
+name: chartplotter
+
+services:
+ chartplotter:
+ image: ghcr.io/beetlebugorg/chartplotter:latest
+ # `docker compose build` / `--build` builds this instead of pulling.
+ build:
+ context: .
+ args:
+ VERSION: dev
+ ports:
+ - "8080:8080"
+ volumes:
+ # Source ENC (downloaded district zips, raw cells) + baked tiles + client
+ # settings. A named volume survives image upgrades and `compose down`.
+ - chartplotter-data:/data
+ restart: unless-stopped
+ # NOTE: no Docker HEALTHCHECK — the FROM scratch image has no shell/curl for a
+ # CMD probe. Check health from the host: `curl -fsS http://localhost:8080/`.
+
+volumes:
+ chartplotter-data:
diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md
index b5d68ab..78cb40d 100644
--- a/docs/docs/architecture.md
+++ b/docs/docs/architecture.md
@@ -7,95 +7,112 @@ sidebar_position: 5
# Architecture
This page explains how chartplotter turns an S-57 chart cell into vector tiles,
-and how the pieces of the codebase fit together.
+and how the pieces fit together.
+
+## Two repositories, one program
+
+chartplotter is built from two repos:
+
+- **`chartplotter`** (this repo, Go) — the application: the HTTP server and
+ chart library, the CLI, NMEA 0183 ingestion, and the web frontend.
+- **[`tile57`](https://github.com/beetlebugorg/tile57)** (Zig) — the chart
+ engine. It builds **libtile57**, a native static library that is linked into
+ the Go binary via CGO and does *all* of the chart work: S-57 decoding, S-101
+ portrayal, tiling, tile encoding, and generating the MapLibre style and
+ client assets.
+
+The Go code is the hub around the engine; the browser only renders what the
+engine baked.
## The pipeline
A chart cell flows through these stages:
```
-S-57 ENC cell (.000)
- │ decode the binary file pkg/iso8211
+S-57 ENC cells (.000 + .001… updates)
+ │
▼
-S-57 feature + geometry model pkg/s57
- │ apply S-101 portrayal pkg/s100, internal/engine/s101
+libtile57 — the native engine (linked via CGO)
+ │ ISO 8211 decode → S-57 feature model → S-101 portrayal →
+ │ web-mercator tiling → MLT/MVT encode →
+ │ MapLibre style + sprites, color tables, line styles
▼
-Primitive drawing list (lat/lon) internal/engine/portrayal
- │ project to web mercator + clip internal/engine/tile
+Chart bundle: tiles/chart.pmtiles + style-{day,dusk,night}.json + assets
+ │
▼
-Mapbox Vector Tiles internal/engine/mvt
- │ dedup + stream to one file internal/engine/pmtiles
+Go server — the hub (internal/engine/server)
+ │ chart library + background bakes, /tiles + /api,
+ │ settings, NMEA 0183 / AIS, aux attachments
▼
-charts.pmtiles ───────────────▶ the web viewer (MapLibre GL JS)
+ web component (web/) — MapLibre GL JS
```
Here is what each stage does:
-1. **Decode (ISO 8211).** S-57 cells use the ISO 8211 binary container format.
- The decoder reads the raw records and fields.
-2. **Build the S-57 model.** The features (depth areas, buoys, coastlines, and so
- on), their attributes, and their geometry become a queryable in-memory model.
-3. **Apply S-101 portrayal.** The S-101 Portrayal Catalogue decides how to draw
- each feature: which symbol, which color, which line style. This includes
- conditional symbology, where the right symbol depends on a feature's
- attributes.
-4. **Build primitives.** The portrayal output is a list of simple drawing
- primitives in latitude/longitude: filled polygons, stroked lines, symbols,
- patterns, text, and sector lights.
-5. **Project and clip.** Each primitive is projected to web-mercator tile
- coordinates and clipped to tile boundaries.
-6. **Encode to MVT.** The clipped geometry becomes Mapbox Vector Tile bytes.
-7. **Write PMTiles.** Identical tiles are stored once (deduplicated), and all
- tiles stream into a single PMTiles archive.
+1. **Decode and model (libtile57).** S-57 cells use the ISO 8211 binary
+ container format. The engine decodes the records, applies the sequential
+ update files (`.001`, `.002`, …), and builds the feature and geometry model.
+2. **Apply S-101 portrayal (libtile57).** The S-101 Portrayal Catalogue —
+ compiled into the engine — decides how to draw each feature: which symbol,
+ which color, which line style, including conditional symbology.
+3. **Tile and encode (libtile57).** Features are projected to web-mercator,
+ clipped, and encoded as **MLT** (MapLibre Tile, the default) or **MVT**
+ tiles, deduplicated and written into a **PMTiles** archive.
+4. **Style and assets (libtile57).** The engine also generates the matching
+ MapLibre style (per color scheme) and the client assets: the symbol sprite
+ atlas, color tables, line styles, and area patterns.
+5. **Serve (Go).** The server hosts the frontend and the tiles, runs background
+ bake jobs for chart imports, proxies NOAA cell downloads, persists display
+ settings, and ingests NMEA 0183 for own-ship and AIS.
+6. **Render (browser).** MapLibre GL JS draws the pre-baked tiles with the
+ engine's style. The browser does no portrayal of its own.
## Design decisions
A few choices shape the whole project:
-- **All tile generation runs in the backend.** The CLI or server does the baking.
- The browser only renders pre-baked tiles. There is no heavy in-browser pipeline
- to ship.
+- **One engine.** libtile57 is the *sole* tile, portrayal, style, and asset
+ engine. The Go side never draws a chart; it orchestrates the engine and
+ serves its output. This means CGO is required — `CGO_ENABLED=0` does not
+ build — and cross-compilation uses Zig as the C toolchain.
+- **All tile generation runs in the backend.** The CLI or server does the
+ baking. The browser only renders pre-baked tiles.
- **Colors are names, not RGB.** Tiles store S-101 color *tokens*. The browser
resolves Day, Dusk, or Night from `colortables.json`. Switching the lighting
mode is an instant restyle, with no re-baking.
- **Generate once, adjust live.** Mariner settings — depth shading, soundings,
contours, and danger highlighting — come from attributes baked into the tiles.
The viewer applies them live.
-- **One archive is the source of truth.** A baked region is a single `.pmtiles`
- file. Long downloads and bakes run as background jobs that the viewer watches
- through `/api/import/status` and `/api/import/events`.
-- **The binary is self-contained.** The web frontend, the S-101 catalogue and
- client assets, the basemap, and the NOAA catalog are embedded in the program,
- so `chartplotter serve` runs from a single file. Everything baked from a user
+- **The binary is self-contained.** The web frontend is embedded in the Go
+ binary and the S-101 catalogue is compiled into libtile57, so
+ `chartplotter serve` runs from a single file. Everything baked from a user
action is written to the cache directory, never into the embedded assets.
## Code layout
| Path | What lives there |
| --- | --- |
-| `pkg/geo` | Shared `LatLon`, `Point`, and `BoundingBox` types. |
-| `pkg/iso8211` | The ISO 8211 binary decoder. |
-| `pkg/s57` | The S-57 cell model and spatial/class queries. |
-| `pkg/s100` | The S-101 Portrayal Catalogue: feature catalogue, drawing instructions, symbols, and colors. |
-| `internal/engine/s101` | The S-101 rule engine: applies the portrayal catalogue to S-57 features. |
-| `internal/engine/portrayal` | Turns portrayal output into the primitive drawing list. |
-| `internal/engine/tile` | Web-mercator projection and clipping. |
-| `internal/engine/mvt` | The Mapbox Vector Tile encoder. |
-| `internal/engine/pmtiles` | The streaming, deduplicating PMTiles writer. |
-| `internal/engine/bake`, `internal/engine/baker` | The baker: cells in, tiles out, plus the high-level helpers the CLI and server use. |
+| `../tile57` | The native engine (separate repo, Zig): S-57 decode, S-101 portrayal, tiling, MLT/MVT encode, style + asset generation. Linked as `libtile57.a`. |
+| `pkg/iso8211` | A pure-Go ISO 8211 reader, kept for cell *metadata* (headers, coverage) — not for portrayal. |
+| `pkg/s57` | The Go S-57 cell model, slimmed to metadata and simulator needs (e.g. depth areas for the traffic simulator). |
+| `internal/engine/baker` | Cell metadata + parse helpers: base + update grouping, header/coverage extraction, and the compilation-scale → navigational-band mapping. It does not bake tiles. |
+| `internal/engine/server` | The HTTP server: chart library, background bake jobs, tile serving, settings, aux files, NMEA APIs. |
+| `internal/engine/tilesource` | The tile-source abstraction the server serves from: libtile57 live sets, PMTiles, MBTiles. |
+| `internal/engine/pmtiles` | A minimal PMTiles v3 reader/writer used by the serving path. |
| `internal/engine/catalog` | Distils the NOAA product catalog into `catalog.json`. |
-| `internal/engine/assets` | Generates client assets: color tables, line styles, and sprite atlases. |
-| `internal/engine/nmea` | NMEA 0183 ingestion for own-ship and AIS overlays. |
-| `internal/engine/server` | The HTTP server, baking API, and tile serving. |
-| `cmd/chartplotter` | The command-line interface and dev server. |
+| `internal/engine/nmea` | NMEA 0183 ingestion (own-ship, AIS) and the traffic simulator. |
+| `internal/engine/auxfiles` | ENC companion files (TXTDSC text notes, PICREP pictures) served via `/api/aux`. |
+| `cmd/chartplotter` | The command-line interface: `bake`, `serve`, `simulate`, `emit-assets`, … |
| `web` | The MapLibre frontend that renders pre-baked tiles. |
## The web viewer
The frontend is a `` web component built on
-[MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/). It reads the
-`.pmtiles` archive and the client assets the server hosts, draws the chart, and
-handles the Day/Dusk/Night restyle. It does no tile generation of its own.
+[MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/) (5.12+ is required
+to decode the default MLT tiles; the vendored copy is newer). It loads the
+engine-generated style and assets, reads the tiles the server hosts, draws the
+chart, and handles the Day/Dusk/Night restyle. It does no tile generation of
+its own.
## Learn more
diff --git a/docs/docs/cli.md b/docs/docs/cli.md
index 5529955..5585c61 100644
--- a/docs/docs/cli.md
+++ b/docs/docs/cli.md
@@ -11,8 +11,8 @@ flags for a command at any time.
## version
-Print the version and whether the S-101 Portrayal Catalogue is built into the
-binary.
+Print the chartplotter version and the version of the linked libtile57 engine
+(which carries the embedded S-101 catalogue).
```sh
chartplotter version
@@ -20,9 +20,10 @@ chartplotter version
## emit-assets
-Generate the S-101 client assets into a directory. These files tell the browser
-how to draw the chart: the color tables, the symbol sprites, the line styles, and
-the area patterns.
+Generate the S-101 client assets into a directory, using the same libtile57
+asset baker that renders the tiles. These files tell the browser how to draw
+the chart: the color tables, the symbol sprites, the line styles, and the area
+patterns.
```sh
chartplotter emit-assets DIR
@@ -31,30 +32,41 @@ chartplotter emit-assets DIR
| Argument / flag | Description |
| --- | --- |
| `DIR` | Output directory. The command writes the asset files here. |
-| `--s101 DIR` | Emit from an external S-101 PortrayalCatalog directory instead of the embedded catalogue. |
+| `--s101 DIR` | Emit from an external S-101 PortrayalCatalog directory instead of libtile57's embedded catalogue (for iterating on symbology rules). |
| `--css FILE` | Palette stylesheet under `Symbols/` (default `daySvgStyle.css`). |
## bake
-Generate a PMTiles archive from S-57 ENC data. Inputs can be `.000` base cells,
-directories (scanned for `*.000` and `*.zip`), and NOAA ENC `.zip` bundles. The
-command groups each cell with its update files (`.001`, `.002`, …) and applies
-them.
+Bake S-57 ENC data into chart tiles with the libtile57 engine. Inputs can be
+`.000` base cells, directories (scanned for `*.000` and `*.zip`), and NOAA ENC
+`.zip` bundles. The command groups each cell with its update files (`.001`,
+`.002`, …) and the engine applies them.
+
+By default the output is a **self-contained chart bundle** directory:
+`tiles/chart.pmtiles`, a per-scheme MapLibre style
+(`assets/style-{day,dusk,night}.json`), the client assets, and a
+`manifest.json`. A `-o` value ending in `.pmtiles` names the bundle directory
+by its stem (`-o charts.pmtiles` → `charts/`).
```sh
-chartplotter bake -o OUT.pmtiles IN [IN ...]
+chartplotter bake -o charts IN [IN ...]
```
+With `--bands`, the output is instead one gap-clipped PMTiles archive **per
+navigational band** (`-.pmtiles`), plus an optional manifest and a
+`-aux.zip` of companion files — the format the static demo and widget
+workflows use.
+
| Flag | Default | Description |
| --- | --- | --- |
-| `-o, --out FILE` | `charts.pmtiles` | Output archive. |
-| `--bands` | off | Write one gap-clipped archive per navigational band (`-.pmtiles`) so the client reproduces the best-available display. |
-| `--manifest FILE` | — | Also write a `charts-index.json` manifest for the app's `catalog=…` option. |
+| `-o, --out PATH` | `charts.pmtiles` | Output bundle directory (default mode) or archive stem (`--bands`). |
+| `--bands` | off | Write one gap-clipped archive per navigational band so the client reproduces the best-available display. |
+| `--format mlt\|mvt` | `mlt` | Tile encoding. MLT (MapLibre Tile) is the engine default and needs MapLibre GL JS 5.12+ to decode; use `mvt` for consumers without an MLT decoder. |
+| `--manifest FILE` | — | With `--bands`, also write a `charts-index.json` manifest for the app's `catalog=…` option. |
| `--base-url URL` | archive basename | URL or prefix for the archive in the manifest. |
-| `--overzoom` | off | Overzoom every band down to the world view, so a standalone large-scale set stays visible when zoomed out. |
+| `--overzoom` | off | With `--bands`, overzoom every band down to the world view, so a standalone large-scale set stays visible when zoomed out. |
| `--max-zoom N` | native | Cap the highest baked zoom (`0` = each cell's native band max), then let the client overzoom. |
-| `--s101 DIR` | embedded | Override the embedded catalogue with an external S-101 PortrayalCatalog (requires `--s101-fc`). |
-| `--s101-fc FILE` | — | S-101 `FeatureCatalogue.xml` path, used with `--s101`. |
+| `--tile57` | — | Accepted for backwards compatibility and ignored: libtile57 is the only engine, and the bundle output it selected is now the default. |
## catalog-json
@@ -74,7 +86,7 @@ chartplotter catalog-json IN.xml OUT.json
Serve the web frontend together with the server-side baking and tile-serving API.
The frontend is built into the binary, so the server needs no files on disk.
-Chart imports are parsed and baked into tiles in the backend; the browser only
+Chart imports are baked into tiles in the backend by libtile57; the browser only
renders pre-baked tiles.
```sh
@@ -86,10 +98,11 @@ chartplotter serve [flags]
| `--host` | `127.0.0.1` | Address to bind. |
| `--port` | `8080` | Port to bind. |
| `--assets DIR` | embedded | Serve static assets from this directory instead of the embedded bundle. Use this when you develop the frontend. |
-| `--cache DIR` | XDG cache | Directory for regenerable baked `.pmtiles` tile sets. Defaults to `~/.cache/chartplotter`. |
+| `--cache DIR` | XDG cache | Directory for regenerable baked tile sets. Defaults to `~/.cache/chartplotter`. |
| `--data DIR` | XDG data | Directory for source ENC (district zips, raw cells). This is kept safe and never auto-deleted. |
| `--clear-cache` | off | Delete the cached baked archives on startup for a clean slate (source ENC is kept). |
-| `--s101 DIR` | embedded | Override the embedded catalogue with an external S-101 PortrayalCatalog (requires `--s101-fc`). |
+| `--tile57 PATH` | — | Also register a **live** libtile57 tile set from this ENC root, `.zip`, or `.000`: tiles are generated on demand from the cells, with no prebake. The set is registered as `tile57` (TileJSON at `/tiles/tile57.json`). |
+| `--s101 DIR` | embedded | Generate the served S-101 client assets from an external PortrayalCatalog directory instead of libtile57's embedded catalogue (for iterating on symbology rules; requires `--s101-fc`). |
| `--s101-fc FILE` | — | S-101 `FeatureCatalogue.xml` path, used with `--s101`. |
When you bind to a loopback address (`127.0.0.1`, `localhost`, or `::1`), the
@@ -130,10 +143,14 @@ chartplotter simulate [flags]
| --- | --- | --- |
| `--host` | `127.0.0.1` | Bind host. |
| `--port` | `10110` | Bind port (IANA NMEA-0183-over-IP). |
-| `--center LAT,LON` | `38.978,-76.478` | Own-ship start position. |
+| `--scenario NAME` | — | Named Annapolis preset that sets the start, route, and traffic. Use `--scenario list` to print the presets. |
+| `--center LAT,LON` | `38.978,-76.478` | Own-ship start position (ignored when `--scenario` is set). |
| `--course` | `45` | Own-ship course, degrees true. |
| `--speed` | `6` | Own-ship speed, knots. |
| `--targets` | `6` | Number of AIS targets. |
| `--collision` | on | Put one target on a collision course (`--no-collision` to disable). |
+| `--sailing` | off | Own-ship tacks (COG weaves) with varying leeway, so heading ≠ COG. |
+| `--drop-gps N` | `0` | Stop own-ship position fixes after N seconds, to test stale or lost GPS (`0` = never). |
| `--seed` | `1` | RNG seed for reproducible scenarios. |
-| `--cell FILE` | — | S-57 cell to keep traffic in navigable water. |
+| `--cell FILE` | — | S-57 cell (`.000` or exchange `.zip`) to keep traffic in navigable water. |
+| `--min-depth M` | `2` | Minimum charted depth (DRVAL1, meters) that counts as navigable water when `--cell` is set. |
diff --git a/docs/docs/installation.md b/docs/docs/installation.md
index 27787d4..8328559 100644
--- a/docs/docs/installation.md
+++ b/docs/docs/installation.md
@@ -6,73 +6,153 @@ sidebar_position: 2
# Installation
-You can install chartplotter from a pre-built binary, with `go install`, or from
-source.
+The quickest path is to **download a release**: every tagged release publishes a
+self-contained `chartplotter` for **linux and windows** (amd64 + arm64) on the
+[releases page](https://github.com/beetlebugorg/chartplotter/releases). Unpack
+the archive for your platform and run it — the web frontend and the S-101
+catalogue are compiled in, so you supply only the ENC cells. **macOS** is not
+shipped as a prebuilt binary (the engine links Apple frameworks Zig can't
+cross-compile); Mac users run the [Docker image](#run-with-docker-recommended)
+or [build from source](#build-from-source). To build it yourself on any platform,
+follow [Build from source](#build-from-source) below. `go install …@latest` does
+**not** work — the build statically links a native library and uses a local
+`replace` directive.
+
+:::info About the embedded IHO catalogues
+
+The build embeds the **IHO S-101 Portrayal and Feature Catalogues** into the
+chart engine. The IHO publishes those catalogues in its own GitHub repositories
+with **no declared license**. The build fetches them via git submodules directly
+from the IHO's own repositories, and the resulting binaries — both what you build
+locally and what the project publishes on the releases page — embed that IHO
+material. The project distributes those binaries as an accepted position; see
+[THIRD-PARTY-NOTICES.md](https://github.com/beetlebugorg/chartplotter/blob/main/THIRD-PARTY-NOTICES.md).
+
+:::
+
+## Run with Docker (recommended)
+
+The simplest way to run chartplotter — and the primary path for the
+**server-hub-on-a-boat** deployment (a Raspberry Pi, laptop, or mini PC that
+holds all chart state while every screen just points a browser at it) — is the
+published container image:
-## Pre-built binaries
-
-1. Go to the [Releases](https://github.com/beetlebugorg/chartplotter/releases)
- page.
-2. Download the archive for your operating system and CPU. Builds cover Linux,
- macOS, and Windows on both amd64 (Intel) and arm64 (Apple Silicon, ARM).
-3. Extract the archive.
-4. Move the `chartplotter` binary somewhere on your `PATH`.
+```sh
+docker run -p 8080:8080 -v chartplotter-data:/data \
+ ghcr.io/beetlebugorg/chartplotter
+# open http://localhost:8080
+```
-Check that it works:
+Or with Docker Compose, using the [`compose.yaml`](https://github.com/beetlebugorg/chartplotter/blob/main/compose.yaml)
+in the repo:
```sh
-chartplotter version
+docker compose up -d
```
-## With go install
+The image is **multi-arch** (`linux/amd64` + `linux/arm64`), so the same command
+runs on a Raspberry Pi (arm64) and on an amd64 box. It is built `FROM scratch`
+around a **fully-static musl binary**, so it is tiny — essentially just the
+~26 MB binary plus a CA-certificate bundle. The named `/data` volume holds the
+downloaded ENC source, the baked tiles, and your settings, and survives image
+upgrades (`docker compose pull && docker compose up -d`).
-If you have Go 1.26 or newer, install the latest release with one command:
+The container binds `0.0.0.0:8080` inside, so map it with `-p 8080:8080` (or any
+host port). It writes source ENC to `/data` and regenerable baked tiles to
+`/data/cache`.
-```sh
-go install github.com/beetlebugorg/chartplotter/cmd/chartplotter@latest
-```
+:::tip macOS and Windows
+Run the same image via **Docker Desktop** — no native Mac or Windows binary is
+needed. The native binaries below remain available as a secondary option for
+bare-metal installs.
+:::
+
+## Requirements
+
+- **Go 1.26 or newer.**
+- **Zig 0.16** — builds the native `libtile57` chart engine.
+- **git** — clones the repos and the engine's IHO catalogue submodules.
-Go places the binary in `$(go env GOBIN)` (or `$(go env GOPATH)/bin`). Add that
-directory to your `PATH` if it is not there already.
+## Build from source
-## From source
+chartplotter is two repos that must sit **side by side**:
-Clone the repository and build it:
+- `chartplotter` (Go) — the app: server, CLI, web frontend.
+- `tile57` (Zig) — the chart engine, built as the `libtile57` static library.
+ It has nested submodules of its own (the IHO catalogues).
+
+The app's `go.mod` points at `../tile57/bindings/go`, and its Makefile builds
+`../tile57/zig-out/lib/libtile57.a` on demand — so the engine checkout (or a
+symlink to it) must be a **sibling directory named `tile57`**.
```sh
+# 1. The engine, with its IHO catalogue submodules.
+git clone https://github.com/beetlebugorg/tile57.git
+cd tile57
+git submodule update --init --recursive
+cd ..
+
+# 2. The app, as a sibling.
git clone https://github.com/beetlebugorg/chartplotter.git
cd chartplotter
+
+# 3. Build: zig-builds libtile57, then a CGO go build.
make build
```
-The build writes the binary to `bin/chartplotter`. Run it:
+The build writes the binary to `bin/chartplotter`. Check that it works:
```sh
bin/chartplotter version
```
-## Requirements
+It prints the chartplotter version and the libtile57 engine version. The binary
+is self-contained — the web frontend and the S-101 catalogue are compiled in —
+so you can copy it to your `PATH` and run it anywhere on the same platform.
+
+If you keep the engine checkout somewhere else, symlink it into place instead:
+
+```sh
+ln -s /path/to/your/tile57-checkout ../tile57
+```
-- **Go 1.26 or newer** to build from source or use `go install`.
-- Nothing extra to run a pre-built binary. The S-101 catalogue is built into the
- program.
+### Make targets
+
+The [`Makefile`](https://github.com/beetlebugorg/chartplotter/blob/main/Makefile)
+is the ground truth for the build — `make build` zig-builds `libtile57` on demand
+and links it into the CGO binary, so there is no separate engine-build step. The
+targets you'll use most:
+
+| Target | What it does |
+| --- | --- |
+| `make build` | Build `bin/chartplotter` (zig-builds `libtile57`, then a CGO `go build`). |
+| `make test` | `go test ./...`. |
+| `make vet` | `go vet ./...`. |
+| `make fmt` | `gofmt -w .`. |
+| `make serve` | Build, then serve the web frontend on `:8080` (`HOST`/`PORT`/`ASSETS` overridable). |
+| `make xbuild` | Cross-compile release binaries with `zig cc` (linux + windows, amd64/arm64). |
+| `make musl` | Fully-static musl binaries (linux amd64 + arm64) for the `FROM scratch` Docker image. |
+
+Run `make fmt vet test` before you commit. `make xbuild` deliberately skips
+macOS — Go's `crypto/x509` links Apple frameworks Zig can't cross-compile, so a
+Mac binary must be built natively on a Mac. See
+[`CLAUDE.md`](https://github.com/beetlebugorg/chartplotter/blob/main/CLAUDE.md)
+for the full build contract.
## Memory and disk
-Baking tiles is the heavy step. It is memory-intensive: a single large cell holds
-all of its geometry in memory while it builds tiles, so a bake can use **several
-gigabytes of RAM**. Memory scales with the size and number of cells you bake at
-once, and baking many regions in parallel multiplies it. If you run on a small
-machine, such as a Raspberry Pi, bake one region at a time.
+Baking tiles is the heavy step. Memory scales with the size and number of cells
+you bake at once, and baking many regions in parallel multiplies it. If you run
+on a small machine, such as a Raspberry Pi, bake one region at a time.
Once the tiles are built, the cost drops sharply. Serving charts streams
pre-baked tiles from disk, so a running `chartplotter serve` uses only **modest
RAM** — well within a small machine's budget. Plan your memory for the bake, not
for everyday use.
-Baked tiles live in your cache directory (`~/.cache/chartplotter`). A region is a
-single `.pmtiles` archive; size depends on the area and detail, from a few
-megabytes for one harbor to gigabytes for a whole district.
+Baked tiles live in your cache directory (`~/.cache/chartplotter`). Size depends
+on the area and detail, from a few megabytes for one harbor to gigabytes for a
+whole district.
## Next steps
diff --git a/docs/docs/intro.mdx b/docs/docs/intro.mdx
index 4a7fe21..933d2dc 100644
--- a/docs/docs/intro.mdx
+++ b/docs/docs/intro.mdx
@@ -18,7 +18,8 @@ real-world navigation.**
:::
-**chartplotter** is a marine chart engine written in Go. It turns official NOAA
+**chartplotter** is a marine chart plotter: a Go application built around
+**tile57**, a native chart engine written in Zig. It turns official NOAA
nautical charts into fast, offline map tiles that you can view in a web browser.
The chart below is **live** — official NOAA charts of Annapolis, Maryland, running
@@ -30,16 +31,18 @@ static, embeddable, no server.
It reads **S-57** electronic navigational chart (ENC) cells, draws them with the
**S-101 Portrayal Catalogue** — the modern IHO standard for how charts look — and
-writes the result to a single **PMTiles** archive of vector tiles. A small web
+writes the result to **PMTiles** archives of vector tiles. A small web
component built on [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/)
draws the chart.
## Goal
-Implement the IHO chart standards — **S-57** (ENC data), **S-101** portrayal (the
-successor to S-52), and the wider **S-100 / S-102** family — in **pure Go**, with
-**minimal dependencies and no CGO**, so the whole thing cross-compiles to a single
-static binary for any platform with just `GOOS`/`GOARCH`.
+Implement the IHO chart standards — **S-57** (ENC data) and **S-101** portrayal
+(the successor to S-52), with the wider **S-100 / S-102** family planned — as a
+fast, low-memory native engine (**libtile57**, from the
+[tile57](https://github.com/beetlebugorg/tile57) repo) wrapped by a small Go
+server, so one locally-built binary bakes and serves real charts on anything
+from a laptop to a Raspberry Pi on a boat.
## What it does
@@ -53,8 +56,10 @@ static binary for any platform with just `GOOS`/`GOARCH`.
- **Applies chart settings in the browser.** Depth shading, soundings, depth
contours, and danger highlights all come from data baked into the tiles. You
generate the tiles once and adjust these settings live.
-- **Ships as one binary.** The S-101 catalogue is built into the program. You do
- not install extra data files.
+- **Ships as one self-contained binary.** The S-101 catalogue and the web
+ frontend are built into the program, so it runs from a single file. Download a
+ per-platform build from the releases page, or build it yourself; see
+ [Installation](./installation.md).
- **Shows live position and AIS (early).** Point a NMEA 0183 feed at the server
over TCP and it draws your own ship and basic AIS targets on the chart; a
`simulate` command generates traffic for testing.
@@ -96,5 +101,7 @@ Use chartplotter if you want to:
| **S-57** | The international file format for electronic navigational charts. NOAA distributes U.S. charts as S-57 cells. |
| **ENC cell** | One S-57 chart file, named like `US4MD81M.000`. |
| **S-101** | The IHO standard that defines how to draw an ENC: colors, symbols, and line styles. Its Portrayal Catalogue holds those rules. |
-| **MVT** | Mapbox Vector Tile. A compact binary format for map data. |
+| **libtile57** | The native chart engine (from the [tile57](https://github.com/beetlebugorg/tile57) repo, written in Zig) that does all decoding, portrayal, and tiling. It is statically linked into the Go binary. |
+| **MLT** | MapLibre Tile. The compact binary tile format chartplotter bakes by default; MapLibre GL JS 5.12+ decodes it. |
+| **MVT** | Mapbox Vector Tile. The older vector-tile format, available with `bake --format mvt`. |
| **PMTiles** | A single-file archive that holds many vector tiles. You can serve it with simple byte-range requests. |
diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index 6f42480..4a029ca 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -4,7 +4,7 @@ import {themes as prismThemes} from 'prism-react-renderer';
/** @type {import('@docusaurus/types').Config} */
const config = {
title: 'chartplotter',
- tagline: '⚓ An S-52 marine chart plotter, in Go.',
+ tagline: '⚓ An S-101 marine chart plotter.',
url: 'https://beetlebugorg.github.io',
baseUrl: '/chartplotter/',
diff --git a/docs/static/img/ui/chart-day.png b/docs/static/img/ui/chart-day.png
index beae37c..8e30204 100644
Binary files a/docs/static/img/ui/chart-day.png and b/docs/static/img/ui/chart-day.png differ
diff --git a/docs/static/img/ui/chart-library.png b/docs/static/img/ui/chart-library.png
index abc9e4b..79ff21a 100644
Binary files a/docs/static/img/ui/chart-library.png and b/docs/static/img/ui/chart-library.png differ
diff --git a/docs/static/img/ui/palette-dusk.png b/docs/static/img/ui/palette-dusk.png
index 0d39e5e..e4d56e8 100644
Binary files a/docs/static/img/ui/palette-dusk.png and b/docs/static/img/ui/palette-dusk.png differ
diff --git a/docs/static/img/ui/palette-night.png b/docs/static/img/ui/palette-night.png
index 53a0a73..a534b48 100644
Binary files a/docs/static/img/ui/palette-night.png and b/docs/static/img/ui/palette-night.png differ
diff --git a/docs/static/img/ui/pick-report.png b/docs/static/img/ui/pick-report.png
index 72a9da5..d84a3fc 100644
Binary files a/docs/static/img/ui/pick-report.png and b/docs/static/img/ui/pick-report.png differ
diff --git a/docs/static/img/ui/settings.png b/docs/static/img/ui/settings.png
index 0e0c2da..fcfc8d6 100644
Binary files a/docs/static/img/ui/settings.png and b/docs/static/img/ui/settings.png differ
diff --git a/go.mod b/go.mod
index 5e90c99..eab5947 100644
--- a/go.mod
+++ b/go.mod
@@ -7,18 +7,16 @@ toolchain go1.26.4
require (
github.com/BertoldVdb/go-ais v0.4.0
github.com/alecthomas/kong v1.15.0
- github.com/dhconnelly/rtreego v1.2.0
- github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
- github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
- github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f
github.com/stretchr/testify v1.11.1
- github.com/yuin/gopher-lua v1.1.2
golang.org/x/image v0.43.0
modernc.org/sqlite v1.53.0
)
+require golang.org/x/sync v0.21.0 // indirect
+
require (
github.com/adrianmo/go-nmea v1.3.0 // indirect
+ github.com/beetlebugorg/tile57/bindings/go v0.0.0
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -26,11 +24,17 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
- golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+
+// The engine binding lives in the ./tile57 git submodule (github.com/beetlebugorg/tile57;
+// clone with --recurse-submodules, or run `git submodule update --init --recursive`).
+// The CGO binding needs the engine source tree relative to itself, so this is a local
+// replace, not a module-proxy dependency. To build against a DIFFERENT engine checkout,
+// override with a gitignored go.work instead of editing this line — see README.md
+// "Developing the engine".
+replace github.com/beetlebugorg/tile57/bindings/go => ./tile57/bindings/go
diff --git a/go.sum b/go.sum
index a22cb9a..99e9234 100644
--- a/go.sum
+++ b/go.sum
@@ -11,8 +11,6 @@ github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dhconnelly/rtreego v1.2.0 h1:LWhGPhw+iGuhg8hmHA/H8WV60qKtzecOjii0FMevGlk=
-github.com/dhconnelly/rtreego v1.2.0/go.mod h1:SDozu0Fjy17XH1svEXJgdYq8Tah6Zjfa/4Q33Z80+KM=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -31,31 +29,19 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
-github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
-github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
-github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
-github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f h1:uLR2GaV0kWYZ3Ns3l3sjtiN+mOWAQadvrL8HXcyKjl0=
-github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f/go.mod h1:LZwgIPG9X6nH6j5Ef+xMFspl6Hru4b5EJxzMfeqHYJY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
-github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
-golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
-golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
-golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
diff --git a/internal/engine/assets/emit_s101.go b/internal/engine/assets/emit_s101.go
deleted file mode 100644
index 76940d2..0000000
--- a/internal/engine/assets/emit_s101.go
+++ /dev/null
@@ -1,108 +0,0 @@
-// Package assets generates the client-side asset files from the S-101 Portrayal
-// Catalogue: colortables.json (token -> hex per scheme), linestyles.json (dash
-// data), and the sprite/pattern PNG atlases. Colour is the only place RGB
-// appears; everything in the tiles stays a token.
-package assets
-
-import (
- "encoding/json"
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
-
- "github.com/beetlebugorg/chartplotter/pkg/s100/catalog"
-)
-
-// EmitS101 writes the client asset files from the S-101 Portrayal Catalogue:
-// colortables.json from colorProfile.xml, linestyles.json from the LineStyles,
-// the sprite atlas from the S-101 SVG symbols, and the pattern atlas from the
-// AreaFills. portrayalCatalogDir is a PortrayalCatalog directory; cssName selects
-// the palette stylesheet (e.g. "daySvgStyle.css", under Symbols/).
-func EmitS101(portrayalCatalogDir, cssName, dir string) ([]string, error) {
- return EmitS101FS(os.DirFS(portrayalCatalogDir), cssName, dir)
-}
-
-// EmitS101FS is EmitS101 over an fs.FS rooted at a PortrayalCatalog (e.g. an
-// embed.FS sub-tree).
-func EmitS101FS(catalogFS fs.FS, cssName, dir string) ([]string, error) {
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return nil, err
- }
- var written []string
- write := func(name string, data []byte) error {
- p := filepath.Join(dir, name)
- if err := os.WriteFile(p, data, 0o644); err != nil {
- return err
- }
- written = append(written, p)
- return nil
- }
-
- cp, err := catalog.LoadFS(catalogFS)
- if err != nil {
- return nil, fmt.Errorf("catalogue: %w", err)
- }
- ct, err := colorTablesJSONFromProfile(cp.Colors)
- if err != nil {
- return nil, fmt.Errorf("colortables: %w", err)
- }
- if err := write("colortables.json", ct); err != nil {
- return nil, err
- }
-
- ls, err := LinestylesJSONS101(cp.LineStyles)
- if err != nil {
- return nil, fmt.Errorf("linestyles: %w", err)
- }
- if err := write("linestyles.json", ls); err != nil {
- return nil, err
- }
-
- symbolsFS, err := fs.Sub(catalogFS, "Symbols")
- if err != nil {
- return nil, err
- }
- spriteJSON, spritePNG, err := SpriteAtlasS101FS(symbolsFS, cssName)
- if err != nil {
- return nil, fmt.Errorf("sprites: %w", err)
- }
- if err := write("sprite.json", spriteJSON); err != nil {
- return nil, err
- }
- if err := write("sprite.png", spritePNG); err != nil {
- return nil, err
- }
-
- patJSON, patPNG, err := PatternAtlasS101FS(catalogFS, cssName)
- if err != nil {
- return nil, fmt.Errorf("patterns: %w", err)
- }
- if err := write("patterns.json", patJSON); err != nil {
- return nil, err
- }
- if err := write("patterns.png", patPNG); err != nil {
- return nil, err
- }
- return written, nil
-}
-
-// colorTablesJSONFromProfile renders colortables.json ({scheme:{token:"#rrggbb"}})
-// from the S-101 colour profile — the same shape ColorTablesJSON emits from the
-// S-52 library (verified byte-identical by cmd/s101-color-diff).
-func colorTablesJSONFromProfile(cp *catalog.ColorProfile) ([]byte, error) {
- out := map[string]map[string]string{
- "day": paletteHex(cp.Day),
- "dusk": paletteHex(cp.Dusk),
- "night": paletteHex(cp.Night),
- }
- return json.MarshalIndent(out, "", " ")
-}
-
-func paletteHex(p catalog.Palette) map[string]string {
- m := make(map[string]string, len(p))
- for tok, c := range p {
- m[tok] = fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B)
- }
- return m
-}
diff --git a/internal/engine/assets/emit_s101_test.go b/internal/engine/assets/emit_s101_test.go
deleted file mode 100644
index ebf974c..0000000
--- a/internal/engine/assets/emit_s101_test.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package assets
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestEmitS101(t *testing.T) {
- pc := os.Getenv("S101_CATALOG")
- if pc == "" {
- pc = "/home/jcollins/Projects/s101-portrayal-catalogue/PortrayalCatalog"
- }
- if _, err := os.Stat(filepath.Join(pc, "Symbols", "BCNCAR01.svg")); err != nil {
- t.Skipf("S-101 catalogue not present; set S101_CATALOG")
- }
-
- dir := t.TempDir()
- files, err := EmitS101(pc, "daySvgStyle.css", dir)
- if err != nil {
- t.Fatal(err)
- }
- if len(files) != 6 {
- t.Errorf("wrote %d files, want 6", len(files))
- }
- for _, name := range []string{"colortables.json", "linestyles.json", "sprite.json", "sprite.png", "patterns.json", "patterns.png"} {
- if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
- t.Errorf("missing %s", name)
- }
- }
-
- // ACHARE51 (anchorage boundary: dashed magenta + placed symbols) must come
- // through with a real period and its embedded symbols.
- lsData, err := os.ReadFile(filepath.Join(dir, "linestyles.json"))
- if err != nil {
- t.Fatal(err)
- }
- var lsDoc map[string]struct {
- PeriodPx float64 `json:"period_px"`
- ColorToken string `json:"color_token"`
- Symbols []struct {
- N string `json:"n"`
- } `json:"symbols"`
- }
- if err := json.Unmarshal(lsData, &lsDoc); err != nil {
- t.Fatalf("linestyles.json: %v", err)
- }
- if ach, ok := lsDoc["ACHARE51"]; !ok {
- t.Error("ACHARE51 missing from linestyles.json")
- } else if ach.PeriodPx <= 0 || ach.ColorToken != "CHMGD" || len(ach.Symbols) == 0 {
- t.Errorf("ACHARE51 looks wrong: %+v", ach)
- }
-
- // The S-101 colour tables carry a populated Day/Dusk/Night palette with
- // well-formed #rrggbb hex (colorTablesJSONFromProfile over the colour
- // profile). DEPDW (deep-water fill) is a representative always-present token.
- s101CT := loadColorTables(t, filepath.Join(dir, "colortables.json"))
- for _, scheme := range []string{"day", "dusk", "night"} {
- pal := s101CT[scheme]
- if len(pal) == 0 {
- t.Errorf("%s: empty palette", scheme)
- continue
- }
- for tok, hex := range pal {
- if len(hex) != 7 || hex[0] != '#' {
- t.Errorf("%s/%s: malformed hex %q", scheme, tok, hex)
- }
- }
- if _, ok := pal["DEPDW"]; !ok {
- t.Errorf("%s: DEPDW token missing", scheme)
- }
- }
-}
-
-func loadColorTables(t *testing.T, path string) map[string]map[string]string {
- t.Helper()
- data, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- var ct map[string]map[string]string
- if err := json.Unmarshal(data, &ct); err != nil {
- t.Fatal(err)
- }
- return ct
-}
diff --git a/internal/engine/assets/linestyles.go b/internal/engine/assets/linestyles.go
deleted file mode 100644
index 264e8c9..0000000
--- a/internal/engine/assets/linestyles.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package assets
-
-import (
- "fmt"
- "math"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/portrayal"
-)
-
-// featureScale is screen px per 0.01-mm PresLib symbol unit — the identical
-// scale the portrayal backend uses, so the dash period lands at the same screen
-// dimension as the SC symbol marks it interleaves with.
-const featureScale = float64(portrayal.DefaultPxPerSymbolUnit)
-
-// onRun is one drawn run within a single pattern period, in screen px relative
-// to the bbox left edge.
-type onRun struct{ lo, hi float64 }
-
-// lsSym is one embedded SC symbol within a single pattern period.
-type lsSym struct {
- offset float64 // along-line offset px from bbox left edge (arc distance 0)
- name string
- rot float64 // sub-rotation in degrees (PresLib tenths ×0.1)
-}
-
-// lsPattern is the analysed dash data for one linestyle.
-type lsPattern struct {
- periodPx float64
- runs []onRun
- symbols []lsSym
- colorToken string
- widthPx float64
-}
-
-// dashArray converts sorted on-runs over [0, period] into a flat portrayal dash
-// array: [on,off,on,off,…], starting with an "on" entry (a leading 0 is
-// inserted when the pattern opens with a gap) and padded to even length so the
-// pattern repeats cleanly.
-func dashArray(p lsPattern) []float64 {
- var out []float64
- pos := 0.0 // end of the last consumed run
-
- flush := func(lo, hi float64) {
- if lo > pos+1e-6 {
- if len(out) == 0 {
- out = append(out, 0) // leading gap → 0 "on"
- }
- out = append(out, lo-pos) // off
- }
- out = append(out, hi-lo) // on
- pos = hi
- }
-
- havePrev := false
- var prevLo, prevHi float64
- for _, run := range p.runs {
- if !havePrev {
- havePrev = true
- prevLo, prevHi = run.lo, run.hi
- continue
- }
- if run.lo <= prevHi+1e-6 {
- prevHi = math.Max(prevHi, run.hi) // overlap/adjacent → merge
- } else {
- flush(prevLo, prevHi)
- prevLo, prevHi = run.lo, run.hi
- }
- }
- if havePrev {
- flush(prevLo, prevHi)
- }
-
- // Trailing gap to the period end.
- if p.periodPx-pos > 1e-6 {
- if len(out) == 0 {
- out = append(out, 0) // pure-gap pattern
- }
- out = append(out, p.periodPx-pos) // off
- }
- // Even length so the pattern tiles cleanly (a trailing "on" needs a 0 off).
- if len(out)%2 == 1 {
- out = append(out, 0)
- }
- if len(out) == 0 {
- out = append(out, 0, p.periodPx)
- }
- return out
-}
-
-// f3 formats a float with 3 decimals (%.3f).
-func f3(v float64) string { return fmt.Sprintf("%.3f", v) }
-
-func clampf(v, lo, hi float64) float64 {
- if v < lo {
- return lo
- }
- if v > hi {
- return hi
- }
- return v
-}
diff --git a/internal/engine/assets/linestyles_s101.go b/internal/engine/assets/linestyles_s101.go
deleted file mode 100644
index 481781e..0000000
--- a/internal/engine/assets/linestyles_s101.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package assets
-
-import (
- "bytes"
- "fmt"
- "sort"
-
- "github.com/beetlebugorg/chartplotter/pkg/s100/catalog"
-)
-
-// linestylePxPerMM converts S-101 millimetre dimensions to the on-screen feature
-// scale used by linestyles.json (featureScale is px per 0.01-mm unit; ×100 =
-// px per mm).
-const linestylePxPerMM = featureScale * 100
-
-// LinestylesJSONS101 renders linestyles.json from the S-101 LineStyles, matching
-// the schema LinestylesJSON emits from the S-52 library: per id, period_px, a
-// flat [on,off,…] dash array, the pen colour token + width, and the symbols
-// placed along the period. A compositeLineStyle (double line) is emitted as its
-// first component (the client schema is single-component); ids are sorted.
-func LinestylesJSONS101(lines map[string]*catalog.LineStyle) ([]byte, error) {
- ids := make([]string, 0, len(lines))
- for id := range lines {
- ids = append(ids, id)
- }
- sort.Strings(ids)
-
- var buf bytes.Buffer
- buf.WriteString("{\n")
- first := true
- for _, id := range ids {
- ls := lines[id]
- if len(ls.Components) > 0 {
- ls = &ls.Components[0] // composite: emit the primary component
- }
- pat, ok := s101Pattern(ls)
- if !ok {
- continue
- }
- dash := dashArray(pat)
-
- if !first {
- buf.WriteString(",\n")
- }
- first = false
- fmt.Fprintf(&buf, " %q: { \"period_px\": %s, \"dash\": [", id, f3(pat.periodPx))
- for i, v := range dash {
- if i > 0 {
- buf.WriteString(", ")
- }
- buf.WriteString(f3(v))
- }
- fmt.Fprintf(&buf, "], \"color_token\": %q, \"width_px\": %s, \"symbols\": [", pat.colorToken, f3(pat.widthPx))
- for i, sym := range pat.symbols {
- if i > 0 {
- buf.WriteString(", ")
- }
- fmt.Fprintf(&buf, "{ \"o\": %s, \"n\": %q, \"r\": %s }", f3(sym.offset), sym.name, f3(sym.rot))
- }
- buf.WriteString("] }")
- }
- buf.WriteString("\n}\n")
- return buf.Bytes(), nil
-}
-
-// s101Pattern converts one S-101 line style (single component) into the analysed
-// dash pattern. No means a solid pen line (one run over the whole
-// period); symbols carry no rotation in the S-101 schema.
-func s101Pattern(ls *catalog.LineStyle) (lsPattern, bool) {
- periodPx := ls.IntervalLength * linestylePxPerMM
- if periodPx < 0.5 {
- // No interval (e.g. a pure-symbol style with no length): nothing to tile.
- return lsPattern{}, false
- }
-
- var runs []onRun
- if len(ls.Dashes) == 0 {
- runs = []onRun{{lo: 0, hi: periodPx}} // solid pen
- } else {
- for _, d := range ls.Dashes {
- lo := clampf(d.Start*linestylePxPerMM, 0, periodPx)
- hi := clampf((d.Start+d.Length)*linestylePxPerMM, 0, periodPx)
- if hi-lo > 1e-6 {
- runs = append(runs, onRun{lo: lo, hi: hi})
- }
- }
- sort.SliceStable(runs, func(i, j int) bool { return runs[i].lo < runs[j].lo })
- }
-
- var symbols []lsSym
- for _, s := range ls.Symbols {
- symbols = append(symbols, lsSym{offset: s.Position * linestylePxPerMM, name: s.Reference})
- }
-
- return lsPattern{
- periodPx: periodPx,
- runs: runs,
- symbols: symbols,
- colorToken: ls.PenColor,
- widthPx: ls.PenWidth * linestylePxPerMM,
- }, true
-}
diff --git a/internal/engine/assets/patterns_s101.go b/internal/engine/assets/patterns_s101.go
deleted file mode 100644
index 08a63b3..0000000
--- a/internal/engine/assets/patterns_s101.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package assets
-
-import (
- "fmt"
- "image"
- "io/fs"
- "math"
-
- "github.com/beetlebugorg/chartplotter/pkg/s100/catalog"
- "github.com/beetlebugorg/chartplotter/pkg/s100/symbols"
-)
-
-// PatternAtlasS101FS builds the area-fill pattern atlas (patterns.{png,json})
-// from the S-101 AreaFills: each fill rasterizes its referenced symbol and tiles
-// it on the v1/v2 lattice into a seamless rectangular tile (one GL fill-pattern
-// per fill name).
-//
-// v1 is always horizontal in the S-101 catalogue, so a STRAIGHT lattice (v2.x==0)
-// is a v1.x × v2.y rectangle with the symbol stamped at the cell centre plus its
-// 8 neighbours (edge-crossing art wraps cleanly). A STAGGERED lattice (v2.x != 0)
-// is rendered as a half-drop brick: a TWO-row tile (height 2·v2.y) whose second
-// row is offset half a cell horizontally, so the pattern closes seamlessly — the
-// same construction the S-52 STG atlas uses (e.g. DQUALA11 → 248×414, not the
-// single-row 248×207 that repeated at half the period and clipped). The exact
-// v2.x offset is approximated by half-width — the only stagger a 2-row rectangle
-// can close — so it is a textured fill, not a pixel-exact stagger.
-func PatternAtlasS101FS(catalogFS fs.FS, cssName string) (jsonBytes, pngBytes []byte, err error) {
- cat, err := catalog.LoadFS(catalogFS)
- if err != nil {
- return nil, nil, fmt.Errorf("area fills: %w", err)
- }
- fills := cat.AreaFills
- symbolsFS, err := fs.Sub(catalogFS, "Symbols")
- if err != nil {
- return nil, nil, err
- }
- cssData, err := fs.ReadFile(symbolsFS, cssName)
- if err != nil {
- return nil, nil, fmt.Errorf("read css: %w", err)
- }
- css := symbols.LoadCSS(cssData)
-
- var rasters []raster
- skipped := 0
- for id, af := range fills {
- if af.SymbolRef == "" {
- skipped++
- continue
- }
- svg, err := fs.ReadFile(symbolsFS, af.SymbolRef+".svg")
- if err != nil {
- skipped++
- continue
- }
- sym, err := symbols.Render(svg, css, s101PxPerMM)
- if err != nil {
- skipped++
- continue
- }
- tile, ok := seamlessTile(sym, af.V1, af.V2)
- if !ok {
- skipped++
- continue
- }
- w := uint32(tile.Rect.Dx())
- h := uint32(tile.Rect.Dy())
- rasters = append(rasters, raster{name: id, w: w, h: h, rgba: tile.Pix})
- }
-
- a := packInto(rasters, skipped, s101AtlasWidth)
- pngBytes, err = a.encodePNG()
- if err != nil {
- return nil, nil, err
- }
- return a.toJSON(), pngBytes, nil
-}
-
-// seamlessTile stamps the rendered symbol onto the lattice cell (in px),
-// wrapping at the edges. A staggered v2 (v2.x != 0) doubles the tile height and
-// half-drops the second row. ok is false for a degenerate/oversized cell.
-func seamlessTile(sym *symbols.Rendered, v1, v2 catalog.Vec) (*image.NRGBA, bool) {
- w := int(math.Round(v1.X * s101PxPerMM))
- rowH := int(math.Round(v2.Y * s101PxPerMM)) // one row's vertical pitch
- if w < 1 || rowH < 1 {
- return nil, false
- }
- rows := 1
- if math.Abs(v2.X) > 1e-6 { // staggered lattice → 2-row half-drop brick
- rows = 2
- }
- h := rowH * rows
- if w > maxCellSide || h > maxCellSide {
- return nil, false
- }
- tile := image.NewNRGBA(image.Rect(0, 0, w, h))
- // Row centres within the tile; the staggered second row sits half a cell to
- // the right (x = w wraps to x = 0, i.e. offset w/2 from the first row).
- centres := [][2]float64{{float64(w) / 2, float64(rowH) / 2}}
- if rows == 2 {
- centres = append(centres, [2]float64{float64(w), float64(rowH) * 1.5})
- }
- pw, ph := float64(w), float64(h)
- for _, c := range centres {
- // Stamp each row centre plus its 8 neighbours (full tile period) so
- // edge-crossing art wraps in cleanly.
- for jy := -1; jy <= 1; jy++ {
- for ix := -1; ix <= 1; ix++ {
- dx := int(math.Round(c[0] + float64(ix)*pw - sym.PivotX))
- dy := int(math.Round(c[1] + float64(jy)*ph - sym.PivotY))
- stampOver(tile, sym.Image, dx, dy)
- }
- }
- }
- return tile, true
-}
-
-// stampOver copies src's non-transparent pixels into dst at (dx,dy), clipped.
-func stampOver(dst, src *image.NRGBA, dx, dy int) {
- sw, sh := src.Rect.Dx(), src.Rect.Dy()
- dw, dh := dst.Rect.Dx(), dst.Rect.Dy()
- for sy := 0; sy < sh; sy++ {
- y := dy + sy
- if y < 0 || y >= dh {
- continue
- }
- for sx := 0; sx < sw; sx++ {
- x := dx + sx
- if x < 0 || x >= dw {
- continue
- }
- si := src.PixOffset(sx, sy)
- if src.Pix[si+3] == 0 {
- continue
- }
- di := dst.PixOffset(x, y)
- copy(dst.Pix[di:di+4], src.Pix[si:si+4])
- }
- }
-}
diff --git a/internal/engine/assets/patterns_s101_test.go b/internal/engine/assets/patterns_s101_test.go
deleted file mode 100644
index aed12b3..0000000
--- a/internal/engine/assets/patterns_s101_test.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package assets
-
-import (
- "image"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/s100/catalog"
- "github.com/beetlebugorg/chartplotter/pkg/s100/symbols"
-)
-
-// TestSeamlessTileStagger: a straight lattice (v2.x==0) yields a one-row tile
-// (height = v2.y), while a staggered lattice (v2.x != 0) yields a TWO-row
-// half-drop tile (height = 2·v2.y) — matching the S-52 STG atlas. Regression:
-// the staggered fill was emitted single-row, so it repeated at half the period
-// and the data-quality pattern clipped.
-func TestSeamlessTileStagger(t *testing.T) {
- // A tiny opaque 4×4 symbol, pivot at its centre.
- img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
- for i := range img.Pix {
- img.Pix[i] = 255
- }
- sym := &symbols.Rendered{Image: img, PivotX: 2, PivotY: 2}
-
- rowH := int(roundf(10 * s101PxPerMM))
-
- straight, ok := seamlessTile(sym, catalog.Vec{X: 10, Y: 10}, catalog.Vec{X: 0, Y: 10})
- if !ok {
- t.Fatal("straight tile not built")
- }
- if got := straight.Rect.Dy(); got != rowH {
- t.Errorf("straight tile height = %d, want one row (%d)", got, rowH)
- }
-
- staggered, ok := seamlessTile(sym, catalog.Vec{X: 10, Y: 10}, catalog.Vec{X: 5, Y: 10})
- if !ok {
- t.Fatal("staggered tile not built")
- }
- if got := staggered.Rect.Dy(); got != rowH*2 {
- t.Errorf("staggered tile height = %d, want two rows (%d)", got, rowH*2)
- }
- if straight.Rect.Dx() != staggered.Rect.Dx() {
- t.Errorf("stagger changed width: %d vs %d", straight.Rect.Dx(), staggered.Rect.Dx())
- }
-}
-
-func roundf(f float64) int {
- if f < 0 {
- return int(f - 0.5)
- }
- return int(f + 0.5)
-}
diff --git a/internal/engine/assets/raster.go b/internal/engine/assets/raster.go
deleted file mode 100644
index 8b989fd..0000000
--- a/internal/engine/assets/raster.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package assets
-
-// Atlas packing helpers shared by the sprite / pattern emitters. The actual
-// glyph rasterisation is done from SVG via oksvg/rasterx (see pkg/s100/symbols);
-// this file holds the page constants and the buffer-blit used to pack rasterised
-// cells into an atlas.
-
-const (
- pxPerUnit = 0.08 // device px per 0.01-mm symbol unit (8 px/mm)
- atlasPad = 1 // final-px gap between atlas cells
- maxCellSide = 640 // skip pathological cells larger than this (final px)
-)
-
-// raster is a rasterised-but-not-yet-packed cell.
-type raster struct {
- name string
- w, h uint32
- pivotX float32
- pivotY float32
- rgba []byte
-}
-
-// blit copies a cell's RGBA into the atlas at (dx, dy).
-func blit(atlas []byte, atlasW uint32, cell []byte, cw, ch, dx, dy uint32) {
- for y := uint32(0); y < ch; y++ {
- srcOff := int(y) * int(cw) * 4
- dstOff := (int(dy+y)*int(atlasW) + int(dx)) * 4
- copy(atlas[dstOff:dstOff+int(cw)*4], cell[srcOff:srcOff+int(cw)*4])
- }
-}
-
-func maxU32(a, b uint32) uint32 {
- if a > b {
- return a
- }
- return b
-}
diff --git a/internal/engine/assets/sprites.go b/internal/engine/assets/sprites.go
deleted file mode 100644
index 897762c..0000000
--- a/internal/engine/assets/sprites.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package assets
-
-import (
- "bytes"
- "fmt"
- "image"
- "image/png"
- "sort"
-)
-
-// Sprite / pattern atlas packer + encoder: already-rasterised cells are
-// shelf-packed into one RGBA atlas and described by JSON the MapLibre client
-// references as Icon sub-rects. The S-101 sprite/pattern builders
-// (sprites_s101.go, patterns_s101.go) feed this; colour is the only place RGB
-// is baked into the artwork.
-
-// cell is one packed atlas cell.
-type cell struct {
- x, y, w, h uint32
- pivotX, pivotY float32
-}
-
-// atlas is a packed sprite/pattern atlas.
-type atlas struct {
- width, height uint32
- rgba []byte
- cells map[string]cell
- skipped int
-}
-
-// packInto shelf-packs already-rasterised cells (tallest-first) into one atlas
-// of the given width. width must stay ≤ the WebGL MAX_TEXTURE_SIZE (commonly
-// 4096 in Chrome) and so must the resulting height — pick a width wide enough
-// that the atlas doesn't grow taller than that, or icons render broken (the
-// whole atlas is one GL texture in the client).
-func packInto(rasters []raster, skipped int, width uint32) atlas {
- sort.SliceStable(rasters, func(i, j int) bool { return rasters[i].h > rasters[j].h })
-
- cells := make(map[string]cell, len(rasters))
- penX := uint32(atlasPad)
- penY := uint32(atlasPad)
- shelfH := uint32(0)
- totalH := uint32(atlasPad)
- for _, r := range rasters {
- if penX+r.w+atlasPad > width {
- penX = atlasPad
- penY += shelfH + atlasPad
- shelfH = 0
- }
- cells[r.name] = cell{x: penX, y: penY, w: r.w, h: r.h, pivotX: r.pivotX, pivotY: r.pivotY}
- penX += r.w + atlasPad
- shelfH = maxU32(shelfH, r.h)
- totalH = maxU32(totalH, penY+r.h+atlasPad)
- }
- height := maxU32(totalH, 1)
-
- rgba := make([]byte, int(width)*int(height)*4)
- for _, r := range rasters {
- c := cells[r.name]
- blit(rgba, width, r.rgba, r.w, r.h, c.x, c.y)
- }
-
- return atlas{width: width, height: height, rgba: rgba, cells: cells, skipped: skipped}
-}
-
-// toJSON renders the atlas description (_meta + per-name cells), names sorted.
-// Float formatting: px_per_unit plain, pivots %.2f.
-func (a atlas) toJSON() []byte {
- names := make([]string, 0, len(a.cells))
- for n := range a.cells {
- names = append(names, n)
- }
- sort.Strings(names)
-
- var buf bytes.Buffer
- fmt.Fprintf(&buf, "{\n \"_meta\": { \"px_per_unit\": %s, \"width\": %d, \"height\": %d }", fmtNum(pxPerUnit), a.width, a.height)
- for _, n := range names {
- c := a.cells[n]
- fmt.Fprintf(&buf, ",\n %q: { \"x\": %d, \"y\": %d, \"w\": %d, \"h\": %d, \"pivot_x\": %.2f, \"pivot_y\": %.2f }",
- n, c.x, c.y, c.w, c.h, c.pivotX, c.pivotY)
- }
- buf.WriteString("\n}\n")
- return buf.Bytes()
-}
-
-// encodePNG encodes the atlas RGBA as a PNG.
-func (a atlas) encodePNG() ([]byte, error) {
- img := &image.NRGBA{
- Pix: a.rgba,
- Stride: int(a.width) * 4,
- Rect: image.Rect(0, 0, int(a.width), int(a.height)),
- }
- var buf bytes.Buffer
- enc := png.Encoder{CompressionLevel: png.BestCompression}
- if err := enc.Encode(&buf, img); err != nil {
- return nil, err
- }
- return buf.Bytes(), nil
-}
-
-// fmtNum formats a float without trailing zeros (used for px_per_unit).
-func fmtNum(v float64) string {
- s := fmt.Sprintf("%g", v)
- return s
-}
diff --git a/internal/engine/assets/sprites_s101.go b/internal/engine/assets/sprites_s101.go
deleted file mode 100644
index 203358c..0000000
--- a/internal/engine/assets/sprites_s101.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package assets
-
-import (
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "sort"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/s100/symbols"
-)
-
-// S-101 sprite-atlas builder: rasterizes every IHO S-101 symbol SVG (CSS colour
-// classes resolved against the chosen palette stylesheet) with pure-Go SVG and
-// shelf-packs it into the sprites.json / atlas PNG the MapLibre client consumes.
-
-// s101PxPerMM is the final device px per millimetre (pxPerUnit is px per
-// 0.01-mm unit; ×100 = px per mm).
-const s101PxPerMM = pxPerUnit * 100
-
-// s101AtlasWidth is the packed-atlas width for the ~724 S-101 symbols. Wider
-// than the S-52 512 so the atlas stays under the 4096 WebGL texture limit in
-// height too (one GL texture; Chrome's MAX_TEXTURE_SIZE is 4096).
-const s101AtlasWidth = 2048
-
-// SpriteAtlasS101 builds the symbol atlas from an S-101 Symbols directory
-// (path) and one of its *SvgStyle.css palettes. Returns sprites.json + atlas
-// PNG bytes.
-func SpriteAtlasS101(symbolsDir, cssPath string) (jsonBytes, pngBytes []byte, err error) {
- return SpriteAtlasS101FS(os.DirFS(symbolsDir), filepath.Base(cssPath))
-}
-
-// SpriteAtlasS101FS builds the atlas from an fs.FS rooted at the Symbols
-// directory (e.g. an embed.FS sub-tree); cssName is the stylesheet file within
-// it (e.g. "daySvgStyle.css").
-func SpriteAtlasS101FS(symbolsFS fs.FS, cssName string) (jsonBytes, pngBytes []byte, err error) {
- cssData, err := fs.ReadFile(symbolsFS, cssName)
- if err != nil {
- return nil, nil, fmt.Errorf("read css: %w", err)
- }
- css := symbols.LoadCSS(cssData)
-
- entries, err := fs.ReadDir(symbolsFS, ".")
- if err != nil {
- return nil, nil, fmt.Errorf("read symbols dir: %w", err)
- }
-
- var rasters []raster
- skipped := 0
- missing := map[string]bool{}
- for _, e := range entries {
- if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".svg") {
- continue
- }
- id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
- svg, err := fs.ReadFile(symbolsFS, e.Name())
- if err != nil {
- return nil, nil, fmt.Errorf("read %s: %w", e.Name(), err)
- }
- r, err := symbols.Render(svg, css, s101PxPerMM)
- if err != nil {
- skipped++
- continue
- }
- for _, m := range r.Missing {
- missing[m] = true
- }
- w := uint32(r.Image.Rect.Dx())
- h := uint32(r.Image.Rect.Dy())
- if w == 0 || h == 0 || w > maxCellSide || h > maxCellSide {
- skipped++
- continue
- }
- rasters = append(rasters, raster{
- name: id,
- w: w,
- h: h,
- pivotX: float32(r.PivotX),
- pivotY: float32(r.PivotY),
- rgba: r.Image.Pix,
- })
- }
-
- // Wider than the S-52 atlas (512) so the ~724 S-101 symbols pack into an
- // atlas that stays well under the 4096 WebGL texture limit in BOTH
- // dimensions (at 512 wide it grew to ~4823 tall and broke in Chrome).
- a := packInto(rasters, skipped, s101AtlasWidth)
- pngBytes, err = a.encodePNG()
- if err != nil {
- return nil, nil, err
- }
- if len(missing) > 0 {
- fmt.Fprintf(os.Stderr, "SpriteAtlasS101: %d unresolved CSS class(es): %s\n", len(missing), strings.Join(sortedKeys(missing), " "))
- }
- return a.toJSON(), pngBytes, nil
-}
-
-func sortedKeys(m map[string]bool) []string {
- out := make([]string, 0, len(m))
- for k := range m {
- out = append(out, k)
- }
- sort.Strings(out)
- return out
-}
diff --git a/internal/engine/assets/sprites_s101_test.go b/internal/engine/assets/sprites_s101_test.go
deleted file mode 100644
index 8abd398..0000000
--- a/internal/engine/assets/sprites_s101_test.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package assets
-
-import (
- "bytes"
- "encoding/json"
- "image/png"
- "os"
- "path/filepath"
- "testing"
-)
-
-func s101SymbolsDir(t *testing.T) string {
- t.Helper()
- dir := os.Getenv("S101_CATALOG")
- if dir == "" {
- dir = "/home/jcollins/Projects/s101-portrayal-catalogue/PortrayalCatalog"
- }
- sym := filepath.Join(dir, "Symbols")
- if _, err := os.Stat(filepath.Join(sym, "BCNCAR01.svg")); err != nil {
- t.Skipf("S-101 symbols not present (%s); set S101_CATALOG to run", sym)
- }
- return sym
-}
-
-func TestSpriteAtlasS101(t *testing.T) {
- sym := s101SymbolsDir(t)
- jsonBytes, pngBytes, err := SpriteAtlasS101(sym, filepath.Join(sym, "daySvgStyle.css"))
- if err != nil {
- t.Fatal(err)
- }
-
- // Atlas PNG decodes and has positive dimensions.
- img, err := png.Decode(bytes.NewReader(pngBytes))
- if err != nil {
- t.Fatalf("atlas PNG: %v", err)
- }
- if img.Bounds().Dx() != s101AtlasWidth || img.Bounds().Dy() < 1 || img.Bounds().Dy() > 4096 {
- t.Errorf("atlas dims = %v (want %d wide, <=4096 tall)", img.Bounds(), s101AtlasWidth)
- }
-
- // JSON parses; _meta present; a known symbol is packed with a sane cell.
- var doc map[string]json.RawMessage
- if err := json.Unmarshal(jsonBytes, &doc); err != nil {
- t.Fatalf("sprites.json: %v", err)
- }
- if _, ok := doc["_meta"]; !ok {
- t.Error("missing _meta")
- }
- var c struct {
- W uint32 `json:"w"`
- H uint32 `json:"h"`
- PivotX float64 `json:"pivot_x"`
- PivotY float64 `json:"pivot_y"`
- }
- raw, ok := doc["BCNCAR01"]
- if !ok {
- t.Fatal("BCNCAR01 not in atlas")
- }
- if err := json.Unmarshal(raw, &c); err != nil {
- t.Fatal(err)
- }
- if c.W == 0 || c.H == 0 || c.PivotX <= 0 {
- t.Errorf("BCNCAR01 cell looks wrong: %+v", c)
- }
- t.Logf("atlas %dx%d; BCNCAR01 w=%d h=%d pivot=(%.1f,%.1f)", img.Bounds().Dx(), img.Bounds().Dy(), c.W, c.H, c.PivotX, c.PivotY)
-}
diff --git a/internal/engine/auxfiles/auxfiles.go b/internal/engine/auxfiles/auxfiles.go
index 94e660e..46b280a 100644
--- a/internal/engine/auxfiles/auxfiles.go
+++ b/internal/engine/auxfiles/auxfiles.go
@@ -1,18 +1,19 @@
// Package aux packages an ENC exchange set's auxiliary files — the external
// resources features point at by filename rather than carrying inline: TXTDSC/
-// NTXTDS textual descriptions (.TXT) and PICREP pictures (.TIF/.JPG) — into a
-// single companion archive ("-aux.zip") the client fetches once and reads by
-// filename for the pick report. Pictures in TIFF (which browsers can't render) are
-// transcoded to PNG; an index.json maps each referenced filename to its stored
-// entry. Shared by the CLI bake and the server's import/bake.
+// NTXTDS textual descriptions (.TXT) and PICREP pictures (.TIF/.JPG) — as LOOSE
+// static files under an "aux/" directory the client reads by filename for the
+// pick report. Pictures in TIFF (which browsers can't render) are transcoded to
+// PNG; an index.json maps each referenced filename to its stored file. The loose
+// layout works OFFLINE from any static host (SD card, file://, CDN) with no zip to
+// download and no server extraction endpoint. Shared by the CLI bake and the
+// server's import/bake.
package auxfiles
import (
- "archive/zip"
"bytes"
"encoding/json"
"image/png"
- "io"
+ "os"
"path/filepath"
"strings"
@@ -24,13 +25,24 @@ import (
// sets are case-inconsistent across platforms).
func Key(name string) string { return strings.ToUpper(filepath.Base(name)) }
-// entry is one file's record in the companion zip's index.json.
-type entry struct {
- Stored string `json:"stored"` // entry name inside the zip
+// Entry is one file's record in the aux dir's index.json. Exported so readers (the
+// server index, the CLI) can decode the manifest without redeclaring the shape.
+type Entry struct {
+ Stored string `json:"stored"` // stored filename inside the aux dir
Type string `json:"type"` // MIME type the client should make a Blob with
From string `json:"from,omitempty"` // original filename, when transcoded (TIFF→PNG)
}
+// Manifest is the aux dir's index.json: a version tag plus every referenced
+// filename (Key) → its stored file.
+type Manifest struct {
+ Version int `json:"version"`
+ Files map[string]Entry `json:"files"`
+}
+
+// IndexName is the manifest filename written into (and read from) an aux dir.
+const IndexName = "index.json"
+
// contentType maps a stored filename to the MIME type the pick report renders.
func contentType(name string) string {
switch strings.ToLower(filepath.Ext(name)) {
@@ -46,53 +58,50 @@ func contentType(name string) string {
return "application/octet-stream"
}
-// WriteZip writes the aux files (keyed by Key) as a companion zip to w, transcoding
-// TIFF pictures to PNG and writing an index.json mapping each referenced filename
-// to its stored entry. Returns the number of files written. A zero-length aux map
-// writes nothing and returns (0, nil) — callers should skip emitting a file then.
-func WriteZip(w io.Writer, files map[string][]byte) (int, error) {
+// transcode resolves one aux file to the (stored filename, MIME type, original name
+// when transcoded, bytes to write). TIFF pictures browsers can't render are re-encoded
+// to PNG; everything else is stored verbatim.
+func transcode(key string, data []byte) (stored, typ, from string, out []byte) {
+ stored = filepath.Base(key)
+ typ = contentType(stored)
+ out = data
+ if ext := strings.ToLower(filepath.Ext(stored)); ext == ".tif" || ext == ".tiff" {
+ if pngBytes, e := tiffToPNG(data); e == nil {
+ from = stored
+ stored = strings.TrimSuffix(stored, filepath.Ext(stored)) + ".png"
+ typ = "image/png"
+ out = pngBytes
+ }
+ }
+ return
+}
+
+// WriteDir writes the aux files (keyed by Key) as LOOSE static files under dir,
+// transcoding TIFF pictures to PNG and writing an index.json mapping each referenced
+// filename to its stored file. The client fetches dir/index.json then dir/
+// by plain HTTP — no zip to download, no server extraction endpoint, so it works
+// offline from a static export. Returns the number of files written; an empty map
+// writes nothing and returns (0, nil) — callers should skip emitting a dir then.
+func WriteDir(dir string, files map[string][]byte) (int, error) {
if len(files) == 0 {
return 0, nil
}
- zw := zip.NewWriter(w)
- index := map[string]entry{}
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return 0, err
+ }
+ index := map[string]Entry{}
for key, data := range files {
- stored := filepath.Base(key)
- typ := contentType(stored)
- from := ""
- // Browsers have no TIFF decoder; transcode to PNG so the picture renders
- // inline. On failure, ship the original and let the client offer a download.
- if ext := strings.ToLower(filepath.Ext(stored)); ext == ".tif" || ext == ".tiff" {
- if pngBytes, e := tiffToPNG(data); e == nil {
- from = stored
- stored = strings.TrimSuffix(stored, filepath.Ext(stored)) + ".png"
- typ = "image/png"
- data = pngBytes
- }
- }
- fw, err := zw.Create(stored)
- if err != nil {
- zw.Close()
+ stored, typ, from, out := transcode(key, data)
+ if err := os.WriteFile(filepath.Join(dir, stored), out, 0o644); err != nil {
return 0, err
}
- if _, err := fw.Write(data); err != nil {
- zw.Close()
- return 0, err
- }
- index[key] = entry{Stored: stored, Type: typ, From: from}
+ index[key] = Entry{Stored: stored, Type: typ, From: from}
}
- iw, err := zw.Create("index.json")
+ b, err := json.MarshalIndent(Manifest{Version: 1, Files: index}, "", " ")
if err != nil {
- zw.Close()
- return 0, err
- }
- enc := json.NewEncoder(iw)
- enc.SetIndent("", " ")
- if err := enc.Encode(map[string]any{"version": 1, "files": index}); err != nil {
- zw.Close()
return 0, err
}
- if err := zw.Close(); err != nil {
+ if err := os.WriteFile(filepath.Join(dir, IndexName), b, 0o644); err != nil {
return 0, err
}
return len(index), nil
diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go
deleted file mode 100644
index 3d102df..0000000
--- a/internal/engine/bake/bake.go
+++ /dev/null
@@ -1,2645 +0,0 @@
-// Package bake routes the cached lat/lon Primitive IR into MVT layers and tiles
-// many cells together. Each cell is assigned a zoom band from its compilation
-// scale so a coarse overview cell bakes at low zooms and a harbour cell at high
-// zooms. Colour stays an S-52 token string — the client restyles Day/Dusk/Night
-// for free.
-//
-// This is the single-archive (provisioned) "spec display" bake: a feature's
-// display z-min comes from SCAMIN (S-52 §10.4.2,
-// defaulting to z0 so coverage never goes blank on zoom-out), and where cells of
-// different scales overlap, emitTile applies best-available-data suppression both
-// ways (below the native bands only the coarsest cell's blanket shows; above them
-// only the finest cell's chart). A normalized-world bbox reject prunes far
-// primitives before projection.
-//
-// Covers: SCAMIN z-min, native bands + best-available suppression,
-// sounding-number grouping, OBSTRN/WRECKS danger-depth carriage, and per-zoom
-// sector-light tessellation into the lines layer.
-package bake
-
-import (
- "encoding/json"
- "fmt"
- "log"
- "math"
- "slices"
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/mvt"
- "github.com/beetlebugorg/chartplotter/internal/engine/pmtiles"
- "github.com/beetlebugorg/chartplotter/internal/engine/portrayal"
- "github.com/beetlebugorg/chartplotter/internal/engine/tile"
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-const maxBandZ uint32 = 18
-
-// Display categories (DisplayBase/Standard/Other = 6/7/8). Local copies so the
-// bake path needn't import pkg/s52; these enum values are carried through for
-// the client filter.
-const (
- displayCatBase = 6
- displayCatStandard = 7
- displayCatOther = 8
-)
-
-// generalOverzoomMin is the lowest zoom the general band displays at (below its
-// native min of 7) so general charts don't vanish when zoomed out — all the way
-// to the world view. Where an overview cell overlaps, best-available suppression
-// defers to it; general only fills the gap. SCAMIN keeps minor features gated, so
-// a coarse-zoom tile carries only the skeleton (land/coast/major depth).
-const generalOverzoomMin uint32 = 0
-
-// bandBakeCeil is the TOP zoom a per-band archive bakes to (its source's maxzoom;
-// the client overzooms above it for free). A band only bakes past its native max
-// to sharpen the suppression CUT against the next finer band — so the cut isn't
-// stair-stepped at coarse-tile granularity when overzoomed. Beyond that the extra
-// levels are pure overzoom buffer the client recreates, and at high zoom they cost
-// 4×/16× the tiles, so we don't bake them:
-//
-// overview 7, general 9 — capped client-side (lines/patterns don't overzoom), so
-// no cut to sharpen; base fills overzoom from the native max.
-// coastal 11→13, approach 13→15 — +2 sharpens the cut vs the next finer band.
-// harbor 16 — native max already cuts vs berthing at ~0.4 km; z17/18 would be
-// pure buffer (this is the big win: drops ~16× of the harbor archive).
-// berthing 18 — finest band, nothing finer to cut against; native detail.
-func bandBakeCeil(bandMax uint32) uint32 {
- switch bandMax {
- case 11: // coastal: native max (z11) cuts too coarse; +2 → z13 to sharpen vs approach
- return 13
- case 13: // approach: native max (z13) cuts too coarse; +2 → z15 to sharpen vs harbor
- return 15
- default: // everyone else bakes to native max:
- // overview(7)/general(9) are capped client-side (no overzoom cut to sharpen);
- // harbor(16) cuts vs berthing; berthing(18) is the finest band.
- return bandMax
- }
-}
-
-// ZoomRange is a baked [min,max] Web-Mercator zoom span.
-type ZoomRange struct{ Min, Max uint32 }
-
-// Band is a NOAA ENC navigational-purpose band. Each bakes over its own zoom
-// range and overzooms above max client-side.
-type Band uint8
-
-const (
- BandOverview Band = iota
- BandGeneral
- BandCoastal
- BandApproach
- BandHarbor
- BandBerthing
-)
-
-// ZoomRange returns the band's native [minzoom, maxzoom] — the scale range the
-// band's cells are compiled for. Adjacent bands overlap by one zoom at the
-// endpoints. Used for SCAMIN/CSCL context and the frontend's overzoom envelope.
-func (b Band) ZoomRange() ZoomRange {
- switch b {
- case BandOverview:
- return ZoomRange{0, 7}
- case BandGeneral:
- return ZoomRange{7, 9}
- case BandCoastal:
- return ZoomRange{9, 11}
- case BandApproach:
- return ZoomRange{11, 13}
- case BandHarbor:
- return ZoomRange{13, 16}
- default: // berthing
- return ZoomRange{16, 18}
- }
-}
-
-// BakeBand is one navigational-purpose band's identity for per-band archive
-// baking: its frontend slug and native [Min,Max] zoom span.
-type BakeBand struct {
- Slug string
- Min, Max uint32
-}
-
-// BakeBands lists the bands coarse→fine for per-band archive baking — must match
-// the frontend's CHART_BANDS (slug + zoom span) so each archive loads into its
-// chart- source. Max feeds EmitTileBandInto's band filter (natMax == Max).
-func BakeBands() []BakeBand {
- return []BakeBand{
- {"overview", 0, 7},
- {"general", 7, 9},
- {"coastal", 9, 11},
- {"approach", 11, 13},
- {"harbor", 13, 16},
- {"berthing", 16, 18},
- }
-}
-
-// BandForScale maps a compilation-scale denominator (CSCL) to a band.
-func BandForScale(cscl uint32) Band {
- n := cscl
- if n == 0 {
- n = 50_000
- }
- switch {
- case n <= 8_000:
- return BandBerthing
- case n <= 32_000:
- return BandHarbor
- case n <= 130_000:
- return BandApproach
- case n <= 500_000:
- return BandCoastal
- case n <= 2_300_000:
- return BandGeneral
- default:
- return BandOverview
- }
-}
-
-// scaminZoom maps an S-52 SCAMIN (1:N denominator) to the lowest Web-Mercator
-// zoom that keeps the object visible: it shows when the DISPLAY-scale denominator
-// (at the cell latitude) is ≤ SCAMIN, i.e. at display zoom ≥ z* = log2(denomZ0/
-// SCAMIN). Vector tiles are integer-zoom and the tile serving a fractional display
-// zoom d is floor(d), so to keep the object available right down to its SCAMIN
-// scale it must live in the floor(z*) tile — hence FLOOR. The client's per-SCAMIN
-// bucket layer then applies the EXACT fractional z* as a native layer minzoom, so
-// the visible cutoff is exact in both directions. S-52 §8.4: cell latitude, not equator.
-//
-// SCAMIN is a PRODUCER scale (a real 1:N paper scale), so denomZ0 is the PHYSICAL
-// display scale at z0 — MapLibre's true 512-tile geometry (78271.517m/px ÷ OGC
-// 0.28mm pixel = 279_541_132), NOT the 256-tile nominal (559M, which would make
-// features vanish at ~½ their SCAMIN). This matches the client's scaminDisplayZoom
-// so the baked tile floor and the visible cutoff agree, and a 1:N feature survives
-// until the screen truly reads 1:N (see chart-sources.scaminDisplayZoom).
-func scaminZoom(scamin uint32, lat float64) uint32 {
- if scamin == 0 {
- return 0
- }
- denomZ0 := 279_541_132.0 * math.Cos(lat*math.Pi/180) // 1:N at z0 at this latitude (physical 512-tile scale)
- s := float64(scamin)
- if denomZ0 <= s {
- return 0
- }
- z := math.Floor(math.Log2(denomZ0 / s))
- if z >= float64(maxBandZ) {
- return maxBandZ
- }
- if z < 0 {
- return 0
- }
- return uint32(z)
-}
-
-// bandZMin is the spec-resolution display z-min for the merged provisioned
-// archive: a feature is gated to its native scale band rather than floated to z0.
-// This replaces the old float-to-z0 "spec display" z-min, which made every cell —
-// a harbor cell included — eligible at every coarse zoom, piling most of all the
-// cells' prims onto each coarse tile (the source of the import halt + memory
-// blow-up). With band-gating, a coarse tile only sees the few coarse-band cells;
-// best-available suppression and the frontend fan still compose bands across the
-// band-overlap zooms exactly as before, so tiles stay complete.
-//
-// Note this is identical to the old z-min for any feature WITH SCAMIN (both use
-// scaminZoom) — e.g. soundings. It changes only features WITHOUT SCAMIN (and
-// DISPLAYBASE), which previously floated to z0 and now sit at their band min.
-// Per S-52 the visible result is unchanged wherever coverage is complete: a coarse
-// zoom shows the scale-appropriate (coarser) cell anyway.
-//
-// - DISPLAYBASE: the band min (always shown in-band; SCAMIN never removes base,
-// and base features stay band-gated — the coarse cell shows them at coarse scale).
-// - SCAMIN present: scaminZoom is AUTHORITATIVE and may fall BELOW the band min —
-// a SCAMIN is the producer explicitly saying "keep this visible down to 1:N", so
-// the object stays AVAILABLE down to its SCAMIN scale (crossing into coarser
-// bands). The client's per-SCAMIN bucket layer gates the exact display cutoff;
-// best-available point suppression keeps it from doubling up with a coarse cell.
-// - no SCAMIN: the band min — no over-scale flag ⇒ no display beyond the cell's band.
-func bandZMin(displayCategory int, scamin, bandMin uint32, lat float64) uint32 {
- if displayCategory == displayCatBase {
- return bandMin
- }
- if scamin != 0 {
- return scaminZoom(scamin, lat)
- }
- return bandMin
-}
-
-// routed is one primitive ready to tile: target layer, geometry (lat/lon), the
-// normalized-world bbox (for the spatial reject + overlap test), the display and
-// native zoom spans, and the pre-built MVT attributes (minus geometry).
-type routed struct {
- layer string
- kind mvt.GeomType
- // Geometry pre-projected to normalized-world coordinates (X,Y in [0,1],
- // Web-Mercator) ONCE at add time, so per-tile emit is a cheap affine
- // transform (Projector.ProjectNorm) instead of recomputing log/sin/tan for
- // every tile a primitive appears in.
- nrings [][]tile.UPoint // polygon
- nline []tile.UPoint // linestring
- npoint tile.UPoint // point
-
- wMinX, wMinY, wMaxX, wMaxY float64 // normalized world bbox [0,1]
- zMin, zMax uint32 // display zoom span
- natMin, natMax uint32 // native band zoom span (for suppression)
- cscl uint32 // owning cell's compilation-scale denominator (per-cell best-available)
-
- // ls (non-nil only for a complex_lines prim) is the linestyle's period
- // geometry; when set the prim is tessellated per zoom at emit (emitComplexLine)
- // instead of drawn as a plain polyline.
- ls *lsInfo
-
- // attrs is the feature's tag list. For prims routed through route() (the bulk),
- // bcBase is set and attrs holds ONLY the variable tags (color_token, drval…,
- // objnam/light/s57); the always-present base (class/cell/draw_prio/cat/bnd/pts)
- // lives in the compact bc* fields below and is rebuilt at emit (attrsFor) —
- // replacing a ~5–8 entry []KeyValue per feature (the heap-profile hot spot) with
- // a few interned ints. Prims from other constructors keep bcBase=false and a
- // full attrs list.
- attrs []mvt.KeyValue
- bcClass uint32 // interned class index (classTab)
- bcCell uint32 // interned cell index (cellTab)
- bcDrawP int16
- bcCat int16
- bcBnd int8
- bcPts int16
- bcScamin uint32 // SCAMIN denominator (0 = none); emitted as `scamin` for the client's per-SCAMIN bucket layers
- bcHasPts bool
- bcBase bool // attrs is variable-only; rebuild the base from bc* at emit
-}
-
-// sectorPrim is one constructed sector-light figure element (a dashed leg, or a
-// black-backed coloured arc / ring) the S-101 rule emitted via AugmentedRay /
-// ArcByRadius. Its mm sizes are screen-px, so it is tessellated per zoom at emit
-// time into the sector_lines layer rather than stored as fixed lat/lon geometry —
-// driven by the catalogue's bearings/radii/colours, not a Go re-derivation.
-type sectorPrim struct {
- fig portrayal.AugmentedFigure
- class string
- cell string
- drawPrio int
- cat int
- zMin uint32
- natMax uint32
- scamin uint32 // SCAMIN denominator of the parent LIGHTS (0 = none); emitted as `scamin` so the client's per-SCAMIN bucket layer gates the exact display cutoff, same as point symbols/text
- // Date validity of the parent LIGHTS (S-52 §10.4.1.1); empty when none. Carried
- // because sectors tessellate at tile-emit time, after b.curDate* has moved on.
- dateStart, dateEnd string
- // legNorm is the full-length leg reach (VALNMR nominal range) as a fraction
- // of the normalized world — a fixed GROUND distance, so zoom-independent
- // (unlike the 25 mm short leg / arc, which are screen-px). Drives the tile
- // enumeration + emit margin so the long legs aren't culled near tile edges.
- // 0 for an arc, or a leg whose light has no VALNMR (only the screen-px spills).
- legNorm float64
-}
-
-// Baker accumulates routed primitives from many cells, then tiles them.
-type Baker struct {
- prims []routed
- sectors []sectorPrim
- // emitIndex is an inverted tile→prim-index map (packed z<<40|x<<20|y → prim
- // indices) built once by BuildEmitIndex before the (possibly parallel) emit
- // loop. When present, EmitTileInto iterates only the prims that touch a tile
- // instead of scanning all of b.prims — turning the whole bake from
- // O(#tiles × #prims) into O(Σ prims-on-tile). nil ⇒ EmitTileInto falls back to
- // the full scan (the EmitTile convenience path / tests). Read-only after build.
- emitIndex map[uint64][]int32
- linestyles map[string]*lsInfo // complex-linestyle period geometry, built once (lazily) from the PresLib
- // portrayer drives portrayal via the S-101 rule engine. A portrayer is
- // required. Internal (not a user-facing toggle).
- portrayer Portrayer
- bbox geo.BoundingBox
- curCell string // dataset name of the cell currently being added (stamped on each feature)
- curCscl uint32 // compilation-scale denominator of the cell currently being added (per-cell best-available)
- curScamin uint32 // SCAMIN (1:N min display scale) of the feature currently being expanded; 0 = none
- curObjnam string // OBJNAM of the feature currently being expanded (for the inspector)
- curLight string // light characteristic string of the current LIGHTS feature (e.g. "Fl.R.4s")
- curAttrs string // compact JSON of the feature's full S-57 attribute set (acronym→value) for the cursor-pick report (S-52 PresLib §10.8); "" when the feature has none
- // Date dependency (S-52 PresLib §10.4.1.1 / Fig 1): the current feature's
- // validity period, baked onto every one of its primitives so the client can
- // apply the MANDATORY date filter — a date-dependent object outside its period
- // is not displayed for the current date. Empty when the feature has no period.
- // Values are S-57 date strings: full "YYYYMMDD" (fixed) or partial "--MMDD"
- // recurring each year (periodic).
- curDateStart string
- curDateEnd string
- // Co-located-light combination (S-52 LIGHTS06): when several LIGHTS share a
- // position, the first is "primary" (one flare + a merged multi-line label);
- // the rest are suppressed (flare + text dropped, sectors kept). seenSector
- // dedupes identical sector geometry within the current cell.
- curLightSkip bool // current LIGHTS is a non-primary co-located light
- curLightText string // merged multi-line characteristic for the primary
- seenSector map[sectorKey]struct{} // sector dedup for the current cell
- scaminSeen map[uint32]struct{} // distinct SCAMIN denominators routed this band → published manifest
- coverage []CellCoverage // M_COVR data-coverage polygons of added cells (debug)
-
- // DATCVR §10.1.9.1 chart scale boundaries: per-cell M_COVR(CATCOV=1) coverage
- // + native band, used by emitScaleBoundaries to draw a line where the
- // navigational purpose changes (a finer cell's coverage edge sitting inside
- // coarser data). Internal seams between same-band cells are suppressed.
- covMeta []covMeta
- scaleBndEmitted bool
- skipCoverage bool // AddCell skips M_COVR extraction (covMeta pre-built; streaming bake)
-
- // Interning for the route() base attributes: `class` (one of ~170 S-57 object
- // classes) and `cell` (the source dataset name) are the same string repeated
- // across millions of features. Store one shared copy here and a uint32 index on
- // each prim (see routed.bcClass/bcCell), rebuilding the KeyValue tags at emit —
- // far cheaper than a per-feature []KeyValue. Built single-threaded in AddCell,
- // read-only (concurrent-safe) during the parallel emit.
- classTab []string
- classIdx map[string]uint32
- cellTab []string
- cellIdx map[string]uint32
-
- // OverzoomAllBands makes EVERY band overzoom DOWN to the world view (like the
- // general band always does), not just BandGeneral. Set on the realtime/upload
- // path so a handful of uploaded cells (e.g. a single large-scale inland ENC)
- // stay visible as a SCAMIN-gated skeleton when you zoom out, instead of
- // vanishing until their native detail zoom. Left false for the prebaked NOAA
- // bake, where thousands of cells would bloat low-zoom tiles (the overview /
- // general bands already supply the zoomed-out skeleton there).
- OverzoomAllBands bool
-
- // MaxBakeZoom caps the highest zoom tiles are emitted at (0 = uncapped, use each
- // prim's native band max). Large-scale cells over a wide area (e.g. an IENC
- // river network at 1:5000) would otherwise emit tens of millions of z17–18
- // tiles; capping the bake and letting MapLibre overzoom the vector tiles
- // client-side keeps the archive small with no visible detail loss (every
- // feature is already clipped into the capped-zoom tile).
- MaxBakeZoom uint32
-}
-
-// clampZMax applies MaxBakeZoom to a prim/sector display max (0 = uncapped).
-func (b *Baker) clampZMax(zMax uint32) uint32 {
- if b.MaxBakeZoom != 0 && zMax > b.MaxBakeZoom {
- return b.MaxBakeZoom
- }
- return zMax
-}
-
-// CellCoverage is one M_COVR (CATCOV=1) data-coverage polygon of an added cell,
-// in lon/lat — the area the cell ACTUALLY carries data for (vs its rectangular
-// bounding box). Drives the debug overlay's coverage-vs-gap diagnosis.
-type CellCoverage struct {
- Cell string // cell name
- Rings [][][]float64 // GeoJSON Polygon rings: [ring][point][lon,lat]
-}
-
-// Coverage returns the M_COVR data-coverage polygons of every cell added so far.
-func (b *Baker) Coverage() []CellCoverage { return b.coverage }
-
-// covMeta is one cell's data-coverage (M_COVR CATCOV=1) outline plus its native
-// scale band, the input to emitScaleBoundaries. rings are GeoJSON
-// [ring][point][lon,lat]; bb is their lon/lat bounding box.
-type covMeta struct {
- bandMin, bandMax uint32
- cscl uint32 // compilation-scale denominator (per-cell best-available: finer = smaller wins)
- displayMin uint32 // lowest zoom this cell's data is shown at (0 for overview/general which overzoom down)
- bb geo.BoundingBox
- rings [][][]float64
- // derived is true when these rings were synthesised from the cell's geometry
- // extent because it carried no M_COVR (see extractCoverage). A derived rectangle
- // marks where the cell IS, not where it actually has area data — so it is trusted
- // only for POINT suppression (a finer cell's footprint supersedes a coarser cell's
- // point symbol), NOT for area/line FILL suppression, where it would punch nodata
- // holes wherever the finer cell's extent has no fill (a sparse legend cell, an
- // inter-cell gap). Always false for conformant cells, where M_COVR == data extent.
- derived bool
-}
-
-// sectorKey identifies one constructed sector-figure element (anchor + ray/arc
-// params + stroke) for dedup — co-located lights often repeat identical sectors.
-type sectorKey struct {
- lat, lon int64
- ray bool
- p1, p2, p3 int64 // ray: bearing, length, 0 — arc: radius, start, sweep
- col string
- w int64
- dashed bool
-}
-
-func quantDeg(f float64) int64 { return int64(math.Round(f * 1e6)) }
-
-// New returns an empty Baker.
-func New() *Baker { return &Baker{bbox: geo.EmptyBox()} }
-
-// Bounds is the union lat/lon bbox of every ingested cell's primitives.
-func (b *Baker) Bounds() geo.BoundingBox { return b.bbox }
-
-// lightCharText returns the light-characteristic label the S-101 rule (LITDSN02,
-// via LightFlareAndDescription) produced for a LIGHTS feature — the first DrawText
-// in its portrayal. The catalogue is the single source of truth for the
-// characteristic string (S-52 LITDSN is a CSP), so the inspector and the
-// co-located merge harvest THIS rather than re-deriving it in Go.
-func lightCharText(passes []portrayal.FeatureBuildPass) string {
- for _, pass := range passes {
- for _, p := range pass.Build.Primitives {
- if t, ok := p.(portrayal.DrawText); ok {
- return t.Text
- }
- }
- }
- return ""
-}
-
-// groupCoLocatedLights finds LIGHTS features that share an exact position and,
-// for each such group, returns the merged multi-line characteristic keyed by the
-// first (primary) feature index, plus the set of non-primary indices to suppress.
-// S-52 PresLib §LIGHTS06: co-located lights combine into one flare + one stacked
-// characteristic label rather than drawing N flares/labels on top of each other.
-// Each light's characteristic comes from the catalogue (lightCharText), not a
-// second Go reimplementation.
-func (b *Baker) groupCoLocatedLights(features []s57.Feature) (primaryText map[int]string, skip map[int]bool) {
- type pos struct{ lat, lon float64 }
- groups := map[pos][]int{}
- for i := range features {
- f := &features[i]
- if f.ObjectClass() != "LIGHTS" {
- continue
- }
- g := f.Geometry()
- if g.Type != s57.GeometryTypePoint || len(g.Coordinates) == 0 || len(g.Coordinates[0]) < 2 {
- continue
- }
- c := g.Coordinates[0]
- k := pos{lat: c[1], lon: c[0]}
- groups[k] = append(groups[k], i)
- }
- for _, idxs := range groups {
- if len(idxs) < 2 {
- continue
- }
- var lines []string
- seen := map[string]bool{}
- for _, i := range idxs {
- ch := lightCharText(b.portrayer.Passes(&features[i]))
- if ch == "" || seen[ch] {
- continue
- }
- seen[ch] = true
- lines = append(lines, ch)
- }
- if primaryText == nil {
- primaryText, skip = map[int]string{}, map[int]bool{}
- }
- primaryText[idxs[0]] = strings.Join(lines, "\n")
- for _, i := range idxs[1:] {
- skip[i] = true
- }
- }
- return primaryText, skip
-}
-
-// extractCoverage records a cell's M_COVR (CATCOV=1) data-coverage polygons into
-// covMeta (keyed to the cell's native band [zr]) — the input to best-available
-// suppression and DATCVR scale boundaries.
-func (b *Baker) extractCoverage(features []s57.Feature, zr ZoomRange, cell string, cscl, displayMin uint32) int {
- added := 0
- for i := range features {
- f := &features[i]
- if f.ObjectClass() != "M_COVR" || intAttr(f.Attributes(), "CATCOV") != 1 {
- continue
- }
- rings := f.Geometry().Rings
- if len(rings) == 0 {
- continue
- }
- cov := CellCoverage{Cell: cell}
- cm := covMeta{bandMin: zr.Min, bandMax: zr.Max, cscl: cscl, displayMin: displayMin, bb: geo.EmptyBox()}
- for _, r := range rings {
- cov.Rings = append(cov.Rings, r.Coordinates)
- cm.rings = append(cm.rings, r.Coordinates)
- for _, pt := range r.Coordinates {
- cm.bb.ExtendPoint(geo.LatLon{Lon: pt[0], Lat: pt[1]})
- }
- }
- b.coverage = append(b.coverage, cov)
- b.covMeta = append(b.covMeta, cm)
- b.bbox.ExtendBox(cm.bb) // so the streaming bake has full bounds after pass 1
- added++
- }
- // Fallback for a cell with no M_COVR(CATCOV=1) coverage (S-57 requires it, but
- // synthetic/test cells — e.g. the S-52 PresLib ECDIS Chart 1 — omit it): derive a
- // rectangular coverage from the bounding box of all the cell's geometry. Without
- // this the cell contributes NO covMeta, so best-available suppression and DATCVR
- // scale boundaries have nothing to test against and a coarser cell's symbols
- // double-draw over a finer cell covering the same ground. Real NOAA cells always
- // carry M_COVR, so this never triggers for them (no behaviour change).
- if added == 0 {
- if rect, bb, ok := cellExtentRect(features); ok {
- b.coverage = append(b.coverage, CellCoverage{Cell: cell, Rings: [][][]float64{rect}})
- b.covMeta = append(b.covMeta, covMeta{bandMin: zr.Min, bandMax: zr.Max, cscl: cscl, displayMin: displayMin, bb: bb, rings: [][][]float64{rect}, derived: true})
- b.bbox.ExtendBox(bb)
- added++
- }
- }
- return added
-}
-
-// cellExtentRect returns a closed rectangular ring ([lon,lat] points, CW from the
-// SW corner) spanning the bounding box of every feature's geometry in a cell, plus
-// that box. Used as a coverage fallback for cells lacking M_COVR (see
-// extractCoverage). ok is false when the cell has no spatial geometry at all.
-func cellExtentRect(features []s57.Feature) ([][]float64, geo.BoundingBox, bool) {
- bb := geo.EmptyBox()
- any := false
- ext := func(pt []float64) {
- if len(pt) >= 2 {
- bb.ExtendPoint(geo.LatLon{Lon: pt[0], Lat: pt[1]})
- any = true
- }
- }
- for i := range features {
- g := features[i].Geometry()
- for _, pt := range g.Coordinates {
- ext(pt)
- }
- for _, r := range g.Rings {
- for _, pt := range r.Coordinates {
- ext(pt)
- }
- }
- }
- if !any || bb.MinLon > bb.MaxLon || bb.MinLat > bb.MaxLat {
- return nil, bb, false
- }
- rect := [][]float64{
- {bb.MinLon, bb.MinLat}, {bb.MaxLon, bb.MinLat},
- {bb.MaxLon, bb.MaxLat}, {bb.MinLon, bb.MaxLat},
- {bb.MinLon, bb.MinLat},
- }
- return rect, bb, true
-}
-
-// cellStem is a cell's dataset name without the .000/.NNN extension.
-func cellStem(name string) string {
- if i := strings.LastIndexByte(name, '.'); i > 0 {
- return name[:i]
- }
- return name
-}
-
-// AddCellCoverage extracts ONLY a cell's coverage + native band (no feature
-// routing) — the streaming bake's first pass, building the global covMeta once so
-// each later per-band routing pass can suppress against finer bands without
-// re-deriving coverage. Returns the cell's native band and how many coverage
-// polygons it contributed (0 ⇒ the cell had no M_COVR and the extent fallback found
-// no geometry — e.g. an M_COVR-only filtered parse; the caller re-parses fully).
-func (b *Baker) AddCellCoverage(chart *s57.Chart) (Band, int) {
- band := BandForScale(uint32(chart.CompilationScale()))
- cscl := uint32(chart.CompilationScale())
- n := b.extractCoverage(chart.Features(), band.ZoomRange(), cellStem(chart.DatasetName()), cscl, cellDisplayMin(band, band.ZoomRange()))
- return band, n
-}
-
-// cellDisplayMin is the lowest zoom a band's cells are actually drawn at (matches
-// the client BAND_DISPLAY_MIN): overview/general overzoom down to z0 to gap-fill, so
-// they're "shown" everywhere; the finer bands start at their native min. Used so the
-// per-cell suppression only yields to a finer cell that is ACTUALLY displayed at the
-// current zoom (a harbor cell doesn't punch holes in approach until harbor zooms in).
-func cellDisplayMin(band Band, zr ZoomRange) uint32 {
- if band <= BandGeneral {
- return 0
- }
- return zr.Min
-}
-
-// SetSkipCoverage makes AddCell skip M_COVR extraction (covMeta already built by
-// AddCellCoverage in the streaming bake's first pass).
-func (b *Baker) SetSkipCoverage(v bool) { b.skipCoverage = v }
-
-// ResetPrims drops the routed primitives, sectors, and emit index so the next
-// band's cells can be routed into the same Baker, while KEEPING the accumulated
-// coverage, bounds, interning tables, and loaded library — so the streaming bake
-// holds only one band's geometry at a time.
-func (b *Baker) ResetPrims() {
- b.prims = nil
- b.sectors = nil
- b.emitIndex = nil
- b.scaleBndEmitted = false
- b.seenSector = nil
- b.scaminSeen = nil
-}
-
-// recordScamin notes a distinct SCAMIN denominator routed into this band, so the
-// bake can publish the band's SCAMIN manifest (pmtiles metadata → TileJSON). The
-// client builds one native-minzoom bucket layer per value ONCE at load — no
-// runtime probe/collect/setStyle needed (the per-frame cost the manifest removes).
-func (b *Baker) recordScamin(scamin uint32) {
- if scamin == 0 {
- return
- }
- if b.scaminSeen == nil {
- b.scaminSeen = map[uint32]struct{}{}
- }
- b.scaminSeen[scamin] = struct{}{}
-}
-
-// ScaminValues returns this band's distinct SCAMIN denominators, ascending.
-func (b *Baker) ScaminValues() []uint32 {
- out := make([]uint32, 0, len(b.scaminSeen))
- for v := range b.scaminSeen {
- out = append(out, v)
- }
- slices.Sort(out)
- return out
-}
-
-// CellPortrayal carries a cell's precomputed portrayal from the parallel
-// PortrayCell step to the serial AddCellPortrayed step. It is opaque to callers,
-// which only shuttle it between the two; supported is false when the portrayer
-// can't precompute (then AddCellPortrayed portrays inline, like AddCell).
-type CellPortrayal struct {
- cp cellPortrayal
- supported bool
-}
-
-// PortrayCell runs a cell's (expensive) S-101 portrayal without mutating the
-// Baker, so callers can do it on a worker pool. The result is replayed by
-// AddCellPortrayed during the serial route/merge. Concurrency-safe: it only reads
-// the read-only portrayer/catalogue and the internally-locked proto cache.
-func (b *Baker) PortrayCell(chart *s57.Chart) CellPortrayal {
- pp, ok := b.portrayer.(precomputingPortrayer)
- if !ok {
- return CellPortrayal{}
- }
- return CellPortrayal{cp: pp.portray(featurePtrs(chart.Features())), supported: true}
-}
-
-// featurePtrs adapts a cell's feature slice to the []*Feature the portrayer takes.
-func featurePtrs(features []s57.Feature) []*s57.Feature {
- fps := make([]*s57.Feature, len(features))
- for i := range features {
- fps[i] = &features[i]
- }
- return fps
-}
-
-// AddCell expands every feature of a parsed cell into routed primitives at the
-// cell's scale band, with per-feature SCAMIN display z-min. It portrays the cell
-// inline; use PortrayCell + AddCellPortrayed to portray off-thread in parallel.
-func (b *Baker) AddCell(chart *s57.Chart) { b.addCell(chart, CellPortrayal{}) }
-
-// AddCellPortrayed is AddCell with the cell's portrayal already computed (by
-// PortrayCell, typically on a worker pool). The routing/merge it does is the same
-// serial, deterministic work as AddCell — call it in a fixed cell order.
-func (b *Baker) AddCellPortrayed(chart *s57.Chart, pc CellPortrayal) { b.addCell(chart, pc) }
-
-// addCell is the shared body: portray (replaying pc if precomputed, else inline)
-// then route the cell's features serially.
-func (b *Baker) addCell(chart *s57.Chart, pc CellPortrayal) {
- if b.portrayer == nil {
- panic("bake: no S-101 portrayer set — build with `make` (-tags embed_s101) or pass --s101")
- }
- // Cell name (sans the .000/.NNN extension) stamped on every feature for the
- // inspector's source-cell pill.
- b.curCell = chart.DatasetName()
- if i := strings.LastIndexByte(b.curCell, '.'); i > 0 {
- b.curCell = b.curCell[:i]
- }
- if b.linestyles == nil {
- // Complex-line dash/symbol geometry comes from the S-101 catalogue (via the
- // portrayer) now, not the S-52 PresLib.
- if src, ok := b.portrayer.(linestyleSource); ok {
- b.linestyles = src.LinestyleTable()
- }
- }
- band := BandForScale(uint32(chart.CompilationScale()))
- b.curCscl = uint32(chart.CompilationScale()) // per-cell best-available: finer (smaller) wins
- zr := band.ZoomRange()
- // Display range vs native band. General cells overzoom OUT (down to z2) so
- // their data doesn't vanish when you zoom out past z7 with no overview
- // coverage. The native band [zr] still drives best-available suppression, so
- // general yields wherever a coarser (overview) cell actually overlaps — it
- // only fills the gaps. (We don't raise the display ceiling: indexing a
- // cell-wide prim up to high zoom would blow up the emit index.)
- dr := zr
- if band == BandGeneral || b.OverzoomAllBands {
- dr.Min = generalOverzoomMin
- }
- cb := chart.Bounds()
- cellLat := (cb.MinLat + cb.MaxLat) / 2 // SCAMIN→zoom uses the cell's display scale
- features := chart.Features()
- // Cell data-coverage (M_COVR CATCOV=1) → covMeta for best-available suppression
- // and scale boundaries. Skipped when the coverage was already built in a prior
- // pass (the streaming bake builds it once up front, then re-routes per band).
- if !b.skipCoverage {
- b.extractCoverage(features, zr, b.curCell, b.curCscl, cellDisplayMin(band, zr))
- }
- // S-101: portray the whole cell in one engine pass (one Lua chunk + context,
- // fresh Lua state) up front instead of per-feature — the per-feature path
- // recompiled the chunk and leaked the catalogue's file-local caches. Either
- // replay a precomputed portrayal (parallel bake) or portray inline now; the
- // result is released after this cell's features are routed.
- switch pp := b.portrayer.(type) {
- case precomputingPortrayer:
- if pc.supported {
- pp.install(pc.cp)
- } else {
- pp.install(pp.portray(featurePtrs(features)))
- }
- defer pp.End()
- case BatchPortrayer:
- pp.Begin(featurePtrs(features))
- defer pp.End()
- }
-
- // Combine co-located lights (S-52 LIGHTS06): one flare + one merged label.
- lightPrimary, lightSkip := b.groupCoLocatedLights(features)
- b.seenSector = make(map[sectorKey]struct{})
- for i := range features {
- f := &features[i]
- // Per-feature inspector data: the object name, and (for lights) the S-52
- // light characteristic string ("Fl.R.4s") so the inspector can show the
- // light data, not just the symbol.
- b.curObjnam = stringAttr(f.Attributes(), "OBJNAM")
- b.curAttrs = encodeS57Attrs(f.Attributes())
- b.curLight = ""
- b.curLightSkip = false
- b.curLightText = ""
- // Boundary symbolization (S-52 §8.6.1): a style-variant area is built
- // twice (plain bnd=0 / symbolized bnd=1) so the client toggles boundary
- // style live; everything else is one pass tagged bnd=2.
- // The portrayer (the build-time embedded catalogue, or --s101) runs the
- // S-101 rules to produce the passes.
- passes := b.portrayer.Passes(f)
- if f.ObjectClass() == "LIGHTS" {
- // The characteristic string comes from the catalogue rule (LITDSN02),
- // harvested from the portrayal — not re-derived in Go.
- b.curLight = lightCharText(passes)
- b.curLightSkip = lightSkip[i]
- if merged, ok := lightPrimary[i]; ok {
- b.curLightText = merged
- b.curLight = merged // inspector shows the combined characteristic
- }
- }
- for _, pass := range passes {
- fb := pass.Build
- bnd := int64(pass.Bnd)
- pts := int64(pass.Pts)
- scamin := intAttr(f.Attributes(), "SCAMIN")
- b.curScamin = scamin // baked as the `scamin` tag → client per-SCAMIN bucket layers
- b.recordScamin(scamin) // publish the band's distinct values (manifest → TileJSON)
- // Date validity period (S-52 §10.4.1.1) — baked onto each primitive so the
- // client applies the mandatory current-date filter.
- b.curDateStart, b.curDateEnd = fb.DateStart, fb.DateEnd
- zMin := bandZMin(fb.DisplayCategory, scamin, dr.Min, cellLat)
- class := f.ObjectClass()
- drval1, drval2 := depthVals(f.Attributes(), class)
- valdco := contourValdco(f.Attributes(), class)
- prims := fb.Primitives
- for pi := 0; pi < len(prims); pi++ {
- // A sounding number (SOUNDG03) emits one digit glyph per column, all at
- // the same anchor (the glyph art carries the column shift). Group a
- // number's consecutive same-anchor digit glyphs into ONE soundings
- // feature so the client renders the whole number and declutter treats
- // it as a single unit.
- if sc, ok := prims[pi].(portrayal.SymbolCall); ok && isSoundingName(sc.SymbolName) {
- names := []string{sc.SymbolName}
- for pi+1 < len(prims) {
- nsc, ok := prims[pi+1].(portrayal.SymbolCall)
- if !ok || !isSoundingName(nsc.SymbolName) || nsc.Anchor != sc.Anchor {
- break
- }
- names = append(names, nsc.SymbolName)
- pi++
- }
- b.routeSoundingGroup(names, sc, class, fb.DisplayPriority, fb.DisplayCategory, zr, zMin, dr.Max, bnd, pts)
- continue
- }
- // Co-located lights (S-52 LIGHTS06): a non-primary light drops its
- // flare + characteristic text (sectors still emit, deduped); the
- // primary's text becomes the merged multi-line characteristic.
- p := prims[pi]
- if class == "LIGHTS" {
- switch v := p.(type) {
- case portrayal.SymbolCall:
- if b.curLightSkip {
- continue
- }
- case portrayal.DrawText:
- if b.curLightSkip {
- continue
- }
- if b.curLightText != "" {
- v.Text = b.curLightText
- p = v
- }
- }
- }
- // DEPCNT contour values are emitted by the rule as SAFCON digit
- // glyphs — fixed metres, and "0" when the contour has no value
- // (the "0 by the shore" labels). Drop them; the client labels the
- // contour from the baked `valdco`, converting to the chosen unit
- // and showing nothing when there is no value.
- if class == "DEPCNT" {
- if sc, ok := p.(portrayal.SymbolCall); ok && strings.HasPrefix(sc.SymbolName, "SAFCON") {
- continue
- }
- }
- // DRGARE dredged depth is emitted by the rule as a fixed-metres text
- // label ("%gm", DredgedArea.lua). Drop it; the client labels the
- // dredged area from the baked `drval1` (carried on the area below),
- // converting to the chosen depth unit — same pattern as DEPCNT/valdco.
- if class == "DRGARE" {
- if _, ok := p.(portrayal.DrawText); ok {
- continue
- }
- }
- // SY(INFORM01) is the S-52 §10.6.1.1 "additional information available"
- // marker — always display priority 8, category Other, regardless of the
- // host feature's category (so it clears Standard display and only shows
- // when the mariner enables Other).
- cat, prio := fb.DisplayCategory, fb.DisplayPriority
- if sc, ok := p.(portrayal.SymbolCall); ok && sc.SymbolName == "INFORM01" {
- cat, prio = displayCatOther, 8
- }
- b.route(p, class, prio, cat, zr, zMin, dr.Max, bnd, pts, drval1, drval2, valdco)
- }
- }
- }
-}
-
-// appendDateTags adds the feature's date-validity period (S-52 §10.4.1.1) to a
-// tile feature's attrs when it has one, so the client's mandatory current-date
-// filter can hide it outside its period. The period is baked in a filter-friendly
-// form: date_start / date_end are the comparable bound strings — a recurring
-// month-day "MMDD" (from an S-57 "--MMDD" partial) or a full "YYYYMMDD" — each
-// present only when that bound exists (a one-sided range is semi-open); the
-// boolean date_recurring (present iff the feature is dated) tells the client which
-// "today" form to compare against and lexicographic compare does the rest.
-func appendDateTags(attrs []mvt.KeyValue, start, end string) []mvt.KeyValue {
- if start == "" && end == "" {
- return attrs
- }
- recurring := strings.HasPrefix(start, "--") || strings.HasPrefix(end, "--")
- norm := func(s string) string { return strings.TrimPrefix(s, "--") }
- if start != "" {
- attrs = append(attrs, mvt.KeyValue{Key: "date_start", Value: mvt.StringVal(norm(start))})
- }
- if end != "" {
- attrs = append(attrs, mvt.KeyValue{Key: "date_end", Value: mvt.StringVal(norm(end))})
- }
- rec := int64(0)
- if recurring {
- rec = 1
- }
- attrs = append(attrs, mvt.KeyValue{Key: "date_recurring", Value: mvt.IntVal(rec)})
- return attrs
-}
-
-// routeSoundingGroup emits one soundings feature for a whole sounding number
-// (the comma-joined digit-glyph list), carrying depth + both palette variants so
-// the client runs SNDFRM04's safety-depth split live.
-func (b *Baker) routeSoundingGroup(names []string, sc portrayal.SymbolCall, class string, drawPrio, cat int, zr ZoomRange, zMin, zMax uint32, bnd, pts int64) {
- joined := strings.Join(names, ",")
- r := routed{layer: "soundings", kind: mvt.GeomPoint, npoint: normPt(sc.Anchor), zMin: zMin, zMax: zMax, natMin: zr.Min, natMax: zr.Max}
- attrs := []mvt.KeyValue{
- {Key: "class", Value: mvt.StringVal(class)},
- {Key: "cell", Value: mvt.StringVal(b.curCell)},
- {Key: "draw_prio", Value: mvt.IntVal(int64(drawPrio))},
- {Key: "cat", Value: mvt.IntVal(catRank(cat))},
- {Key: "bnd", Value: mvt.IntVal(bnd)},
- {Key: "symbol_names", Value: mvt.StringVal(joined)},
- {Key: "scale", Value: mvt.FloatVal(sc.Scale)},
- }
- if pts != ptsAlwaysShown {
- attrs = append(attrs, mvt.KeyValue{Key: "pts", Value: mvt.IntVal(pts)})
- }
- if b.curAttrs != "" {
- attrs = append(attrs, mvt.KeyValue{Key: "s57", Value: mvt.StringVal(b.curAttrs)})
- }
- if b.curScamin != 0 {
- attrs = append(attrs, mvt.KeyValue{Key: "scamin", Value: mvt.IntVal(int64(b.curScamin))})
- }
- if !isNaN32(sc.SoundingDepthM) {
- attrs = append(attrs,
- mvt.KeyValue{Key: "depth", Value: mvt.FloatVal(sc.SoundingDepthM)},
- mvt.KeyValue{Key: "sym_s", Value: mvt.StringVal(soundingVariant(joined, 'S'))},
- mvt.KeyValue{Key: "sym_g", Value: mvt.StringVal(soundingVariant(joined, 'G'))},
- )
- }
- r.attrs = attrs
- b.add(r, ptBbox(sc.Anchor))
-}
-
-// catRank maps the S-52 display category (DisplayBase/Standard/Other = 6/7/8)
-// to the client's category-filter rank (0/1/2). The frontend's categoryFilter
-// tests `cat ∈ {0,1,2}`, so the raw enum values would filter every feature out.
-func catRank(displayCategory int) int64 {
- switch displayCategory {
- case displayCatBase:
- return 0
- case displayCatStandard:
- return 1
- default: // displayCatOther
- return 2
- }
-}
-
-// bndAlwaysShown is the S-52 boundary-symbolization tag the client's
-// boundaryFilter always passes (2 = style-independent), used for geometry that
-// isn't a style-variant area boundary (sector lights here; most features via the
-// single bnd=2 pass). Style-variant areas instead get the plain (0) / symbolized
-// (1) split from portrayal.BuildFeaturePasses, so the boundary-style toggle works.
-const bndAlwaysShown int64 = 2
-
-// ptsAlwaysShown is the point-symbol-style tag the client's pointStyleFilter
-// always passes (2 = style-independent). Emitted only for the paper/simplified
-// variant passes (0/1) of point features that actually differ between the two
-// LUP tables; every other feature omits `pts` and the client coalesces to 2.
-const ptsAlwaysShown int64 = 2
-
-func (b *Baker) add(r routed, bb geo.BoundingBox) {
- b.bbox.ExtendBox(bb)
- if r.cscl == 0 {
- r.cscl = b.curCscl // owning cell's compilation scale (structural prims may preset it)
- }
- r.wMinX = normX(bb.MinLon)
- r.wMaxX = normX(bb.MaxLon)
- r.wMinY = normY(bb.MaxLat) // north -> smaller y
- r.wMaxY = normY(bb.MinLat) // south -> larger y
- b.prims = append(b.prims, r)
-}
-
-func (b *Baker) internClass(s string) uint32 {
- if b.classIdx == nil {
- b.classIdx = map[string]uint32{}
- }
- if i, ok := b.classIdx[s]; ok {
- return i
- }
- i := uint32(len(b.classTab))
- b.classTab = append(b.classTab, s)
- b.classIdx[s] = i
- return i
-}
-
-func (b *Baker) internCell(s string) uint32 {
- if b.cellIdx == nil {
- b.cellIdx = map[string]uint32{}
- }
- if i, ok := b.cellIdx[s]; ok {
- return i
- }
- i := uint32(len(b.cellTab))
- b.cellTab = append(b.cellTab, s)
- b.cellIdx[s] = i
- return i
-}
-
-// attrsFor returns a prim's full MVT tags. For route() prims (bcBase) it rebuilds
-// the base (class/cell/draw_prio/cat/bnd/pts) from the compact fields + interned
-// strings into the caller's reused scratch slice (no per-emit allocation), then
-// appends the variable tags. Other prims return their full attrs unchanged.
-func (b *Baker) attrsFor(r *routed, scratch *[]mvt.KeyValue) []mvt.KeyValue {
- if !r.bcBase {
- return r.attrs
- }
- out := append((*scratch)[:0],
- mvt.KeyValue{Key: "class", Value: mvt.StringVal(b.classTab[r.bcClass])},
- mvt.KeyValue{Key: "cell", Value: mvt.StringVal(b.cellTab[r.bcCell])},
- mvt.KeyValue{Key: "draw_prio", Value: mvt.IntVal(int64(r.bcDrawP))},
- mvt.KeyValue{Key: "cat", Value: mvt.IntVal(int64(r.bcCat))},
- mvt.KeyValue{Key: "bnd", Value: mvt.IntVal(int64(r.bcBnd))},
- )
- if r.bcHasPts {
- out = append(out, mvt.KeyValue{Key: "pts", Value: mvt.IntVal(int64(r.bcPts))})
- }
- if r.bcScamin != 0 {
- out = append(out, mvt.KeyValue{Key: "scamin", Value: mvt.IntVal(int64(r.bcScamin))})
- }
- out = append(out, r.attrs...)
- *scratch = out
- return out
-}
-
-// scaminLayer redirects an area/line primitive's source-layer to a dedicated
-// "_scamin" layer when the feature carries SCAMIN (1:N display limit, S-52
-// §8.4). The client buckets these *_scamin layers into per-SCAMIN fractional-minzoom
-// variants so the feature DISAPPEARS when zoomed out past its 1:N scale; no-SCAMIN
-// features stay in the original always-in-band layer (unchanged). Only the four
-// area/line layers route here — point_symbols/soundings/text/sector_lines already
-// carry `scamin` and are bucketed directly on their own source-layers.
-func scaminLayer(layer string, scamin uint32) string {
- if scamin == 0 {
- return layer
- }
- switch layer {
- case "areas", "area_patterns", "lines", "complex_lines":
- return layer + "_scamin"
- }
- return layer
-}
-
-func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr ZoomRange, zMin, zMax uint32, bnd, pts int64, drval1, drval2, valdco float32) {
- // The always-present base (class/cell/draw_prio/cat/bnd/pts) is stored compactly
- // (interned class/cell + small ints) and rebuilt at emit by attrsFor; `common`
- // returns only the VARIABLE tags — the per-feature extra plus the sparse
- // inspector/pick fields (objnam/light/s57). Tag ORDER is irrelevant (MVT
- // properties are a map), so appending these after extra is fine.
- common := func(extra ...mvt.KeyValue) []mvt.KeyValue {
- if b.curObjnam != "" {
- extra = append(extra, mvt.KeyValue{Key: "objnam", Value: mvt.StringVal(b.curObjnam)})
- }
- if b.curLight != "" {
- extra = append(extra, mvt.KeyValue{Key: "light", Value: mvt.StringVal(b.curLight)})
- }
- // Full S-57 attribute set for the cursor-pick report (S-52 PresLib §10.8).
- if b.curAttrs != "" {
- extra = append(extra, mvt.KeyValue{Key: "s57", Value: mvt.StringVal(b.curAttrs)})
- }
- // Date validity (S-52 §10.4.1.1): the client's mandatory current-date filter
- // hides a date-dependent feature outside its period.
- extra = appendDateTags(extra, b.curDateStart, b.curDateEnd)
- return extra
- }
- r := routed{
- zMin: zMin, zMax: zMax, natMin: zr.Min, natMax: zr.Max,
- bcBase: true,
- bcClass: b.internClass(class),
- bcCell: b.internCell(b.curCell),
- bcDrawP: int16(drawPrio),
- bcCat: int16(catRank(cat)),
- bcBnd: int8(bnd),
- bcScamin: b.curScamin,
- }
- // pts is omitted for the common case (2): only paper/simplified variant passes
- // (0/1) carry it, so most features stay lean.
- if pts != ptsAlwaysShown {
- r.bcHasPts, r.bcPts = true, int16(pts)
- }
-
- switch v := p.(type) {
- case portrayal.FillPolygon:
- r.layer, r.kind, r.nrings = scaminLayer("areas", r.bcScamin), mvt.GeomPolygon, normRings(v.Rings)
- extra := []mvt.KeyValue{{Key: "color_token", Value: mvt.StringVal(v.ColorToken)}}
- // Depth areas (DEPARE/DRGARE) carry DRVAL1/DRVAL2 so the client runs
- // SEABED01 shading + the safety-contour line + shallow pattern LIVE
- // against the mariner's contours (no re-bake). Other areas don't.
- if !isNaN32(drval1) {
- extra = append(extra,
- mvt.KeyValue{Key: "drval1", Value: mvt.FloatVal(drval1)},
- mvt.KeyValue{Key: "drval2", Value: mvt.FloatVal(drval2)})
- }
- r.attrs = common(extra...)
- b.add(r, ringsBbox(v.Rings))
- case portrayal.PatternFill:
- if v.Sparse {
- // Widely-spaced fill pattern (§8.5.4): place discrete WHOLE symbols on a
- // geographic lattice instead of a tiled, edge-clipped texture.
- b.routeSparsePattern(v, common, r)
- break
- }
- r.layer, r.kind, r.nrings = scaminLayer("area_patterns", r.bcScamin), mvt.GeomPolygon, normRings(v.Rings)
- r.attrs = common(mvt.KeyValue{Key: "pattern_name", Value: mvt.StringVal(v.PatternName)})
- b.add(r, ringsBbox(v.Rings))
- case portrayal.StrokeLine:
- r.layer, r.kind, r.nline = scaminLayer("lines", r.bcScamin), mvt.GeomLineString, normPts(v.Points)
- extra := []mvt.KeyValue{
- {Key: "color_token", Value: mvt.StringVal(v.ColorToken)},
- {Key: "width_px", Value: mvt.IntVal(int64(v.WidthPx + 0.5))},
- {Key: "dash", Value: mvt.StringVal(dashName(v.Dash))},
- }
- // DEPCNT depth-contour value (metres) so the client labels the contour in
- // the chosen depth unit (SAFCON01, client-side); only set for contours.
- if !isNaN32(valdco) {
- extra = append(extra, mvt.KeyValue{Key: "valdco", Value: mvt.FloatVal(valdco)})
- }
- r.attrs = common(extra...)
- b.add(r, ptsBbox(v.Points))
- case portrayal.LinePattern:
- // Stored as a polyline + its linestyle; emitComplexLine tessellates the
- // period (dashes → these complex_lines segments, symbols → point_symbols)
- // per zoom at emit time. colour_token drives the live restyle of the dashes.
- r.layer, r.kind, r.nline = scaminLayer("complex_lines", r.bcScamin), mvt.GeomLineString, normPts(v.Points)
- r.ls = b.linestyles[v.LinestyleName]
- ct := v.ColorToken
- if ct == "" && r.ls != nil {
- ct = r.ls.colorToken
- }
- r.attrs = common(
- mvt.KeyValue{Key: "linestyle_name", Value: mvt.StringVal(v.LinestyleName)},
- mvt.KeyValue{Key: "color_token", Value: mvt.StringVal(ct)},
- )
- b.add(r, ptsBbox(v.Points))
- case portrayal.DrawText:
- r.layer, r.kind, r.npoint = "text", mvt.GeomPoint, normPt(v.Anchor)
- r.attrs = common(
- mvt.KeyValue{Key: "text", Value: mvt.StringVal(v.Text)},
- mvt.KeyValue{Key: "font_size_px", Value: mvt.FloatVal(v.FontSizePx)},
- mvt.KeyValue{Key: "color_token", Value: mvt.StringVal(v.ColorToken)},
- mvt.KeyValue{Key: "halign", Value: mvt.StringVal(halignName(v.HAlign))},
- mvt.KeyValue{Key: "valign", Value: mvt.StringVal(valignName(v.VAlign))},
- mvt.KeyValue{Key: "offset_x", Value: mvt.FloatVal(v.OffsetXPx)},
- mvt.KeyValue{Key: "offset_y", Value: mvt.FloatVal(v.OffsetYPx)},
- mvt.KeyValue{Key: "halo_color_token", Value: mvt.StringVal(haloTextColor(v.Halo))},
- mvt.KeyValue{Key: "halo_width", Value: mvt.FloatVal(haloTextWidth(v.Halo))},
- // S-52 §14.4 text grouping (DISPLAY param) so the client can toggle
- // text groups (§14.5) live: 11=important, 21/26/29=names, 23=light, …
- mvt.KeyValue{Key: "tgrp", Value: mvt.IntVal(int64(v.Group))},
- )
- b.add(r, ptBbox(v.Anchor))
- case portrayal.SymbolCall:
- b.routeSymbol(v, common, r)
- case portrayal.AugmentedFigure:
- // One constructed sector-figure element (leg / arc). Dedupe identical
- // elements (co-located lights often repeat sectors).
- var p1, p2, p3 int64
- if v.Ray {
- p1, p2 = quantDeg(v.BearingDeg), quantDeg(v.LengthMM)
- } else {
- p1, p2, p3 = quantDeg(v.RadiusMM), quantDeg(v.StartDeg), quantDeg(v.SweepDeg)
- }
- k := sectorKey{
- lat: quantDeg(v.Anchor.Lat), lon: quantDeg(v.Anchor.Lon),
- ray: v.Ray, p1: p1, p2: p2, p3: p3,
- col: v.ColorToken, w: quantDeg(v.WidthMM), dashed: v.Dash == portrayal.DashDashed,
- }
- if b.seenSector != nil {
- if _, dup := b.seenSector[k]; dup {
- return
- }
- b.seenSector[k] = struct{}{}
- }
- b.bbox.ExtendPoint(v.Anchor)
- var legNorm float64
- if v.Ray && v.FullLengthNM > 0 {
- legNorm = sectorLegFullNorm(v.Anchor.Lat, v.FullLengthNM)
- }
- b.sectors = append(b.sectors, sectorPrim{
- fig: v, class: class, cell: b.curCell,
- drawPrio: drawPrio, cat: cat, zMin: zMin, natMax: zr.Max,
- scamin: b.curScamin,
- dateStart: b.curDateStart, dateEnd: b.curDateEnd,
- legNorm: legNorm,
- })
- }
-}
-
-// routeSymbol routes a non-sounding SY symbol to point_symbols. Sounding digits
-// are grouped in AddCell, so they never reach here.
-func (b *Baker) routeSymbol(v portrayal.SymbolCall, common func(...mvt.KeyValue) []mvt.KeyValue, r routed) {
- r.kind, r.npoint = mvt.GeomPoint, normPt(v.Anchor)
- r.layer = "point_symbols"
- attrs := common(
- mvt.KeyValue{Key: "symbol_name", Value: mvt.StringVal(v.SymbolName)},
- mvt.KeyValue{Key: "rotation_deg", Value: mvt.FloatVal(v.RotationDeg)},
- mvt.KeyValue{Key: "scale", Value: mvt.FloatVal(v.Scale)},
- mvt.KeyValue{Key: "offset_x", Value: mvt.FloatVal(v.OffsetXUnits)},
- mvt.KeyValue{Key: "offset_y", Value: mvt.FloatVal(v.OffsetYUnits)},
- mvt.KeyValue{Key: "halo_color_token", Value: mvt.StringVal(haloSymColor(v.Halo))},
- mvt.KeyValue{Key: "halo_width", Value: mvt.FloatVal(haloSymWidth(v.Halo))},
- )
- // rot_north tags a TRUE-NORTH-referenced rotation (ORIENT etc.), so the client
- // routes it to the map-aligned point-symbol layer that turns with the chart.
- // Absent ⇒ screen-referenced (upright), the common case — kept off the tile to
- // stay compact (S-52 PresLib §9.2 ROT 1/2 vs 3).
- if v.RotationTrueNorth {
- attrs = append(attrs, mvt.KeyValue{Key: "rot_north", Value: mvt.IntVal(1)})
- }
- // pivot_center tags the primary centred-area symbol: the client centres the glyph
- // on the point instead of the catalogue's fan-out pivot (S-52 §8.5.1; CentreOnArea).
- if v.CentreOnArea {
- attrs = append(attrs, mvt.KeyValue{Key: "pivot_center", Value: mvt.IntVal(1)})
- }
- if !isNaN32(v.DangerDepthM) {
- attrs = append(attrs,
- mvt.KeyValue{Key: "danger_depth", Value: mvt.FloatVal(v.DangerDepthM)},
- mvt.KeyValue{Key: "sym_deep", Value: mvt.StringVal(v.DeepSymbolName)},
- )
- }
- r.attrs = attrs
- b.add(r, ptBbox(v.Anchor))
-}
-
-// routeSparsePattern places a widely-spaced S-52 "fill pattern" (PresLib §8.5.4)
-// as individual WHOLE symbols on a fixed geographic lattice, instead of a tiled
-// MapLibre fill-pattern (which clips symbols mid-glyph at the area edge and can't
-// adapt its spacing to the area). The lattice is spanned by the pattern's V1/V2
-// vectors (display millimetres → ground metres at the cell's compilation scale)
-// and phase-anchored to a global origin, so adjacent areas align and the pattern
-// doesn't drift on pan (§8.5.4: "based on a geographical position … not on an edge
-// of the … area"). A symbol is emitted only where a lattice point falls INSIDE the
-// polygon (whole, never clipped); if the area is too small to contain one, a
-// single symbol is centred on the representative point (the small-area case).
-func (b *Baker) routeSparsePattern(v portrayal.PatternFill, common func(...mvt.KeyValue) []mvt.KeyValue, r routed) {
- emit := func(ll geo.LatLon) {
- // No CentreOnArea: a pattern symbol carries its pivot at its designed
- // centre (the at 0,0), so the normal pivot-on-
- // point placement centres it exactly. CentreOnArea's "ctr:" variant centres
- // the content BOUNDING BOX instead — meant for corner-pivot "…RES" symbols —
- // and would nudge these off (a downward triangle's bbox centre ≠ its pivot).
- b.routeSymbol(portrayal.SymbolCall{
- Anchor: ll,
- SymbolName: v.SymbolRef,
- Scale: portrayal.DefaultPxPerSymbolUnit,
- SoundingDepthM: nan32f,
- DangerDepthM: nan32f,
- }, common, r)
- }
-
- scale := float64(b.curCscl)
- if scale <= 0 || len(v.Rings) == 0 || len(v.Rings[0]) < 3 {
- emit(v.Anchor) // no scale/geometry → fall back to one centred symbol
- return
- }
- // Lattice basis in ground metres: P mm on the chart at 1:scale = P/1000·scale m.
- // V1 is horizontal (east); V1[1] is always 0 in the catalogue.
- const mPerMM = 1.0 / 1000.0
- v1e := v.V1[0] * mPerMM * scale
- v2e := v.V2[0] * mPerMM * scale
- v2n := v.V2[1] * mPerMM * scale
- if v1e < 1 || v2n < 1 {
- emit(v.Anchor)
- return
- }
- // Local equirectangular metres with a GLOBAL (0,0) origin so the lattice phase
- // is shared across areas; the east scale uses cos(latRef) for the cell's band.
- bb := ringsBbox(v.Rings)
- latRef := (bb.MinLat + bb.MaxLat) / 2
- const mPerDegLat = 111320.0
- mPerDegLon := mPerDegLat * math.Cos(latRef*math.Pi/180)
- if mPerDegLon < 1 {
- emit(v.Anchor)
- return
- }
- // Convert rings to [lon,lat] for the even-odd point-in-polygon test.
- rings := make([][][]float64, len(v.Rings))
- for i, ring := range v.Rings {
- rr := make([][]float64, len(ring))
- for k, p := range ring {
- rr[k] = []float64{p.Lon, p.Lat}
- }
- rings[i] = rr
- }
- // A symbol must remain INSIDE the area (§8.5.1.1) — placing it only where its
- // centre is inside still lets a glyph overhang the boundary. So require the
- // symbol's footprint to fit: test the lattice point plus four cardinal offsets
- // at ~symbol-half-extent (a fraction of the cell) are all inside. This also
- // makes a SMALL area (≈ one cell, e.g. the Chart-1 quality boxes) hold no
- // fitting lattice point, so it collapses to one centred symbol (§8.5.2 / the
- // spec's "closer together for a small area" intent) rather than a clutter of
- // edge-clipped repeats.
- insetLon := 0.4 * v1e / mPerDegLon
- insetLat := 0.4 * v2n / mPerDegLat
- fits := func(lon, lat float64) bool {
- return pointInRings(lon, lat, rings) &&
- pointInRings(lon-insetLon, lat, rings) && pointInRings(lon+insetLon, lat, rings) &&
- pointInRings(lon, lat-insetLat, rings) && pointInRings(lon, lat+insetLat, rings)
- }
- eMin, eMax := bb.MinLon*mPerDegLon, bb.MaxLon*mPerDegLon
- nMin, nMax := bb.MinLat*mPerDegLat, bb.MaxLat*mPerDegLat
- jLo, jHi := int(math.Floor(nMin/v2n)), int(math.Ceil(nMax/v2n))
- var pts []geo.LatLon
- const maxLattice = 4000 // perf guard; sparse patterns never approach this
- for j := jLo; j <= jHi && len(pts) < maxLattice; j++ {
- n := float64(j) * v2n
- iLo := int(math.Floor((eMin - float64(j)*v2e) / v1e))
- iHi := int(math.Ceil((eMax - float64(j)*v2e) / v1e))
- for i := iLo; i <= iHi && len(pts) < maxLattice; i++ {
- e := float64(i)*v1e + float64(j)*v2e
- lon, lat := e/mPerDegLon, n/mPerDegLat
- if fits(lon, lat) {
- pts = append(pts, geo.LatLon{Lat: lat, Lon: lon})
- }
- }
- }
- // A small area that seats at most one symbol gets a single CENTRED symbol on
- // the representative point (§8.5.2) — centred in its box, not parked at an
- // off-centre global-lattice point. Only a larger area (≥2 fitting points) draws
- // the spaced lattice.
- if len(pts) <= 1 {
- emit(v.Anchor)
- return
- }
- for _, ll := range pts {
- emit(ll)
- }
-}
-
-// emitScaleBoundaries draws S-52 DATCVR §10.1.9.1 "chart scale boundaries": a
-// line where the navigational purpose changes — i.e. along a cell's data-coverage
-// (M_COVR CATCOV=1) edge wherever STRICTLY-COARSER data lies just outside it. The
-// boundary is emitted once, by the finer cell, and extended DOWN to the coarser
-// side's band so it is visible when zoomed out (marking "larger-scale data here").
-// Edges shared with a SAME-band cell are internal seams and are suppressed (the
-// spec draws only navigational-purpose changes, not minor in-band joins); edges
-// facing no data are the end of coverage, shown by the NODATA hatch, not a line.
-// Idempotent; runs once before tile enumeration. Adds prims to b.prims.
-//
-// Outside-side membership is sampled by probing a short perpendicular off each
-// segment's midpoint (the side not inside this cell's own coverage), then a
-// point-in-coverage test against the other cells — robust to mismatched ring
-// tessellation between adjoining cells (no shared-vertex assumption).
-func (b *Baker) emitScaleBoundaries() {
- if b.scaleBndEmitted {
- return
- }
- b.scaleBndEmitted = true
- if len(b.covMeta) < 2 {
- return
- }
- const eps = 0.0006 // ~60 m perpendicular probe (degrees, mid-latitude)
- for ci := range b.covMeta {
- cm := &b.covMeta[ci]
- for _, ring := range cm.rings {
- n := len(ring)
- if n < 2 {
- continue
- }
- var run []geo.LatLon
- minOut := cm.bandMin
- flush := func() {
- if len(run) >= 2 {
- b.addScaleBoundary(run, minOut, cm.bandMax)
- }
- run = run[:0]
- minOut = cm.bandMin
- }
- for i := range n {
- lon1, lat1 := ring[i][0], ring[i][1]
- lon2, lat2 := ring[(i+1)%n][0], ring[(i+1)%n][1]
- dx, dy := lon2-lon1, lat2-lat1
- plen := math.Hypot(dx, dy)
- if plen == 0 {
- continue
- }
- mx, my := (lon1+lon2)/2, (lat1+lat2)/2
- nx, ny := -dy/plen*eps, dx/plen*eps
- ax, ay := mx+nx, my+ny
- cx, cy := mx-nx, my-ny
- aIn := pointInRings(ax, ay, cm.rings)
- cIn := pointInRings(cx, cy, cm.rings)
- if aIn == cIn { // corner/degenerate — can't tell outside; break the run
- flush()
- continue
- }
- ox, oy := cx, cy
- if cIn {
- ox, oy = ax, ay
- }
- // Classify the outside: a same-band neighbour ⇒ internal seam (drop);
- // the coarsest strictly-coarser neighbour sets how far down to show it.
- seam, coarser := false, false
- coarsestMin := cm.bandMin
- for oj := range b.covMeta {
- if oj == ci {
- continue
- }
- o := &b.covMeta[oj]
- if !o.bb.Contains(geo.LatLon{Lat: oy, Lon: ox}) || !pointInRings(ox, oy, o.rings) {
- continue
- }
- if o.bandMin == cm.bandMin {
- seam = true
- break
- }
- if o.bandMin < cm.bandMin { // smaller band min = coarser
- coarser = true
- if o.bandMin < coarsestMin {
- coarsestMin = o.bandMin
- }
- }
- }
- if seam || !coarser {
- flush()
- continue
- }
- if len(run) == 0 {
- run = append(run, geo.LatLon{Lat: lat1, Lon: lon1})
- }
- run = append(run, geo.LatLon{Lat: lat2, Lon: lon2})
- if coarsestMin < minOut {
- minOut = coarsestMin
- }
- }
- flush()
- }
- }
-}
-
-// addScaleBoundary appends one scale-boundary polyline prim (DATCVR §10.1.9.1).
-// natMin==zMin and natMax==zMax so best-available suppression never touches it —
-// the boundary is a structural line, shown across its whole [zMin,zMax] span.
-func (b *Baker) addScaleBoundary(pts []geo.LatLon, zMin, zMax uint32) {
- bb := geo.EmptyBox()
- for _, p := range pts {
- bb.ExtendPoint(p)
- }
- r := routed{
- layer: "scale_boundaries", kind: mvt.GeomLineString, nline: normPts(pts),
- zMin: zMin, zMax: zMax, natMin: zMin, natMax: zMax,
- attrs: []mvt.KeyValue{
- {Key: "class", Value: mvt.StringVal("SCLBDY")},
- {Key: "color_token", Value: mvt.StringVal("CHGRD")},
- {Key: "width_px", Value: mvt.IntVal(1)}, // S-52 §10.1.9.1 LS(SOLD,1,CHGRD)
- },
- }
- b.add(r, bb)
-}
-
-// TileCoords enumerates every tile (across each primitive's display zooms) that
-// the resident primitives touch.
-func (b *Baker) TileCoords(extent uint32) []tile.TileCoord {
- b.emitScaleBoundaries() // adds scale-boundary prims; must precede enumeration
- seen := map[uint64]struct{}{}
- var out []tile.TileCoord
- for i := range b.prims {
- r := &b.prims[i]
- bb := geo.BoundingBox{
- MinLat: unnormY(r.wMaxY), MinLon: r.wMinX*360 - 180,
- MaxLat: unnormY(r.wMinY), MaxLon: r.wMaxX*360 - 180,
- }
- out = addRange(out, seen, bb, r.zMin, b.clampZMax(r.zMax), extent)
- }
- // A sector light's screen-px figure (the 26 mm ring/legs) spills well beyond
- // its anchor tile — up to ~0.3 tile at any zoom. Enumerate every tile that
- // spill touches (per zoom, since the figure is a fixed fraction of a tile)
- // so a neighbour tile with no other primitives is still emitted; otherwise
- // the arc is clipped dead at the tile boundary.
- for i := range b.sectors {
- sp := &b.sectors[i]
- ax, ay := normX(sp.fig.Anchor.Lon), normY(sp.fig.Anchor.Lat)
- for z := sp.zMin; z <= b.clampZMax(sp.natMax); z++ {
- // Full-length legs (sp.legNorm, a fixed ground distance) can reach far
- // past the screen-px figure, so enumerate every tile they cross.
- r := math.Max(sectorRadiusNorm(z), sp.legNorm)
- bb := geo.BoundingBox{
- MinLat: unnormY(ay + r), MinLon: (ax-r)*360 - 180,
- MaxLat: unnormY(ay - r), MaxLon: (ax+r)*360 - 180,
- }
- out = addRange(out, seen, bb, z, z, extent)
- }
- }
- return out
-}
-
-// TileCoordsBand enumerates the tiles for ONE per-band archive: only this band's
-// own prims (natMax == bandMax), across [bandMin, bandBakeCeil(bandMax)]. The
-// client overzooms above the ceiling.
-func (b *Baker) TileCoordsBand(extent, bandMin, bandMax uint32) []tile.TileCoord {
- b.emitScaleBoundaries() // idempotent; adds scale-boundary prims before enumeration
- ceil := b.clampZMax(bandBakeCeil(bandMax))
- seen := map[uint64]struct{}{}
- var out []tile.TileCoord
- for i := range b.prims {
- r := &b.prims[i]
- if r.natMax != bandMax {
- continue
- }
- // lo is the prim's display zMin — normally ≥ bandMin, but a SCAMIN-bearing
- // feature may sit BELOW bandMin (it crosses into coarser bands down to its
- // SCAMIN scale). Emit it into the band archive at that lower zoom so the
- // source can serve it; the client's per-SCAMIN bucket layer gates the exact
- // cutoff. Non-SCAMIN features still have zMin == bandMin (bandZMin floors them).
- lo := r.zMin
- if lo > ceil {
- continue
- }
- bb := geo.BoundingBox{
- MinLat: unnormY(r.wMaxY), MinLon: r.wMinX*360 - 180,
- MaxLat: unnormY(r.wMinY), MaxLon: r.wMaxX*360 - 180,
- }
- out = addRange(out, seen, bb, lo, ceil, extent)
- }
- for i := range b.sectors {
- sp := &b.sectors[i]
- if sp.natMax != bandMax {
- continue
- }
- ax, ay := normX(sp.fig.Anchor.Lon), normY(sp.fig.Anchor.Lat)
- // Like the flare prim (see lo := r.zMin above), a SCAMIN-bearing sector may
- // sit BELOW bandMin — keep it AVAILABLE down to its SCAMIN scale so the
- // client's per-SCAMIN bucket gates the exact cutoff. Non-SCAMIN sectors have
- // zMin == bandMin (bandZMin floors them), so this is a no-op for them.
- lo := sp.zMin
- for z := lo; z <= b.clampZMax(sp.natMax); z++ {
- r := math.Max(sectorRadiusNorm(z), sp.legNorm)
- bb := geo.BoundingBox{
- MinLat: unnormY(ay + r), MinLon: (ax-r)*360 - 180,
- MaxLat: unnormY(ay - r), MaxLon: (ax+r)*360 - 180,
- }
- out = addRange(out, seen, bb, z, z, extent)
- }
- }
- return out
-}
-
-// BuildEmitIndex builds the inverted tile→prim index (b.emitIndex) for the given
-// extent and clip buffer, so EmitTileInto can iterate only the prims that touch a
-// tile rather than scanning every b.prims entry. Call once after all cells are
-// added and before the emit loop; the index is read-only thereafter (safe for the
-// parallel EmitTileInto workers). The index keys each tile a prim's
-// buffer-expanded bbox covers across its display-zoom span, so it is a strict
-// superset of EmitTileInto's in-tile reject — the reject still runs and trims the
-// boundary-tile over-inclusion, so behaviour is identical to the full scan.
-func (b *Baker) BuildEmitIndex(extent uint32, buffer float64) {
- b.buildEmitIndex(extent, buffer, 0)
-}
-
-// BuildEmitIndexBand builds the index for ONE band's archive: only that band's own
-// prims (natMax == bandMax), keyed up to the band's bake ceiling. Built+freed per
-// band (see BakeToPMTilesBands) so the indexes for all six bands are never resident
-// at once — the index for a full district is the dominant peak-RAM cost otherwise.
-func (b *Baker) BuildEmitIndexBand(extent uint32, buffer float64, bandMax uint32) {
- b.buildEmitIndex(extent, buffer, bandMax)
-}
-
-// ClearEmitIndex drops the emit index so its memory is reclaimed between bands.
-func (b *Baker) ClearEmitIndex() { b.emitIndex = nil }
-
-// buildEmitIndex builds the tile→prim index. bandMax==0 indexes every prim across
-// its full display zoom span (the merged tile); bandMax!=0 indexes only that band's
-// own prims (natMax == bandMax) up to the band's bake ceiling.
-func (b *Baker) buildEmitIndex(extent uint32, buffer float64, bandMax uint32) {
- b.emitScaleBoundaries() // adds scale-boundary prims; must precede indexing
- idx := map[uint64][]int32{}
- bufFrac := buffer / float64(extent)
- var dropped int // prims skipped for a pathological (corrupt-geometry) bbox
- for i := range b.prims {
- r := &b.prims[i]
- hi := b.clampZMax(r.zMax)
- if bandMax != 0 {
- if r.natMax != bandMax {
- continue // per-band index: skip other bands' prims
- }
- hi = b.clampZMax(bandBakeCeil(bandMax)) // bake the band's overzoom ceiling
- }
- // Guard against a single prim with a corrupt, world-spanning bbox (e.g. a
- // mis-assembled ring with a stray vertex): at a fine zoom it would expand to
- // hundreds of millions of tiles here and blow up memory (a 20 GB+ index)
- // before a single tile is baked. A real chart object never spans this many
- // tiles, so skip+log it rather than OOM the whole bake.
- if z0n := primTileSpan(r, b.clampZMax(hi), bufFrac); z0n > maxPrimTilesPerZoom {
- dropped++
- log.Printf("bake: skipping prim with implausible bbox (corrupt geometry): cell=%s class=%s bbox=[%.4f,%.4f,%.4f,%.4f] ~%d tiles @z%d",
- b.cellTab[r.bcCell], b.classTab[r.bcClass],
- r.wMinX*360-180, unnormY(r.wMaxY), r.wMaxX*360-180, unnormY(r.wMinY), z0n, hi)
- continue
- }
- for z := r.zMin; z <= hi; z++ {
- n := math.Pow(2, float64(z))
- last := int64(n) - 1
- bufN := bufFrac / n
- // A prim is eligible on tile x iff its bbox intersects the tile window
- // expanded by the render buffer: x ∈ [ceil((wMin-buf)·n)-1, floor((wMax+buf)·n)].
- xMin := clampTile(int64(math.Ceil((r.wMinX-bufN)*n))-1, last)
- xMax := clampTile(int64(math.Floor((r.wMaxX+bufN)*n)), last)
- yMin := clampTile(int64(math.Ceil((r.wMinY-bufN)*n))-1, last)
- yMax := clampTile(int64(math.Floor((r.wMaxY+bufN)*n)), last)
- for x := xMin; x <= xMax; x++ {
- for y := yMin; y <= yMax; y++ {
- key := uint64(z)<<40 | uint64(x)<<20 | uint64(y)
- idx[key] = append(idx[key], int32(i))
- }
- }
- }
- }
- if dropped > 0 {
- log.Printf("bake: dropped %d prim(s) with corrupt world-spanning geometry from the emit index", dropped)
- }
- b.emitIndex = idx
-}
-
-func clampTile(v, last int64) int64 {
- if v < 0 {
- return 0
- }
- if v > last {
- return last
- }
- return v
-}
-
-// maxPrimTilesPerZoom caps how many tiles a single prim may span at its bake
-// ceiling before it's treated as corrupt geometry (a stray vertex inflating the
-// bbox toward world scale) and dropped from the emit index. A real chart object —
-// even a coarse overview cell at its own coarse ceiling (world ≈ 65 k tiles at z8)
-// — stays far below this; only a mis-assembled ring reaches it, and indexing it
-// would expand to hundreds of millions of tiles and OOM the bake.
-const maxPrimTilesPerZoom int64 = 2_000_000
-
-// primTileSpan is the number of tiles a prim's (buffer-expanded) bbox covers at
-// zoom z — the per-prim cost it would add to the emit index at that zoom.
-func primTileSpan(r *routed, z uint32, bufFrac float64) int64 {
- n := math.Pow(2, float64(z))
- last := int64(n) - 1
- bufN := bufFrac / n
- xMin := clampTile(int64(math.Ceil((r.wMinX-bufN)*n))-1, last)
- xMax := clampTile(int64(math.Floor((r.wMaxX+bufN)*n)), last)
- yMin := clampTile(int64(math.Ceil((r.wMinY-bufN)*n))-1, last)
- yMax := clampTile(int64(math.Floor((r.wMaxY+bufN)*n)), last)
- return (xMax - xMin + 1) * (yMax - yMin + 1)
-}
-
-// sectorRadiusNorm is the sector figure's maximum screen-px extent (the 26 mm
-// all-round ring) in normalized-world units at zoom z. The geometry is laid out
-// in a 256-px-per-tile space (see tessellateFigure's worldPx), so the spill is a
-// fixed fraction of a tile at every zoom: 26 mm × px/mm ÷ 256 ÷ 2^z.
-func sectorRadiusNorm(z uint32) float64 {
- return 26.0 * float64(portrayal.DefaultPxPerSymbolUnit) * 100.0 / 256.0 / math.Pow(2, float64(z))
-}
-
-func addRange(out []tile.TileCoord, seen map[uint64]struct{}, bb geo.BoundingBox, zMin, zMax, extent uint32) []tile.TileCoord {
- for z := zMin; z <= zMax; z++ {
- rng := tile.RangeForBbox(z, bb, extent)
- for x := rng.XMin; x <= rng.XMax; x++ {
- for y := rng.YMin; y <= rng.YMax; y++ {
- key := uint64(z)<<40 | uint64(x)<<20 | uint64(y)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, tile.TileCoord{Z: z, X: x, Y: y})
- }
- }
- }
- return out
-}
-
-// BakePMTiles bakes every resident tile into a PMTiles archive builder (empty
-// tiles dropped, identical tiles deduped). Call WriteTo/Finish on the result.
-func (b *Baker) BakePMTiles(extent uint32, buffer float64) *pmtiles.Builder {
- pb := pmtiles.New()
- var ts TileScratch
- b.BuildEmitIndex(extent, buffer)
- for _, c := range b.TileCoords(extent) {
- if data := b.EmitTileInto(c, extent, buffer, &ts); data != nil {
- pb.AddTile(uint8(c.Z), c.X, c.Y, data)
- }
- }
- pb.SetScamin(b.ScaminValues()) // SCAMIN manifest in metadata (client builds buckets at load)
- // Override the tile-derived bounds (a z0 world tile would make them global)
- // with the real cell-union extent so clients frame to the charts.
- if bb := b.bbox; bb.MinLon <= bb.MaxLon && bb.MinLat <= bb.MaxLat {
- pb.SetBounds(bb.MinLon, bb.MinLat, bb.MaxLon, bb.MaxLat)
- }
- return pb
-}
-
-// TileScratch holds the per-tile working buffers EmitTile would otherwise
-// allocate fresh every tile — the clipper's ping-pong arrays, the ring
-// projection scratch, and the candidate index list. Reuse ONE per goroutine
-// across many EmitTileInto calls so the buffers grow once and amortise; this is
-// the dominant bake allocation otherwise (the clipper alone is ~⅓ of it).
-type TileScratch struct {
- clip tile.Clipper
- proj []tile.FPoint
- eligible []int
- attrs []mvt.KeyValue // reused per feature by attrsFor (no per-emit alloc)
-}
-
-// EmitTile bakes one tile with a throwaway scratch — convenience for the serial
-// path / tests. Hot parallel callers should reuse a TileScratch via EmitTileInto.
-func (b *Baker) EmitTile(coord tile.TileCoord, extent uint32, buffer float64) []byte {
- return b.EmitTileInto(coord, extent, buffer, &TileScratch{})
-}
-
-// EmitTileInto bakes the merged (all-band) MVT for one tile, or nil if empty,
-// reusing ts's buffers.
-func (b *Baker) EmitTileInto(coord tile.TileCoord, extent uint32, buffer float64, ts *TileScratch) []byte {
- return b.emitTileInto(coord, extent, buffer, ts, 0)
-}
-
-// EmitTileBandInto bakes ONLY the single navigational band whose native max zoom
-// == bandMax (one per-band archive tile), still gap-clipped above that band where
-// a finer cell's M_COVR actually covers — so a coarser band's overzoomed fill is
-// dropped wherever finer data exists. bandMax==0 emits the merged all-band tile.
-func (b *Baker) EmitTileBandInto(coord tile.TileCoord, extent uint32, buffer float64, ts *TileScratch, bandMax uint32) []byte {
- return b.emitTileInto(coord, extent, buffer, ts, bandMax)
-}
-
-// emitTileInto bakes one tile (bandMax==0 ⇒ every band merged), or nil if empty,
-// reusing ts's buffers.
-func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64, ts *TileScratch, bandMax uint32) []byte {
- // Full-scan / realtime path emits without a prebuilt index, so make sure the
- // finer-coverage cuts exist (idempotent; the prebaked paths already ran it
- // single-threaded in BuildEmitIndex before any parallel worker calls this).
- tb := mvt.NewTileBuilder(extent)
- proj := tile.NewProjector(coord, extent)
- rect := tile.RectForTile(extent, buffer)
- e := float64(extent)
- bandZ := coord.Z
-
- // Spatial reject in normalized-world coords, then in-display-range filter.
- n := math.Pow(2, float64(coord.Z))
- bufN := (buffer / float64(extent)) / n
- tnx0, tnx1 := float64(coord.X)/n-bufN, float64(coord.X+1)/n+bufN
- tny0, tny1 := float64(coord.Y)/n-bufN, float64(coord.Y+1)/n+bufN
-
- eligible := ts.eligible[:0]
- var gatedZMin int // polys that overlap this tile but are hidden below their display zMin (SCAMIN) — diagnostic
- minNatMin := uint32(math.MaxUint32)
- consider := func(i int) {
- r := &b.prims[i]
- // Spatial reject first so the zMin diagnostic below counts only prims that
- // actually overlap this tile (the full-scan path considers every prim).
- if r.wMaxX < tnx0 || r.wMinX > tnx1 || r.wMaxY < tny0 || r.wMinY > tny1 {
- return
- }
- // Per-band archive: keep only this band's own prims. Coarser bands fill the
- // gaps from THEIR sources (whose client overzoom reaches into this band's
- // zooms); they must not be baked into this band's tiles or they'd bleed.
- if bandMax != 0 && r.natMax != bandMax {
- return
- }
- // Lower gate only. The UPPER end is governed by best-available suppression
- // below: the full-scan (wasm) path overzooms a coarse prim UP past its native
- // band to fill where no finer cell exists, suppressed only where a finer cell
- // actually covers. The indexed (prebaked) path lists each prim only across
- // [zMin, zMax], so it never overzooms up — there the per-band sources +
- // MapLibre client overzoom do the same job. r.zMax bounds the index only.
- if coord.Z < r.zMin {
- if r.kind == mvt.GeomPolygon {
- gatedZMin++
- }
- return
- }
- eligible = append(eligible, i)
- if r.natMin < minNatMin {
- minNatMin = r.natMin
- }
- }
- if b.emitIndex != nil {
- // Indexed path: only the prims whose buffer-expanded bbox covers this tile.
- key := uint64(coord.Z)<<40 | uint64(coord.X)<<20 | uint64(coord.Y)
- for _, ci := range b.emitIndex[key] {
- consider(int(ci))
- }
- } else {
- // Fallback (EmitTile / tests, no prebuilt index): scan all prims.
- for i := range b.prims {
- consider(i)
- }
- }
-
- ts.eligible = eligible // persist the (possibly grown) backing array for reuse
- scratch := ts.proj // reused per-ring projection buffer (across tiles)
- clip := &ts.clip // reused clipper (across tiles)
- // Tile centre (used by both the merged-path band test and the per-band cell-scale
- // test) — a coarse line/fill is the same band/scale across the whole small tile.
- ctrLon := (float64(coord.X)+0.5)/n*360 - 180
- ctrLat := unnormY((float64(coord.Y) + 0.5) / n)
- // tileCovBand: finest band whose M_COVR data-coverage contains this tile's centre
- // — the merged-path up-suppression's "is a finer cell actually here" test.
- tileCovBand, tileCovDone := uint32(0), false
- covBandAt := func() uint32 {
- if !tileCovDone {
- tileCovBand, tileCovDone = b.coverageBandAt(ctrLat, ctrLon), true
- }
- return tileCovBand
- }
- var suppDown, suppUp, emptyGeom int // tile-generation diagnostics (see TileDiag)
- var polyElig, polyEmit int // polygon prims eligible vs actually emitted (diagnostic)
- for _, i := range eligible {
- r := &b.prims[i]
- // Down-fill: a prim displayed BELOW its native band (general/overzoom cells)
- // yields only where no coarser cell covers, so coarse bands stay best-
- // available when zoomed out.
- if bandZ < r.natMin && r.natMin > minNatMin && b.anyCoarserOverlaps(eligible, r) {
- suppDown++
- continue
- }
- // Best-available suppression: a prim yields where a strictly-FINER cell that
- // is actually shown at this zoom covers it.
- // • MERGED tile (bandMax==0, realtime cp://): band-gated, by band (every band
- // shares one tile). Points use the per-feature overlap test.
- // • PER-BAND archive (bandMax!=0): by per-CELL compilation SCALE, so the finer
- // cell wins both ACROSS bands and BETWEEN same-band cells of different scale
- // (US1GC09M 1:2.16M vs US2EC02M 1:1.2M, both "general"). coverageScaleAt is
- // zoom-gated, so a finer cell that isn't drawn yet at this zoom doesn't punch
- // a hole in the coarser one — which replaces the old `bandZ >= natMax` gate.
- var suppressed bool
- if bandMax == 0 {
- if bandZ >= r.natMax {
- if r.kind == mvt.GeomPoint {
- suppressed = b.anyFinerOverlaps(eligible, r)
- } else {
- suppressed = r.natMax < covBandAt()
- }
- }
- } else if r.cscl != 0 && r.layer != "scale_boundaries" &&
- (r.kind == mvt.GeomPoint || r.kind == mvt.GeomLineString || r.layer == "area_patterns" || r.layer == "areas") {
- switch r.kind {
- case mvt.GeomPoint:
- // A point tests its OWN position — a boundary tile keeps coarse points
- // that fall outside the finer coverage.
- if s := b.coverageScaleAt(unnormY(r.wMinY), r.wMinX*360-180, bandZ, true); s != 0 && s < r.cscl {
- suppressed = true
- }
- case mvt.GeomLineString:
- // Lines (incl. complex/symbolised) do NOT occlude. Each per-band archive
- // is a SEPARATE client source with no maxzoom cap on the fine bands, so a
- // coarse line kept in a tile a finer cell also covers DOUBLE-DRAWS: the
- // finer band redraws the same stroke (a restricted-area/CTNARE boundary, a
- // depth contour, a coastline) offset beside it — there is no opaque fill to
- // hide the coarse copy. The whole-tile (5-corner) relaxation below is for
- // FILLS, where a seam gap would show through; it must NOT apply to strokes,
- // or every partially-covered seam tile keeps a doubled line. So suppress a
- // coarse line by the tile CENTRE (a line spans the tile, so the centre is
- // its representative point): it yields only where the centre has no finer
- // cell — best-available where the finer cell genuinely carries no data.
- // includeDerived=true: a coarse line over a finer cell with only a derived
- // extent (no M_COVR, e.g. the PresLib Chart-1 cells) still double-draws
- // across bands and must be suppressed (S-52 §10.1.4 largest-scale wins).
- // Lines never punch no-data fill holes, so this is hole-safe for a derived
- // rect too — and it removes the live cross-band line bleed on Chart 1.
- if s := b.coverageScaleAt(ctrLat, ctrLon, bandZ, true); s != 0 && s < r.cscl {
- suppressed = true
- }
- default:
- // Area/pattern FILLS span the tile, so testing the tile CENTRE alone punched
- // a hole at cell SEAMS: a tile straddling the boundary between a coarse cell
- // and an adjacent finer cell has its centre in the finer cell, which
- // suppressed the coarse cell's portion on the OTHER side of the seam where
- // the finer cell has NO data (e.g. US4MD1ED's depth fill just north of the
- // 39.0 line it shares with the finer US4MD1DD — the "bottom half disappears"
- // gap). Suppress only when a finer cell covers the WHOLE tile (centre + 4
- // corners); a partially-covered seam tile keeps the coarse fill, and the
- // finer cell — drawn later, on top — OCCLUDES it where it actually has data.
- // No visible double-draw (opaque finer fill wins on top); no seam gap.
- wLon, eLon := float64(coord.X)/n*360-180, float64(coord.X+1)/n*360-180
- nLat, sLat := unnormY(float64(coord.Y)/n), unnormY(float64(coord.Y+1)/n)
- suppressed = true
- for _, pt := range [...][2]float64{{ctrLat, ctrLon}, {nLat, wLon}, {nLat, eLon}, {sLat, wLon}, {sLat, eLon}} {
- if s := b.coverageScaleAt(pt[0], pt[1], bandZ, false); s == 0 || s >= r.cscl {
- suppressed = false // part of the tile has no finer cell — keep the coarse prim
- break
- }
- }
- }
- }
- if suppressed {
- suppUp++
- continue
- }
- switch r.kind {
- case mvt.GeomPolygon:
- polyElig++
- var outRings [][]mvt.IPoint
- for _, ring := range r.nrings {
- scratch = projectNormRing(ring, proj, scratch)
- clipped := clip.Polygon(scratch, rect)
- if len(clipped) < 3 {
- continue
- }
- q := quantizeRing(clipped)
- if len(q) < 3 {
- // DP over-collapsed a small ring — keep it unsimplified rather
- // than drop a still-renderable polygon. Truly degenerate rings
- // (sub-grid) are skipped.
- if q = quantizeRingExact(clipped); len(q) < 3 {
- continue
- }
- }
- outRings = append(outRings, q)
- }
- if len(outRings) > 0 {
- tb.Layer(r.layer).AddPolygon(outRings, b.attrsFor(r, &ts.attrs))
- polyEmit++
- } else {
- emptyGeom++
- }
- case mvt.GeomLineString:
- if r.ls != nil {
- // Complex (symbolised) linestyle: tessellate its period per zoom.
- b.emitComplexLine(r, proj, rect, coord.Z, extent, tb, &scratch, &ts.attrs)
- continue
- }
- scratch = projectNormRing(r.nline, proj, scratch)
- runs := tile.ClipLine(scratch, rect)
- paths := make([][]mvt.IPoint, 0, len(runs))
- for _, run := range runs {
- if len(run) >= 2 {
- paths = append(paths, quantizeRing(run))
- }
- }
- if len(paths) > 0 {
- tb.Layer(r.layer).AddLines(paths, b.attrsFor(r, &ts.attrs))
- }
- case mvt.GeomPoint:
- p := proj.ProjectNormU(r.npoint)
- if p.X < 0 || p.X >= e || p.Y < 0 || p.Y >= e {
- continue
- }
- tb.Layer(r.layer).AddPoints([]mvt.IPoint{tile.Quantize(p)}, b.attrsFor(r, &ts.attrs))
- }
- }
- ts.proj = scratch // persist the (possibly grown) projection buffer for reuse
-
- // Sector lights: tessellate per zoom into the lines layer. Their screen-px
- // geometry can spill into neighbouring tiles, so reject with a margin sized to
- // the largest radius (the ring's 26 mm, or the longer full-length leg) plus the
- // clip buffer. The figure is laid out in 256-px-per-tile space, so the radius
- // term divides by 256 (NOT the MVT extent) — matching sectorRadiusNorm and
- // TileCoords' enumeration. The full-leg term is per-sector (its VALNMR reach).
- spill := (buffer / float64(extent)) / n
- for i := range b.sectors {
- sp := &b.sectors[i]
- if coord.Z < sp.zMin || coord.Z > sp.natMax {
- continue
- }
- if bandMax != 0 && sp.natMax != bandMax {
- continue
- }
- margin := math.Max(sectorRadiusNorm(coord.Z), sp.legNorm) + spill
- ax, ay := normX(sp.fig.Anchor.Lon), normY(sp.fig.Anchor.Lat)
- if ax < tnx0-margin || ax > tnx1+margin || ay < tny0-margin || ay > tny1+margin {
- continue
- }
- for _, st := range tessellateFigure(sp, coord.Z) {
- runs := tile.ClipLine(projectRing(st.points, proj), rect)
- paths := make([][]mvt.IPoint, 0, len(runs))
- for _, run := range runs {
- if len(run) >= 2 {
- paths = append(paths, quantizeRing(run))
- }
- }
- if len(paths) == 0 {
- continue
- }
- dash := "solid"
- if st.dashed {
- dash = "dashed"
- }
- attrs := []mvt.KeyValue{
- {Key: "class", Value: mvt.StringVal(sp.class)},
- {Key: "cell", Value: mvt.StringVal(sp.cell)},
- {Key: "color_token", Value: mvt.StringVal(st.colorToken)},
- {Key: "width_px", Value: mvt.IntVal(int64(st.widthPx + 0.5))},
- {Key: "dash", Value: mvt.StringVal(dash)},
- {Key: "cat", Value: mvt.IntVal(catRank(sp.cat))},
- {Key: "bnd", Value: mvt.IntVal(bndAlwaysShown)},
- {Key: "draw_prio", Value: mvt.IntVal(int64(sp.drawPrio))},
- }
- // Leg-length variant tag — only on the short/full legs (0/1); arcs and
- // rings (sleg -1) stay untagged so the client always shows them.
- if st.sleg >= 0 {
- attrs = append(attrs, mvt.KeyValue{Key: "sleg", Value: mvt.IntVal(int64(st.sleg))})
- }
- // SCAMIN of the parent LIGHTS — so the client's per-SCAMIN bucket layer
- // gates the sector figure at the EXACT display scale, in both directions,
- // matching the light's flare (point_symbols) and characteristic text. Its
- // own `sector_lines` layer keeps the bucket fan-out off the shared `lines`.
- if sp.scamin != 0 {
- attrs = append(attrs, mvt.KeyValue{Key: "scamin", Value: mvt.IntVal(int64(sp.scamin))})
- }
- // Date validity of the parent light (S-52 §10.4.1.1) — so a seasonal
- // sector light's figure hides with its flare/text under the date filter.
- attrs = appendDateTags(attrs, sp.dateStart, sp.dateEnd)
- tb.Layer("sector_lines").AddLines(paths, attrs)
- }
- }
-
- if TileDiag != nil {
- TileDiag(fmt.Sprintf("tile %d/%d/%d: eligible=%d gatedZMin=%d polyElig=%d polyEmit=%d suppDown=%d suppUp=%d emptyGeom=%d empty=%t",
- coord.Z, coord.X, coord.Y, len(eligible), gatedZMin, polyElig, polyEmit, suppDown, suppUp, emptyGeom, tb.IsEmpty()))
- }
- if tb.IsEmpty() {
- return nil
- }
- return tb.Encode()
-}
-
-// DebugTilePolyOverlap counts polygon prims overlapping a tile two ways: by the
-// fast cached-bbox reject the bake actually uses (stored), and by their TRUE
-// vertex bbox recomputed from the normalized rings (trueOv). A gap (trueOv >
-// stored) means a cached prim bbox is wrong and the bake is silently dropping
-// polygons that belong in the tile — the one polygon-loss the TileDiag funnel
-// can't see (the reject happens before any counter). Diagnostic only.
-func (b *Baker) DebugTilePolyOverlap(coord tile.TileCoord, buffer float64, extent uint32) (stored, trueOv int) {
- n := math.Pow(2, float64(coord.Z))
- bufN := (buffer / float64(extent)) / n
- tnx0, tnx1 := float64(coord.X)/n-bufN, float64(coord.X+1)/n+bufN
- tny0, tny1 := float64(coord.Y)/n-bufN, float64(coord.Y+1)/n+bufN
- overlaps := func(minx, miny, maxx, maxy float64) bool {
- return !(maxx < tnx0 || minx > tnx1 || maxy < tny0 || miny > tny1)
- }
- for i := range b.prims {
- r := &b.prims[i]
- if r.kind != mvt.GeomPolygon || coord.Z < r.zMin {
- continue
- }
- if overlaps(r.wMinX, r.wMinY, r.wMaxX, r.wMaxY) {
- stored++
- }
- minx, miny, maxx, maxy := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
- for _, ring := range r.nrings {
- for _, p := range ring {
- px, py := float64(p.X)/4294967296.0, float64(p.Y)/4294967296.0 // UPoint → [0,1]
- minx, maxx = math.Min(minx, px), math.Max(maxx, px)
- miny, maxy = math.Min(miny, py), math.Max(maxy, py)
- }
- }
- if overlaps(minx, miny, maxx, maxy) {
- trueOv++
- }
- }
- return
-}
-
-// TileDiag, when non-nil, receives one line per EmitTile call with the
-// per-stage primitive counts (eligible → suppressed → empty-geometry → empty).
-// It's the hook for debugging tiles that bake empty: a tile with eligible>0 but
-// empty=true lost everything to suppression or clipping. Off (nil) by default;
-// tests set it directly.
-var TileDiag func(string)
-
-// earthCircumM is the Web-Mercator equatorial circumference (m), used to convert
-// a VALNMR nominal range (nautical miles) into a normalized-world fraction.
-const earthCircumM = 40075016.686
-
-// sectorLegFullNorm is the full-length sector-leg reach (VALNMR nautical miles) as
-// a fraction of the normalized world at the light's latitude — a ground distance,
-// so zoom-independent. 0 when no/!positive VALNMR.
-func sectorLegFullNorm(lat, radiusNM float64) float64 {
- if radiusNM <= 0 {
- return 0
- }
- cosLat := math.Cos(lat * math.Pi / 180.0)
- if cosLat < 1e-6 {
- return 0
- }
- return radiusNM * 1852.0 / (cosLat * earthCircumM)
-}
-
-// sectorStroke is one tessellated piece of sector geometry: a lat/lon polyline
-// plus the S-52 pen token + width the lines layer carries. sleg tags leg-length
-// variants for the client's full-length-sector toggle: -1 = no tag / always shown
-// (arcs, rings), 0 = the 25 mm short leg, 1 = the full VALNMR-length leg.
-type sectorStroke struct {
- points []geo.LatLon
- colorToken string
- widthPx float32
- dashed bool
- sleg int
-}
-
-// tessellateFigure tessellates one constructed sector-figure element (sp.fig) at
-// integer zoom z into lat/lon line strokes, screen-px sized (the mm sizes are
-// fixed display millimetres, hence per-zoom). A leg (ray) becomes one stroke from
-// the anchor along its bearing; when the light has a nominal range it also emits
-// the extended "full light lines" leg, the two tagged sleg 0/1 for the client's
-// live toggle. An arc/ring becomes one polyline stroke. Colour, width and dash
-// all come from the rule's LineStyle — including the black backing under a
-// coloured arc and a white light's yellow (LITYW) arc — not a Go re-derivation.
-// The rule has already applied the from-seaward +180 bearing reversal, so the
-// bearings/angles are used as-is.
-func tessellateFigure(sp *sectorPrim, z uint32) []sectorStroke {
- worldPx := 256.0 * math.Pow(2, float64(z))
- ax, ay := normX(sp.fig.Anchor.Lon)*worldPx, normY(sp.fig.Anchor.Lat)*worldPx
- pxPerMM := float64(portrayal.DefaultPxPerSymbolUnit) * 100.0
- widthPx := sp.fig.WidthMM * pxPerMM
- dashed := sp.fig.Dash == portrayal.DashDashed
-
- if !sp.fig.Ray { // arc / ring
- radius := sp.fig.RadiusMM * pxPerMM
- if radius <= 0 {
- return nil
- }
- sweep := sp.fig.SweepDeg
- if sweep == 0 {
- sweep = 360 // a zero sweep is a full all-round ring
- }
- n := max(int(math.Ceil(math.Abs(sweep)/3.0)), 8)
- pts := make([]geo.LatLon, n+1)
- for i := range pts {
- brg := sp.fig.StartDeg + sweep*float64(i)/float64(n)
- dx, dy := bearingToScreen(brg)
- pts[i] = sunproject(ax+dx*radius, ay+dy*radius, worldPx)
- }
- // One stroke; the rule emits the black backing and the coloured arc as
- // separate figures, so the double-stroke is preserved by draw order. Arcs/
- // rings carry no leg tag (sleg -1) — always shown, regardless of the toggle.
- return []sectorStroke{{points: pts, colorToken: sp.fig.ColorToken, widthPx: float32(widthPx), dashed: dashed, sleg: -1}}
- }
-
- // Leg (ray): the rule's length, plus the extended full-length variant when a
- // nominal range is known.
- emit := func(out []sectorStroke, lenPx float64, sleg int) []sectorStroke {
- if lenPx <= 0 {
- return out
- }
- dx, dy := bearingToScreen(sp.fig.BearingDeg)
- pts := []geo.LatLon{
- sunproject(ax, ay, worldPx),
- sunproject(ax+dx*lenPx, ay+dy*lenPx, worldPx),
- }
- return append(out, sectorStroke{points: pts, colorToken: sp.fig.ColorToken, widthPx: float32(widthPx), dashed: dashed, sleg: sleg})
- }
- // Short leg: display mm by default, but a GeographicCRS leg length is a fixed
- // GROUND distance (metres) — convert it to px at this zoom (like the full leg),
- // not metres-as-mm (which rendered legs ~10× too long, "shooting out").
- legShort := sp.fig.LengthMM * pxPerMM
- if sp.fig.LengthGroundM > 0 {
- if cosLat := math.Cos(sp.fig.Anchor.Lat * math.Pi / 180.0); cosLat > 1e-6 {
- legShort = sp.fig.LengthGroundM / (cosLat * earthCircumM) * worldPx
- }
- }
- if sp.fig.FullLengthNM <= 0 {
- return emit(nil, legShort, -1) // can't extend: the leg is always shown
- }
- legFull := sectorLegFullNorm(sp.fig.Anchor.Lat, sp.fig.FullLengthNM) * worldPx
- if legFull < legShort {
- legFull = legShort
- }
- var out []sectorStroke
- out = emit(out, legShort, 0)
- out = emit(out, legFull, 1)
- return out
-}
-
-func bearingToScreen(deg float64) (float64, float64) {
- r := deg * math.Pi / 180.0
- return math.Sin(r), -math.Cos(r) // y grows southward: north=(0,-1), east=(1,0)
-}
-
-func sunproject(x, y, worldPx float64) geo.LatLon {
- return geo.LatLon{Lat: unnormY(y / worldPx), Lon: x/worldPx*360 - 180}
-}
-
-// anyCoarserOverlaps reports whether a strictly-coarser-band eligible primitive's
-// world bbox overlaps r (AABB only). Gates down-fill suppression.
-func (b *Baker) anyCoarserOverlaps(eligible []int, r *routed) bool {
- for _, qi := range eligible {
- q := &b.prims[qi]
- if q.natMin >= r.natMin {
- continue // not coarser than r
- }
- if q.wMinX <= r.wMaxX && q.wMaxX >= r.wMinX && q.wMinY <= r.wMaxY && q.wMaxY >= r.wMinY {
- return true
- }
- }
- return false
-}
-
-// anyFinerOverlaps reports whether a strictly-finer-band eligible primitive's
-// world bbox overlaps r (AABB only). Gates up-direction suppression of POINT
-// features only: a coarse symbol survives unless a finer prim sits on it. Area/
-// line fills use coverageBandAt (actual M_COVR coverage) instead.
-func (b *Baker) anyFinerOverlaps(eligible []int, r *routed) bool {
- for _, qi := range eligible {
- q := &b.prims[qi]
- if q.natMax <= r.natMax {
- continue // not finer than r
- }
- if q.wMinX <= r.wMaxX && q.wMaxX >= r.wMinX && q.wMinY <= r.wMaxY && q.wMaxY >= r.wMinY {
- return true
- }
- }
- return false
-}
-
-// coverageBandAt returns the finest native band whose M_COVR (CATCOV=1) data
-// coverage contains (lat,lon), or 0 if none — the best-available band at a point.
-// Gates up-direction suppression of area/line fills: a coarse fill overzoomed
-// above its band is hidden only where a strictly-finer cell genuinely carries data.
-func (b *Baker) coverageBandAt(lat, lon float64) uint32 {
- var best uint32
- p := geo.LatLon{Lat: lat, Lon: lon}
- for i := range b.covMeta {
- cm := &b.covMeta[i]
- if cm.derived || cm.bandMax <= best || !cm.bb.Contains(p) {
- continue // derived extents gate point suppression only, not area/line fills (see covMeta.derived)
- }
- if pointInRings(lon, lat, cm.rings) {
- best = cm.bandMax
- }
- }
- return best
-}
-
-// coverageScaleAt returns the FINEST (smallest) compilation-scale denominator among
-// cells whose M_COVR coverage contains (lat,lon) AND that are displayed at zoom
-// bandZ, or 0 if none. This drives per-CELL best-available suppression: a prim is
-// hidden where a strictly-finer cell (smaller cscl) covers it — which works both
-// across bands AND between cells of different scale that fall in the SAME band (the
-// per-band coverageBandAt above can't distinguish those). bandZ-gated so a finer
-// cell that isn't shown yet at this zoom doesn't punch a hole in the coarser one.
-func (b *Baker) coverageScaleAt(lat, lon float64, bandZ uint32, includeDerived bool) uint32 {
- var best uint32 // 0 = none found yet; otherwise the finest (smallest) cscl
- p := geo.LatLon{Lat: lat, Lon: lon}
- for i := range b.covMeta {
- cm := &b.covMeta[i]
- if cm.cscl == 0 || cm.displayMin > bandZ {
- continue // unscaled, or this cell isn't drawn at this zoom
- }
- if cm.derived && !includeDerived {
- // A derived rectangle marks where a cell IS, not where it has DATA, so it
- // over-claims coverage. Per S-52 §10.1.4 a coarser FILL must remain to fill
- // genuine gaps in the finer data (the finer fill occludes it on top where it
- // has data) — so derived coverage must NOT suppress fills, or it would punch
- // no-data holes inside the finer cell's sparse interior. POINTS and LINES are
- // different: a coarse point/line drawn where a finer chart covers violates the
- // "largest-scale data takes precedence" rule and double-draws across bands (no
- // opaque fill hides it), so callers pass includeDerived=true for those. NB: the
- // per-cell preslib harness can't show this (it frames one cell, one band), but
- // it IS visible live when bands overlap — don't be fooled by a 0-pixel diff.
- continue
- }
- if best != 0 && cm.cscl >= best {
- continue // not finer than the best so far — skip the costly point test
- }
- if !cm.bb.Contains(p) || !pointInRings(lon, lat, cm.rings) {
- continue
- }
- best = cm.cscl
- }
- return best
-}
-
-// -- helpers -----------------------------------------------------------------
-
-func normX(lon float64) float64 { return (lon + 180.0) / 360.0 }
-
-func normY(lat float64) float64 {
- sin := math.Sin(lat * math.Pi / 180.0)
- return 0.5 - math.Log((1.0+sin)/(1.0-sin))/(4.0*math.Pi)
-}
-
-func unnormY(y float64) float64 {
- // Inverse of normY: solve for latitude (Web-Mercator).
- return math.Atan(math.Sinh((0.5-y)*2.0*math.Pi)) * 180.0 / math.Pi
-}
-
-func projectRing(ring []geo.LatLon, proj tile.Projector) []tile.FPoint {
- out := make([]tile.FPoint, len(ring))
- for i, p := range ring {
- out[i] = proj.Project(p)
- }
- return out
-}
-
-// normPt / normPts / normRings pre-project geometry to normalized-world
-// coordinates ([0,1] Web-Mercator) once, so per-tile emit is a cheap affine
-// transform (see Projector.ProjectNorm).
-func normPt(ll geo.LatLon) tile.UPoint {
- return tile.UPoint{X: tile.NormU(normX(ll.Lon)), Y: tile.NormU(normY(ll.Lat))}
-}
-
-func normPts(pts []geo.LatLon) []tile.UPoint {
- out := make([]tile.UPoint, len(pts))
- for i, p := range pts {
- out[i] = normPt(p)
- }
- return out
-}
-
-func normRings(rings [][]geo.LatLon) [][]tile.UPoint {
- out := make([][]tile.UPoint, len(rings))
- for i, r := range rings {
- out[i] = normPts(r)
- }
- return out
-}
-
-// projectNormRing affine-projects a normalized-world ring (32-bit fixed point)
-// into tile-pixel space, writing into scratch (grown as needed) to avoid per-call
-// allocation. The returned slice aliases scratch and is valid until the next call.
-func projectNormRing(npts []tile.UPoint, proj tile.Projector, scratch []tile.FPoint) []tile.FPoint {
- if cap(scratch) < len(npts) {
- scratch = make([]tile.FPoint, len(npts))
- }
- scratch = scratch[:len(npts)]
- for i, n := range npts {
- scratch[i] = proj.ProjectNormU(n)
- }
- return scratch
-}
-
-// simplifyTolerance is the Douglas-Peucker tolerance for per-tile geometry, in MVT
-// extent units. At MVTExtent=4096 over a ~512px tile that's 8 units/screen-px, so
-// ~4 units ≈ ½ px — below visible resolution, yet it collapses the dense S-57
-// coastlines that otherwise carry 100k+ vertices into one tile.
-const simplifyTolerance = 4.0
-
-// quantizeRing simplifies a clipped ring/line to tile resolution, then snaps it to
-// the MVT integer grid: first Douglas-Peucker (drops vertices within ½ px of the
-// kept line — the only step that meaningfully thins a crenulated coastline), then
-// exact consecutive-duplicate and collinear-midpoint removal during quantization.
-// Without this a single dense polygon can carry 100k+ vertices into one tile and
-// blow MapLibre's 65535-vertex-per-fill-segment cap, so the whole polygon silently
-// fails to render. Endpoints are always kept (closed rings keep their seam vertex).
-func quantizeRing(pts []tile.FPoint) []mvt.IPoint {
- return quantizePts(douglasPeucker(pts, simplifyTolerance))
-}
-
-// quantizeRingExact is quantizeRing without Douglas-Peucker — the fallback for a
-// ring that DP would collapse below 3 points. A small polygon is only a few
-// vertices, so it can't trip the fill-segment cap; keeping it unsimplified means
-// simplification never deletes a whole (still-renderable) polygon.
-func quantizeRingExact(pts []tile.FPoint) []mvt.IPoint { return quantizePts(pts) }
-
-// quantizePts snaps to the MVT integer grid, dropping exact consecutive duplicates
-// and collinear midpoints as it goes (both lossless at integer resolution).
-func quantizePts(pts []tile.FPoint) []mvt.IPoint {
- out := make([]mvt.IPoint, 0, len(pts))
- for _, p := range pts {
- q := tile.Quantize(p)
- if n := len(out); n > 0 && out[n-1] == q {
- continue // exact consecutive duplicate
- }
- if n := len(out); n >= 2 {
- a, b := out[n-2], out[n-1]
- // b is collinear with a→q when the cross product is zero; collapse it
- // (replace the midpoint with q) so straight runs reduce to endpoints.
- if int64(b.X-a.X)*int64(q.Y-a.Y) == int64(b.Y-a.Y)*int64(q.X-a.X) {
- out[n-1] = q
- continue
- }
- }
- out = append(out, q)
- }
- return out
-}
-
-// douglasPeucker returns the subset of pts that approximates the polyline within
-// eps (perpendicular distance, in the input's units), always keeping the first and
-// last vertex. Iterative (explicit stack) to avoid deep recursion on long rings.
-func douglasPeucker(pts []tile.FPoint, eps float64) []tile.FPoint {
- n := len(pts)
- if n < 3 {
- return pts
- }
- keep := make([]bool, n)
- keep[0], keep[n-1] = true, true
- eps2 := eps * eps
- stack := [][2]int{{0, n - 1}}
- for len(stack) > 0 {
- top := stack[len(stack)-1]
- stack = stack[:len(stack)-1]
- s, e := top[0], top[1]
- if e <= s+1 {
- continue
- }
- ax, ay := pts[s].X, pts[s].Y
- dx, dy := pts[e].X-ax, pts[e].Y-ay
- den := dx*dx + dy*dy
- maxD, maxI := -1.0, -1
- for i := s + 1; i < e; i++ {
- ex, ey := pts[i].X-ax, pts[i].Y-ay
- var d float64
- if den == 0 {
- d = ex*ex + ey*ey // degenerate segment: distance to the point
- } else {
- num := ex*dy - ey*dx // cross product (= perp dist × √den)
- d = num * num / den
- }
- if d > maxD {
- maxD, maxI = d, i
- }
- }
- if maxD > eps2 {
- keep[maxI] = true
- stack = append(stack, [2]int{s, maxI}, [2]int{maxI, e})
- }
- }
- out := make([]tile.FPoint, 0, n)
- for i, k := range keep {
- if k {
- out = append(out, pts[i])
- }
- }
- return out
-}
-
-func ringsBbox(rings [][]geo.LatLon) geo.BoundingBox {
- bb := geo.EmptyBox()
- for _, r := range rings {
- for _, p := range r {
- bb.ExtendPoint(p)
- }
- }
- return bb
-}
-
-func ptsBbox(pts []geo.LatLon) geo.BoundingBox {
- bb := geo.EmptyBox()
- for _, p := range pts {
- bb.ExtendPoint(p)
- }
- return bb
-}
-
-func ptBbox(p geo.LatLon) geo.BoundingBox {
- return geo.BoundingBox{MinLat: p.Lat, MinLon: p.Lon, MaxLat: p.Lat, MaxLon: p.Lon}
-}
-
-// depthVals returns a depth area's DRVAL1/DRVAL2 (metres) for the client's live
-// SEABED01 shading, or (NaN, NaN) for non-depth areas (so route() omits them).
-// DRVAL2 falls back to DRVAL1 when absent. Mirrors the is_depth gate.
-func depthVals(attrs map[string]any, class string) (float32, float32) {
- if class != "DEPARE" && class != "DRGARE" {
- return nan32f, nan32f
- }
- d1, ok := floatAttr(attrs, "DRVAL1")
- if !ok {
- return nan32f, nan32f
- }
- d2, ok := floatAttr(attrs, "DRVAL2")
- if !ok {
- d2 = d1
- }
- return float32(d1), float32(d2)
-}
-
-// contourValdco returns a DEPCNT depth contour's VALDCO (metres) so the client can
-// label it in the chosen depth unit, or NaN for non-contours / contours with no
-// value (so route() omits the tag and the client draws no label — not a "0").
-func contourValdco(attrs map[string]any, class string) float32 {
- if class != "DEPCNT" {
- return nan32f
- }
- // Bake the value whenever VALDCO is explicitly present — INCLUDING 0, the
- // drying line / chart-datum contour, which the spec plots label "0". A missing
- // VALDCO is unknown (not zero), so it's left unlabelled. Whether any of these
- // actually draw is the mariner's "contour labels" toggle (off by default, so
- // the "0 by the shore" stays decluttered until the mariner asks for labels).
- if v, ok := floatAttr(attrs, "VALDCO"); ok {
- return float32(v)
- }
- return nan32f
-}
-
-var nan32f = float32(math.NaN())
-
-// floatAttr reads a numeric S-57 attribute (int/float/string) as a float64.
-func floatAttr(attrs map[string]any, key string) (float64, bool) {
- v, ok := attrs[key]
- if !ok {
- return 0, false
- }
- switch t := v.(type) {
- case float64:
- return t, true
- case int:
- return float64(t), true
- case int64:
- return float64(t), true
- case string:
- if n, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil {
- return n, true
- }
- }
- return 0, false
-}
-
-// encodeS57Attrs serialises a feature's full S-57 attribute set into the compact
-// JSON blob carried in the tile for the cursor-pick report (S-52 PresLib §10.8).
-// Keys are S-57 acronyms; values are kept as raw strings (e.g. "3", list "1,3",
-// "Fl.R.4s") so the client decodes names/enums/units against the catalogue — and
-// numbers are formatted minimally, satisfying the no-padding rule (§10.8 rule 3).
-// Returns "" for an attribute-free feature. json.Marshal sorts map keys, so the
-// output is deterministic (bake reproducibility).
-func encodeS57Attrs(attrs map[string]any) string {
- if len(attrs) == 0 {
- return ""
- }
- out := make(map[string]string, len(attrs))
- for k, v := range attrs {
- if k == "DEPTHS" { // synthetic (SOUNDG Z values), not an S-57 attribute
- continue
- }
- switch t := v.(type) {
- case string:
- if s := strings.TrimSpace(t); s != "" {
- out[k] = s
- }
- case float64:
- out[k] = strconv.FormatFloat(t, 'g', -1, 64)
- case float32:
- out[k] = strconv.FormatFloat(float64(t), 'g', -1, 32)
- case int:
- out[k] = strconv.Itoa(t)
- case int64:
- out[k] = strconv.FormatInt(t, 10)
- default:
- out[k] = fmt.Sprint(t)
- }
- }
- if len(out) == 0 {
- return ""
- }
- buf, err := json.Marshal(out)
- if err != nil {
- return ""
- }
- return string(buf)
-}
-
-// stringAttr returns the trimmed string value of a string attribute, or "".
-func stringAttr(attrs map[string]any, key string) string {
- if s, ok := attrs[key].(string); ok {
- return strings.TrimSpace(s)
- }
- return ""
-}
-
-func intAttr(attrs map[string]any, key string) uint32 {
- v, ok := attrs[key]
- if !ok {
- return 0
- }
- switch t := v.(type) {
- case int:
- if t > 0 {
- return uint32(t)
- }
- case int64:
- if t > 0 {
- return uint32(t)
- }
- case float64:
- if t > 0 {
- return uint32(t)
- }
- case string:
- if n, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil && n > 0 {
- return uint32(n)
- }
- }
- return 0
-}
-
-func dashName(d portrayal.Dash) string {
- switch d {
- case portrayal.DashDashed:
- return "dashed"
- case portrayal.DashDotted:
- return "dotted"
- default:
- return "solid"
- }
-}
-
-func halignName(a portrayal.HAlign) string {
- switch a {
- case portrayal.HAlignCenter:
- return "center"
- case portrayal.HAlignRight:
- return "right"
- default:
- return "left"
- }
-}
-
-func valignName(a portrayal.VAlign) string {
- switch a {
- case portrayal.VAlignTop:
- return "top"
- case portrayal.VAlignMiddle:
- return "middle"
- case portrayal.VAlignBaseline:
- return "baseline"
- default:
- return "bottom"
- }
-}
-
-func haloTextColor(h *portrayal.TextHalo) string {
- if h == nil {
- return ""
- }
- return h.ColorToken
-}
-
-func haloTextWidth(h *portrayal.TextHalo) float32 {
- if h == nil {
- return 0
- }
- return h.WidthPx
-}
-
-func haloSymColor(h *portrayal.SymbolHalo) string {
- if h == nil {
- return ""
- }
- return h.ColorToken
-}
-
-func haloSymWidth(h *portrayal.SymbolHalo) float32 {
- if h == nil {
- return 0
- }
- return h.ExtraWidthPx
-}
-
-func isSoundingName(name string) bool {
- return strings.HasPrefix(name, "SOUNDG") || strings.HasPrefix(name, "SOUNDS")
-}
-
-// soundingVariant forces every SOUND? glyph token in a comma-joined sounding name
-// list to the given palette letter (S = bold/shallow, G = faint/deep), so the
-// client runs SNDFRM04's safety-depth split live.
-func soundingVariant(names string, letter byte) string {
- parts := strings.Split(names, ",")
- for i, p := range parts {
- if len(p) >= 6 && strings.HasPrefix(p, "SOUND") {
- parts[i] = p[:5] + string(letter) + p[6:]
- }
- }
- return strings.Join(parts, ",")
-}
-
-func isNaN32(f float32) bool { return f != f }
diff --git a/internal/engine/bake/bake_test.go b/internal/engine/bake/bake_test.go
deleted file mode 100644
index 4e24d68..0000000
--- a/internal/engine/bake/bake_test.go
+++ /dev/null
@@ -1,444 +0,0 @@
-package bake
-
-import (
- "bytes"
- "math"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/mvt"
- "github.com/beetlebugorg/chartplotter/internal/engine/portrayal"
- "github.com/beetlebugorg/chartplotter/internal/engine/tile"
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-const goldenCell = "../../../testdata/US4MD81M.000"
-
-func TestBandForScale(t *testing.T) {
- if BandForScale(12_000).ZoomRange() != (ZoomRange{13, 16}) {
- t.Error("12k should be harbor [13,16]")
- }
- if BandForScale(3_000_000) != BandOverview {
- t.Error("3M should be overview")
- }
- if BandForScale(0) != BandApproach {
- t.Error("unknown scale defaults to 50k -> approach band")
- }
-}
-
-// TestBakeGoldenCell bakes the Annapolis cell and decodes one populated tile,
-// asserting the expected layers and that colour is a string token.
-func TestBakeGoldenCell(t *testing.T) {
- chart, err := s57.Parse(goldenCell)
- if err != nil {
- t.Fatalf("parse: %v", err)
- }
- b := New()
- b.SetPortrayer(testS101Portrayer(t))
- b.AddCell(chart)
-
- coords := b.TileCoords(mvt.ExtentDefault)
- if len(coords) == 0 {
- t.Fatal("no tiles enumerated")
- }
-
- // Bake every tile; find the one with the most bytes (densest) and decode it.
- var best []byte
- var bestCoord tile.TileCoord
- var nonEmpty int
- for _, c := range coords {
- data := b.EmitTile(c, mvt.ExtentDefault, 64)
- if data == nil {
- continue
- }
- nonEmpty++
- if len(data) > len(best) {
- best, bestCoord = data, c
- }
- }
- t.Logf("tiles=%d nonEmpty=%d densest=%v (%d bytes)", len(coords), nonEmpty, bestCoord, len(best))
- if nonEmpty == 0 {
- t.Fatal("every tile was empty")
- }
-
- layers := decodeLayers(best)
- t.Logf("densest tile layers: %v", layerNames(layers))
- if len(layers) == 0 {
- t.Fatal("densest tile decoded to no layers")
- }
- // Areas should exist on a harbour cell, and colour must be a string token.
- if a := layers["areas"]; a != nil {
- if !a.firstFeatureHasStringKey("color_token") {
- t.Error("areas color_token must be a string token, not RGB")
- }
- }
-}
-
-// -- tiny MVT layer-name/string-attr decoder ---------------------------------
-
-type decLayer struct {
- name string
- keys []string
- values []decVal
- feats [][]uint32 // tag lists
-}
-
-type decVal struct {
- isString bool
-}
-
-func (l *decLayer) firstFeatureHasStringKey(key string) bool {
- if len(l.feats) == 0 {
- return false
- }
- tags := l.feats[0]
- for i := 0; i+1 < len(tags); i += 2 {
- if int(tags[i]) < len(l.keys) && l.keys[tags[i]] == key {
- vi := int(tags[i+1])
- return vi < len(l.values) && l.values[vi].isString
- }
- }
- return false
-}
-
-func layerNames(m map[string]*decLayer) []string {
- var out []string
- for n := range m {
- out = append(out, n)
- }
- return out
-}
-
-type rdr struct {
- d []byte
- p int
-}
-
-func (r *rdr) end() bool { return r.p >= len(r.d) }
-func (r *rdr) uv() uint64 {
- var v uint64
- var s uint
- for r.p < len(r.d) {
- b := r.d[r.p]
- r.p++
- v |= uint64(b&0x7f) << s
- if b < 0x80 {
- break
- }
- s += 7
- }
- return v
-}
-
-// next returns field, wiretype, payload(len-delimited), varint, ok.
-func (r *rdr) next() (uint32, uint64, []byte, uint64, bool) {
- if r.end() {
- return 0, 0, nil, 0, false
- }
- tag := r.uv()
- f := uint32(tag >> 3)
- wt := tag & 7
- switch wt {
- case 0:
- return f, wt, nil, r.uv(), true
- case 2:
- n := int(r.uv())
- b := r.d[r.p : r.p+n]
- r.p += n
- return f, wt, b, 0, true
- case 5:
- b := r.d[r.p : r.p+4]
- r.p += 4
- return f, wt, b, 0, true
- default:
- return f, wt, nil, 0, false
- }
-}
-
-func decodeLayers(data []byte) map[string]*decLayer {
- out := map[string]*decLayer{}
- r := &rdr{d: data}
- for {
- f, _, b, _, ok := r.next()
- if !ok {
- break
- }
- if f != 3 {
- continue
- }
- l := &decLayer{}
- lr := &rdr{d: b}
- for {
- lf, lv, lb, vv, lok := lr.next()
- if !lok {
- break
- }
- switch lf {
- case 1:
- l.name = string(lb)
- case 2:
- var tags []uint32
- fr := &rdr{d: lb}
- for {
- ff, _, fb, _, fok := fr.next()
- if !fok {
- break
- }
- if ff == 2 {
- tr := &rdr{d: fb}
- for !tr.end() {
- tags = append(tags, uint32(tr.uv()))
- }
- }
- }
- l.feats = append(l.feats, tags)
- case 3:
- l.keys = append(l.keys, string(lb))
- case 4:
- isStr := false
- vr := &rdr{d: lb}
- for {
- vf, _, _, _, vok := vr.next()
- if !vok {
- break
- }
- if vf == 1 {
- isStr = true
- }
- }
- l.values = append(l.values, decVal{isString: isStr})
- case 5:
- _ = lv
- _ = vv
- }
- }
- out[l.name] = l
- }
- return out
-}
-
-func TestBakePMTilesArchive(t *testing.T) {
- chart, err := s57.Parse(goldenCell)
- if err != nil {
- t.Fatalf("parse: %v", err)
- }
- b := New()
- b.SetPortrayer(testS101Portrayer(t))
- b.AddCell(chart)
- pb := b.BakePMTiles(mvt.ExtentDefault, 64)
- if pb.Count() == 0 {
- t.Fatal("archive has no tiles")
- }
- arc := pb.Finish()
- if string(arc[0:7]) != "PMTiles" || arc[7] != 3 {
- t.Fatal("not a valid PMTiles v3 archive")
- }
- t.Logf("archive: %d tiles, %d bytes", pb.Count(), len(arc))
-}
-
-// TestEmitIndexEquivalence asserts the inverted tile→prim index (BuildEmitIndex)
-// produces byte-identical tiles to the full-scan fallback for every tile —
-// across two overlapping cells of different bands (so suppression is exercised).
-func TestEmitIndexEquivalence(t *testing.T) {
- build := func() *Baker {
- b := New()
- b.SetPortrayer(testS101Portrayer(t))
- for _, cell := range []string{goldenCell, "../../../testdata/US5MD1MC.000"} {
- chart, err := s57.Parse(cell)
- if err != nil {
- t.Fatalf("parse %s: %v", cell, err)
- }
- b.AddCell(chart)
- }
- return b
- }
-
- const buf = 64.0
- scan := build() // emitIndex nil → full scan
- indexed := build()
- indexed.BuildEmitIndex(mvt.ExtentDefault, buf)
-
- // The emit index covers each prim only over its native [zMin, zMax]; the full
- // scan additionally overzooms a coarse prim above its zMax wherever no finer
- // cell overlaps (the realtime path's best-available behaviour). So the two
- // agree only at NON-overzoom tiles — skip any tile a coarse prim overzooms
- // into (a prim with zMax < z whose bbox covers the tile).
- overzoomsInto := func(c tile.TileCoord) bool {
- n := math.Pow(2, float64(c.Z))
- bufN := (buf / float64(mvt.ExtentDefault)) / n
- tnx0, tnx1 := float64(c.X)/n-bufN, float64(c.X+1)/n+bufN
- tny0, tny1 := float64(c.Y)/n-bufN, float64(c.Y+1)/n+bufN
- for i := range scan.prims {
- r := &scan.prims[i]
- if c.Z < r.zMin || c.Z <= r.zMax {
- continue // not overzooming this prim
- }
- if r.wMaxX < tnx0 || r.wMinX > tnx1 || r.wMaxY < tny0 || r.wMinY > tny1 {
- continue
- }
- return true
- }
- return false
- }
-
- coords := scan.TileCoords(mvt.ExtentDefault)
- var checked, skipped int
- for _, c := range coords {
- if overzoomsInto(c) {
- skipped++
- continue
- }
- var ts1, ts2 TileScratch
- a := scan.EmitTileInto(c, mvt.ExtentDefault, buf, &ts1)
- b := indexed.EmitTileInto(c, mvt.ExtentDefault, buf, &ts2)
- if !bytes.Equal(a, b) {
- t.Fatalf("tile %v differs: scan=%d bytes indexed=%d bytes", c, len(a), len(b))
- }
- checked++
- }
- t.Logf("verified %d tiles byte-identical (full scan vs indexed); skipped %d overzoom tiles", checked, skipped)
- if checked == 0 {
- t.Fatal("no tiles checked")
- }
-}
-
-func TestSoundingGrouping(t *testing.T) {
- chart, err := s57.Parse(goldenCell)
- if err != nil {
- t.Fatalf("parse: %v", err)
- }
- b := New()
- b.SetPortrayer(testS101Portrayer(t))
- b.AddCell(chart)
-
- // Find a tile carrying soundings and confirm the grouped attributes.
- var found bool
- for _, c := range b.TileCoords(mvt.ExtentDefault) {
- data := b.EmitTile(c, mvt.ExtentDefault, 64)
- if data == nil {
- continue
- }
- layers := decodeLayers(data)
- s := layers["soundings"]
- if s == nil || len(s.feats) == 0 {
- continue
- }
- found = true
- if !s.firstFeatureHasStringKey("symbol_names") {
- t.Error("soundings feature missing symbol_names")
- }
- // sym_s/sym_g are the SNDFRM04 safety-depth palette variants, only baked
- // when a SymbolCall carries SoundingDepthM. The S-101 lower path sets
- // SoundingDepthM=NaN, so it does not produce them — this assertion needs an
- // S-101 equivalent.
- if !s.firstFeatureHasStringKey("sym_s") || !s.firstFeatureHasStringKey("sym_g") {
- t.Skip("SNDFRM04 sym_s/sym_g palette variants not produced by the S-101 lower path; needs an S-101 equivalent assertion")
- }
- t.Logf("soundings present at %v: %d features", c, len(s.feats))
- break
- }
- if !found {
- t.Fatal("no soundings layer found in any tile")
- }
-}
-
-func TestSectorLights(t *testing.T) {
- // tessellateFigure drives each constructed figure element off the rule's params.
- // A leg with a nominal range emits the short (sleg 0) + extended full-length
- // (sleg 1) variants, both dashed in the rule's colour, for the client's toggle.
- leg := tessellateFigure(legPrim(90, 8), 14)
- if len(leg) != 2 {
- t.Fatalf("leg strokes = %d, want 2 (short + full)", len(leg))
- }
- if !leg[0].dashed || leg[0].colorToken != "CHBLK" || leg[0].sleg != 0 {
- t.Error("stroke 0 should be the dashed CHBLK short leg (sleg 0)")
- }
- if leg[1].sleg != 1 {
- t.Error("stroke 1 should be the full-length leg (sleg 1)")
- }
- legLen := func(s sectorStroke) float64 {
- return absf(s.points[1].Lat-s.points[0].Lat) + absf(s.points[1].Lon-s.points[0].Lon)
- }
- if legLen(leg[1]) <= legLen(leg[0]) {
- t.Errorf("full leg (%.6f) should be longer than short leg (%.6f)", legLen(leg[1]), legLen(leg[0]))
- }
- // A leg with no nominal range is a single always-shown stroke (sleg -1).
- plain := tessellateFigure(legPrim(90, 0), 14)
- if len(plain) != 1 || plain[0].sleg != -1 {
- t.Errorf("plain leg = %+v, want 1 stroke sleg -1", plain)
- }
- // An arc/ring is one stroke in the rule's colour (white light → yellow LITYW),
- // always shown (sleg -1). Screen-fixed: the radius ~halves per zoom level.
- arc14 := tessellateFigure(arcPrim(26, 0, 360, "LITYW"), 14)
- if len(arc14) != 1 || arc14[0].colorToken != "LITYW" || arc14[0].sleg != -1 {
- t.Fatalf("arc = %+v, want 1 LITYW stroke sleg -1", arc14)
- }
- arc15 := tessellateFigure(arcPrim(26, 0, 360, "LITYW"), 15)
- anchor := mustLatLon(38.97, -76.49)
- span := func(s []sectorStroke) float64 { return absf(s[0].points[0].Lat - anchor.Lat) }
- if ratio := span(arc14) / span(arc15); ratio < 1.9 || ratio > 2.1 {
- t.Errorf("ring radius ratio z14/z15 = %.3f, want ~2", ratio)
- }
-}
-
-// TestUpSuppressionPointOverlap exercises the up-direction gate for POINT features
-// in the full-scan (wasm) path: a coarse symbol overzoomed above its native band
-// survives where no strictly-finer prim overlaps it (disappearing-light), and is
-// suppressed only where a finer prim sits on it. zMax is set > natMax to simulate
-// the overzoomed coarse prim and drive the branch directly.
-func TestUpSuppressionPointOverlap(t *testing.T) {
- coastal := BandCoastal.ZoomRange() // {9,11}
- harbor := BandHarbor.ZoomRange() // {13,16}
- base := geo.LatLon{Lat: 38.97, Lon: -76.49}
- mk := func(b *Baker, ll geo.LatLon, layer string, zr ZoomRange, zMax uint32) {
- r := routed{layer: layer, kind: mvt.GeomPoint, npoint: normPt(ll),
- zMin: zr.Min, zMax: zMax, natMin: zr.Min, natMax: zr.Max}
- r.attrs = []mvt.KeyValue{{Key: "class", Value: mvt.StringVal("X")}}
- b.add(r, ptBbox(ll))
- }
- rng := tile.RangeForBbox(14, ptBbox(base), mvt.ExtentDefault)
- coord := tile.TileCoord{Z: 14, X: rng.XMin, Y: rng.YMin}
-
- // A: finer feature elsewhere on the tile (disjoint) → coarse survives.
- bA := New()
- mk(bA, base, "point_symbols", coastal, 18)
- mk(bA, geo.LatLon{Lat: base.Lat + 0.0008, Lon: base.Lon + 0.0008}, "soundings", harbor, harbor.Max)
- layersA := decodeLayers(bA.EmitTile(coord, mvt.ExtentDefault, 64))
- if layersA["point_symbols"] == nil {
- t.Error("A: coarse overzoomed symbol suppressed even though no finer prim overlaps it")
- }
- // B: finer feature at the SAME location → coarse suppressed.
- bB := New()
- mk(bB, base, "point_symbols", coastal, 18)
- mk(bB, base, "soundings", harbor, harbor.Max)
- layersB := decodeLayers(bB.EmitTile(coord, mvt.ExtentDefault, 64))
- if layersB["point_symbols"] != nil {
- t.Error("B: coarse symbol should be suppressed where a finer prim overlaps it")
- }
-}
-
-func mustLatLon(lat, lon float64) geo.LatLon { return geo.LatLon{Lat: lat, Lon: lon} }
-
-// legPrim / arcPrim build a one-element sector figure (as the rule emits) at a
-// fixed anchor: a dashed CHBLK leg at the given bearing (fullNM>0 enables the
-// extended variant), or a coloured arc/ring.
-func legPrim(bearingDeg, fullNM float64) *sectorPrim {
- return §orPrim{fig: portrayal.AugmentedFigure{
- Anchor: mustLatLon(38.97, -76.49), Ray: true, BearingDeg: bearingDeg,
- LengthMM: 25, ColorToken: "CHBLK", WidthMM: 0.32, Dash: portrayal.DashDashed,
- FullLengthNM: fullNM,
- }}
-}
-func arcPrim(radiusMM, startDeg, sweepDeg float64, color string) *sectorPrim {
- return §orPrim{fig: portrayal.AugmentedFigure{
- Anchor: mustLatLon(38.97, -76.49), RadiusMM: radiusMM,
- StartDeg: startDeg, SweepDeg: sweepDeg, ColorToken: color, WidthMM: 0.64,
- }}
-}
-
-func absf(x float64) float64 {
- if x < 0 {
- return -x
- }
- return x
-}
diff --git a/internal/engine/bake/cell_attr_test.go b/internal/engine/bake/cell_attr_test.go
deleted file mode 100644
index 8f77969..0000000
--- a/internal/engine/bake/cell_attr_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package bake
-
-import (
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/mvt"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// The inspector's source-cell pill reads a baked `cell` attribute; verify it is
-// emitted on point_symbols (and equals the dataset name).
-func TestCellAttributeBaked(t *testing.T) {
- chart, err := s57.Parse(goldenCell)
- if err != nil {
- t.Fatal(err)
- }
- t.Logf("dataset name = %q", chart.DatasetName())
- if chart.DatasetName() == "" {
- t.Fatal("dataset name empty — cell pill would be blank")
- }
- b := New()
- b.SetPortrayer(testS101Portrayer(t))
- b.AddCell(chart)
-
- for _, c := range b.TileCoords(mvt.ExtentDefault) {
- data := b.EmitTile(c, mvt.ExtentDefault, 64)
- if data == nil {
- continue
- }
- layers := decodeLayers(data)
- ps := layers["point_symbols"]
- if ps == nil || len(ps.feats) == 0 {
- continue
- }
- hasCell := false
- for _, k := range ps.keys {
- if k == "cell" {
- hasCell = true
- }
- }
- if !hasCell {
- t.Fatalf("point_symbols layer has no `cell` key; keys=%v", ps.keys)
- }
- return // found a populated point_symbols tile with the cell key
- }
- t.Skip("no point_symbols tile found in golden cell")
-}
diff --git a/internal/engine/bake/complexline.go b/internal/engine/bake/complexline.go
deleted file mode 100644
index cd55b58..0000000
--- a/internal/engine/bake/complexline.go
+++ /dev/null
@@ -1,228 +0,0 @@
-package bake
-
-import (
- "math"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/mvt"
- "github.com/beetlebugorg/chartplotter/internal/engine/tile"
-)
-
-// Complex (symbolised) linestyles are tessellated PER ZOOM at emit time, exactly
-// like sector lights (their period is screen-px sized, so a fixed lat/lon
-// geometry can't carry it). For each tile we walk the line by arc length —
-// anchored at the line's first vertex so the pattern is continuous across tile
-// boundaries (tile.ClipLinePhased keeps that phase) — and emit, per period:
-// - the dash "on" runs as real line segments into the restyleable complex_lines
-// layer (colour stays a client expression → Day/Dusk/Night switches live);
-// - each embedded symbol as a point into point_symbols, rotated to the local
-// line tangent (so chevrons/anchors/"!" sit ON the line at every zoom with no
-// pattern-stretch).
-// This replaces drawing the polyline once + a client-side line-pattern (which
-// stretched during zoom) or a phase-free symbol pass (symbols drifted off).
-
-// lsOnRun is one drawn dash run within a period, in screen px from period start.
-type lsOnRun struct{ lo, hi float64 }
-
-// lsEmbed is one embedded symbol within a period.
-type lsEmbed struct {
- offset float64 // screen px from period start
- name string
-}
-
-// lsPen is one stroke of a (possibly composite) line style. A compositeLineStyle
-// (double line) has several, listed background-first: e.g. INDHLT02 = a wide black
-// backing then a narrow yellow pen ON TOP, drawn in order so the yellow highlight
-// sits inside a black outline.
-type lsPen struct {
- colorToken string
- widthPx float64
-}
-
-// lsInfo is the per-zoom-independent geometry of one complex linestyle.
-type lsInfo struct {
- periodPx float64
- onRuns []lsOnRun
- symbols []lsEmbed
- pens []lsPen // ≥1, background→foreground; the dash geometry is stroked once per pen
- // colorToken/widthPx mirror the foreground (last) pen — the prim's fallback tag.
- colorToken string
- widthPx float64
-}
-
-// lsFeatureScale is screen px per 0.01-mm PresLib unit — the scale the dash/
-// symbol offsets are measured in (matches the assets + portrayal backend).
-const lsFeatureScale = float64(0.01 / 0.35278)
-
-// emitComplexLine tessellates one complex-line prim into this tile at coord.Z.
-func (b *Baker) emitComplexLine(r *routed, proj tile.Projector, rect tile.Rect, z uint32, extent uint32, tb *mvt.TileBuilder, scratch *[]tile.FPoint, attrScratch *[]mvt.KeyValue) {
- info := r.ls
- if info == nil || len(r.nline) < 2 {
- return
- }
- pts := projectNormRing(r.nline, proj, *scratch)
- *scratch = pts
- if len(pts) < 2 {
- return
- }
- // Cumulative arc length (tile-pixel units) along the full projected line.
- arc := make([]float64, len(pts))
- for i := 1; i < len(pts); i++ {
- arc[i] = arc[i-1] + math.Hypot(pts[i].X-pts[i-1].X, pts[i].Y-pts[i-1].Y)
- }
-
- // Screen px -> tile units. The baker lays figures out in 256-px-per-tile space
- // (see sectorRadiusNorm/tessellateFigure); one tile is `extent` units wide.
- pxScale := float64(extent) / 256.0
- period := info.periodPx * pxScale
- if period < 1e-6 {
- return
- }
-
- full := b.attrsFor(r, attrScratch) // rebuild base+variable (aliases attrScratch; stable here)
- // color_token + width_px are added PER PEN below (a composite line strokes each),
- // so strip any the prim carried — otherwise the foreground colour would leak onto
- // the backing pen.
- dashBase := make([]mvt.KeyValue, 0, len(full)+2)
- for _, kv := range full {
- if kv.Key == "color_token" || kv.Key == "width_px" {
- continue
- }
- dashBase = append(dashBase, kv)
- }
- symBase := full // class/cell/draw_prio/cat/bnd (+inspector extras)
- symScale := float64(0.01 / 0.35278)
-
- var dashPaths [][]mvt.IPoint
- var symPts []mvt.IPoint
- var symAttrs [][]mvt.KeyValue
-
- // Phase-stable clip: each run carries Arc0 (global arc at its first vertex).
- for _, run := range tile.ClipLinePhased(pts, arc, rect) {
- rp := run.Points
- if len(rp) < 2 {
- continue
- }
- rarc := make([]float64, len(rp))
- for i := 1; i < len(rp); i++ {
- rarc[i] = rarc[i-1] + math.Hypot(rp[i].X-rp[i-1].X, rp[i].Y-rp[i-1].Y)
- }
- g0 := run.Arc0
- runEnd := g0 + rarc[len(rarc)-1]
- kStart := int(math.Floor(g0 / period))
- for k := kStart; float64(k)*period < runEnd; k++ {
- base := float64(k) * period
- // Dash on-runs.
- for _, on := range info.onRuns {
- lo := math.Max(base+on.lo*pxScale, g0)
- hi := math.Min(base+on.hi*pxScale, runEnd)
- if hi-lo < 1e-6 {
- continue
- }
- if sub := subPathByArc(rp, rarc, lo-g0, hi-g0); len(sub) >= 2 {
- dashPaths = append(dashPaths, quantizeRing(sub))
- }
- }
- // Embedded symbols.
- for _, sym := range info.symbols {
- if sym.name == "" {
- continue
- }
- gp := base + sym.offset*pxScale
- if gp < g0 || gp > runEnd {
- continue
- }
- pt, ok, dx, dy := pointAndTangent(rp, rarc, gp-g0)
- if !ok {
- continue
- }
- rot := math.Atan2(dy, dx) * 180.0 / math.Pi
- symPts = append(symPts, tile.Quantize(pt))
- symAttrs = append(symAttrs, append(append([]mvt.KeyValue(nil), symBase...),
- mvt.KeyValue{Key: "symbol_name", Value: mvt.StringVal(sym.name)},
- mvt.KeyValue{Key: "rotation_deg", Value: mvt.FloatVal(float32(rot))},
- mvt.KeyValue{Key: "scale", Value: mvt.FloatVal(float32(symScale))},
- // The mark is oriented to the edge tangent in chart space, so it must
- // turn with the chart — map-aligned, like an ORIENT symbol (rot_north).
- mvt.KeyValue{Key: "rot_north", Value: mvt.IntVal(1)},
- ))
- }
- }
- }
-
- if len(dashPaths) > 0 {
- // r.layer is "complex_lines" normally, or "complex_lines_scamin" when the
- // feature carries SCAMIN (set by route via scaminLayer) so the dashes land
- // in the SCAMIN-bucketed source-layer. The embedded symbols below stay in
- // point_symbols (already bucketed; they carry `scamin` via attrsFor).
- // Stroke the geometry once per pen, background→foreground, so a composite
- // double line (e.g. INDHLT02: black backing + yellow top) renders the bright
- // pen inside a dark outline — same paths, each pen's own colour + width.
- lay := tb.Layer(r.layer)
- for _, pen := range info.pens {
- attrs := append(append([]mvt.KeyValue(nil), dashBase...),
- mvt.KeyValue{Key: "color_token", Value: mvt.StringVal(pen.colorToken)},
- mvt.KeyValue{Key: "width_px", Value: mvt.IntVal(int64(pen.widthPx + 0.5))})
- lay.AddLines(dashPaths, attrs)
- }
- }
- // AddPoints shares one attr set per call, so emit each symbol on its own (their
- // rotations differ).
- lay := tb.Layer("point_symbols")
- for i := range symPts {
- lay.AddPoints(symPts[i:i+1], symAttrs[i])
- }
-}
-
-// subPathByArc returns the sub-polyline of rp between local arc distances d0..d1
-// (rarc is the per-vertex cumulative arc). Endpoints are interpolated.
-func subPathByArc(rp []tile.FPoint, rarc []float64, d0, d1 float64) []tile.FPoint {
- total := rarc[len(rarc)-1]
- d0 = clampf64(d0, 0, total)
- d1 = clampf64(d1, 0, total)
- if d1-d0 < 1e-9 {
- return nil
- }
- out := []tile.FPoint{lerpArc(rp, rarc, d0)}
- for i := range rp {
- if rarc[i] > d0 && rarc[i] < d1 {
- out = append(out, rp[i])
- }
- }
- out = append(out, lerpArc(rp, rarc, d1))
- return out
-}
-
-// pointAndTangent returns the point at local arc d plus the (un-normalised)
-// tangent direction (dx,dy) of the segment containing it.
-func pointAndTangent(rp []tile.FPoint, rarc []float64, d float64) (tile.FPoint, bool, float64, float64) {
- total := rarc[len(rarc)-1]
- d = clampf64(d, 0, total)
- for i := 0; i+1 < len(rp); i++ {
- if d <= rarc[i+1] || i+2 == len(rp) {
- seg := rarc[i+1] - rarc[i]
- t := 0.0
- if seg > 1e-12 {
- t = (d - rarc[i]) / seg
- }
- p := tile.FPoint{X: rp[i].X + t*(rp[i+1].X-rp[i].X), Y: rp[i].Y + t*(rp[i+1].Y-rp[i].Y)}
- return p, true, rp[i+1].X - rp[i].X, rp[i+1].Y - rp[i].Y
- }
- }
- return tile.FPoint{}, false, 0, 0
-}
-
-// lerpArc returns the point at local arc distance d along rp.
-func lerpArc(rp []tile.FPoint, rarc []float64, d float64) tile.FPoint {
- p, _, _, _ := pointAndTangent(rp, rarc, d)
- return p
-}
-
-func clampf64(v, lo, hi float64) float64 {
- if v < lo {
- return lo
- }
- if v > hi {
- return hi
- }
- return v
-}
diff --git a/internal/engine/bake/crossband_line_test.go b/internal/engine/bake/crossband_line_test.go
deleted file mode 100644
index 7fb73b0..0000000
--- a/internal/engine/bake/crossband_line_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package bake
-
-import (
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/mvt"
- "github.com/beetlebugorg/chartplotter/internal/engine/tile"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// TestCrossBandLineNoDoubleDraw guards the best-available LINE suppression on the
-// real US4MD81M (approach) + US5MD1MC (harbor) overlapping pair. On the client
-// both per-band sources render at the overlap zoom (the fine bands carry no
-// maxzoom cap), so a coarse line kept in a tile a finer cell also draws into
-// DOUBLE-DRAWS — the RESARE/CTNARE boundary, depth contour or coastline appears
-// twice (a doubled stroke). Unlike opaque area FILLS, a line does not occlude, so
-// the whole-tile (5-corner) seam relaxation must NOT keep it: a coarse line is
-// suppressed by the tile CENTRE. The invariant: no tile that carries a line in
-// BOTH bands may have its centre covered by a strictly-finer cell (that would be
-// an interior double-draw, not an unavoidable ≤1-tile seam sliver).
-func TestCrossBandLineNoDoubleDraw(t *testing.T) {
- b := New()
- b.SetPortrayer(testS101Portrayer(t))
- for _, cell := range []string{goldenCell, "../../../testdata/US5MD1MC.000"} {
- chart, err := s57.Parse(cell)
- if err != nil {
- t.Fatalf("parse %s: %v", cell, err)
- }
- b.AddCell(chart)
- }
-
- const ext = mvt.ExtentDefault
- const buf = 64.0
- const z = 14 // a zoom where the client shows both approach and harbor
-
- // Coarse (approach) cell compilation scale, for the finer-cover comparison.
- var approachCscl uint32
- for i := range b.covMeta {
- if cm := &b.covMeta[i]; cm.bandMax == BandApproach.ZoomRange().Max {
- approachCscl = cm.cscl
- }
- }
- if approachCscl == 0 {
- t.Fatal("approach cell compilation scale not found in coverage metadata")
- }
-
- lineLayers := map[string]bool{"lines": true, "complex_lines": true}
- emitLineTiles := func(bandMax uint32) map[tile.TileCoord]bool {
- b.BuildEmitIndexBand(ext, buf, bandMax)
- defer b.ClearEmitIndex()
- out := map[tile.TileCoord]bool{}
- var ts TileScratch
- for _, c := range b.TileCoordsBand(ext, bandMax, bandMax) {
- if c.Z != z {
- continue
- }
- data := b.EmitTileBandInto(c, ext, buf, &ts, bandMax)
- if data == nil {
- continue
- }
- for name, l := range decodeLayers(data) {
- if lineLayers[name] && len(l.feats) > 0 {
- out[c] = true
- break
- }
- }
- }
- return out
- }
-
- app := emitLineTiles(BandApproach.ZoomRange().Max)
- har := emitLineTiles(BandHarbor.ZoomRange().Max)
-
- n := float64(int64(1) << uint(z))
- var doubled, interior int
- for c := range app {
- if !har[c] {
- continue
- }
- doubled++
- ctrLon := (float64(c.X)+0.5)/n*360 - 180
- ctrLat := unnormY((float64(c.Y) + 0.5) / n)
- // A strictly-finer cell covers this tile's centre, yet a coarse line was
- // kept here AND the finer band also drew one: an interior double-draw.
- if s := b.coverageScaleAt(ctrLat, ctrLon, z, false); s != 0 && s < approachCscl {
- interior++
- t.Errorf("interior line double-draw at %v: tile centre covered by finer cell (cscl %d < approach %d)", c, s, approachCscl)
- }
- }
- t.Logf("z%d: %d tiles carry a line in both bands; %d of them are interior (must be 0), the rest are ≤1-tile seam slivers", z, doubled, interior)
-}
diff --git a/internal/engine/bake/lightverify_test.go b/internal/engine/bake/lightverify_test.go
deleted file mode 100644
index e0a3699..0000000
--- a/internal/engine/bake/lightverify_test.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package bake
-
-import (
- "os"
- "strings"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/mvt"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// TestLightTagFromCatalogue bakes a real cell and confirms the baked `light`
-// inspector tag carries the catalogue (LITDSN02) string — no spurious single
-// group "(1)", and the aero-light category prefix present — proving the Go
-// reimplementation is gone and the value comes from Lua. Run with CELL set.
-func TestLightTagFromCatalogue(t *testing.T) {
- cell := os.Getenv("CELL")
- if cell == "" {
- cell = goldenCell // committed harbour cell, rich in lights
- }
- chart, err := s57.Parse(cell)
- if err != nil {
- t.Fatal(err)
- }
- b := New()
- b.SetPortrayer(testS101Portrayer(t))
- b.AddCell(chart)
-
- lights := map[string]bool{}
- for _, c := range b.TileCoords(mvt.ExtentDefault) {
- data := b.EmitTile(c, mvt.ExtentDefault, 64)
- if data == nil {
- continue
- }
- for _, v := range lightValues(data) {
- lights[v] = true
- }
- }
- if len(lights) == 0 {
- t.Fatal("no baked light tags found")
- }
- var aero bool
- for v := range lights {
- if strings.Contains(v, "(1)") {
- t.Errorf("baked light tag still has spurious single group: %q", v)
- }
- if strings.HasPrefix(v, "Aero ") {
- aero = true
- }
- }
- if !aero {
- t.Error("expected at least one Aero-prefixed light (catalogue category prefix)")
- }
- t.Logf("%d distinct baked light tags, e.g.:", len(lights))
- n := 0
- for v := range lights {
- t.Logf(" %q", v)
- if n++; n >= 12 {
- break
- }
- }
-}
-
-// lightValues extracts every `light` string-tag value from an MVT tile.
-func lightValues(data []byte) []string {
- var out []string
- r := &rdr{d: data}
- for {
- f, _, b, _, ok := r.next()
- if !ok {
- break
- }
- if f != 3 {
- continue
- }
- var keys []string
- var vals []string
- var valIsStr []bool
- var feats [][]uint32
- lr := &rdr{d: b}
- for {
- lf, _, lb, _, lok := lr.next()
- if !lok {
- break
- }
- switch lf {
- case 2:
- var tags []uint32
- fr := &rdr{d: lb}
- for {
- ff, _, fb, _, fok := fr.next()
- if !fok {
- break
- }
- if ff == 2 {
- tr := &rdr{d: fb}
- for !tr.end() {
- tags = append(tags, uint32(tr.uv()))
- }
- }
- }
- feats = append(feats, tags)
- case 3:
- keys = append(keys, string(lb))
- case 4:
- s, isStr := "", false
- vr := &rdr{d: lb}
- for {
- vf, _, vb, _, vok := vr.next()
- if !vok {
- break
- }
- if vf == 1 {
- isStr, s = true, string(vb)
- }
- }
- vals = append(vals, s)
- valIsStr = append(valIsStr, isStr)
- }
- }
- for _, tags := range feats {
- for i := 0; i+1 < len(tags); i += 2 {
- ki, vi := int(tags[i]), int(tags[i+1])
- if ki < len(keys) && keys[ki] == "light" && vi < len(vals) && valIsStr[vi] {
- out = append(out, vals[vi])
- }
- }
- }
- }
- return out
-}
diff --git a/internal/engine/bake/linestyle_catalog.go b/internal/engine/bake/linestyle_catalog.go
deleted file mode 100644
index fd86728..0000000
--- a/internal/engine/bake/linestyle_catalog.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package bake
-
-import "github.com/beetlebugorg/chartplotter/pkg/s100/catalog"
-
-// lsPxPerMM converts S-100 line-style millimetre measures to screen px. It is the
-// inverse of the PresLib's 0.35278 mm-per-px (lsFeatureScale = 0.01/0.35278 px per
-// 0.01-mm unit ⇒ pxPerMM = 100*lsFeatureScale), so a line style shared by name
-// between the catalogue and the PresLib lands at the same period geometry.
-const lsPxPerMM = 100.0 * lsFeatureScale
-
-// buildLinestyleTableFromCatalog analyses every S-101 catalogue line style into
-// its period geometry — the catalogue replacement for buildLinestyleTable (which
-// read the S-52 PresLib). The baker reuses the table for every tile.
-func buildLinestyleTableFromCatalog(cat *catalog.Catalog) map[string]*lsInfo {
- out := make(map[string]*lsInfo)
- if cat == nil {
- return out
- }
- for id, ls := range cat.LineStyles {
- if info := lsInfoFromCatalog(ls); info != nil {
- out[id] = info
- }
- }
- return out
-}
-
-// lsInfoFromCatalog converts one catalogue LineStyle (flattening composite
-// Components onto the longest interval) to the tessellator's lsInfo.
-func lsInfoFromCatalog(ls *catalog.LineStyle) *lsInfo {
- info := &lsInfo{colorToken: ls.PenColor, widthPx: ls.PenWidth * lsPxPerMM}
- hasDash := false
- addRuns := func(src *catalog.LineStyle) {
- if src.IntervalLength*lsPxPerMM > info.periodPx {
- info.periodPx = src.IntervalLength * lsPxPerMM
- }
- // Each pen of a (possibly composite) line is stroked. A compositeLineStyle
- // (double line) stacks a wide dark backing then a narrower bright pen ON TOP —
- // e.g. the indication highlight INDHLT02 = black 1.28 under yellow 0.64.
- // Components are listed background-first, so emitting them in order draws the
- // bright pen inside a dark outline. colorToken/widthPx mirror the foreground
- // (last) pen — the prim's fallback tag.
- if src.PenColor != "" {
- w := src.PenWidth * lsPxPerMM
- info.pens = append(info.pens, lsPen{colorToken: src.PenColor, widthPx: w})
- info.colorToken = src.PenColor
- if src.PenWidth > 0 {
- info.widthPx = w
- }
- }
- for _, d := range src.Dashes {
- lo, hi := d.Start*lsPxPerMM, (d.Start+d.Length)*lsPxPerMM
- if hi-lo > 1e-6 {
- info.onRuns = append(info.onRuns, lsOnRun{lo: lo, hi: hi})
- hasDash = true
- }
- }
- for _, s := range src.Symbols {
- info.symbols = append(info.symbols, lsEmbed{offset: s.Position * lsPxPerMM, name: s.Reference})
- }
- }
- if len(ls.Components) > 0 {
- for i := range ls.Components {
- addRuns(&ls.Components[i])
- }
- } else {
- addRuns(ls)
- }
- // Solid pen (no dash pattern, e.g. INDHLT02): stroke the whole line continuously.
- // Without this the tessellator finds no on-runs and emits nothing — the line is
- // invisible. Give it a period (if the style declared none) and one full-coverage
- // run so every period is 100% "on" ⇒ a continuous stroke. Mirrors s101Pattern in
- // internal/engine/assets/linestyles_s101.go (the client-asset path already does this).
- if !hasDash {
- if info.periodPx < 0.5 {
- info.periodPx = 16 * lsPxPerMM // arbitrary; a 100%-on run makes the value moot
- }
- info.onRuns = []lsOnRun{{lo: 0, hi: info.periodPx}}
- }
- if info.periodPx < 0.5 {
- return nil
- }
- if len(info.pens) == 0 {
- info.pens = []lsPen{{colorToken: info.colorToken, widthPx: info.widthPx}}
- }
- // Sub-pixel pens vanish; floor them (matches the legacy single-width floor).
- for i := range info.pens {
- if info.pens[i].widthPx < 0.6 {
- info.pens[i].widthPx = 0.9
- }
- }
- info.widthPx = info.pens[len(info.pens)-1].widthPx // foreground mirror
- return info
-}
diff --git a/internal/engine/bake/portrayer_test.go b/internal/engine/bake/portrayer_test.go
deleted file mode 100644
index c6406c9..0000000
--- a/internal/engine/bake/portrayer_test.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package bake
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-// testS101Portrayer builds an S-101 portrayer for the bake tests. The S-52
-// engine was removed, so AddCell now REQUIRES a portrayer; every test that
-// bakes a cell installs this one via SetPortrayer.
-//
-// The catalogue locations come from S101_CATALOG / S101_FC (falling back to the
-// developer's default checkout paths). If the portrayal catalogue isn't present
-// the test is skipped rather than failed, since it's an external dependency.
-func testS101Portrayer(tb testing.TB) Portrayer {
- tb.Helper()
-
- home, _ := os.UserHomeDir()
- pc := os.Getenv("S101_CATALOG")
- if pc == "" {
- pc = filepath.Join(home, "Projects", "s101-portrayal-catalogue", "PortrayalCatalog")
- }
- fc := os.Getenv("S101_FC")
- if fc == "" {
- fc = filepath.Join(home, "Projects", "s101-feature-catalogue", "S-101FC", "FeatureCatalogue.xml")
- }
-
- if _, err := os.Stat(filepath.Join(pc, "Rules", "main.lua")); err != nil {
- tb.Skip("S-101 catalogue not present; set S101_CATALOG/S101_FC")
- return nil
- }
-
- p, err := NewS101Portrayer(pc, fc)
- if err != nil {
- tb.Fatalf("build S-101 portrayer: %v", err)
- }
- return p
-}
diff --git a/internal/engine/bake/preslib_chart1_test.go b/internal/engine/bake/preslib_chart1_test.go
deleted file mode 100644
index eb5fcba..0000000
--- a/internal/engine/bake/preslib_chart1_test.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package bake
-
-import (
- "archive/zip"
- "strings"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// preslibZip is the IHO S-52 PresLib e4.0.0 digital-files download (untracked;
-// see scripts/preslib-chart1.sh). Its "ECDIS Chart 1" is a fully symbol-exercising
-// dataset of 14 cells, one overview (1:60 000) + 13 harbor pages (1:14 000), but —
-// unlike conformant NOAA data — the cells carry NO M_COVR data-coverage features.
-const preslibZip = "../../../testdata/S-52_PresLib_e4.0.0_Digital_Files_Draft.zip"
-
-// loadPresLibChart1 parses every ECDIS-Chart-1 .000 cell straight out of the zip,
-// or skips the test if the (untracked) download is absent.
-func loadPresLibChart1(t *testing.T) []*s57.Chart {
- t.Helper()
- zr, err := zip.OpenReader(preslibZip)
- if err != nil {
- t.Skipf("PresLib zip not present (%v); see scripts/preslib-chart1.sh", err)
- }
- t.Cleanup(func() { zr.Close() })
- var charts []*s57.Chart
- for _, f := range zr.File {
- if !strings.Contains(f.Name, "ECDIS_Chart_1/") || !strings.HasSuffix(f.Name, ".000") {
- continue
- }
- chart, err := s57.ParseFS(zr, f.Name)
- if err != nil {
- t.Fatalf("parse %s: %v", f.Name, err)
- }
- charts = append(charts, chart)
- }
- if len(charts) == 0 {
- t.Fatal("no ECDIS_Chart_1 .000 cells found in zip")
- }
- return charts
-}
-
-// TestPresLibChart1DerivedCoverage guards the cross-band best-available fix for
-// cells lacking M_COVR. The S-52 PresLib ECDIS Chart 1 stacks a 1:60 000 overview
-// over thirteen 1:14 000 harbor pages covering the same ground, but none of the
-// cells carry an M_COVR coverage polygon — so the M_COVR-driven suppression had
-// nothing to test and every overview feature double-drew over the harbor pages
-// (the "smashed together" symbols). extractCoverage now derives a coverage
-// rectangle from each cell's data extent when M_COVR is absent, restoring
-// per-cell (block) suppression. The invariant: a point inside a harbor page must
-// report a FINER (smaller) covering scale than the overview, so an overview
-// symbol there is suppressed.
-func TestPresLibChart1DerivedCoverage(t *testing.T) {
- charts := loadPresLibChart1(t)
-
- b := New()
- var overviewCscl, harborCscl uint32
- for _, chart := range charts {
- // Sanity: these cells really have no M_COVR — that's why the fix is needed.
- for i := range chart.Features() {
- if chart.Features()[i].ObjectClass() == "M_COVR" {
- t.Fatalf("cell %s unexpectedly has M_COVR — fixture changed", chart.DatasetName())
- }
- }
- band, n := b.AddCellCoverage(chart)
- if n == 0 {
- t.Errorf("cell %s contributed no coverage (derived-extent fallback failed)", chart.DatasetName())
- }
- switch cscl := uint32(chart.CompilationScale()); band {
- case BandApproach:
- overviewCscl = cscl
- case BandHarbor:
- harborCscl = cscl
- }
- }
- if overviewCscl == 0 || harborCscl == 0 {
- t.Fatalf("expected both an approach overview and harbor pages (overview=%d harbor=%d)", overviewCscl, harborCscl)
- }
- if harborCscl >= overviewCscl {
- t.Fatalf("harbor scale %d should be finer (smaller) than overview %d", harborCscl, overviewCscl)
- }
-
- // A point in the middle of harbor page AA5C1CDE (top row, third column). At a
- // harbor display zoom the finest covering cell there must be the harbor page,
- // not the overview — i.e. an overview prim at this point gets suppressed.
- const harborZ = 13 // BandHarbor display min
- if got := b.coverageScaleAt(15.11405, -5.05035, harborZ, true); got != harborCscl {
- t.Errorf("coverageScaleAt inside harbor page = %d, want harbor cscl %d "+
- "(no finer cover ⇒ overview would double-draw)", got, harborCscl)
- }
-
- // Every cell's derived coverage must lie within the overview's footprint
- // (they tile the same ground), confirming the rectangles are sane.
- ov := geo.LatLon{Lat: 15.0668, Lon: -5.0669} // overview centre
- if !b.coverageBandAtOK(ov) {
- t.Error("overview centre not covered by any derived coverage polygon")
- }
-}
-
-// coverageBandAtOK reports whether any coverage polygon contains p (test helper).
-func (b *Baker) coverageBandAtOK(p geo.LatLon) bool {
- for i := range b.covMeta {
- cm := &b.covMeta[i]
- if cm.bb.Contains(p) && pointInRings(p.Lon, p.Lat, cm.rings) {
- return true
- }
- }
- return false
-}
-
-// TestPresLibChart1NoMCovrIsExtentFallback verifies the lower-level contract: a
-// cell with no M_COVR yields exactly one DERIVED coverage rectangle spanning its
-// geometry, and a (hypothetical) conformant cell would not.
-func TestPresLibChart1NoMCovrIsExtentFallback(t *testing.T) {
- charts := loadPresLibChart1(t)
- b := New()
- for _, chart := range charts {
- before := len(b.covMeta)
- b.AddCellCoverage(chart)
- added := b.covMeta[before:]
- if len(added) != 1 {
- t.Errorf("cell %s: expected 1 derived coverage rect, got %d", chart.DatasetName(), len(added))
- continue
- }
- if !added[0].derived {
- t.Errorf("cell %s: coverage not flagged derived", chart.DatasetName())
- }
- }
-}
diff --git a/internal/engine/bake/rotnorth_test.go b/internal/engine/bake/rotnorth_test.go
deleted file mode 100644
index 9bf347c..0000000
--- a/internal/engine/bake/rotnorth_test.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package bake
-
-import (
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/mvt"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-func featHasKey(l *decLayer, tags []uint32, key string) bool {
- for i := 0; i+1 < len(tags); i += 2 {
- if int(tags[i]) < len(l.keys) && l.keys[tags[i]] == key {
- return true
- }
- }
- return false
-}
-
-// rot_north tags a point symbol whose rotation is referenced to TRUE NORTH (an
-// S-57 attribute like ORIENT, or a complex-line edge tangent), so the client
-// routes it to the map-aligned layer that turns with the chart; its absence means
-// the symbol is screen-up (no rotation, or a literal-angle flare) — S-52 6.1.1
-// §3.1.6 / PresLib §9.2 ROT. The split must be SELECTIVE: an approach cell carries
-// both kinds, so the baked flag must appear on some point symbols and not others.
-func TestRotNorthSelective(t *testing.T) {
- chart, err := s57.Parse(goldenCell)
- if err != nil {
- t.Fatal(err)
- }
- b := New()
- b.SetPortrayer(testS101Portrayer(t))
- b.AddCell(chart)
-
- var total, withNorth int
- for _, c := range b.TileCoords(mvt.ExtentDefault) {
- data := b.EmitTile(c, mvt.ExtentDefault, 64)
- if data == nil {
- continue
- }
- ps := decodeLayers(data)["point_symbols"]
- if ps == nil {
- continue
- }
- for _, f := range ps.feats {
- total++
- if featHasKey(ps, f, "rot_north") {
- withNorth++
- }
- }
- }
- t.Logf("point_symbols feats=%d true-north=%d screen-up=%d", total, withNorth, total-withNorth)
- if total == 0 {
- t.Fatal("no point_symbols features baked from golden cell")
- }
- if withNorth == 0 {
- t.Error("no point symbol got rot_north — ORIENT/tangent symbols are not flagged true-north")
- }
- if withNorth == total {
- t.Error("every point symbol got rot_north — plain symbols must stay screen-up (rot_north absent)")
- }
-}
diff --git a/internal/engine/bake/s101portrayer.go b/internal/engine/bake/s101portrayer.go
deleted file mode 100644
index b772fb0..0000000
--- a/internal/engine/bake/s101portrayer.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package bake
-
-import (
- "io/fs"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/portrayal"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// Portrayer produces the portrayal passes for one S-57 feature. It is the seam
-// the bake pipeline drives portrayal through: the S-101 rule engine, selected
-// via SetPortrayer. A portrayer is required.
-type Portrayer interface {
- Passes(f *s57.Feature) []portrayal.FeatureBuildPass
-}
-
-// BatchPortrayer is a Portrayer that precomputes a whole cell's features at
-// once. AddCell calls Begin(features) before iterating and End() after. The
-// S-101 engine implements this so it runs ONE portrayal pass per cell (one Lua
-// chunk compile, one context) with a fresh Lua state per cell — instead of
-// per-feature, which recompiled + leaked the catalogue's file-local caches.
-type BatchPortrayer interface {
- Portrayer
- Begin(features []*s57.Feature)
- End()
-}
-
-// precomputingPortrayer is a BatchPortrayer that can portray a cell off the
-// routing thread — portray is concurrency-safe and returns an opaque result that
-// install replays onto the active state before that cell's serial routing. This
-// lets the baker portray cells in parallel (the dominant bake cost) while keeping
-// the stateful routing/merge serial and deterministic. The S-101 portrayer
-// implements it; other portrayers fall back to the serial Begin/End path.
-type precomputingPortrayer interface {
- BatchPortrayer
- portray(features []*s57.Feature) cellPortrayal
- install(cp cellPortrayal)
-}
-
-// SetPortrayer installs the portrayal engine on the Baker. Set the S-101
-// portrayer (NewS101Portrayer) to bake with S-101 symbology. Set before AddCell.
-func (b *Baker) SetPortrayer(p Portrayer) { b.portrayer = p }
-
-// s101Portrayer adapts portrayal.S101Builder to the Portrayer seam. The S-101
-// rules branch on the PlainBoundaries / SimplifiedSymbols context parameters, so
-// to let the client toggle boundary style (areas) and point-symbol style live we
-// portray the cell THREE times — default (symbolized boundaries + paper symbols),
-// PlainBoundaries=true, and SimplifiedSymbols=true — and emit per-feature variant
-// passes (bnd 0/1 for areas, pts 0/1 for points). Features that don't vary stay a
-// single common pass.
-type s101Portrayer struct {
- builder *portrayal.S101Builder
- cache map[int64]portrayal.FeatureBuild // default: symbolized boundaries + paper symbols
- plain map[int64]portrayal.FeatureBuild // PlainBoundaries=true (area boundaries)
- simplified map[int64]portrayal.FeatureBuild // SimplifiedSymbols=true (point symbols)
-}
-
-// NewS101Portrayer builds the S-101 portrayer from a PortrayalCatalog directory
-// and a FeatureCatalogue.xml path.
-func NewS101Portrayer(portrayalCatalogDir, featureCataloguePath string) (Portrayer, error) {
- bld, err := portrayal.NewS101Builder(portrayalCatalogDir, featureCataloguePath)
- if err != nil {
- return nil, err
- }
- return &s101Portrayer{builder: bld}, nil
-}
-
-// LinestyleTable builds the complex-line dash/symbol period geometry from the
-// S-101 catalogue's LineStyles — what the baker's emitComplexLine tessellates.
-// This is the S-101 source that replaced the S-52 PresLib linestyle table.
-func (p *s101Portrayer) LinestyleTable() map[string]*lsInfo {
- return buildLinestyleTableFromCatalog(p.builder.Catalog)
-}
-
-// linestyleSource is an optional Portrayer capability: the complex-line period
-// geometry keyed by line-style name (the S-101 catalogue provides it).
-type linestyleSource interface {
- LinestyleTable() map[string]*lsInfo
-}
-
-// NewS101PortrayerFS builds the S-101 portrayer from an in-memory PortrayalCatalog
-// FS + FeatureCatalogue.xml bytes — the build-time embedded catalogue path.
-func NewS101PortrayerFS(catalogFS fs.FS, featureCatalogueXML []byte) (Portrayer, error) {
- bld, err := portrayal.NewS101BuilderFS(catalogFS, featureCatalogueXML)
- if err != nil {
- return nil, err
- }
- return &s101Portrayer{builder: bld}, nil
-}
-
-// cellPortrayal is one cell's three portrayal-pass result maps. portray produces
-// it off the routing thread; install replays it onto the active state just before
-// that cell's serial routing — so cells can be portrayed in parallel and merged
-// serially (deterministically).
-type cellPortrayal struct {
- cache, plain, simplified map[int64]portrayal.FeatureBuild
-}
-
-// Begin portrays the whole cell up front and caches results. It runs the default
-// pass plus the PlainBoundaries / SimplifiedSymbols variant passes so the client
-// can toggle boundary + point-symbol style live. Variant passes are best-effort:
-// if one fails, that axis degrades to the common pass.
-func (p *s101Portrayer) Begin(features []*s57.Feature) { p.install(p.portray(features)) }
-
-// portray computes the cell's three portrayal passes WITHOUT touching the
-// portrayer's active state, so different cells can be portrayed concurrently:
-// each pass builds its own Lua engine and the builder is read-only (the proto
-// cache is internally locked). The result is replayed by install.
-func (p *s101Portrayer) portray(features []*s57.Feature) cellPortrayal {
- m, err := p.builder.BuildBatch(features)
- if err != nil {
- return cellPortrayal{} // empty → Passes falls back to per-feature builds
- }
- cp := cellPortrayal{cache: m}
- // Each override pass portrays ONLY the geometry type whose variant it
- // contributes — PlainBoundaries varies area boundaries (consumed only for
- // polygons in Passes), SimplifiedSymbols varies point symbols (consumed only
- // for non-SOUNDG points). Lines and soundings never read either variant, so
- // portraying them here is wasted rule evaluation. The predicates mirror the
- // consumption in Passes exactly, so output is unchanged.
- if v, err := p.builder.BuildBatchFiltered(features, map[string]string{"PlainBoundaries": "true"}, isAreaFeature); err == nil {
- cp.plain = v
- }
- if v, err := p.builder.BuildBatchFiltered(features, map[string]string{"SimplifiedSymbols": "true"}, isSimplifiablePoint); err == nil {
- cp.simplified = v
- }
- return cp
-}
-
-// install replays a precomputed portrayal onto the active state Passes reads.
-func (p *s101Portrayer) install(cp cellPortrayal) {
- p.cache, p.plain, p.simplified = cp.cache, cp.plain, cp.simplified
-}
-
-// isAreaFeature selects Surface features — the only consumers of the
-// PlainBoundaries (plain area-boundary) variant in Passes.
-func isAreaFeature(f *s57.Feature) bool {
- return f.Geometry().Type == s57.GeometryTypePolygon
-}
-
-// isSimplifiablePoint selects non-SOUNDG point features — the only consumers of
-// the SimplifiedSymbols (simplified point-symbol) variant in Passes. SOUNDG digit
-// glyphs don't vary, so they stay a single common pass.
-func isSimplifiablePoint(f *s57.Feature) bool {
- return f.Geometry().Type == s57.GeometryTypePoint && f.ObjectClass() != "SOUNDG"
-}
-
-// End drops the per-cell caches so their memory is released before the next cell.
-func (p *s101Portrayer) End() { p.cache, p.plain, p.simplified = nil, nil, nil }
-
-func (p *s101Portrayer) Passes(f *s57.Feature) []portrayal.FeatureBuildPass {
- if p.cache == nil {
- // Fallback (Begin not called): single-feature build, no variants.
- build, ok := p.builder.Build(f)
- if !ok {
- return nil
- }
- return []portrayal.FeatureBuildPass{{Build: build, Bnd: portrayal.BndCommon, Pts: portrayal.PtsCommon}}
- }
- def, ok := p.cache[f.ID()]
- if !ok {
- return nil
- }
- switch f.Geometry().Type {
- case s57.GeometryTypePolygon:
- // Area boundaries: symbolized (default, bnd=1) + plain (bnd=0) so the
- // client's "Area boundaries" toggle switches between them. Falls back to
- // one common pass if the plain build is unavailable.
- if pl, ok := p.plain[f.ID()]; ok {
- return []portrayal.FeatureBuildPass{
- {Build: def, Bnd: portrayal.BndSymbolized, Pts: portrayal.PtsCommon},
- {Build: pl, Bnd: portrayal.BndPlain, Pts: portrayal.PtsCommon},
- }
- }
- case s57.GeometryTypePoint:
- // Point symbols: paper-chart (default, pts=0) + simplified (pts=1). SOUNDG
- // digit glyphs don't vary, so leave them a common pass (no doubling).
- if f.ObjectClass() != "SOUNDG" {
- if sm, ok := p.simplified[f.ID()]; ok {
- return []portrayal.FeatureBuildPass{
- {Build: def, Bnd: portrayal.BndCommon, Pts: portrayal.PtsPaper},
- {Build: sm, Bnd: portrayal.BndCommon, Pts: portrayal.PtsSimplified},
- }
- }
- }
- }
- return []portrayal.FeatureBuildPass{{Build: def, Bnd: portrayal.BndCommon, Pts: portrayal.PtsCommon}}
-}
diff --git a/internal/engine/bake/s57attr_test.go b/internal/engine/bake/s57attr_test.go
deleted file mode 100644
index 97f6298..0000000
--- a/internal/engine/bake/s57attr_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package bake
-
-import (
- "encoding/json"
- "slices"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/mvt"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// encodeS57Attrs feeds the cursor-pick report (S-52 PresLib §10.8): a compact,
-// deterministic acronym→value blob the client decodes against the catalogue.
-func TestEncodeS57Attrs(t *testing.T) {
- if got := encodeS57Attrs(nil); got != "" {
- t.Errorf("nil attrs: got %q, want empty", got)
- }
- if got := encodeS57Attrs(map[string]any{"DEPTHS": []float64{1, 2}}); got != "" {
- t.Errorf("synthetic-only attrs: got %q, want empty (DEPTHS dropped)", got)
- }
-
- attrs := map[string]any{
- "OBJNAM": " Buoy 7 ", // trimmed
- "COLOUR": "1,3", // list kept raw for client-side enum decode
- "VALSOU": 10.0, // minimal number formatting (rule 3: no padding)
- "CATLIT": 4,
- "DEPTHS": []float64{1, 2}, // synthetic — must be dropped
- "BLANK": " ", // empty after trim — dropped
- }
- got := encodeS57Attrs(attrs)
-
- var m map[string]string
- if err := json.Unmarshal([]byte(got), &m); err != nil {
- t.Fatalf("blob is not valid JSON: %v (%q)", err, got)
- }
- want := map[string]string{"OBJNAM": "Buoy 7", "COLOUR": "1,3", "VALSOU": "10", "CATLIT": "4"}
- if len(m) != len(want) {
- t.Errorf("key set mismatch: got %v, want %v", m, want)
- }
- for k, v := range want {
- if m[k] != v {
- t.Errorf("%s: got %q, want %q", k, m[k], v)
- }
- }
- if _, ok := m["DEPTHS"]; ok {
- t.Error("DEPTHS leaked into blob")
- }
-
- // Deterministic: same input → byte-identical output (json.Marshal sorts keys).
- if again := encodeS57Attrs(attrs); again != got {
- t.Errorf("non-deterministic encode:\n %q\n %q", got, again)
- }
-}
-
-// The pick report needs the s57 attribute blob on real baked features; assert it
-// is emitted on at least one layer of the golden cell.
-func TestS57BlobBaked(t *testing.T) {
- chart, err := s57.Parse(goldenCell)
- if err != nil {
- t.Fatal(err)
- }
- b := New()
- b.SetPortrayer(testS101Portrayer(t))
- b.AddCell(chart)
-
- for _, c := range b.TileCoords(mvt.ExtentDefault) {
- data := b.EmitTile(c, mvt.ExtentDefault, 64)
- if data == nil {
- continue
- }
- for _, l := range decodeLayers(data) {
- if slices.Contains(l.keys, "s57") {
- return // found the attribute blob
- }
- }
- }
- t.Fatal("no `s57` attribute blob emitted on any layer of the golden cell")
-}
diff --git a/internal/engine/bake/spatial.go b/internal/engine/bake/spatial.go
deleted file mode 100644
index 8f855df..0000000
--- a/internal/engine/bake/spatial.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package bake
-
-// pointInRings reports whether (lon, lat) is inside the even-odd union of the
-// polygon rings (ray-cast). Shared geometry helper used by the bake emit path.
-func pointInRings(lon, lat float64, rings [][][]float64) bool {
- inside := false
- for _, ring := range rings {
- n := len(ring)
- if n < 3 {
- continue
- }
- j := n - 1
- for i := 0; i < n; i++ {
- xi, yi := ring[i][0], ring[i][1]
- xj, yj := ring[j][0], ring[j][1]
- if (yi > lat) != (yj > lat) {
- xint := (xj-xi)*(lat-yi)/(yj-yi) + xi
- if lon < xint {
- inside = !inside
- }
- }
- j = i
- }
- }
- return inside
-}
diff --git a/internal/engine/baker/baker.go b/internal/engine/baker/baker.go
deleted file mode 100644
index c201e86..0000000
--- a/internal/engine/baker/baker.go
+++ /dev/null
@@ -1,511 +0,0 @@
-// Package baker wires S-57 cell bytes through parse + S-101 portrayal into a
-// PMTiles archive. It is the shared core behind the bake-zip / provision CLI
-// paths and the server's background provision job.
-package baker
-
-import (
- "bytes"
- "compress/gzip"
- "path"
- "runtime"
- "sort"
- "strings"
- "sync"
- "sync/atomic"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/bake"
- "github.com/beetlebugorg/chartplotter/internal/engine/pmtiles"
- "github.com/beetlebugorg/chartplotter/internal/engine/s101catalog"
- "github.com/beetlebugorg/chartplotter/internal/engine/tile"
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// MVT bake parameters (default values).
-const (
- MVTExtent uint32 = 4096
- MVTBuffer float64 = 64
-)
-
-// s101Portrayer is an optional external-catalogue override set via
-// UseS101Catalog. When set, every Baker built here portrays from that catalogue;
-// otherwise baking uses the build-time embedded catalogue.
-var s101Portrayer bake.Portrayer
-
-// UseS101Catalog overrides the embedded catalogue, loading the S-101 portrayal
-// engine from a PortrayalCatalog directory + a FeatureCatalogue.xml path. Call
-// once before baking.
-func UseS101Catalog(portrayalCatalogDir, featureCataloguePath string) error {
- p, err := bake.NewS101Portrayer(portrayalCatalogDir, featureCataloguePath)
- if err != nil {
- return err
- }
- s101Portrayer = p
- return nil
-}
-
-var (
- embeddedOnce sync.Once
- embeddedPort bake.Portrayer
-)
-
-// embeddedPortrayer lazily builds the portrayer from the build-time embedded
-// S-101 catalogue (internal/engine/s101catalog), or returns nil if this binary
-// was built without it (a plain `go build`, no -tags embed_s101).
-func embeddedPortrayer() bake.Portrayer {
- embeddedOnce.Do(func() {
- if !s101catalog.Available() {
- return
- }
- catFS, err := s101catalog.PortrayalFS()
- if err != nil {
- return
- }
- fcXML, err := s101catalog.FeatureCatalogue()
- if err != nil {
- return
- }
- if p, err := bake.NewS101PortrayerFS(catFS, fcXML); err == nil {
- embeddedPort = p
- }
- })
- return embeddedPort
-}
-
-func applyPortrayer(b *bake.Baker) {
- // An explicit --s101 override (UseS101Catalog) wins; otherwise use the
- // build-time embedded catalogue.
- p := s101Portrayer
- if p == nil {
- p = embeddedPortrayer()
- }
- if p != nil {
- b.SetPortrayer(p)
- }
-}
-
-// ParseCellBytes parses an S-57 base cell held entirely in memory (e.g. a zip
-// entry or a downloaded NOAA cell) by staging it on an in-memory filesystem.
-// Updates are not applied (the cell is parsed at its base edition).
-func ParseCellBytes(name string, data []byte) (*s57.Chart, error) {
- p := "/" + path.Base(name)
- opts := s57.DefaultParseOptions()
- opts.Fs = iso8211.MemFS{p: data}
- opts.ApplyUpdates = false
- opts.MaskCoastlineCoincidentBoundaries = true
- return s57.ParseWithOptions(p, opts)
-}
-
-// Session is an incremental Baker builder: cells are parsed and added one at a
-// time (AddCell) into a long-lived Baker, instead of all-at-once via BuildBaker.
-// This is the real-time wasm path — parsing a single large cell can take seconds
-// in wasm, so loading the set one cell per call lets the host yield between cells
-// (servicing tile requests / reporting progress) rather than blocking on the
-// whole set.
-type Session struct {
- Baker *bake.Baker
-}
-
-// NewSession returns an empty incremental Session. Add cells with AddCellBytes,
-// then bake tiles off Session.Baker.
-func NewSession() (*Session, error) {
- b := bake.New()
- applyPortrayer(b)
- b.OverzoomAllBands = true // realtime/upload path: keep a few uploaded cells visible (skeleton) when zoomed out
- return &Session{Baker: b}, nil
-}
-
-// AddCellBytes parses one raw cell and adds it to the session's Baker. The caller
-// should rebuild the emit index (Baker.BuildEmitIndex) before baking tiles after
-// any add.
-func (s *Session) AddCellBytes(name string, data []byte) (s57.Bounds, error) {
- chart, err := ParseCellBytes(name, data)
- if err != nil {
- return s57.Bounds{}, err
- }
- s.Baker.AddCell(chart)
- return chart.Bounds(), nil
-}
-
-// BuildBaker parses and adds each named cell to a fresh Baker. cells maps a
-// cell name (or path) to its raw bytes. onSkip, if non-nil, is called for each
-// cell that fails to parse. Returns the Baker and the names successfully added,
-// in sorted order for deterministic output.
-func BuildBaker(cells map[string][]byte, onSkip func(name string, err error)) (*bake.Baker, []string, error) {
- names := make([]string, 0, len(cells))
- for n := range cells {
- names = append(names, n)
- }
- sort.Strings(names)
-
- b := bake.New()
- applyPortrayer(b)
- ok := addCellsParallel(b, names, func(name string) (*s57.Chart, error) {
- return ParseCellBytes(name, cells[name])
- }, onSkip, nil)
- return b, ok, nil
-}
-
-// parseInOrder parses cells on a bounded worker pool and delivers each to consume
-// serially in `names` order. parse and precompute run in the worker (concurrent,
-// for the independent per-cell work — S-57 parse + S-101 portrayal); consume runs
-// on the calling goroutine in order, so it may mutate shared baker state and the
-// result stays deterministic. At most ~NumCPU cells are resident at once: a token
-// is taken before a worker starts and released by the consumer after that cell is
-// handled. onSkip fires in `names` order for parse errors.
-func parseInOrder[T any](
- names []string,
- parse func(name string) (*s57.Chart, error),
- precompute func(name string, chart *s57.Chart) T,
- consume func(name string, chart *s57.Chart, pre T),
- onSkip func(name string, err error),
-) {
- workers := runtime.NumCPU()
- if workers > len(names) {
- workers = len(names)
- }
- if workers < 1 {
- return
- }
- type result struct {
- chart *s57.Chart
- pre T
- err error
- }
- slots := make([]chan result, len(names))
- for i := range slots {
- slots[i] = make(chan result, 1)
- }
- sem := make(chan struct{}, workers)
- go func() {
- for i, name := range names {
- sem <- struct{}{}
- go func(i int, name string) {
- chart, err := parse(name)
- if err != nil {
- slots[i] <- result{err: err}
- return
- }
- slots[i] <- result{chart: chart, pre: precompute(name, chart)}
- }(i, name)
- }
- }()
- for i, name := range names {
- r := <-slots[i]
- if r.err != nil {
- if onSkip != nil {
- onSkip(name, r.err)
- }
- <-sem
- continue
- }
- consume(name, r.chart, r.pre)
- <-sem
- }
-}
-
-// addCellsParallel parses and portrays cells on a worker pool (parseInOrder), then
-// routes them into b serially in `names` order. Parsing and S-101 portrayal are
-// the dominant bake cost and are independent per cell, so they run concurrently;
-// the stateful route/merge stays single-threaded and ordered, so the archive is
-// byte-for-byte identical to the serial path. Returns the routed cell names.
-// onCell, if non-nil, is called once per cell as it is routed (done, total) so the
-// caller can report parse+portray progress — the dominant per-band cost, which is
-// otherwise an invisible pause before any tile is emitted.
-func addCellsParallel(b *bake.Baker, names []string, parse func(name string) (*s57.Chart, error), onSkip func(name string, err error), onCell func(done, total int)) []string {
- ok := make([]string, 0, len(names))
- total := len(names)
- parseInOrder(names, parse,
- func(_ string, chart *s57.Chart) bake.CellPortrayal { return b.PortrayCell(chart) },
- func(name string, chart *s57.Chart, pc bake.CellPortrayal) {
- b.AddCellPortrayed(chart, pc)
- ok = append(ok, name)
- if onCell != nil {
- onCell(len(ok), total)
- }
- }, onSkip)
- return ok
-}
-
-// CellData is a base cell (.000) plus its sequential update files (.001, .002, …)
-// keyed by filename. Updates are applied in order to bring the cell to its current
-// edition.
-type CellData struct {
- Base []byte
- Updates map[string][]byte
-}
-
-// cellParseOpts stages a base cell + its update files on an in-memory filesystem
-// (so the parser discovers and applies the .001/.002/… chain) and returns the
-// path + the baker's standard parse options.
-func cellParseOpts(name string, base []byte, updates map[string][]byte) (string, s57.ParseOptions) {
- p := "/" + path.Base(name)
- fsys := iso8211.MemFS{p: base}
- dir := path.Dir(p)
- for un, ub := range updates {
- fsys[path.Join(dir, path.Base(un))] = ub
- }
- opts := s57.DefaultParseOptions()
- opts.Fs = fsys
- opts.ApplyUpdates = true
- opts.MaskCoastlineCoincidentBoundaries = true
- return p, opts
-}
-
-// ParseCellWithUpdates parses a base cell with its update files applied (vs
-// ParseCellBytes, which parses base-only).
-func ParseCellWithUpdates(name string, base []byte, updates map[string][]byte) (*s57.Chart, error) {
- p, opts := cellParseOpts(name, base, updates)
- return s57.ParseWithOptions(p, opts)
-}
-
-// ParseCellCoverage parses ONLY a cell's M_COVR coverage features, skipping every
-// other feature's geometry construction (the expensive topology/ring assembly).
-// The streaming bake's coverage pass reads only M_COVR (extractCoverage) and the
-// cell's band comes from the header scale, so this yields the same covMeta far
-// cheaper than a full parse. Updates are still applied (the filter acts after
-// them), so the coverage reflects the cell's current edition.
-func ParseCellCoverage(name string, base []byte, updates map[string][]byte) (*s57.Chart, error) {
- p, opts := cellParseOpts(name, base, updates)
- opts.ObjectClassFilter = []string{"M_COVR"}
- opts.MaskCoastlineCoincidentBoundaries = false // irrelevant to M_COVR rings; skip the coastline-edge setup
- return s57.ParseWithOptions(p, opts)
-}
-
-// BuildBakerWithUpdates is BuildBaker, but each cell's update files are applied.
-// cells maps a cell name (the base filename) to its base+update bytes. When
-// overzoom is true every band overzooms DOWN to the world view (Baker
-// .OverzoomAllBands) so a standalone large-scale set (e.g. an IENC bundle with no
-// overview cells) stays visible zoomed out; leave it false for a full NOAA bake
-// whose overview/general bands already supply the zoomed-out skeleton.
-func BuildBakerWithUpdates(cells map[string]CellData, overzoom bool, onSkip func(name string, err error)) (*bake.Baker, []string, error) {
- names := make([]string, 0, len(cells))
- for n := range cells {
- names = append(names, n)
- }
- sort.Strings(names)
-
- b := bake.New()
- applyPortrayer(b)
- b.OverzoomAllBands = overzoom
- ok := addCellsParallel(b, names, func(name string) (*s57.Chart, error) {
- cd := cells[name]
- return ParseCellWithUpdates(name, cd.Base, cd.Updates)
- }, onSkip, nil)
- return b, ok, nil
-}
-
-// BakeToPMTiles bakes every tile from b into a PMTiles builder. Tiles are emitted
-// in parallel across all CPUs (EmitTileInto only reads the Baker) and each worker
-// adds its tile to the builder directly under a mutex, so only one encoded tile
-// per worker is live at a time instead of holding every tile's bytes in a second
-// full-size slice. (Add order is no longer deterministic, so the blob LAYOUT can
-// vary run-to-run; the tiles served — sorted by TileID at write — are identical.)
-// progress, if non-nil, is called as (tilesEmitted, totalTiles).
-func BakeToPMTiles(b *bake.Baker, progress func(done, total int)) *pmtiles.Builder {
- // Build the inverted tile→prim index once (single-threaded) so each parallel
- // worker's EmitTileInto iterates only on-tile prims instead of scanning all of
- // b.prims. Read-only after this point, so concurrent reads are safe.
- b.BuildEmitIndex(MVTExtent, MVTBuffer)
- pb := pmtiles.New()
- emitTiles(b.TileCoords(MVTExtent), pb, progress, func(c tile.TileCoord, ts *bake.TileScratch) []byte {
- return b.EmitTileInto(c, MVTExtent, MVTBuffer, ts)
- })
- // Override the tile-derived bounds (the spec-display z0 world tile would make
- // them global) with the real cell-union extent so clients frame to the charts.
- if bb := b.Bounds(); bb.MinLon <= bb.MaxLon && bb.MinLat <= bb.MaxLat {
- pb.SetBounds(bb.MinLon, bb.MinLat, bb.MaxLon, bb.MaxLat)
- }
- return pb
-}
-
-// emitTiles bakes coords in parallel and adds each non-empty tile to pb under a
-// mutex — no intermediate full-archive slice of encoded bytes (only one tile per
-// worker is live at a time). emit returns the encoded tile (or nil to skip).
-func emitTiles(coords []tile.TileCoord, pb *pmtiles.Builder, progress func(done, total int), emit func(tile.TileCoord, *bake.TileScratch) []byte) {
- total := len(coords)
- workers := runtime.NumCPU()
- if workers > total {
- workers = total
- }
- if workers < 1 {
- workers = 1
- }
- pb.SetTilesGzipped() // each tile is gzipped in the worker below
- var mu sync.Mutex
- var next, done int64 = -1, 0
- var wg sync.WaitGroup
- for w := 0; w < workers; w++ {
- wg.Add(1)
- go func() {
- var ts bake.TileScratch // reused across every tile this worker bakes
- gz := gzip.NewWriter(nil)
- var buf bytes.Buffer
- defer wg.Done()
- for {
- i := int(atomic.AddInt64(&next, 1))
- if i >= total {
- return
- }
- c := coords[i]
- if data := emit(c, &ts); data != nil {
- // gzip in the worker (parallel); the archive stores compressed
- // tiles (~3–5× smaller blob + file). Deterministic (no mtime), so
- // identical tiles still dedup.
- buf.Reset()
- gz.Reset(&buf)
- gz.Write(data)
- gz.Close()
- mu.Lock()
- pb.AddTile(uint8(c.Z), c.X, c.Y, buf.Bytes())
- mu.Unlock()
- }
- if progress != nil {
- progress(int(atomic.AddInt64(&done, 1)), total)
- }
- }
- }()
- }
- wg.Wait()
-}
-
-// BakeToPMTilesBandsStreaming bakes per-band archives while holding only ONE
-// band's parsed geometry in memory at a time — the key to baking a large district
-// without keeping every cell's prims resident. It works in two passes:
-//
-// Pass 1 — parse each cell and extract only its coverage + native band (no
-// feature routing), building the global covMeta once so best-available
-// suppression and scale boundaries have full cross-band coverage.
-// Pass 2 — for each band, re-parse just that band's cells, route them, bake the
-// band's archive (emit), then drop the prims before the next band.
-//
-// Cells are therefore parsed twice, but pass 1 skips the expensive portrayal +
-// routing, so the overhead is small relative to the memory saved. emit(slug,
-// builder) is called per band that produced tiles; returns the cell-union bounds
-// and the number of cells parsed.
-//
-// progress(stage, done, total, band) reports both visible stages so neither pause
-// is a dead bar: stage "prepare" while a band's cells are parsed + portrayed (the
-// long gap before any tile emits; band "" during the pass-1 coverage scan), then
-// stage "tiles" while that band's tiles are emitted.
-func BakeToPMTilesBandsStreaming(cells map[string]CellData, maxZoom uint32, onSkip func(name string, err error), progress func(stage string, done, total int, band string), emit func(slug string, pb *pmtiles.Builder) error) (geo.BoundingBox, int, error) {
- b := bake.New()
- applyPortrayer(b)
- if maxZoom > 0 {
- b.MaxBakeZoom = maxZoom
- }
-
- names := make([]string, 0, len(cells))
- for n := range cells {
- names = append(names, n)
- }
- sort.Strings(names)
-
- parse := func(name string) (*s57.Chart, error) {
- cd := cells[name]
- return ParseCellWithUpdates(name, cd.Base, cd.Updates)
- }
-
- // Pass 1: coverage + band per cell (no routing). Parse in parallel — and only
- // the M_COVR coverage (extractCoverage reads nothing else), so the geometry of
- // every other feature isn't built here; the full parse happens once, in pass 2.
- // The coverage merge (covMeta) stays serial and ordered.
- byBand := map[uint32][]string{}
- parsed := 0
- parseInOrder(names, func(name string) (*s57.Chart, error) {
- cd := cells[name]
- return ParseCellCoverage(name, cd.Base, cd.Updates)
- },
- func(string, *s57.Chart) struct{} { return struct{}{} },
- func(name string, chart *s57.Chart, _ struct{}) {
- band, n := b.AddCellCoverage(chart)
- if n == 0 {
- // The M_COVR-only coverage parse found no data-coverage polygon (the
- // cell omits M_COVR — non-conformant, e.g. the S-52 PresLib test cells).
- // Re-parse it fully so AddCellCoverage's bounding-box fallback has the
- // cell's geometry to derive a coverage rectangle from; otherwise the cell
- // contributes nothing to covMeta and a coarser band's symbols double-draw
- // over it. Rare (real ENCs always carry M_COVR), so the extra parse is fine.
- cd := cells[name]
- if full, err := ParseCellWithUpdates(name, cd.Base, cd.Updates); err == nil {
- band, _ = b.AddCellCoverage(full)
- }
- }
- byBand[band.ZoomRange().Max] = append(byBand[band.ZoomRange().Max], name)
- parsed++
- if progress != nil {
- progress("prepare", parsed, len(names), "") // coverage scan; no band yet
- }
- }, onSkip)
- b.SetSkipCoverage(true) // covMeta is now global; don't re-derive it per band
-
- // Pass 2: per band, re-parse + route + bake + free.
- for _, bd := range bake.BakeBands() {
- bandCells := byBand[bd.Max]
- if len(bandCells) == 0 {
- continue
- }
- b.ResetPrims()
- // Parse + portray this band's cells in parallel; route them serially in
- // order (deterministic, same as the per-cell loop). Coverage is skipped
- // (SetSkipCoverage above), so re-parsing is just for this band's geometry.
- // This is the long pre-tile pause, so report it ("prepare") per routed cell.
- var prep func(done, total int)
- if progress != nil {
- progress("prepare", 0, len(bandCells), bd.Slug) // announce the band up front
- prep = func(done, total int) { progress("prepare", done, total, bd.Slug) }
- }
- addCellsParallel(b, bandCells, parse, onSkip, prep)
- coords := b.TileCoordsBand(MVTExtent, bd.Min, bd.Max)
- if len(coords) == 0 {
- continue
- }
- b.BuildEmitIndexBand(MVTExtent, MVTBuffer, bd.Max)
- pb := pmtiles.New()
- var tileProg func(done, total int)
- if progress != nil {
- tileProg = func(done, total int) { progress("tiles", done, total, bd.Slug) }
- }
- emitTiles(coords, pb, tileProg, func(c tile.TileCoord, ts *bake.TileScratch) []byte {
- return b.EmitTileBandInto(c, MVTExtent, MVTBuffer, ts, bd.Max)
- })
- b.ClearEmitIndex()
- pb.SetScamin(b.ScaminValues()) // publish this band's SCAMIN manifest in the archive metadata
- if bb := b.Bounds(); bb.MinLon <= bb.MaxLon && bb.MinLat <= bb.MaxLat {
- pb.SetBounds(bb.MinLon, bb.MinLat, bb.MaxLon, bb.MaxLat)
- }
- if pb.Count() == 0 {
- continue
- }
- if err := emit(bd.Slug, pb); err != nil { // write + free this band before the next
- return b.Bounds(), parsed, err
- }
- }
- return b.Bounds(), parsed, nil
-}
-
-// IsBaseCell reports whether name is an S-57 base cell (…/.000).
-func IsBaseCell(name string) bool { return cellExtension(name) == ".000" }
-
-// IsUpdateCell reports whether name is an S-57 update (…/.NNN, NNN != 000).
-func IsUpdateCell(name string) bool {
- ext := cellExtension(name)
- if len(ext) != 4 || ext[0] != '.' {
- return false
- }
- for _, c := range ext[1:] {
- if c < '0' || c > '9' {
- return false
- }
- }
- return ext != ".000"
-}
-
-func cellExtension(name string) string {
- i := strings.LastIndexByte(name, '.')
- if i < 0 {
- return ""
- }
- return name[i:]
-}
diff --git a/internal/engine/baker/baker_bench_test.go b/internal/engine/baker/baker_bench_test.go
deleted file mode 100644
index 2db119d..0000000
--- a/internal/engine/baker/baker_bench_test.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package baker
-
-import (
- "os"
- "testing"
-)
-
-func goldenCellBytes(tb testing.TB) map[string][]byte {
- data, err := os.ReadFile("../../../testdata/US4MD81M.000")
- if err != nil {
- tb.Skip("no golden cell")
- }
- return map[string][]byte{"US4MD81M.000": data}
-}
-
-func TestParallelDeterministic(t *testing.T) {
- cells := goldenCellBytes(t)
- b1, _, _ := BuildBaker(cells, nil)
- a := BakeToPMTiles(b1, nil).Finish()
- b2, _, _ := BuildBaker(cells, nil)
- bb := BakeToPMTiles(b2, nil).Finish()
- if len(a) != len(bb) || string(a) != string(bb) {
- t.Fatalf("non-deterministic output: %d vs %d bytes", len(a), len(bb))
- }
- t.Logf("archive %d bytes", len(a))
-}
-
-func BenchmarkBakeGoldenParallel(b *testing.B) {
- cells := goldenCellBytes(b)
- bk, _, _ := BuildBaker(cells, nil)
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _ = BakeToPMTiles(bk, nil).Count()
- }
-}
diff --git a/internal/engine/baker/bands.go b/internal/engine/baker/bands.go
new file mode 100644
index 0000000..fc12212
--- /dev/null
+++ b/internal/engine/baker/bands.go
@@ -0,0 +1,64 @@
+package baker
+
+// NOAA ENC navigational-purpose bands and the compilation-scale → band mapping.
+// This lives in the (surviving, CGO-free) metadata package because grouping cells
+// into bands for the tile57 per-band bake needs it independently of any tile
+// baker. The slugs + zoom spans MUST match the frontend's CHART_BANDS so each
+// per-band archive loads into its chart- source.
+
+// Band is a NOAA ENC navigational-purpose band. Each bakes over its own zoom
+// range and overzooms above max client-side.
+type Band uint8
+
+const (
+ BandOverview Band = iota
+ BandGeneral
+ BandCoastal
+ BandApproach
+ BandHarbor
+ BandBerthing
+)
+
+// BakeBand is one navigational-purpose band's identity for per-band archive
+// baking: its frontend slug and native [Min,Max] zoom span.
+type BakeBand struct {
+ Slug string
+ Min, Max uint32
+}
+
+// BakeBands lists the bands coarse→fine for per-band archive baking — must match
+// the frontend's CHART_BANDS (slug + zoom span) so each archive loads into its
+// chart- source. The order matches the Band enum, so BakeBands()[Band] is
+// that band's BakeBand.
+func BakeBands() []BakeBand {
+ return []BakeBand{
+ {"overview", 0, 7},
+ {"general", 7, 9},
+ {"coastal", 9, 11},
+ {"approach", 11, 13},
+ {"harbor", 13, 16},
+ {"berthing", 16, 18},
+ }
+}
+
+// BandForScale maps a compilation-scale denominator (CSCL) to a band.
+func BandForScale(cscl uint32) Band {
+ n := cscl
+ if n == 0 {
+ n = 50_000
+ }
+ switch {
+ case n <= 8_000:
+ return BandBerthing
+ case n <= 32_000:
+ return BandHarbor
+ case n <= 130_000:
+ return BandApproach
+ case n <= 500_000:
+ return BandCoastal
+ case n <= 2_300_000:
+ return BandGeneral
+ default:
+ return BandOverview
+ }
+}
diff --git a/internal/engine/baker/buildphase_bench_test.go b/internal/engine/baker/buildphase_bench_test.go
deleted file mode 100644
index ebb571f..0000000
--- a/internal/engine/baker/buildphase_bench_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package baker
-
-import "testing"
-
-// BenchmarkBuildBakerGolden measures the parse + portrayal + routing phase
-// (BuildBaker) on the golden cell — the phase BenchmarkBakeGoldenParallel
-// excludes by resetting its timer after the build. This is where the S-101 rule
-// engine runs, so it's the benchmark to watch for portrayal-side optimizations.
-func BenchmarkBuildBakerGolden(b *testing.B) {
- cells := goldenCellBytes(b)
- b.ReportAllocs()
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _, _, _ = BuildBaker(cells, nil)
- }
-}
diff --git a/internal/engine/baker/foreign_test.go b/internal/engine/baker/foreign_test.go
deleted file mode 100644
index 1228a6a..0000000
--- a/internal/engine/baker/foreign_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package baker
-
-import (
- "bytes"
- "os"
- "testing"
-)
-
-// Some S-57 producers pad the file after the last record (spaces, nulls, or
-// ASCII zeros). The ISO 8211 parser must treat a blank/zero-length leader as
-// end-of-records instead of failing (the symptom: "record length must be >= 24,
-// got 0" mid-parse on foreign cells like 2WBDK017).
-func TestTrailingPaddingTolerated(t *testing.T) {
- data, err := os.ReadFile("../../../testdata/US4MD81M.000")
- if err != nil {
- t.Fatalf("read: %v", err)
- }
- base, err := ParseCellBytes("US4MD81M.000", data)
- if err != nil {
- t.Fatalf("base parse: %v", err)
- }
- pads := map[string][]byte{
- "spaces": bytes.Repeat([]byte{' '}, 48),
- "nulls": bytes.Repeat([]byte{0}, 48),
- "ascii-zeros": bytes.Repeat([]byte{'0'}, 48),
- }
- for name, pad := range pads {
- padded := append(append([]byte{}, data...), pad...)
- c, err := ParseCellBytes("US4MD81M.000", padded)
- if err != nil {
- t.Errorf("%s padding: parse failed: %v", name, err)
- continue
- }
- if c.FeatureCount() != base.FeatureCount() {
- t.Errorf("%s padding: feature count %d != base %d", name, c.FeatureCount(), base.FeatureCount())
- }
- }
-}
-
-// A non-US S-57 cell (Netherlands, producer code "1R") must parse and bake just
-// like a US cell — the producer code is alphanumeric, not always two letters.
-// (The browser zip importer's cell-name regex was US-only; see web/zip-import.mjs.)
-func TestForeignCellBakes(t *testing.T) {
- data, err := os.ReadFile("../../../testdata/1R7YM012.000")
- if err != nil {
- t.Fatalf("read foreign cell: %v", err)
- }
- b, ok, err := BuildBaker(map[string][]byte{"1R7YM012.000": data}, func(n string, e error) { t.Errorf("skip %s: %v", n, e) })
- if err != nil {
- t.Fatalf("build baker: %v", err)
- }
- if len(ok) != 1 {
- t.Fatalf("expected 1 cell parsed, got %d", len(ok))
- }
- // Coordinates must decode to the cell's real location (the Netherlands:
- // lon ≈ 5°E, lat ≈ 52.6°N), not a mis-scaled global spread.
- bb := b.Bounds()
- if bb.MinLon < 3 || bb.MaxLon > 7 || bb.MinLat < 51 || bb.MaxLat > 54 {
- t.Fatalf("bounds %v not in the Netherlands — coordinate decode wrong?", bb)
- }
- if pb := BakeToPMTiles(b, nil); pb.Count() == 0 {
- t.Fatal("baked no tiles")
- }
-}
diff --git a/internal/engine/baker/main_test.go b/internal/engine/baker/main_test.go
deleted file mode 100644
index 9afa28a..0000000
--- a/internal/engine/baker/main_test.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package baker
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "testing"
-)
-
-// TestMain installs the S-101 portrayer for the package's bake tests. The baker
-// requires a portrayer, and untagged `go test` builds have no embedded
-// catalogue — so load the external dev catalogue if present, else skip the whole
-// package (e.g. CI without it).
-func TestMain(m *testing.M) {
- pc := os.Getenv("S101_CATALOG")
- if pc == "" {
- pc = filepath.Join(os.Getenv("HOME"), "Projects", "s101-portrayal-catalogue", "PortrayalCatalog")
- }
- fc := os.Getenv("S101_FC")
- if fc == "" {
- fc = filepath.Join(os.Getenv("HOME"), "Projects", "s101-feature-catalogue", "S-101FC", "FeatureCatalogue.xml")
- }
- if _, err := os.Stat(filepath.Join(pc, "Rules", "main.lua")); err != nil {
- fmt.Println("baker tests: S-101 catalogue not present; skipping (set S101_CATALOG/S101_FC)")
- return
- }
- if err := UseS101Catalog(pc, fc); err != nil {
- fmt.Println("baker tests: S-101 catalogue load failed:", err)
- return
- }
- os.Exit(m.Run())
-}
diff --git a/internal/engine/baker/meta.go b/internal/engine/baker/meta.go
index bff8d7c..6b21906 100644
--- a/internal/engine/baker/meta.go
+++ b/internal/engine/baker/meta.go
@@ -1,16 +1,35 @@
+// Package baker holds the S-57 cell metadata helpers shared by the server chart
+// library, the cell index, and the tile57 bake paths — per-cell header metadata
+// via the native engine (tile57_chart_cells) and the compilation-scale →
+// navigational-band mapping (bands.go) — plus the CellData staging type. It does
+// not parse S-57 itself: libtile57 is the sole S-57 reader and tile/portrayal
+// engine; the Go side only stages .000/.NNN bytes for it.
package baker
import (
+ "fmt"
+ "os"
+ "path/filepath"
"sort"
"strconv"
+
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
)
+// CellData is a base cell (.000) plus its sequential update files (.001, .002, …)
+// keyed by filename. Updates are applied in order to bring the cell to its current
+// edition.
+type CellData struct {
+ Base []byte
+ Updates map[string][]byte
+}
+
// CellMeta is the per-cell metadata extracted at import time for the chart
-// library to display. It comes from the cell's S-57 header (DSID/DSPM) plus its
-// M_COVR coverage — gathered with the same cheap coverage-only parse the bake's
-// pass 1 uses, so it adds no full re-parse. Title is left to the caller to fill
-// from the exchange-set catalogue (CATALOG.031 LFIL), since S-57 headers carry
-// no human chart name.
+// library to display: the cell's S-57 header identity (DSID/DSPM after the
+// update chain) plus its coverage bbox, as reported by the native engine
+// (tile57_chart_cells). Title is left to the caller to fill from the
+// exchange-set catalogue (CATALOG.031 LFIL), since S-57 headers carry no human
+// chart name.
type CellMeta struct {
Name string `json:"name"` // cell stem, e.g. "US5MD1MC"
Title string `json:"title,omitempty"` // human chart name (from CATALOG.031 LFIL); empty if none — consumers show Name
@@ -23,45 +42,77 @@ type CellMeta struct {
HasBBox bool `json:"-"`
}
-// ExtractCellMeta parses each cell's header + coverage (coverage-only, cheap) and
-// returns per-cell metadata keyed by cell stem. Cells that fail to parse are
-// reported via onSkip and omitted. Title is left empty (S-57 headers carry no human
-// chart name — only the cell code); the caller overlays the CATALOG.031 long name
-// where the exchange set provides one.
+// ExtractCellMeta stages the cells (base + updates) to a temporary ENC dir,
+// opens it with the native engine, and returns per-cell metadata keyed by cell
+// stem (the DSNM stem the engine reports). Cells that fail to parse are
+// reported via onSkip and omitted. Title is left empty (S-57 headers carry no
+// human chart name — only the cell code); the caller overlays the CATALOG.031
+// long name where the exchange set provides one.
func ExtractCellMeta(cells map[string]CellData, onSkip func(name string, err error)) map[string]CellMeta {
- out := make(map[string]CellMeta, len(cells))
names := make([]string, 0, len(cells))
for n := range cells {
names = append(names, n)
}
sort.Strings(names)
+ skipAll := func(err error) map[string]CellMeta {
+ if onSkip != nil {
+ for _, n := range names {
+ onSkip(n, err)
+ }
+ }
+ return map[string]CellMeta{}
+ }
+ if len(cells) == 0 {
+ return map[string]CellMeta{}
+ }
+
+ dir, err := os.MkdirTemp("", "cp-cellmeta-")
+ if err != nil {
+ return skipAll(err)
+ }
+ defer os.RemoveAll(dir)
for _, name := range names {
cd := cells[name]
- chart, err := ParseCellCoverage(name, cd.Base, cd.Updates)
- if err != nil {
- if onSkip != nil {
- onSkip(name, err)
- }
- continue
+ if err := os.WriteFile(filepath.Join(dir, filepath.Base(name)), cd.Base, 0o644); err != nil {
+ return skipAll(err)
}
- stem := cellStem(chart.DatasetName())
- if stem == "" {
- stem = cellStem(name)
+ for un, ub := range cd.Updates {
+ if err := os.WriteFile(filepath.Join(dir, filepath.Base(un)), ub, 0o644); err != nil {
+ return skipAll(err)
+ }
}
- m := CellMeta{
- Name: stem,
- Scale: int(chart.CompilationScale()),
- Edition: chart.Edition(),
- Update: chart.UpdateNumber(),
- IssueDate: chart.IssueDate(),
- Agency: chart.ProducingAgency(),
+ }
+
+ src, err := tile57.Open(dir)
+ if err != nil {
+ return skipAll(err)
+ }
+ defer src.Close()
+ infos, err := src.Cells()
+ if err != nil {
+ return skipAll(err)
+ }
+
+ out := make(map[string]CellMeta, len(infos))
+ for _, ci := range infos {
+ out[ci.Name] = CellMeta{
+ Name: ci.Name,
+ Scale: ci.Scale,
+ Edition: ci.Edition,
+ Update: ci.Update,
+ IssueDate: ci.IssueDate,
+ Agency: ci.Agency,
+ BBox: ci.BBox,
+ HasBBox: ci.HasBBox,
}
- b := chart.Bounds()
- if b.MaxLon > b.MinLon && b.MaxLat > b.MinLat {
- m.BBox = [4]float64{b.MinLon, b.MinLat, b.MaxLon, b.MaxLat}
- m.HasBBox = true
+ }
+ // Report the input cells the engine skipped (didn't parse into the inventory).
+ if onSkip != nil {
+ for _, name := range names {
+ if _, ok := out[cellStem(name)]; !ok {
+ onSkip(name, fmt.Errorf("cell did not parse"))
+ }
}
- out[stem] = m
}
return out
}
diff --git a/internal/engine/baker/parallel_test.go b/internal/engine/baker/parallel_test.go
deleted file mode 100644
index 6d8d673..0000000
--- a/internal/engine/baker/parallel_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package baker
-
-import (
- "crypto/sha256"
- "fmt"
- "testing"
-)
-
-// TestMultiCellParallelDeterministic guards the property the parallel bake relies
-// on: cells are parsed and portrayed concurrently (completing out of order) but
-// routed serially in sorted order, so the archive is identical run to run. A
-// single-cell bake (TestParallelDeterministic) only runs one worker and doesn't
-// exercise the ordered merge, so this uses several cells.
-func TestMultiCellParallelDeterministic(t *testing.T) {
- one := goldenCellBytes(t)["US4MD81M.000"]
- cells := map[string][]byte{}
- for i := 0; i < 4; i++ {
- cells[fmt.Sprintf("US4MD81M_%02d.000", i)] = one
- }
- var first string
- for run := 0; run < 2; run++ {
- b, ok, _ := BuildBaker(cells, nil)
- if len(ok) != len(cells) {
- t.Fatalf("run %d: routed %d/%d cells", run, len(ok), len(cells))
- }
- h := fmt.Sprintf("%x", sha256.Sum256(BakeToPMTiles(b, nil).Finish()))
- if run == 0 {
- first = h
- } else if h != first {
- t.Fatalf("nondeterministic parallel merge: run %d hash %s != run0 %s", run, h, first)
- }
- }
-}
diff --git a/internal/engine/baker/s101_bake_test.go b/internal/engine/baker/s101_bake_test.go
deleted file mode 100644
index e969363..0000000
--- a/internal/engine/baker/s101_bake_test.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package baker_test
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/bake"
- "github.com/beetlebugorg/chartplotter/internal/engine/baker"
-)
-
-// TestS101BakeProducesTiles bakes a real NOAA cell through the S-101 portrayal
-// engine and confirms tiles come out — an end-to-end S-101 render in the bake
-// pipeline. Skips without the vendored catalogue.
-func TestS101BakeProducesTiles(t *testing.T) {
- pc := os.Getenv("S101_CATALOG")
- if pc == "" {
- pc = "/home/jcollins/Projects/s101-portrayal-catalogue/PortrayalCatalog"
- }
- fcPath := os.Getenv("S101_FC")
- if fcPath == "" {
- fcPath = "/home/jcollins/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml"
- }
- if _, err := os.Stat(filepath.Join(pc, "Rules", "main.lua")); err != nil {
- t.Skipf("S-101 catalogue not present; set S101_CATALOG/S101_FC")
- }
- if _, err := os.Stat(fcPath); err != nil {
- t.Skipf("S-101 feature catalogue not present")
- }
-
- portrayer, err := bake.NewS101Portrayer(pc, fcPath)
- if err != nil {
- t.Fatalf("build S-101 portrayer: %v", err)
- }
-
- data, err := os.ReadFile("../../../testdata/US4MD81M.000")
- if err != nil {
- t.Fatalf("read cell: %v", err)
- }
- chart, err := baker.ParseCellBytes("US4MD81M.000", data)
- if err != nil {
- t.Fatalf("parse cell: %v", err)
- }
-
- b := bake.New()
- b.SetPortrayer(portrayer) // <- S-101 symbology instead of S-52
- b.AddCell(chart)
-
- pb := baker.BakeToPMTiles(b, nil)
- if pb.Count() == 0 {
- t.Fatal("S-101 bake produced no tiles")
- }
- t.Logf("S-101 bake of US4MD81M produced %d tiles", pb.Count())
-}
diff --git a/internal/engine/baker/s101audit_test.go b/internal/engine/baker/s101audit_test.go
deleted file mode 100644
index 811709c..0000000
--- a/internal/engine/baker/s101audit_test.go
+++ /dev/null
@@ -1,246 +0,0 @@
-package baker_test
-
-import (
- "os"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/baker"
- "github.com/beetlebugorg/chartplotter/internal/engine/portrayal"
- "github.com/beetlebugorg/chartplotter/internal/engine/s101"
- "github.com/beetlebugorg/chartplotter/pkg/s100/fc"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// TestS101Audit runs the engine over every testdata cell and reports, per class:
-// total, ok, errored, unmapped, and empty (mapped+no-error but emitted nothing).
-// Plus the distinct unmapped classes and distinct error messages.
-func TestS101Audit(t *testing.T) {
- pc := os.Getenv("S101_CATALOG")
- if pc == "" {
- pc = "../s101catalog/catalog/PortrayalCatalog"
- }
- fcPath := os.Getenv("S101_FC")
- if fcPath == "" {
- fcPath = "../s101catalog/catalog/FeatureCatalogue.xml"
- }
- if _, err := os.Stat(filepath.Join(pc, "Rules", "main.lua")); err != nil {
- t.Skip("no catalogue")
- }
- cat, err := fc.Load(fcPath)
- if err != nil {
- t.Fatal(err)
- }
-
- glob := os.Getenv("S101_CELLS")
- if glob == "" {
- glob = "../../../testdata/*.000"
- }
- cells, _ := filepath.Glob(glob)
- type stat struct{ total, ok, errd, unmapped, empty int }
- classStat := map[string]*stat{}
- unmappedClasses := map[string]int{}
- errMsgs := map[string]int{}
- // CSP-relevant classes we care about validating.
- watch := map[string]bool{
- "LIGHTS": true, "WRECKS": true, "OBSTRN": true, "UWTROC": true,
- "SOUNDG": true, "DEPARE": true, "DEPCNT": true, "BOYLAT": true,
- "BCNLAT": true, "TOPMAR": true, "RESARE": true, "ACHARE": true,
- "BRIDGE": true, "SBDARE": true, "M_QUAL": true,
- }
- watchSample := map[string]string{} // class -> a sample emitted stream head
- sectorLights := 0
- sectorSamples := []string{}
-
- for _, cellPath := range cells {
- data, err := os.ReadFile(cellPath)
- if err != nil {
- t.Fatal(err)
- }
- chart, err := baker.ParseCellBytes(filepath.Base(cellPath), data)
- if err != nil {
- t.Logf("parse %s: %v", cellPath, err)
- continue
- }
- features := chart.Features()
- ptrs := make([]*s57.Feature, len(features))
- for i := range features {
- ptrs[i] = &features[i]
- }
- eng, err := s101.NewEngine(filepath.Join(pc, "Rules"), cat)
- if err != nil {
- t.Fatal(err)
- }
- depthIdx := portrayal.BuildDepthIndex(ptrs)
- var batch []s101.Feature
- idClass := map[string]string{}
- for i := range features {
- f := &features[i]
- g := f.Geometry()
- if len(g.Coordinates) == 0 && len(g.Rings) == 0 {
- continue
- }
- // TOPMAR is folded into its co-located buoy/beacon by S101Builder (the
- // real bake path); skip it here too so this lower-level harness doesn't
- // report it as a false "unmapped" gap.
- if f.ObjectClass() == "TOPMAR" {
- continue
- }
- id := strconv.Itoa(i)
- idClass[id] = f.ObjectClass()
- primitive := prim(g.Type)
- var points [][3]float64
- if f.ObjectClass() == "SOUNDG" {
- primitive = "MultiPoint"
- for _, c := range g.Coordinates {
- if len(c) >= 3 {
- points = append(points, [3]float64{c[0], c[1], c[2]})
- } else if len(c) == 2 {
- points = append(points, [3]float64{c[0], c[1], 0})
- }
- }
- } else if g.Type == s57.GeometryTypePoint {
- for _, c := range g.Coordinates {
- if len(c) >= 3 {
- points = append(points, [3]float64{c[0], c[1], c[2]})
- } else if len(c) == 2 {
- points = append(points, [3]float64{c[0], c[1], 0})
- }
- }
- }
- batch = append(batch, s101.Feature{
- ID: id,
- ObjectClass: f.ObjectClass(),
- Primitive: primitive,
- Attributes: strAttrs(f.Attributes()),
- Derived: portrayal.DerivedAttrs(f, depthIdx),
- Points: points,
- })
- }
- res, err := eng.Portray(batch)
- if err != nil {
- t.Fatal(err)
- }
- for id, stream := range res {
- cls := idClass[id]
- s := classStat[cls]
- if s == nil {
- s = &stat{}
- classStat[cls] = s
- }
- s.total++
- switch {
- case strings.HasPrefix(stream, "UNMAPPED:"):
- s.unmapped++
- unmappedClasses[cls]++
- case strings.HasPrefix(stream, "ERROR:"):
- s.errd++
- errMsgs[normErr(stream)]++
- case stream == "":
- s.empty++
- default:
- s.ok++
- // Sector-light probe: LIGHTS carrying SECTR1 should portray arcs.
- if cls == "LIGHTS" {
- if a := batchAttr(batch, id); a != nil && a["SECTR1"] != "" {
- sectorLights++
- if len(sectorSamples) < 4 {
- h := stream
- if len(h) > 400 {
- h = h[:400]
- }
- sectorSamples = append(sectorSamples, h)
- }
- }
- }
- if watch[cls] && watchSample[cls] == "" {
- h := stream
- if len(h) > 140 {
- h = h[:140]
- }
- watchSample[cls] = h
- }
- }
- }
- eng.Close()
- }
-
- t.Logf("=== sector lights (LIGHTS with SECTR1) = %d ===", sectorLights)
- for i, s := range sectorSamples {
- t.Logf(" sector[%d]: %s", i, s)
- }
- t.Logf("=== unmapped classes (no S-57→S-101 alias) ===")
- for _, c := range sortedKeys(unmappedClasses) {
- t.Logf(" %-8s x%d", c, unmappedClasses[c])
- }
- t.Logf("=== error messages ===")
- for m, n := range errMsgs {
- t.Logf(" [%d] %s", n, m)
- }
- t.Logf("=== watched CSP classes (total/ok/err/unmapped/empty) ===")
- for _, c := range sortedKeys2(watch) {
- s := classStat[c]
- if s == nil {
- t.Logf(" %-8s (absent)", c)
- continue
- }
- t.Logf(" %-8s %d/%d/%d/%d/%d", c, s.total, s.ok, s.errd, s.unmapped, s.empty)
- }
- t.Logf("=== sample emitted streams for watched classes ===")
- for _, c := range sortedKeys2(watch) {
- if watchSample[c] != "" {
- t.Logf(" %-8s %s", c, watchSample[c])
- }
- }
- // Empty-but-mapped is the silent-suppression gap; list those classes.
- t.Logf("=== classes with errors (errd>0) ===")
- for _, c := range sortedKeys3(classStat) {
- if s := classStat[c]; s.errd > 0 {
- t.Logf(" %-8s errd=%d total=%d ok=%d", c, s.errd, s.total, s.ok)
- }
- }
- t.Logf("=== classes with empty (mapped, no-error, emitted nothing) ===")
- for _, c := range sortedKeys3(classStat) {
- s := classStat[c]
- if s.empty > 0 {
- t.Logf(" %-8s empty=%d / total=%d", c, s.empty, s.total)
- }
- }
-}
-
-func batchAttr(batch []s101.Feature, id string) map[string]string {
- for i := range batch {
- if batch[i].ID == id {
- return batch[i].Attributes
- }
- }
- return nil
-}
-
-func sortedKeys(m map[string]int) []string {
- var k []string
- for x := range m {
- k = append(k, x)
- }
- sort.Strings(k)
- return k
-}
-func sortedKeys2(m map[string]bool) []string {
- var k []string
- for x := range m {
- k = append(k, x)
- }
- sort.Strings(k)
- return k
-}
-func sortedKeys3[V any](m map[string]V) []string {
- var k []string
- for x := range m {
- k = append(k, x)
- }
- sort.Strings(k)
- return k
-}
diff --git a/internal/engine/baker/s101diag_test.go b/internal/engine/baker/s101diag_test.go
deleted file mode 100644
index c466cc8..0000000
--- a/internal/engine/baker/s101diag_test.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package baker_test
-
-import (
- "os"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/baker"
- "github.com/beetlebugorg/chartplotter/internal/engine/portrayal"
- "github.com/beetlebugorg/chartplotter/internal/engine/s101"
- "github.com/beetlebugorg/chartplotter/pkg/s100/fc"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// TestS101Diag runs the S-101 engine over a real cell and tallies which rules
-// error (→ the QUESMRK1 placeholder flood) and the error messages. Run with -v.
-func TestS101Diag(t *testing.T) {
- pc := os.Getenv("S101_CATALOG")
- if pc == "" {
- pc = "/home/jcollins/Projects/s101-portrayal-catalogue/PortrayalCatalog"
- }
- fcPath := os.Getenv("S101_FC")
- if fcPath == "" {
- fcPath = "/home/jcollins/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml"
- }
- if _, err := os.Stat(filepath.Join(pc, "Rules", "main.lua")); err != nil {
- t.Skip("no catalogue")
- }
- cat, err := fc.Load(fcPath)
- if err != nil {
- t.Fatal(err)
- }
- eng, err := s101.NewEngine(filepath.Join(pc, "Rules"), cat)
- if err != nil {
- t.Fatal(err)
- }
- defer eng.Close()
-
- data, err := os.ReadFile("../../../testdata/US4MD81M.000")
- if err != nil {
- t.Fatal(err)
- }
- chart, err := baker.ParseCellBytes("US4MD81M.000", data)
- if err != nil {
- t.Fatal(err)
- }
-
- features := chart.Features()
- ptrs := make([]*s57.Feature, len(features))
- for i := range features {
- ptrs[i] = &features[i]
- }
- depthIdx := portrayal.BuildDepthIndex(ptrs) // mirrors S101Builder (danger depths)
- var batch []s101.Feature
- idClass := map[string]string{}
- for i := range features {
- f := &features[i]
- g := f.Geometry()
- if len(g.Coordinates) == 0 && len(g.Rings) == 0 {
- continue // non-spatial collection object (C_AGGR/C_ASSO) — mirrors S101Builder
- }
- id := strconv.Itoa(i)
- idClass[id] = f.ObjectClass()
- primitive := prim(f.Geometry().Type)
- var points [][3]float64
- if f.ObjectClass() == "SOUNDG" { // multipoint bridge (mirrors S101Builder)
- primitive = "MultiPoint"
- for _, c := range f.Geometry().Coordinates {
- if len(c) >= 3 {
- points = append(points, [3]float64{c[0], c[1], c[2]})
- } else if len(c) == 2 {
- points = append(points, [3]float64{c[0], c[1], 0})
- }
- }
- }
- batch = append(batch, s101.Feature{
- ID: id,
- ObjectClass: f.ObjectClass(),
- Primitive: primitive,
- Attributes: strAttrs(f.Attributes()),
- Derived: portrayal.DerivedAttrs(f, depthIdx),
- Points: points,
- })
- }
- res, err := eng.Portray(batch)
- if err != nil {
- t.Fatal(err)
- }
-
- errByClass := map[string]int{}
- errMsg := map[string]int{}
- ok, errs, unmapped := 0, 0, 0
- for id, stream := range res {
- switch {
- case strings.HasPrefix(stream, "UNMAPPED:"):
- unmapped++
- case strings.HasPrefix(stream, "ERROR:"):
- errs++
- errByClass[idClass[id]]++
- errMsg[normErr(stream)]++
- default:
- ok++
- }
- }
- t.Logf("features=%d ok=%d errored=%d unmapped=%d", len(features), ok, errs, unmapped)
- t.Logf("errors by class: %s", top(errByClass, 15))
- t.Logf("error messages: %s", topMsg(errMsg, 8))
-}
-
-func prim(t s57.GeometryType) string {
- switch t {
- case s57.GeometryTypeLineString:
- return "Curve"
- case s57.GeometryTypePolygon:
- return "Surface"
- default:
- return "Point"
- }
-}
-
-func strAttrs(a map[string]interface{}) map[string]string {
- out := map[string]string{}
- for k, v := range a {
- switch t := v.(type) {
- case string:
- out[k] = t
- case int:
- out[k] = strconv.Itoa(t)
- case int64:
- out[k] = strconv.FormatInt(t, 10)
- case float64:
- out[k] = strconv.FormatFloat(t, 'g', -1, 64)
- }
- }
- return out
-}
-
-// normErr strips the variable feature-id tail so messages group.
-func normErr(s string) string {
- if i := strings.Index(s, ".lua:"); i >= 0 {
- // keep file:line + a bit of the message
- end := i + 5
- for end < len(s) && s[end] >= '0' && s[end] <= '9' {
- end++
- }
- rest := s[end:]
- if len(rest) > 60 {
- rest = rest[:60]
- }
- // keep from the last '/' of the path
- start := strings.LastIndexByte(s[:i], '/') + 1
- return s[start:i] + ".lua" + s[i+4:end] + rest
- }
- if len(s) > 80 {
- return s[:80]
- }
- return s
-}
-
-func top(m map[string]int, n int) string {
- type kv struct {
- k string
- v int
- }
- var s []kv
- for k, v := range m {
- s = append(s, kv{k, v})
- }
- sort.Slice(s, func(i, j int) bool { return s[i].v > s[j].v })
- out := ""
- for i := 0; i < n && i < len(s); i++ {
- out += s[i].k + "=" + strconv.Itoa(s[i].v) + " "
- }
- return out
-}
-
-func topMsg(m map[string]int, n int) string {
- type kv struct {
- k string
- v int
- }
- var s []kv
- for k, v := range m {
- s = append(s, kv{k, v})
- }
- sort.Slice(s, func(i, j int) bool { return s[i].v > s[j].v })
- out := "\n"
- for i := 0; i < n && i < len(s); i++ {
- out += " [" + strconv.Itoa(s[i].v) + "] " + s[i].k + "\n"
- }
- return out
-}
diff --git a/internal/engine/baker/streamhash_test.go b/internal/engine/baker/streamhash_test.go
deleted file mode 100644
index 4040a2a..0000000
--- a/internal/engine/baker/streamhash_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package baker
-
-import (
- "crypto/sha256"
- "fmt"
- "sort"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/pmtiles"
-)
-
-// TestStreamingBakeDeterministic guards the parallel streaming bake (the UI
-// import path): cells are parsed+portrayed concurrently in both passes but
-// merged/routed serially in order, so each band archive is identical run to run.
-func TestStreamingBakeDeterministic(t *testing.T) {
- one := goldenCellBytes(t)["US4MD81M.000"]
- cells := map[string]CellData{}
- for i := 0; i < 4; i++ {
- cells[fmt.Sprintf("US4MD81M_%02d.000", i)] = CellData{Base: one}
- }
- bake := func() string {
- var lines []string
- _, _, err := BakeToPMTilesBandsStreaming(cells, 0, nil, nil,
- func(slug string, pb *pmtiles.Builder) error {
- arc := pb.Finish()
- lines = append(lines, fmt.Sprintf("%s=%x", slug, sha256.Sum256(arc)))
- return nil
- })
- if err != nil {
- t.Fatal(err)
- }
- sort.Strings(lines)
- return fmt.Sprint(lines)
- }
- if a, b := bake(), bake(); a != b {
- t.Fatalf("nondeterministic streaming bake:\n%s\n%s", a, b)
- }
-}
diff --git a/internal/engine/baker/tilefunnel_test.go b/internal/engine/baker/tilefunnel_test.go
deleted file mode 100644
index 9b7316b..0000000
--- a/internal/engine/baker/tilefunnel_test.go
+++ /dev/null
@@ -1,197 +0,0 @@
-package baker_test
-
-import (
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/bake"
- "github.com/beetlebugorg/chartplotter/internal/engine/baker"
- "github.com/beetlebugorg/chartplotter/internal/engine/tile"
-)
-
-// TestTileFunnel bakes one tile from one or more cached cells and logs the
-// per-stage primitive funnel (eligible → zMin-gated → emitted) from TileDiag, to
-// pinpoint where polygons disappear from a baked tile. Opt-in via env:
-//
-// CELLS=$HOME/.cache/chartplotter/ENC_ROOT/US2EC02M/US2EC02M.000 TILE=7/35/53 \
-// go test -run TestTileFunnel -v ./internal/engine/baker/
-//
-// CELLS is a comma-separated list of .000 paths; TILE is z/x/y. Skips when unset.
-func TestTileFunnel(t *testing.T) {
- cells, tl := os.Getenv("CELLS"), os.Getenv("TILE")
- if cells == "" || tl == "" {
- t.Skip("set CELLS=path[,path] and TILE=z/x/y to run")
- }
- parts := strings.Split(tl, "/")
- if len(parts) != 3 {
- t.Fatalf("TILE must be z/x/y, got %q", tl)
- }
- z, _ := strconv.Atoi(parts[0])
- x, _ := strconv.Atoi(parts[1])
- y, _ := strconv.Atoi(parts[2])
-
- sess, err := baker.NewSession()
- if err != nil {
- t.Fatal(err)
- }
- for _, cp := range strings.Split(cells, ",") {
- cp = strings.TrimSpace(cp)
- data, err := os.ReadFile(cp)
- if err != nil {
- t.Fatal(err)
- }
- name := strings.TrimSuffix(filepath.Base(cp), ".000")
- if _, err := sess.AddCellBytes(name, data); err != nil {
- t.Fatalf("AddCellBytes %s: %v", name, err)
- }
- t.Logf("loaded %s (%d bytes)", name, len(data))
- }
-
- bake.TileDiag = func(s string) { t.Log(s) }
- defer func() { bake.TileDiag = nil }()
- var ts bake.TileScratch
- coord := tile.TileCoord{Z: uint32(z), X: uint32(x), Y: uint32(y)}
- out := sess.Baker.EmitTileInto(coord, baker.MVTExtent, baker.MVTBuffer, &ts)
- t.Logf("tile %d/%d/%d → %d MVT bytes", z, x, y, len(out))
- stored, trueOv := sess.Baker.DebugTilePolyOverlap(coord, baker.MVTBuffer, baker.MVTExtent)
- t.Logf("polygon overlap: storedBBox=%d trueBBox=%d (gap ⇒ cached bbox drops polys)", stored, trueOv)
- for _, l := range decodeMVTLayers(t, out) {
- t.Logf(" layer %-14s features=%d (point=%d line=%d polygon=%d) maxVerts/feature=%d%s",
- l.name, l.total, l.byType[1], l.byType[2], l.byType[3], l.maxVerts,
- map[bool]string{true: " ⚠ EXCEEDS MapLibre 65535/segment", false: ""}[l.maxVerts > 65535])
- }
-}
-
-type mvtLayer struct {
- name string
- total int
- byType map[uint32]int // 1=point 2=line 3=polygon
- maxVerts int // most vertices in a single feature (the MapLibre fill-segment limit is 65535)
-}
-
-// decodeMVTLayers is a minimal Mapbox-Vector-Tile reader: it walks the protobuf
-// just enough to count features per layer by geometry type — the ground truth of
-// what a PMTiles/MVT viewer sees in this tile.
-func decodeMVTLayers(t *testing.T, b []byte) []mvtLayer {
- t.Helper()
- var out []mvtLayer
- p := 0
- for p < len(b) {
- field, wt, n := readTag(b, p)
- p += n
- if wt == 2 { // length-delimited
- l, m := readVarint(b, p)
- p += m
- body := b[p : p+int(l)]
- p += int(l)
- if field == 3 { // Tile.layers
- out = append(out, decodeLayer(body))
- }
- } else {
- _, m := readVarint(b, p)
- p += m
- }
- }
- return out
-}
-
-func decodeLayer(b []byte) mvtLayer {
- lay := mvtLayer{byType: map[uint32]int{}}
- p := 0
- for p < len(b) {
- field, wt, n := readTag(b, p)
- p += n
- switch {
- case field == 1 && wt == 2: // name
- l, m := readVarint(b, p)
- p += m
- lay.name = string(b[p : p+int(l)])
- p += int(l)
- case field == 2 && wt == 2: // feature
- l, m := readVarint(b, p)
- p += m
- lay.total++
- gt, nv := featureGeom(b[p : p+int(l)])
- lay.byType[gt]++
- if nv > lay.maxVerts {
- lay.maxVerts = nv
- }
- p += int(l)
- case wt == 2:
- l, m := readVarint(b, p)
- p += m + int(l)
- default:
- _, m := readVarint(b, p)
- p += m
- }
- }
- return lay
-}
-
-// featureGeom returns a feature's geometry type (1=point 2=line 3=polygon) and
-// its total vertex count (sum of MoveTo/LineTo command counts) — the figure
-// MapLibre's fill bucket caps at 65535 per segment.
-func featureGeom(b []byte) (gtype uint32, nverts int) {
- p := 0
- for p < len(b) {
- field, wt, n := readTag(b, p)
- p += n
- if field == 3 && wt == 0 { // Feature.type (varint)
- v, m := readVarint(b, p)
- p += m
- gtype = uint32(v)
- continue
- }
- if field == 4 && wt == 2 { // Feature.geometry (packed command ints)
- l, m := readVarint(b, p)
- p += m
- geom := b[p : p+int(l)]
- p += int(l)
- q := 0
- for q < len(geom) {
- cmd, mm := readVarint(geom, q)
- q += mm
- id, count := cmd&7, int(cmd>>3)
- if id == 1 || id == 2 { // MoveTo / LineTo: count points, each 2 varints
- nverts += count
- for k := 0; k < count*2 && q < len(geom); k++ {
- _, mc := readVarint(geom, q)
- q += mc
- }
- }
- // id==7 ClosePath: no operands
- }
- continue
- }
- if wt == 2 {
- l, m := readVarint(b, p)
- p += m + int(l)
- } else {
- _, m := readVarint(b, p)
- p += m
- }
- }
- return gtype, nverts
-}
-
-func readTag(b []byte, p int) (field uint32, wt uint32, n int) {
- v, m := readVarint(b, p)
- return uint32(v >> 3), uint32(v & 7), m
-}
-func readVarint(b []byte, p int) (uint64, int) {
- var v uint64
- var s, n int
- for p+n < len(b) {
- c := b[p+n]
- v |= uint64(c&0x7f) << s
- n++
- if c < 0x80 {
- break
- }
- s += 7
- }
- return v, n
-}
diff --git a/internal/engine/mvt/mvt.go b/internal/engine/mvt/mvt.go
deleted file mode 100644
index 06fa048..0000000
--- a/internal/engine/mvt/mvt.go
+++ /dev/null
@@ -1,467 +0,0 @@
-// Package mvt is a Mapbox Vector Tile (MVT 2.1) encoder. A TileBuilder collects
-// named layers; a LayerBuilder collects features and owns the per-layer
-// key/value dictionaries (MVT interns attribute keys and string values once per
-// layer; features reference them by index). Geometry is the MVT command/parameter
-// integer stream (MoveTo/LineTo/ClosePath, zig-zag deltas).
-//
-// Callers hand geometry already in tile-local integer coordinates (see package
-// tile). Crucially, colour is carried as a string *token* attribute, never RGB.
-package mvt
-
-import (
- "math"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/tile"
-)
-
-func boolToU64(b bool) uint64 {
- if b {
- return 1
- }
- return 0
-}
-
-func float32bits(f float32) uint32 { return math.Float32bits(f) }
-
-// ExtentDefault is the standard MVT tile extent.
-const ExtentDefault uint32 = 4096
-
-// GeomType is the MVT geometry type.
-type GeomType uint32
-
-const (
- GeomUnknown GeomType = 0
- GeomPoint GeomType = 1
- GeomLineString GeomType = 2
- GeomPolygon GeomType = 3
-)
-
-// IPoint is a tile-local integer vertex (alias of the tile package's type).
-type IPoint = tile.IPoint
-
-// Value is one attribute value. MVT's Value message is a typed union.
-type Value struct {
- kind valueKind
- str string
- intV int64
- float float32
- boolV bool
-}
-
-type valueKind uint8
-
-const (
- valString valueKind = iota
- valInt
- valFloat
- valBool
-)
-
-// StringVal / IntVal / FloatVal construct attribute values.
-func StringVal(s string) Value { return Value{kind: valString, str: s} }
-func IntVal(n int64) Value { return Value{kind: valInt, intV: n} }
-func FloatVal(f float32) Value { return Value{kind: valFloat, float: f} }
-
-// KeyValue is one feature attribute.
-type KeyValue struct {
- Key string
- Value Value
-}
-
-func commandInteger(id, count uint32) uint32 {
- return (id & 0x7) | (count << 3)
-}
-
-// -- geometry command stream -------------------------------------------------
-
-// encodePolygon encodes one (multi)polygon feature. The input is a flat list of
-// rings (exteriors and holes intermixed). Rings are classified by geometric
-// NESTING depth — how many other rings contain them — so a feature that is really
-// several disjoint polygons (e.g. a dredged area split into separate basins, or a
-// depth area with island holes) encodes as a proper MVT multipolygon rather than
-// forcing only ring 0 to be the exterior and punching every other ring out as a
-// hole (which left no-data slivers where a second exterior was wrongly treated as
-// a hole). Even depth ⇒ exterior (starts a new polygon, positive winding); odd
-// depth ⇒ hole (negative winding). Exteriors are emitted immediately followed by
-// their child holes so a decoder attaches each hole to the right exterior.
-func encodePolygon(rings [][]IPoint) []uint32 {
- type pring struct {
- pts []IPoint
- minX, minY, maxX, maxY int32
- depth int
- }
- ps := make([]pring, 0, len(rings))
- for _, raw := range rings {
- ring := dropClosingDuplicate(raw)
- if len(ring) < 3 {
- continue
- }
- pr := pring{pts: ring, minX: ring[0].X, minY: ring[0].Y, maxX: ring[0].X, maxY: ring[0].Y}
- for _, p := range ring {
- if p.X < pr.minX {
- pr.minX = p.X
- }
- if p.X > pr.maxX {
- pr.maxX = p.X
- }
- if p.Y < pr.minY {
- pr.minY = p.Y
- }
- if p.Y > pr.maxY {
- pr.maxY = p.Y
- }
- }
- ps = append(ps, pr)
- }
- if len(ps) == 0 {
- return nil
- }
-
- // Nesting depth: count rings that contain this ring's first vertex. Distinct
- // rings never share a vertex, so vertex[0] is strictly inside-or-outside every
- // other ring (no on-boundary ambiguity). Bounding boxes prune most pairs.
- contains := func(j int, p IPoint) bool {
- b := &ps[j]
- if p.X < b.minX || p.X > b.maxX || p.Y < b.minY || p.Y > b.maxY {
- return false
- }
- return pointInRingI(p, b.pts)
- }
- for i := range ps {
- d := 0
- v := ps[i].pts[0]
- for j := range ps {
- if i != j && contains(j, v) {
- d++
- }
- }
- ps[i].depth = d
- }
-
- // Emit each exterior (even depth) followed by the holes it directly contains
- // (depth exactly one greater and geometrically inside it).
- var out []uint32
- cursor := IPoint{X: 0, Y: 0}
- emit := func(pr *pring) {
- wantPositive := pr.depth%2 == 0
- reversed := (signedArea(pr.pts) >= 0) != wantPositive
- out = emitRing(out, &cursor, pr.pts, reversed, true)
- }
- done := make([]bool, len(ps))
- for i := range ps {
- if done[i] || ps[i].depth%2 != 0 {
- continue
- }
- done[i] = true
- emit(&ps[i])
- for j := range ps {
- if done[j] || ps[j].depth != ps[i].depth+1 {
- continue
- }
- if contains(i, ps[j].pts[0]) {
- done[j] = true
- emit(&ps[j])
- }
- }
- }
- // Safety net: emit anything not yet placed (malformed nesting) as its own ring.
- for i := range ps {
- if !done[i] {
- emit(&ps[i])
- }
- }
- return out
-}
-
-// pointInRingI reports whether p is inside the polygon ring (even-odd rule).
-func pointInRingI(p IPoint, ring []IPoint) bool {
- in := false
- j := len(ring) - 1
- for i := range ring {
- pi, pj := ring[i], ring[j]
- if (pi.Y > p.Y) != (pj.Y > p.Y) {
- xCross := float64(pi.X) + float64(pj.X-pi.X)*float64(p.Y-pi.Y)/float64(pj.Y-pi.Y)
- if float64(p.X) < xCross {
- in = !in
- }
- }
- j = i
- }
- return in
-}
-
-func encodeLines(lines [][]IPoint) []uint32 {
- var out []uint32
- cursor := IPoint{X: 0, Y: 0}
- for _, line := range lines {
- if len(line) < 2 {
- continue
- }
- out = emitRing(out, &cursor, line, false, false)
- }
- return out
-}
-
-func encodePoints(pts []IPoint) []uint32 {
- if len(pts) == 0 {
- return nil
- }
- out := make([]uint32, 0, 1+2*len(pts))
- cursor := IPoint{X: 0, Y: 0}
- out = append(out, commandInteger(1, uint32(len(pts))))
- for _, p := range pts {
- out = append(out, zigzag32(p.X-cursor.X), zigzag32(p.Y-cursor.Y))
- cursor = p
- }
- return out
-}
-
-func emitRing(out []uint32, cursor *IPoint, ring []IPoint, reversed, close bool) []uint32 {
- n := len(ring)
- at := func(i int) IPoint {
- if reversed {
- return ring[n-1-i]
- }
- return ring[i]
- }
- first := at(0)
- out = append(out, commandInteger(1, 1)) // MoveTo, count 1
- out = append(out, zigzag32(first.X-cursor.X), zigzag32(first.Y-cursor.Y))
- *cursor = first
-
- out = append(out, commandInteger(2, uint32(n-1))) // LineTo
- for i := 1; i < n; i++ {
- p := at(i)
- out = append(out, zigzag32(p.X-cursor.X), zigzag32(p.Y-cursor.Y))
- *cursor = p
- }
- if close {
- out = append(out, commandInteger(7, 1)) // ClosePath
- }
- return out
-}
-
-// signedArea is the shoelace signed area (x2); only its sign is used.
-func signedArea(ring []IPoint) int64 {
- var area int64
- j := len(ring) - 1
- for i, p := range ring {
- q := ring[j]
- area += int64(q.X)*int64(p.Y) - int64(p.X)*int64(q.Y)
- j = i
- }
- return area
-}
-
-func dropClosingDuplicate(ring []IPoint) []IPoint {
- if len(ring) >= 2 {
- a, b := ring[0], ring[len(ring)-1]
- if a.X == b.X && a.Y == b.Y {
- return ring[:len(ring)-1]
- }
- }
- return ring
-}
-
-// -- builders ----------------------------------------------------------------
-
-type feature struct {
- geomType GeomType
- geometry []uint32
- tags []uint32
-}
-
-// LayerBuilder collects features and the layer's key/value dictionaries.
-type LayerBuilder struct {
- name string
- extent uint32
- features []feature
- keys []string
- values []Value
- keyIndex map[string]uint32
- strValIdx map[string]uint32
-}
-
-// FeatureCount returns how many features the layer holds.
-func (l *LayerBuilder) FeatureCount() int { return len(l.features) }
-
-func (l *LayerBuilder) internKey(key string) uint32 {
- if idx, ok := l.keyIndex[key]; ok {
- return idx
- }
- idx := uint32(len(l.keys))
- l.keys = append(l.keys, key)
- l.keyIndex[key] = idx
- return idx
-}
-
-func (l *LayerBuilder) internValue(v Value) uint32 {
- // Only strings are deduped (tokens / class names repeat on most features).
- if v.kind == valString {
- if idx, ok := l.strValIdx[v.str]; ok {
- return idx
- }
- idx := uint32(len(l.values))
- l.values = append(l.values, v)
- l.strValIdx[v.str] = idx
- return idx
- }
- idx := uint32(len(l.values))
- l.values = append(l.values, v)
- return idx
-}
-
-func (l *LayerBuilder) addFeature(geomType GeomType, geometry []uint32, attrs []KeyValue) {
- if len(geometry) == 0 {
- return
- }
- tags := make([]uint32, len(attrs)*2)
- for i, a := range attrs {
- tags[i*2] = l.internKey(a.Key)
- tags[i*2+1] = l.internValue(a.Value)
- }
- l.features = append(l.features, feature{geomType: geomType, geometry: geometry, tags: tags})
-}
-
-// AddPolygon adds a polygon feature (exterior ring first, holes after).
-func (l *LayerBuilder) AddPolygon(rings [][]IPoint, attrs []KeyValue) {
- l.addFeature(GeomPolygon, encodePolygon(rings), attrs)
-}
-
-// AddLines adds a (multi-)linestring feature.
-func (l *LayerBuilder) AddLines(lines [][]IPoint, attrs []KeyValue) {
- l.addFeature(GeomLineString, encodeLines(lines), attrs)
-}
-
-// AddPoints adds a (multi-)point feature.
-func (l *LayerBuilder) AddPoints(pts []IPoint, attrs []KeyValue) {
- l.addFeature(GeomPoint, encodePoints(pts), attrs)
-}
-
-func featureBodyLen(f feature) int {
- n := 0
- n += packedU32FieldLen(2, f.tags)
- n += varintFieldLen(3, uint64(f.geomType))
- n += packedU32FieldLen(4, f.geometry)
- return n
-}
-
-func encodeFeatureInto(w *writer, f feature) {
- w.writeTag(2, wireLen)
- w.writeVarint(uint64(featureBodyLen(f)))
- w.writePackedU32(2, f.tags)
- w.writeVarintField(3, uint64(f.geomType))
- w.writePackedU32(4, f.geometry)
-}
-
-func valueBodyLen(v Value) int {
- switch v.kind {
- case valString:
- return bytesFieldLen(1, len(v.str))
- case valFloat:
- return fixed32FieldLen(2)
- case valInt:
- return varintFieldLen(4, uint64(v.intV))
- default: // bool
- return varintFieldLen(7, boolToU64(v.boolV))
- }
-}
-
-func encodeValueInto(w *writer, v Value) {
- w.writeTag(4, wireLen)
- w.writeVarint(uint64(valueBodyLen(v)))
- switch v.kind {
- case valString:
- w.writeStringField(1, v.str)
- case valFloat:
- w.writeFixed32Field(2, float32bits(v.float))
- case valInt:
- w.writeVarintField(4, uint64(v.intV)) // int64 two's-complement bits
- default: // bool
- w.writeVarintField(7, boolToU64(v.boolV))
- }
-}
-
-func (l *LayerBuilder) bodyLen() int {
- n := 0
- n += varintFieldLen(15, 2) // version
- n += bytesFieldLen(1, len(l.name))
- for _, f := range l.features {
- n += msgFieldLen(2, featureBodyLen(f))
- }
- for _, k := range l.keys {
- n += bytesFieldLen(3, len(k))
- }
- for _, v := range l.values {
- n += msgFieldLen(4, valueBodyLen(v))
- }
- n += varintFieldLen(5, uint64(l.extent))
- return n
-}
-
-func (l *LayerBuilder) encodeInto(w *writer) {
- w.writeVarintField(15, 2) // version
- w.writeStringField(1, l.name)
- for _, f := range l.features {
- encodeFeatureInto(w, f)
- }
- for _, k := range l.keys {
- w.writeStringField(3, k)
- }
- for _, v := range l.values {
- encodeValueInto(w, v)
- }
- w.writeVarintField(5, uint64(l.extent))
-}
-
-// TileBuilder collects named layers and serialises the Tile message.
-type TileBuilder struct {
- layers []*LayerBuilder
- extent uint32
-}
-
-// NewTileBuilder creates a tile builder at the given MVT extent.
-func NewTileBuilder(extent uint32) *TileBuilder {
- return &TileBuilder{extent: extent}
-}
-
-// Layer gets or creates a named layer (names are unique per tile).
-func (t *TileBuilder) Layer(name string) *LayerBuilder {
- for _, l := range t.layers {
- if l.name == name {
- return l
- }
- }
- lb := &LayerBuilder{
- name: name,
- extent: t.extent,
- keyIndex: map[string]uint32{},
- strValIdx: map[string]uint32{},
- }
- t.layers = append(t.layers, lb)
- return lb
-}
-
-// IsEmpty reports whether no layer holds any feature.
-func (t *TileBuilder) IsEmpty() bool {
- for _, l := range t.layers {
- if len(l.features) > 0 {
- return false
- }
- }
- return true
-}
-
-// Encode serialises the whole tile to MVT bytes.
-func (t *TileBuilder) Encode() []byte {
- var w writer
- for _, l := range t.layers {
- if len(l.features) == 0 {
- continue // skip empty layers
- }
- w.writeTag(3, wireLen) // Tile.layers = 3
- w.writeVarint(uint64(l.bodyLen()))
- l.encodeInto(&w)
- }
- return w.bytes()
-}
diff --git a/internal/engine/mvt/mvt_test.go b/internal/engine/mvt/mvt_test.go
deleted file mode 100644
index 4a06257..0000000
--- a/internal/engine/mvt/mvt_test.go
+++ /dev/null
@@ -1,227 +0,0 @@
-package mvt
-
-import "testing"
-
-func TestPolygonCommandStream(t *testing.T) {
- // A 5x5 square, clockwise in tile space.
- ring := []IPoint{{X: 0, Y: 0}, {X: 5, Y: 0}, {X: 5, Y: 5}, {X: 0, Y: 5}}
- geom := encodePolygon([][]IPoint{ring})
- if geom[0] != commandInteger(1, 1) {
- t.Errorf("geom[0] = %d, want MoveTo(1)", geom[0])
- }
- if geom[3] != commandInteger(2, 3) {
- t.Errorf("geom[3] = %d, want LineTo(3)", geom[3])
- }
- if geom[len(geom)-1] != commandInteger(7, 1) {
- t.Errorf("last = %d, want ClosePath(1)", geom[len(geom)-1])
- }
-}
-
-func TestTileRoundTrip(t *testing.T) {
- tb := NewTileBuilder(ExtentDefault)
- areas := tb.Layer("areas")
- ring := []IPoint{{X: 0, Y: 0}, {X: 100, Y: 0}, {X: 100, Y: 100}, {X: 0, Y: 100}}
- areas.AddPolygon([][]IPoint{ring}, []KeyValue{
- {"class", StringVal("DEPARE")},
- {"color_token", StringVal("DEPDW")},
- {"draw_prio", IntVal(3)},
- })
- lines := tb.Layer("lines")
- lines.AddLines([][]IPoint{{{X: 10, Y: 10}, {X: 200, Y: 50}}}, []KeyValue{
- {"class", StringVal("COALNE")},
- {"color_token", StringVal("CSTLN")},
- })
-
- dec := decodeTile(t, tb.Encode())
- if len(dec) != 2 {
- t.Fatalf("expected 2 layers, got %d", len(dec))
- }
- a := dec["areas"]
- if a == nil {
- t.Fatal("missing areas layer")
- }
- if a.extent != 4096 {
- t.Errorf("areas extent = %d", a.extent)
- }
- if len(a.features) != 1 {
- t.Fatalf("areas features = %d", len(a.features))
- }
- if got := a.stringAttr("color_token"); got != "DEPDW" {
- t.Errorf("areas color_token = %q, want DEPDW", got)
- }
- if got := a.stringAttr("class"); got != "DEPARE" {
- t.Errorf("areas class = %q", got)
- }
- if !a.attrIsString("color_token") {
- t.Error("color_token must be a string, never RGB int")
- }
- if got := dec["lines"].stringAttr("color_token"); got != "CSTLN" {
- t.Errorf("lines color_token = %q, want CSTLN", got)
- }
-}
-
-// -- minimal MVT decoder for the test ----------------------------------------
-
-type decValue struct {
- isString bool
- str string
-}
-
-type decFeature struct {
- tags []uint32
-}
-
-type decLayer struct {
- name string
- extent uint32
- keys []string
- values []decValue
- features []decFeature
-}
-
-func (l *decLayer) attrIndex(key string) (uint32, bool) {
- if len(l.features) == 0 {
- return 0, false
- }
- tags := l.features[0].tags
- for i := 0; i+1 < len(tags); i += 2 {
- if l.keys[tags[i]] == key {
- return tags[i+1], true
- }
- }
- return 0, false
-}
-
-func (l *decLayer) stringAttr(key string) string {
- vi, ok := l.attrIndex(key)
- if !ok || !l.values[vi].isString {
- return ""
- }
- return l.values[vi].str
-}
-
-func (l *decLayer) attrIsString(key string) bool {
- vi, ok := l.attrIndex(key)
- return ok && l.values[vi].isString
-}
-
-type reader struct {
- data []byte
- pos int
-}
-
-func (r *reader) atEnd() bool { return r.pos >= len(r.data) }
-
-func (r *reader) varint() uint64 {
- var v uint64
- var shift uint
- for r.pos < len(r.data) {
- b := r.data[r.pos]
- r.pos++
- v |= uint64(b&0x7f) << shift
- if b < 0x80 {
- break
- }
- shift += 7
- }
- return v
-}
-
-// field returns (fieldNum, wireType, bytesForLen, varintForVarint, ok).
-func (r *reader) field() (uint32, wireType, []byte, uint64, bool) {
- if r.atEnd() {
- return 0, 0, nil, 0, false
- }
- tag := r.varint()
- field := uint32(tag >> 3)
- wt := wireType(tag & 0x7)
- switch wt {
- case wireVarint:
- return field, wt, nil, r.varint(), true
- case wireLen:
- n := int(r.varint())
- b := r.data[r.pos : r.pos+n]
- r.pos += n
- return field, wt, b, 0, true
- case wireFixed32:
- b := r.data[r.pos : r.pos+4]
- r.pos += 4
- return field, wt, b, 0, true
- default:
- return field, wt, nil, 0, false
- }
-}
-
-func decodeTile(t *testing.T, data []byte) map[string]*decLayer {
- t.Helper()
- out := map[string]*decLayer{}
- r := &reader{data: data}
- for {
- field, _, b, _, ok := r.field()
- if !ok {
- break
- }
- if field != 3 {
- continue
- }
- l := decodeLayer(b)
- out[l.name] = l
- }
- return out
-}
-
-func decodeLayer(data []byte) *decLayer {
- l := &decLayer{extent: 4096}
- r := &reader{data: data}
- for {
- field, _, b, v, ok := r.field()
- if !ok {
- break
- }
- switch field {
- case 1:
- l.name = string(b)
- case 2:
- l.features = append(l.features, decodeFeature(b))
- case 3:
- l.keys = append(l.keys, string(b))
- case 4:
- l.values = append(l.values, decodeValue(b))
- case 5:
- l.extent = uint32(v)
- }
- }
- return l
-}
-
-func decodeFeature(data []byte) decFeature {
- var f decFeature
- r := &reader{data: data}
- for {
- field, _, b, _, ok := r.field()
- if !ok {
- break
- }
- if field == 2 { // packed tags
- ir := &reader{data: b}
- for !ir.atEnd() {
- f.tags = append(f.tags, uint32(ir.varint()))
- }
- }
- }
- return f
-}
-
-func decodeValue(data []byte) decValue {
- r := &reader{data: data}
- for {
- field, _, b, _, ok := r.field()
- if !ok {
- break
- }
- if field == 1 {
- return decValue{isString: true, str: string(b)}
- }
- }
- return decValue{}
-}
diff --git a/internal/engine/mvt/pbf.go b/internal/engine/mvt/pbf.go
deleted file mode 100644
index 4f06044..0000000
--- a/internal/engine/mvt/pbf.go
+++ /dev/null
@@ -1,106 +0,0 @@
-// Minimal protobuf wire-format writer for the MVT encoder: varints,
-// length-delimited fields, packed uint32, and fixed32.
-package mvt
-
-// wireType is the protobuf wire type (low 3 bits of a tag).
-type wireType uint32
-
-const (
- wireVarint wireType = 0
- wireFixed64 wireType = 1
- wireLen wireType = 2
- wireFixed32 wireType = 5
-)
-
-// zigzag32 maps a signed int32 to the unsigned zig-zag encoding MVT uses for
-// geometry deltas.
-func zigzag32(n int32) uint32 {
- return uint32((n << 1) ^ (n >> 31))
-}
-
-func varintLen(v uint64) int {
- n := 1
- for v >= 0x80 {
- n++
- v >>= 7
- }
- return n
-}
-
-func tagLen(field uint32, w wireType) int {
- return varintLen(uint64(field<<3) | uint64(w))
-}
-
-// writer is an append-only protobuf byte buffer.
-type writer struct {
- buf []byte
-}
-
-func (w *writer) bytes() []byte { return w.buf }
-
-func (w *writer) writeVarint(v uint64) {
- for v >= 0x80 {
- w.buf = append(w.buf, byte(v)|0x80)
- v >>= 7
- }
- w.buf = append(w.buf, byte(v))
-}
-
-func (w *writer) writeTag(field uint32, wt wireType) {
- w.writeVarint(uint64(field<<3) | uint64(wt))
-}
-
-func (w *writer) writeVarintField(field uint32, v uint64) {
- w.writeTag(field, wireVarint)
- w.writeVarint(v)
-}
-
-func (w *writer) writeStringField(field uint32, s string) {
- w.writeTag(field, wireLen)
- w.writeVarint(uint64(len(s)))
- w.buf = append(w.buf, s...)
-}
-
-func (w *writer) writeFixed32Field(field uint32, v uint32) {
- w.writeTag(field, wireFixed32)
- w.buf = append(w.buf, byte(v), byte(v>>8), byte(v>>16), byte(v>>24))
-}
-
-// writePackedU32 writes a packed repeated uint32 (varint-encoded) field.
-func (w *writer) writePackedU32(field uint32, vals []uint32) {
- var payload int
- for _, v := range vals {
- payload += varintLen(uint64(v))
- }
- w.writeTag(field, wireLen)
- w.writeVarint(uint64(payload))
- for _, v := range vals {
- w.writeVarint(uint64(v))
- }
-}
-
-// -- length helpers (must match the write* methods above) --------------------
-
-func varintFieldLen(field uint32, v uint64) int {
- return tagLen(field, wireVarint) + varintLen(v)
-}
-
-func bytesFieldLen(field uint32, n int) int {
- return tagLen(field, wireLen) + varintLen(uint64(n)) + n
-}
-
-func fixed32FieldLen(field uint32) int {
- return tagLen(field, wireFixed32) + 4
-}
-
-func packedU32FieldLen(field uint32, vals []uint32) int {
- var payload int
- for _, v := range vals {
- payload += varintLen(uint64(v))
- }
- return tagLen(field, wireLen) + varintLen(uint64(payload)) + payload
-}
-
-func msgFieldLen(field uint32, bodyLen int) int {
- return tagLen(field, wireLen) + varintLen(uint64(bodyLen)) + bodyLen
-}
diff --git a/internal/engine/nmea/sim/water.go b/internal/engine/nmea/sim/water.go
index b51a9cd..4bcec40 100644
--- a/internal/engine/nmea/sim/water.go
+++ b/internal/engine/nmea/sim/water.go
@@ -1,14 +1,16 @@
package sim
import (
+ "encoding/json"
"math"
"math/rand"
+ "strconv"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
)
-// WaterMask is the navigable-water footprint of an S-57 cell: the exterior rings
-// of depth areas (DEPARE/DRGARE) deep enough to float a vessel (DRVAL1 ≥ minDepth).
+// WaterMask is the navigable-water footprint of an S-57 cell: the rings of depth
+// areas (DEPARE/DRGARE) deep enough to float a vessel (DRVAL1 ≥ minDepth).
// Targets and routes are sampled inside it so simulated traffic stays in real
// channels — on the water and off the shoals — not driving over land.
//
@@ -27,30 +29,42 @@ type ringPoly struct {
minLon, minLat, maxLon, maxLat float64
}
-// NewWaterMask collects navigable depth-area polygons from a parsed chart. A
-// DEPARE is kept when its shoalest depth (DRVAL1) is ≥ minDepth (m); DEPARE with
-// no DRVAL1 and dredged areas (DRGARE) are kept. Returns nil if the cell has no
-// usable depth areas (caller falls back to unconstrained placement).
-func NewWaterMask(chart *s57.Chart, minDepth float64) *WaterMask {
+// NewWaterMask collects navigable depth-area polygons from a chart's DEPARE/
+// DRGARE features (tile57 Source.Features GeoJSON). A DEPARE is kept when its
+// shoalest depth (DRVAL1) is ≥ minDepth (m); DEPARE with no DRVAL1 and dredged
+// areas (DRGARE) are kept. Returns nil if the cell has no usable depth areas
+// (caller falls back to unconstrained placement).
+//
+// All of a polygon's rings go into the mask: IsWater is a union test, and a hole
+// ring's interior is a subset of its exterior's, so including holes changes
+// nothing (as before, islands inside a depth area still test as water) while
+// multi-patch areas keep every patch.
+func NewWaterMask(feats []tile57.Feature, minDepth float64) *WaterMask {
var rings [][][2]float64
- for _, f := range chart.Features() {
- switch f.ObjectClass() {
+ for _, f := range feats {
+ switch f.Class {
case "DRGARE":
case "DEPARE":
- if d, ok := f.Attribute("DRVAL1"); ok {
- if v, ok2 := toFloat(d); ok2 && v < minDepth {
+ if d, ok := f.Attrs["DRVAL1"]; ok {
+ if v, err := strconv.ParseFloat(d, 64); err == nil && v < minDepth {
continue
}
}
default:
continue
}
- for _, r := range f.Geometry().Rings {
- if r.Usage != 1 && r.Usage != 3 {
- continue
- }
- poly := make([][2]float64, 0, len(r.Coordinates))
- for _, c := range r.Coordinates {
+ if f.Type != "Polygon" {
+ continue
+ }
+ var g struct {
+ Coordinates [][][]float64 `json:"coordinates"`
+ }
+ if json.Unmarshal(f.Geometry, &g) != nil {
+ continue
+ }
+ for _, r := range g.Coordinates {
+ poly := make([][2]float64, 0, len(r))
+ for _, c := range r {
if len(c) >= 2 {
poly = append(poly, [2]float64{c[0], c[1]})
}
@@ -131,17 +145,3 @@ func pointInPoly(x, y float64, poly [][2]float64) bool {
}
return in
}
-
-func toFloat(v any) (float64, bool) {
- switch n := v.(type) {
- case float64:
- return n, true
- case float32:
- return float64(n), true
- case int:
- return float64(n), true
- case int64:
- return float64(n), true
- }
- return 0, false
-}
diff --git a/internal/engine/nmea/sim/water_test.go b/internal/engine/nmea/sim/water_test.go
index 313f736..9c715d9 100644
--- a/internal/engine/nmea/sim/water_test.go
+++ b/internal/engine/nmea/sim/water_test.go
@@ -5,7 +5,7 @@ import (
"os"
"testing"
- "github.com/beetlebugorg/chartplotter/internal/engine/baker"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -23,15 +23,18 @@ func TestWaterMask_Synthetic(t *testing.T) {
}
func TestWaterMask_RealCell(t *testing.T) {
- // pkg/s57 ships a Chesapeake test cell — build a mask and confirm sampled
+ // The repo ships a Chesapeake test cell — build a mask and confirm sampled
// traffic lands in navigable depth areas.
- data, err := os.ReadFile("../../../../pkg/s57/testdata/US4MD81M.000")
+ data, err := os.ReadFile("../../../../testdata/US4MD81M.000")
if err != nil {
t.Skip("test cell not available")
}
- chart, err := baker.ParseCellBytes("US4MD81M.000", data)
+ src, err := tile57.OpenChartBytes(data)
require.NoError(t, err)
- m := NewWaterMask(chart, 2)
+ defer src.Close()
+ feats, err := src.Features("DEPARE", "DRGARE")
+ require.NoError(t, err)
+ m := NewWaterMask(feats, 2)
require.NotNil(t, m, "cell should yield navigable depth areas")
t.Logf("depth-area polygons: %d bounds lat[%.3f,%.3f] lon[%.3f,%.3f]", len(m.polys), m.minLat, m.maxLat, m.minLon, m.maxLon)
diff --git a/internal/engine/pmtiles/pmtiles.go b/internal/engine/pmtiles/pmtiles.go
index 59964e2..9e0fa71 100644
--- a/internal/engine/pmtiles/pmtiles.go
+++ b/internal/engine/pmtiles/pmtiles.go
@@ -23,11 +23,24 @@ import (
const (
tileTypeMVT uint8 = 1
+ tileTypeMLT uint8 = 6 // tile57's MLT (MapLibre Tile) header type
compressionNone uint8 = 1
compressionGzip uint8 = 2
leafSize = 4096
)
+// tileTypeName maps a PMTiles header tile-type byte to the vector-source
+// `encoding` vocabulary ("mvt"/"mlt"; "" for non-vector/unknown types).
+func tileTypeName(t uint8) string {
+ switch t {
+ case tileTypeMVT:
+ return "mvt"
+ case tileTypeMLT:
+ return "mlt"
+ }
+ return ""
+}
+
// ZxyToTileID maps (z,x,y) to a PMTiles tile id: tiles below this zoom plus the
// Hilbert-curve index within the zoom. Matches the spec's zxy_to_tileid.
func ZxyToTileID(z uint8, x, y uint32) uint64 {
diff --git a/internal/engine/pmtiles/reader.go b/internal/engine/pmtiles/reader.go
index 63f14a3..0a19b24 100644
--- a/internal/engine/pmtiles/reader.go
+++ b/internal/engine/pmtiles/reader.go
@@ -35,6 +35,11 @@ type TileMeta struct {
W, S, E, N float64 // lon/lat bounds (degrees)
Gzipped bool // tile bodies are gzip-compressed
Scamin []uint32 // distinct SCAMIN denominators present (from JSON metadata)
+ // TileType is the stored tile encoding: "mvt" or "mlt" (a tile57 MLT-default
+ // bake); "" for an unrecognised header type. Serving stays bytes-verbatim
+ // either way — this only drives the TileJSON/style `encoding` hint (and the
+ // tile content type) so maplibre-gl picks the matching decoder.
+ TileType string
}
// Open opens a .pmtiles file for reading. Close releases the file handle.
@@ -92,13 +97,14 @@ func NewReader(src io.ReaderAt, size int64) (*Reader, error) {
dataOff: dataOff,
tileGz: h[98] == compressionGzip,
meta: TileMeta{
- MinZoom: h[100],
- MaxZoom: h[101],
- W: float64(int32(binary.LittleEndian.Uint32(h[102:106]))) / 1e7,
- S: float64(int32(binary.LittleEndian.Uint32(h[106:110]))) / 1e7,
- E: float64(int32(binary.LittleEndian.Uint32(h[110:114]))) / 1e7,
- N: float64(int32(binary.LittleEndian.Uint32(h[114:118]))) / 1e7,
- Gzipped: h[98] == compressionGzip,
+ MinZoom: h[100],
+ MaxZoom: h[101],
+ W: float64(int32(binary.LittleEndian.Uint32(h[102:106]))) / 1e7,
+ S: float64(int32(binary.LittleEndian.Uint32(h[106:110]))) / 1e7,
+ E: float64(int32(binary.LittleEndian.Uint32(h[110:114]))) / 1e7,
+ N: float64(int32(binary.LittleEndian.Uint32(h[114:118]))) / 1e7,
+ Gzipped: h[98] == compressionGzip,
+ TileType: tileTypeName(h[99]),
},
}
// JSON metadata (between metaOff and leafOff): parse the SCAMIN manifest so the
diff --git a/internal/engine/portrayal/anchor_test.go b/internal/engine/portrayal/anchor_test.go
deleted file mode 100644
index ec7d766..0000000
--- a/internal/engine/portrayal/anchor_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package portrayal
-
-import (
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/geo"
-)
-
-// An L-shaped (concave) polygon: the plain vertex average / area centroid lands in
-// the missing corner — outside the polygon. areaSurfacePoint must return a point
-// that is actually inside.
-func TestAreaSurfacePointConcave(t *testing.T) {
- // L-shape occupying the region minus the top-right quadrant.
- ring := []geo.LatLon{
- {Lat: 0, Lon: 0},
- {Lat: 0, Lon: 10},
- {Lat: 5, Lon: 10},
- {Lat: 5, Lon: 5},
- {Lat: 10, Lon: 5},
- {Lat: 10, Lon: 0},
- }
- p, ok := areaSurfacePoint(ring)
- if !ok {
- t.Fatal("areaSurfacePoint returned ok=false")
- }
- if !pointInRing(p, ring) {
- t.Fatalf("anchor %+v is outside the polygon", p)
- }
-
- // Sanity: the naive vertex average IS outside this shape (the bug we fixed).
- var sLat, sLon float64
- for _, v := range ring {
- sLat += v.Lat
- sLon += v.Lon
- }
- mean := geo.LatLon{Lat: sLat / float64(len(ring)), Lon: sLon / float64(len(ring))}
- if pointInRing(mean, ring) {
- t.Skip("vertex average happens to be inside; concavity assumption changed")
- }
-}
-
-// For a convex polygon the centroid is inside, so it should be returned as-is.
-func TestAreaSurfacePointConvex(t *testing.T) {
- ring := []geo.LatLon{{Lat: 0, Lon: 0}, {Lat: 0, Lon: 4}, {Lat: 4, Lon: 4}, {Lat: 4, Lon: 0}}
- p, ok := areaSurfacePoint(ring)
- if !ok || !pointInRing(p, ring) {
- t.Fatalf("convex anchor %+v ok=%v not inside", p, ok)
- }
-}
-
-// A square area with a large centred square hole: the area-weighted centroid lands
-// dead-centre — inside the hole (i.e. on the excluded structure). areaLabelPoint
-// must place the symbol in the surrounding ring, off the hole.
-func TestAreaLabelPointHole(t *testing.T) {
- exterior := []geo.LatLon{{Lat: 0, Lon: 0}, {Lat: 0, Lon: 10}, {Lat: 10, Lon: 10}, {Lat: 10, Lon: 0}}
- hole := []geo.LatLon{{Lat: 3, Lon: 3}, {Lat: 3, Lon: 7}, {Lat: 7, Lon: 7}, {Lat: 7, Lon: 3}}
- p, ok := areaLabelPoint([][]geo.LatLon{exterior, hole})
- if !ok {
- t.Fatal("areaLabelPoint returned ok=false")
- }
- if !pointInRing(p, exterior) {
- t.Fatalf("anchor %+v is outside the exterior", p)
- }
- if pointInRing(p, hole) {
- t.Fatalf("anchor %+v landed inside the hole (on the excluded structure)", p)
- }
-}
diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go
deleted file mode 100644
index 1756b97..0000000
--- a/internal/engine/portrayal/build.go
+++ /dev/null
@@ -1,833 +0,0 @@
-package portrayal
-
-import (
- "container/heap"
- "fmt"
- "math"
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// NaN marks "no depth" on sounding/danger fields (the sentinel value).
-var nan32 = float32(math.NaN())
-
-// FeatureBuild is the result of expanding one feature: its viewport-independent
-// Primitive stream plus the S-52 display priority (buckets draw order) and
-// display category (base/standard/other; the client's detail filter).
-type FeatureBuild struct {
- Primitives []Primitive
- DisplayPriority int
- DisplayCategory int
- // DateStart/DateEnd/TimeValid carry the feature's date dependency when it has
- // one (S-57 DATSTA/DATEND fixed window or PERSTA/PEREND periodic window), so the
- // baker can tag the feature and a date-aware client show/hide it against the
- // current date. Empty when the feature is not date-dependent. DateStart/DateEnd
- // are S-57 date strings — full "YYYYMMDD" (fixed) or partial "--MMDD" recurring
- // each year (periodic); TimeValid is the interval kind (closedInterval /
- // geSemiInterval / leSemiInterval). The feature also carries the CHDATD01
- // date-dependent marker symbol among its primitives.
- DateStart string
- DateEnd string
- TimeValid string
-}
-
-// geom is the portrayal-space geometry handed to the instruction walk. It mirrors
-// the s57 geometry union (point / soundings / line / area). currentDepthM is
-// the per-point sounding depth (NaN otherwise), used by SOUNDG03 + carried on the
-// emitted symbol so the client can do SNDFRM04 without a re-bake.
-type geom struct {
- kind geomKind
- point geo.LatLon
- line []geo.LatLon
- lineParts [][]geo.LatLon // drawable line parts (masked/data-limit edges removed, S-52 §8.6.2); nil ⇒ stroke `line`
- area [][]geo.LatLon
- boundary [][]geo.LatLon // drawable border polylines (masked/data-limit edges removed); nil ⇒ stroke `area`
- currentDepth float64
- hasDepth bool
-}
-
-type geomKind uint8
-
-const (
- geomNone geomKind = iota
- geomPoint
- geomLine
- geomArea
-)
-
-// Boundary-symbolization tags (S-52 §8.6.1) stamped on each primitive's baked
-// `bnd`, which the client's boundaryFilter keys off: 2 = style-independent
-// (always shown), 0 = plain-boundary pass, 1 = symbolized-boundary pass.
-const (
- BndCommon = 2
- BndPlain = 0
- BndSymbolized = 1
-)
-
-// Point-symbol style tags (S-52 §11.2.2) stamped on each primitive's baked
-// `pts`, which the client's pointStyleFilter keys off — the same mechanism as
-// `bnd`, but for the simplified vs paper-chart POINT lookup tables: 2 =
-// style-independent (always shown), 0 = paper-chart pass, 1 = simplified pass.
-// Geometry-disjoint from bnd: simplified/paper only applies to point features,
-// plain/symbolized only to area boundaries.
-const (
- PtsCommon = 2
- PtsPaper = 0
- PtsSimplified = 1
-)
-
-// FeatureBuildPass is one display-variant pass: the built primitives plus the
-// bnd (boundary-style) and pts (point-symbol-style) tags the baker stamps on
-// every primitive of the pass, so the client toggles each axis live (no re-bake).
-type FeatureBuildPass struct {
- Build FeatureBuild
- Bnd int
- Pts int
-}
-
-// stringAttr returns an attribute's encoded string value, or "" when absent.
-func stringAttr(attrs map[string]any, key string) string {
- if v, ok := attrs[key]; ok {
- if s, ok := encodeAttr(v); ok {
- return s
- }
- }
- return ""
-}
-
-func floatAttr(attrs map[string]any, key string) (float64, bool) {
- v, ok := attrs[key]
- if !ok || v == nil {
- return 0, false
- }
- switch t := v.(type) {
- case float64:
- return t, true
- case float32:
- return float64(t), true
- case int:
- return float64(t), true
- case int64:
- return float64(t), true
- case string:
- if f, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil {
- return f, true
- }
- }
- return 0, false
-}
-
-// -- helpers -----------------------------------------------------------------
-
-// lookupAttributeText returns the textual value of an attribute for a label, or
-// ok=false when absent/empty (which suppresses the label, per S-52).
-func lookupAttributeText(attrs map[string]any, acronym string) (string, bool) {
- v, ok := attrs[acronym]
- if !ok || v == nil {
- return "", false
- }
- switch t := v.(type) {
- case string:
- if t == "" {
- return "", false
- }
- return t, true
- case []string, []any:
- return "", false // list attributes have no single label value
- default:
- return strings.TrimSpace(stringifyScalar(v)), true
- }
-}
-
-func textAnchor(g geom) (geo.LatLon, bool) {
- switch g.kind {
- case geomPoint:
- return g.point, true
- case geomLine:
- if len(g.line) == 0 {
- return geo.LatLon{}, false
- }
- return g.line[len(g.line)/2], true
- case geomArea:
- if len(g.area) == 0 || len(g.area[0]) == 0 {
- return geo.LatLon{}, false
- }
- return areaLabelPoint(g.area)
- default:
- return geo.LatLon{}, false
- }
-}
-
-// areaSurfacePoint returns a representative interior point for a single ring (no
-// holes). It is a thin wrapper over areaLabelPoint, kept for callers that have
-// only an exterior ring (e.g. the unknown-object question mark).
-func areaSurfacePoint(ring []geo.LatLon) (geo.LatLon, bool) {
- return areaLabelPoint([][]geo.LatLon{ring})
-}
-
-// areaLabelPoint returns the polygon's "pole of inaccessibility" (the Mapbox
-// polylabel algorithm): the interior point farthest from any edge. rings[0] is
-// the exterior boundary; rings[1:] are holes. Containment is the even-odd rule
-// over ALL rings, so the chosen point lies inside the exterior AND outside every
-// hole — keeping a centred symbol (e.g. an anchorage anchor, a restricted-area
-// mark) off an excluded structure, and off the missing limb of a concave (L- or
-// U-shaped) area. This is the S-52 PresLib §8.5.3 "representative point of an
-// area": robust for concave shapes and shapes whose centroid falls outside the
-// area. Falls back to the vertex average for a degenerate (zero-area) ring.
-//
-// Distances are measured with longitude scaled by cos(lat) so "farthest from any
-// edge" is in roughly equal ground units rather than skewed degrees.
-func areaLabelPoint(rings [][]geo.LatLon) (geo.LatLon, bool) {
- if len(rings) == 0 || len(rings[0]) == 0 {
- return geo.LatLon{}, false
- }
- ext := rings[0]
- // S-52 §8.5.3: the representative point is the area's CENTRE OF GRAVITY by
- // default; only when the centroid falls outside the area (concave / holed
- // shapes) is another point used. Prefer the centroid when it's inside — it
- // sits at the true centre, whereas the pole of inaccessibility below drifts
- // off-centre along a wide shape's mid-line (a wide rectangle's pole is not
- // unique), which left centred symbols visibly off-centre.
- if c, ok := ringCentroid(ext); ok && pointInRingsEvenOdd(c, rings) {
- return c, true
- }
- minLat, minLon := math.Inf(1), math.Inf(1)
- maxLat, maxLon := math.Inf(-1), math.Inf(-1)
- var sumLat, sumLon float64
- for _, p := range ext {
- minLat, maxLat = math.Min(minLat, p.Lat), math.Max(maxLat, p.Lat)
- minLon, maxLon = math.Min(minLon, p.Lon), math.Max(maxLon, p.Lon)
- sumLat, sumLon = sumLat+p.Lat, sumLon+p.Lon
- }
- mean := geo.LatLon{Lat: sumLat / float64(len(ext)), Lon: sumLon / float64(len(ext))}
- kx := math.Cos((minLat + maxLat) / 2 * math.Pi / 180)
- if kx < 1e-9 {
- kx = 1 // near-polar guard; charts don't reach here
- }
- // Work in scaled space: X = lon*kx, Y = lat.
- xMin, xMax := minLon*kx, maxLon*kx
- w, h := xMax-xMin, maxLat-minLat
- cellSize := math.Min(w, h)
- if cellSize <= 0 {
- return mean, true // zero-area / degenerate ring
- }
-
- // Signed distance (positive inside) from a scaled point to the polygon: the
- // min distance to any edge of any ring, with the sign from even-odd inclusion.
- dist := func(px, py float64) float64 {
- inside := false
- best := math.Inf(1)
- for _, ring := range rings {
- n := len(ring)
- for i, j := 0, n-1; i < n; j, i = i, i+1 {
- ax, ay := ring[i].Lon*kx, ring[i].Lat
- bx, by := ring[j].Lon*kx, ring[j].Lat
- if (ay > py) != (by > py) && px < (bx-ax)*(py-ay)/(by-ay)+ax {
- inside = !inside
- }
- if d := segDist(px, py, ax, ay, bx, by); d < best {
- best = d
- }
- }
- }
- if inside {
- return best
- }
- return -best
- }
- mkCell := func(x, y, half float64) plCell {
- d := dist(x, y)
- return plCell{x: x, y: y, half: half, d: d, max: d + half*math.Sqrt2}
- }
-
- precision := math.Max(w, h) / 200 // ~0.5% of the span: ample for a label point
- half := cellSize / 2
- best := mkCell((xMin+xMax)/2, (minLat+maxLat)/2, 0) // bbox-centre seed
- pq := &plCellHeap{}
- for x := xMin; x < xMax; x += cellSize {
- for y := minLat; y < maxLat; y += cellSize {
- heap.Push(pq, mkCell(x+half, y+half, half))
- }
- }
- const maxCells = 20000 // safety cap for very large/high-vertex rings
- for processed := 0; pq.Len() > 0; processed++ {
- c := heap.Pop(pq).(plCell)
- if c.d > best.d {
- best = c
- }
- if c.max-best.d <= precision || processed >= maxCells {
- continue
- }
- hh := c.half / 2
- heap.Push(pq, mkCell(c.x-hh, c.y-hh, hh))
- heap.Push(pq, mkCell(c.x+hh, c.y-hh, hh))
- heap.Push(pq, mkCell(c.x-hh, c.y+hh, hh))
- heap.Push(pq, mkCell(c.x+hh, c.y+hh, hh))
- }
- return geo.LatLon{Lat: best.y, Lon: best.x / kx}, true
-}
-
-// ringCentroid returns the area centroid (centre of gravity) of a polygon ring
-// via the shoelace formula. ok is false for a degenerate (zero-area) ring. The
-// ring may be open or closed; edges wrap. Computed in raw lon/lat — the slight
-// cos(lat) skew is immaterial for a centring point over a chart-sized area.
-func ringCentroid(ring []geo.LatLon) (geo.LatLon, bool) {
- n := len(ring)
- if n < 3 {
- return geo.LatLon{}, false
- }
- var a, cx, cy float64
- for i := range n {
- j := (i + 1) % n
- cross := ring[i].Lon*ring[j].Lat - ring[j].Lon*ring[i].Lat
- a += cross
- cx += (ring[i].Lon + ring[j].Lon) * cross
- cy += (ring[i].Lat + ring[j].Lat) * cross
- }
- if math.Abs(a) < 1e-12 {
- return geo.LatLon{}, false
- }
- a *= 0.5
- return geo.LatLon{Lat: cy / (6 * a), Lon: cx / (6 * a)}, true
-}
-
-// pointInRingsEvenOdd reports whether p is inside the even-odd union of rings
-// (exterior boundary + holes): inside the exterior AND outside every hole.
-func pointInRingsEvenOdd(p geo.LatLon, rings [][]geo.LatLon) bool {
- inside := false
- for _, ring := range rings {
- n := len(ring)
- for i, j := 0, n-1; i < n; j, i = i, i+1 {
- if (ring[i].Lat > p.Lat) != (ring[j].Lat > p.Lat) &&
- p.Lon < (ring[j].Lon-ring[i].Lon)*(p.Lat-ring[i].Lat)/(ring[j].Lat-ring[i].Lat)+ring[i].Lon {
- inside = !inside
- }
- }
- }
- return inside
-}
-
-// plCell is one square candidate region in the polylabel search (scaled space).
-type plCell struct {
- x, y, half float64 // centre and half-size
- d float64 // signed distance from centre to the polygon (+ inside)
- max float64 // upper bound on d anywhere in the cell (d + half*√2)
-}
-
-// plCellHeap is a max-heap on plCell.max (most-promising cell first).
-type plCellHeap []plCell
-
-func (h plCellHeap) Len() int { return len(h) }
-func (h plCellHeap) Less(i, j int) bool { return h[i].max > h[j].max }
-func (h plCellHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
-func (h *plCellHeap) Push(x any) { *h = append(*h, x.(plCell)) }
-func (h *plCellHeap) Pop() any {
- old := *h
- n := len(old)
- c := old[n-1]
- *h = old[:n-1]
- return c
-}
-
-// segDist returns the Euclidean distance from point (px,py) to segment a–b.
-func segDist(px, py, ax, ay, bx, by float64) float64 {
- dx, dy := bx-ax, by-ay
- if dx != 0 || dy != 0 {
- t := ((px-ax)*dx + (py-ay)*dy) / (dx*dx + dy*dy)
- switch {
- case t > 1:
- ax, ay = bx, by
- case t > 0:
- ax, ay = ax+dx*t, ay+dy*t
- }
- }
- dx, dy = px-ax, py-ay
- return math.Sqrt(dx*dx + dy*dy)
-}
-
-// pointInRing reports whether p is inside the polygon ring (ray casting).
-func pointInRing(p geo.LatLon, ring []geo.LatLon) bool {
- in := false
- for i, j := 0, len(ring)-1; i < len(ring); j, i = i, i+1 {
- yi, yj := ring[i].Lat, ring[j].Lat
- xi, xj := ring[i].Lon, ring[j].Lon
- if (yi > p.Lat) != (yj > p.Lat) &&
- p.Lon < (xj-xi)*(p.Lat-yi)/(yj-yi)+xi {
- in = !in
- }
- }
- return in
-}
-
-// geometryOf converts s57 geometry to the portrayal geom (lat/lon). SOUNDG is
-// handled separately by BuildFeature (per-point).
-func geometryOf(g s57.Geometry) geom {
- switch g.Type {
- case s57.GeometryTypePoint:
- if len(g.Coordinates) == 0 || len(g.Coordinates[0]) < 2 {
- return geom{kind: geomNone}
- }
- c := g.Coordinates[0]
- return geom{kind: geomPoint, point: geo.LatLon{Lat: c[1], Lon: c[0]}}
- case s57.GeometryTypeLineString:
- // Drawable line parts (masked / data-limit edges already removed by the
- // parser, S-52 §8.6.2). A non-nil Lines means the parser computed the
- // drawable line — stroke each part (empty ⇒ stroke nothing). Nil means no
- // masking applied → stroke the full flat line, unchanged.
- var lineParts [][]geo.LatLon
- if g.Lines != nil {
- lineParts = make([][]geo.LatLon, 0, len(g.Lines))
- for _, lp := range g.Lines {
- if pts := coordsToLatLon(lp); len(pts) >= 2 {
- lineParts = append(lineParts, pts)
- }
- }
- }
- return geom{kind: geomLine, line: coordsToLatLon(g.Coordinates), lineParts: lineParts}
- case s57.GeometryTypePolygon:
- var rings [][]geo.LatLon
- if len(g.Rings) > 0 {
- for _, r := range g.Rings {
- rings = append(rings, coordsToLatLon(r.Coordinates))
- }
- } else if len(g.Coordinates) > 0 {
- rings = append(rings, coordsToLatLon(g.Coordinates))
- }
- // Drawable border polylines (masked / data-limit edges already removed by
- // the parser, S-52 §8.6.2). The fill still uses the complete rings. A
- // non-nil (even if empty) BoundaryLines means the parser computed the
- // drawable border — use it (empty ⇒ stroke nothing). Nil means it wasn't
- // computed (fallback geometry) → stroke the full rings.
- var boundary [][]geo.LatLon
- if g.BoundaryLines != nil {
- boundary = make([][]geo.LatLon, 0, len(g.BoundaryLines))
- for _, bl := range g.BoundaryLines {
- if pts := coordsToLatLon(bl); len(pts) >= 2 {
- boundary = append(boundary, pts)
- }
- }
- }
- return geom{kind: geomArea, area: rings, boundary: boundary}
- default:
- return geom{kind: geomNone}
- }
-}
-
-func coordsToLatLon(coords [][]float64) []geo.LatLon {
- out := make([]geo.LatLon, 0, len(coords))
- for _, c := range coords {
- if len(c) < 2 {
- continue
- }
- out = append(out, geo.LatLon{Lat: c[1], Lon: c[0]})
- }
- return out
-}
-
-// mapHJust / mapVJust map S-52 SHOWTEXT justification codes (§9.1) to alignments.
-// HJUST: 1=centre, 2=right, 3=left. VJUST: 1=bottom, 2=centre, 3=top.
-func mapHJust(h int) HAlign {
- switch h {
- case 1:
- return HAlignCenter
- case 2:
- return HAlignRight
- default:
- return HAlignLeft
- }
-}
-
-func mapVJust(v int) VAlign {
- // S-52 §8.3.3.2 VJUST: 2 centre, 3 top, else (incl. 1) bottom.
- switch v {
- case 2:
- return VAlignMiddle
- case 3:
- return VAlignTop
- default:
- return VAlignBottom
- }
-}
-
-// formatSubstitute substitutes attribute values into a TE/TX C-printf format
-// string (S-52 §8.3.3.3 — e.g. "clr op %4.1lf" with VERCOP -> "clr op 12.3").
-// Handles %[flags][width][.precision][l|h|L]conv; width/flags only affect
-// fixed-pitch padding so they are ignored, precision is honoured for floats.
-// Returns ok=false when a referenced attribute is absent — per S-52 a label with
-// a missing mandatory field is not drawn.
-func formatSubstitute(attrs map[string]any, format string, attrNames []string) (string, bool) {
- var out strings.Builder
- attrIdx := 0
- i := 0
- for i < len(format) {
- if format[i] != '%' || i+1 >= len(format) {
- out.WriteByte(format[i])
- i++
- continue
- }
- if format[i+1] == '%' {
- out.WriteByte('%')
- i += 2
- continue
- }
- // Scan the printf spec: flags, width, .precision, length, conv.
- j := i + 1
- flagsStart := j
- for j < len(format) && strings.IndexByte("-+ #0", format[j]) >= 0 {
- j++
- }
- flags := format[flagsStart:j]
- width := 0
- for j < len(format) && format[j] >= '0' && format[j] <= '9' {
- width = width*10 + int(format[j]-'0')
- j++
- }
- precision := -1
- if j < len(format) && format[j] == '.' {
- j++
- p := 0
- for j < len(format) && format[j] >= '0' && format[j] <= '9' {
- p = p*10 + int(format[j]-'0')
- j++
- }
- precision = p
- }
- for j < len(format) && (format[j] == 'l' || format[j] == 'h' || format[j] == 'L') {
- j++
- }
- if j >= len(format) {
- out.WriteString(format[i:]) // malformed trailing spec -> keep literal
- break
- }
- switch conv := format[j]; conv {
- case 's', 'c', 'd', 'i', 'u', 'x', 'f', 'e', 'g':
- if attrIdx >= len(attrNames) {
- return "", false
- }
- acr := attrNames[attrIdx]
- attrIdx++
- val, ok := lookupAttributeText(attrs, acr)
- if !ok {
- return "", false
- }
- appendConverted(&out, val, conv, precision, width, flags)
- default:
- out.WriteString(format[i : j+1]) // unknown conversion -> literal
- }
- i = j + 1
- }
- return out.String(), true
-}
-
-// appendConverted appends val formatted per the printf conversion: floats honour
-// precision, integer conversions round, everything else passes through. The
-// zero-pad flag + width are applied (e.g. "%03.0lf" → 90 ⇒ "090", the S-52
-// bearing format); space/width padding is intentionally NOT applied (proportional
-// chart text needs no fixed-pitch alignment).
-func appendConverted(out *strings.Builder, val string, conv byte, precision, width int, flags string) {
- var s string
- switch conv {
- case 'f', 'e', 'g':
- x, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
- if err != nil {
- s = val
- } else if precision >= 0 {
- s = strconv.FormatFloat(x, 'f', precision, 64)
- } else {
- s = strconv.FormatFloat(x, 'g', -1, 64)
- }
- case 'd', 'i', 'u', 'x':
- x, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
- if err != nil {
- s = val
- } else {
- s = strconv.FormatInt(int64(math.Round(x)), 10)
- }
- default:
- s = val
- }
- out.WriteString(zeroPad(s, width, flags))
-}
-
-// zeroPad left-pads s with zeros to width when the printf '0' flag is set (and
-// not left-justified '-'), inserting after any leading sign. Other padding is
-// ignored (see appendConverted).
-func zeroPad(s string, width int, flags string) string {
- if width <= len(s) || !strings.ContainsRune(flags, '0') || strings.ContainsRune(flags, '-') {
- return s
- }
- pad := strings.Repeat("0", width-len(s))
- if len(s) > 0 && (s[0] == '-' || s[0] == '+' || s[0] == ' ') {
- return s[:1] + pad + s[1:]
- }
- return pad + s
-}
-
-// stringifyScalar renders a scalar attribute value as label text. Integer-valued
-// floats drop the decimal, matching the lookup attribute-text "{d}" output.
-func stringifyScalar(v any) string {
- switch t := v.(type) {
- case string:
- return t
- case float64:
- if t == math.Trunc(t) && !math.IsInf(t, 0) {
- return strconv.FormatInt(int64(t), 10)
- }
- return strconv.FormatFloat(t, 'g', -1, 64)
- case float32:
- return stringifyScalar(float64(t))
- case int:
- return strconv.Itoa(t)
- case int64:
- return strconv.FormatInt(t, 10)
- default:
- return fmt.Sprintf("%v", v)
- }
-}
-
-// unknownObjectBuild is the §10.1.1 portrayal of an unknown-class feature: a
-// single QUESMRK1 question-mark symbol at the feature's position.
-func unknownObjectBuild(f *s57.Feature) FeatureBuild {
- anchor, ok := representativePoint(f)
- if !ok {
- // No usable coordinate (e.g. a line/area feature whose spatial edges didn't
- // resolve) — there is nowhere to put the question mark, so emit nothing
- // rather than stamp it at null island (0,0).
- return FeatureBuild{DisplayCategory: displayStandard}
- }
- return FeatureBuild{
- Primitives: []Primitive{SymbolCall{
- Anchor: anchor,
- SymbolName: "QUESMRK1",
- Scale: DefaultPxPerSymbolUnit,
- SoundingDepthM: nan32,
- DangerDepthM: nan32,
- }},
- DisplayPriority: 6, // ordinary point-symbol priority
- DisplayCategory: displayStandard,
- }
-}
-
-// newObjectBuild portrays an S-57 NEWOBJ whose primitive the S-101 alias rule
-// (VirtualAISAidToNavigation) rejects: that rule is POINT-only, so a line or area
-// NEWOBJ errors and would otherwise be suppressed (drawing nothing). The S-52
-// PresLib reference (§10.3.3.8, "Default symbol for NEWOBJ") draws line/area new
-// objects with a dashed magenta boundary, so emit that. Point NEWOBJ never reaches
-// here — it portrays through the V-AIS rule, which is correct for real S-101 data
-// (V-AIS is encoded as a point NEWOBJ).
-func newObjectBuild(f *s57.Feature) FeatureBuild {
- g := f.Geometry()
- toLL := func(cs [][]float64) []geo.LatLon {
- out := make([]geo.LatLon, 0, len(cs))
- for _, c := range cs {
- if len(c) >= 2 {
- out = append(out, geo.LatLon{Lat: c[1], Lon: c[0]})
- }
- }
- return out
- }
- dashed := func(pts []geo.LatLon, closed bool) Primitive {
- if closed && len(pts) > 1 && pts[0] != pts[len(pts)-1] {
- pts = append(pts, pts[0]) // close the ring
- }
- return StrokeLine{Points: pts, ColorToken: "CHMGF", WidthPx: 1.5, Dash: DashDashed}
- }
- // Centred "!" — the S-52 "default symbol for NEWOBJ" (§10.3.3.8). NEWOBJ01 is our
- // own symbol (the PresLib glyph isn't in the S-101 catalogue). Used for areas.
- centreMark := func() (SymbolCall, bool) {
- anchor, ok := representativePoint(f)
- return SymbolCall{Anchor: anchor, SymbolName: "NEWOBJ01", Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32}, ok
- }
- var prims []Primitive
- switch g.Type {
- case s57.GeometryTypePoint:
- // A point NEWOBJ only reaches here when the Virtual-AIS rule produced nothing
- // (a real V-AIS point portrays via that rule and never gets here), so a generic
- // new-object point safely falls back to the bare "!".
- if mark, ok := centreMark(); ok {
- prims = append(prims, mark)
- }
- case s57.GeometryTypeLineString:
- // Dashed magenta line with repeated "!" marks — the NEWOBJ01 line style embeds
- // the marks and the complex-line tessellator places them per zoom (so they
- // repeat with breaks at every scale, not one symbol on a plain line).
- if pts := toLL(g.Coordinates); len(pts) >= 2 {
- prims = append(prims, LinePattern{Points: pts, LinestyleName: "NEWOBJ01", ColorToken: "CHMGD"})
- }
- case s57.GeometryTypePolygon:
- for _, r := range g.Rings {
- if pts := toLL(r.Coordinates); len(pts) >= 2 {
- prims = append(prims, dashed(pts, true))
- }
- }
- if mark, ok := centreMark(); ok {
- prims = append(prims, mark)
- }
- }
- if len(prims) == 0 {
- return FeatureBuild{DisplayCategory: displayStandard}
- }
- return FeatureBuild{Primitives: prims, DisplayPriority: 6, DisplayCategory: displayStandard}
-}
-
-// sweptAreaBuild portrays an S-57 SWPARE (swept area). Its S-101 class is
-// SweptArea, but the Portrayal Catalogue ships no SweptArea.lua rule (an IHO gap),
-// so it errors and would be suppressed. The S-52 PresLib reference (page 243)
-// draws a dashed boundary around the area plus a "swept to " depth label,
-// so emit that.
-// navSystemBuild portrays an M_NSYS (navigational system of marks) area. The S-101
-// NavigationalSystemOfMarks rule is an unofficial stub (NullInstruction), so this
-// reproduces the S-52 PresLib lookup (DAI LU00344–347, symbolized boundary):
-// - a region WITHOUT a direction of buoyage (ORIENT) → the MARSYS51 "A-B" line
-// (dashes + the embedded A/B symbols, "boundary between IALA-A and IALA-B");
-// - a region WITH ORIENT → the generic NAVARE51 triangle boundary plus a
-// direction-of-buoyage arrow (DIRBOYA1 for IALA-A, DIRBOYB1 for IALA-B, else
-// DIRBOY01), rotated to ORIENT.
-func navSystemBuild(f *s57.Feature) FeatureBuild {
- g := f.Geometry()
- if g.Type != s57.GeometryTypePolygon {
- return FeatureBuild{DisplayCategory: displayStandard}
- }
- orient, hasOrient := floatAttr(f.Attributes(), "ORIENT")
- boundary := "MARSYS51" // no direction of buoyage → the A-B system boundary line
- if hasOrient {
- boundary = "NAVARE51"
- }
- var prims []Primitive
- for _, r := range g.Rings {
- pts := make([]geo.LatLon, 0, len(r.Coordinates))
- for _, c := range r.Coordinates {
- if len(c) >= 2 {
- pts = append(pts, geo.LatLon{Lat: c[1], Lon: c[0]})
- }
- }
- if len(pts) > 1 && pts[0] != pts[len(pts)-1] {
- pts = append(pts, pts[0]) // close the ring
- }
- if len(pts) >= 2 {
- prims = append(prims, LinePattern{Points: pts, LinestyleName: boundary, ColorToken: "CHGRD"})
- }
- }
- if len(prims) == 0 {
- return FeatureBuild{DisplayCategory: displayStandard}
- }
- // Direction-of-buoyage arrow at the region's representative point (DAI LU00345-347).
- if hasOrient {
- arrow := "DIRBOY01"
- switch m, _ := floatAttr(f.Attributes(), "MARSYS"); int(m) {
- case 1:
- arrow = "DIRBOYA1"
- case 2:
- arrow = "DIRBOYB1"
- }
- if a, ok := areaSurfacePoint(coordsToLatLon(exteriorRing(g))); ok {
- // Centre the arrow on the area's representative point: DIRBOY's catalogue
- // pivot sits at the arrow's tail (SVG 0,0, bottom-centre), so honouring it
- // would push the whole arrow ~30 mm north of the centre and out of a small
- // region. CentreOnArea centres the glyph instead (S-52 §8.5.1).
- prims = append(prims, SymbolCall{
- Anchor: a, SymbolName: arrow, RotationDeg: float32(orient), RotationTrueNorth: true,
- CentreOnArea: true, Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32,
- })
- }
- }
- return FeatureBuild{Primitives: prims, DisplayPriority: 12, DisplayCategory: displayStandard}
-}
-
-func sweptAreaBuild(f *s57.Feature) FeatureBuild {
- g := f.Geometry()
- if g.Type != s57.GeometryTypePolygon {
- return FeatureBuild{DisplayCategory: displayStandard}
- }
- ringLL := func(cs [][]float64) []geo.LatLon {
- out := make([]geo.LatLon, 0, len(cs))
- for _, c := range cs {
- if len(c) >= 2 {
- out = append(out, geo.LatLon{Lat: c[1], Lon: c[0]})
- }
- }
- if len(out) > 1 && out[0] != out[len(out)-1] {
- out = append(out, out[0]) // close the ring
- }
- return out
- }
- var prims []Primitive
- for _, r := range g.Rings {
- if pts := ringLL(r.Coordinates); len(pts) >= 2 {
- prims = append(prims, StrokeLine{Points: pts, ColorToken: "CHGRD", WidthPx: 1, Dash: DashDashed})
- }
- }
- if len(prims) == 0 {
- return FeatureBuild{DisplayCategory: displayStandard}
- }
- // Swept-depth notation at the area's representative point: the SWPARE51 "⊔"
- // bracket centred on the point, with the "swept to " label just above
- // it (S-101 HighConfidenceDepthArea: SY(SWPARE51) + text at LocalOffset 0,-3.51
- // mm). The S-101 SweptArea rule is an IHO gap, so this Go fallback reproduces it.
- if a, ok := areaSurfacePoint(ringLL(exteriorRing(g))); ok {
- prims = append(prims, SymbolCall{
- Anchor: a, SymbolName: "SWPARE51", Scale: DefaultPxPerSymbolUnit,
- SoundingDepthM: nan32, DangerDepthM: nan32,
- })
- if d, ok := floatAttr(f.Attributes(), "DRVAL1"); ok {
- prims = append(prims, DrawText{
- // VAlignTop anchors the text at its top edge on the rep point, so it
- // drops BELOW the SWPARE51 bracket (which extends UP from the same
- // point) instead of overprinting it — the client text layer ignores
- // per-feature pixel offsets, so position via the anchor.
- Anchor: a, Text: "swept to " + strconv.FormatFloat(d, 'f', -1, 64),
- FontSizePx: 11, ColorToken: "CHBLK", HAlign: HAlignCenter, VAlign: VAlignTop,
- })
- }
- }
- return FeatureBuild{Primitives: prims, DisplayPriority: 6, DisplayCategory: displayStandard}
-}
-
-// representativePoint returns a single lat/lon to anchor a point symbol on a
-// feature of any geometry: the point itself, a line's midpoint vertex, or an
-// area's exterior-ring centroid. ok is false when the geometry carries no usable
-// coordinate (so the caller must not place a symbol).
-func representativePoint(f *s57.Feature) (geo.LatLon, bool) {
- g := f.Geometry()
- switch g.Type {
- case s57.GeometryTypeLineString:
- if n := len(g.Coordinates); n > 0 {
- if c := g.Coordinates[n/2]; len(c) >= 2 {
- return geo.LatLon{Lat: c[1], Lon: c[0]}, true
- }
- }
- case s57.GeometryTypePolygon:
- ring := exteriorRing(g)
- pts := make([]geo.LatLon, 0, len(ring))
- for _, c := range ring {
- if len(c) >= 2 {
- pts = append(pts, geo.LatLon{Lat: c[1], Lon: c[0]})
- }
- }
- if len(pts) > 0 {
- return areaSurfacePoint(pts)
- }
- }
- // Point geometry, or a fallback for any geometry whose first coordinate is set.
- if len(g.Coordinates) > 0 && len(g.Coordinates[0]) >= 2 {
- return geo.LatLon{Lat: g.Coordinates[0][1], Lon: g.Coordinates[0][0]}, true
- }
- return geo.LatLon{}, false
-}
-
-// exteriorRing returns the coordinates of a polygon's first exterior ring
-// (USAG 1 or 3), falling back to the first ring present.
-func exteriorRing(g s57.Geometry) [][]float64 {
- for _, r := range g.Rings {
- if r.Usage == 1 || r.Usage == 3 {
- return r.Coordinates
- }
- }
- if len(g.Rings) > 0 {
- return g.Rings[0].Coordinates
- }
- return nil
-}
diff --git a/internal/engine/portrayal/format_test.go b/internal/engine/portrayal/format_test.go
deleted file mode 100644
index 6119b64..0000000
--- a/internal/engine/portrayal/format_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package portrayal
-
-import "testing"
-
-func TestFormatSubstitute(t *testing.T) {
- attrs := map[string]interface{}{"VERCOP": 12.34}
- // The Spa Creek drawbridge label: TE('clr op %4.1lf','VERCOP',...).
- got, ok := formatSubstitute(attrs, "clr op %4.1lf", []string{"VERCOP"})
- if !ok || got != "clr op 12.3" {
- t.Errorf("formatSubstitute = %q ok=%v, want \"clr op 12.3\"", got, ok)
- }
- // %s passes text through.
- got, ok = formatSubstitute(map[string]interface{}{"OBJNAM": "Spa Creek"}, "Nr %s", []string{"OBJNAM"})
- if !ok || got != "Nr Spa Creek" {
- t.Errorf("got %q ok=%v, want \"Nr Spa Creek\"", got, ok)
- }
- // Missing attribute suppresses the whole label.
- if _, ok := formatSubstitute(attrs, "by %s", []string{"OBJNAM"}); ok {
- t.Error("missing attribute should suppress the label (ok=false)")
- }
- // Literal %% and no-attr format.
- if got, _ := formatSubstitute(attrs, "100%%", nil); got != "100%" {
- t.Errorf("got %q, want 100%%", got)
- }
-}
-
-func TestMapJustification(t *testing.T) {
- // HJUST: 1 centre, 2 right, else left.
- if mapHJust(1) != HAlignCenter || mapHJust(2) != HAlignRight || mapHJust(3) != HAlignLeft {
- t.Error("HJUST mapping wrong")
- }
- // VJUST: 2 centre, 3 top, else bottom.
- if mapVJust(2) != VAlignMiddle || mapVJust(3) != VAlignTop || mapVJust(1) != VAlignBottom {
- t.Error("VJUST mapping wrong")
- }
-}
diff --git a/internal/engine/portrayal/inform_test.go b/internal/engine/portrayal/inform_test.go
deleted file mode 100644
index 3e10b6f..0000000
--- a/internal/engine/portrayal/inform_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package portrayal
-
-import (
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// TestInformSymbolAdditional: an object carrying INFORM gets SY(INFORM01) appended
-// (S-52 §10.6.1.1); one without does not.
-func TestInformSymbolAdditional(t *testing.T) {
- pt := s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-5.1, 15.1}}}
- countInform := func(fb FeatureBuild) int {
- n := 0
- for _, p := range fb.Primitives {
- if sc, ok := p.(SymbolCall); ok && sc.SymbolName == "INFORM01" {
- n++
- }
- }
- return n
- }
-
- withInfo := s57.NewFeature(1, "BOYLAT", pt, map[string]any{"INFORM": "lit by night"})
- if got := countInform(addInformSymbol(FeatureBuild{}, &withInfo)); got != 1 {
- t.Errorf("INFORM-bearing feature: INFORM01 count = %d, want 1", got)
- }
-
- noInfo := s57.NewFeature(2, "BOYLAT", pt, map[string]any{"COLOUR": "3"})
- if got := countInform(addInformSymbol(FeatureBuild{}, &noInfo)); got != 0 {
- t.Errorf("plain feature: INFORM01 count = %d, want 0", got)
- }
-
- // TXTDSC also qualifies (case 2 of §10.6.1.1).
- txt := s57.NewFeature(3, "WRECKS", pt, map[string]any{"TXTDSC": "wreck.txt"})
- if got := countInform(addInformSymbol(FeatureBuild{}, &txt)); got != 1 {
- t.Errorf("TXTDSC-bearing feature: INFORM01 count = %d, want 1", got)
- }
-}
diff --git a/internal/engine/portrayal/navsys_test.go b/internal/engine/portrayal/navsys_test.go
deleted file mode 100644
index d28d934..0000000
--- a/internal/engine/portrayal/navsys_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package portrayal
-
-import (
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// TestNavSystemMarsysLine: an M_NSYS region with NO direction of buoyage (no
-// ORIENT) draws its boundary with the MARSYS51 "A-B" line (DAI LU00344) and emits
-// no buoyage arrow. A region WITH ORIENT draws the generic NAVARE51 triangle
-// boundary plus a direction-of-buoyage arrow keyed to MARSYS (DAI LU00345-347).
-func TestNavSystemMarsysLine(t *testing.T) {
- square := [][]float64{{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}}
- nsys := func(attrs map[string]any) *s57.Feature {
- f := s57.NewFeature(0, "M_NSYS", s57.Geometry{
- Type: s57.GeometryTypePolygon,
- Rings: []s57.Ring{{Usage: 1, Coordinates: square}},
- }, attrs)
- return &f
- }
-
- count := func(fb FeatureBuild) (marsys, navare, arrows int, arrow string) {
- for _, p := range fb.Primitives {
- switch v := p.(type) {
- case LinePattern:
- switch v.LinestyleName {
- case "MARSYS51":
- marsys++
- case "NAVARE51":
- navare++
- }
- case SymbolCall:
- arrows++
- arrow = v.SymbolName
- }
- }
- return
- }
-
- // No ORIENT → the A-B system boundary line, no arrow.
- marsys, navare, arrows, _ := count(navSystemBuild(nsys(map[string]any{"MARSYS": "1"})))
- if marsys == 0 {
- t.Error("a region without ORIENT should draw MARSYS51; got none")
- }
- if navare != 0 {
- t.Error("a region without ORIENT must not draw NAVARE51")
- }
- if arrows != 0 {
- t.Error("a region without ORIENT must not draw a buoyage arrow")
- }
-
- // ORIENT present → NAVARE51 boundary + a direction-of-buoyage arrow per MARSYS.
- for _, tc := range []struct {
- marsys string
- want string
- }{
- {"1", "DIRBOYA1"}, // IALA-A
- {"2", "DIRBOYB1"}, // IALA-B
- {"10", "DIRBOY01"}, // other system
- } {
- marsys, navare, arrows, arrow := count(navSystemBuild(nsys(map[string]any{"MARSYS": tc.marsys, "ORIENT": 42.0})))
- if navare == 0 {
- t.Errorf("MARSYS %s with ORIENT should draw NAVARE51; got none", tc.marsys)
- }
- if marsys != 0 {
- t.Errorf("MARSYS %s with ORIENT must not draw MARSYS51", tc.marsys)
- }
- if arrows != 1 || arrow != tc.want {
- t.Errorf("MARSYS %s with ORIENT: want one %s arrow, got %d (%s)", tc.marsys, tc.want, arrows, arrow)
- }
- }
-}
diff --git a/internal/engine/portrayal/primitive.go b/internal/engine/portrayal/primitive.go
deleted file mode 100644
index f7f97ab..0000000
--- a/internal/engine/portrayal/primitive.go
+++ /dev/null
@@ -1,204 +0,0 @@
-// Package portrayal turns one S-57 feature into a stream of viewport-independent
-// lat/lon Primitives by running the S-101 portrayal rules and emitting a
-// primitive for each drawing instruction, stopping short of projection and colour
-// resolution. Colour stays as *token* strings; the tile engine
-// projects/clips/encodes the Primitives into MVT and the browser resolves
-// Day/Dusk/Night from colortables.json.
-//
-// The tile engine (internal/engine/bake) projects the Primitive IR directly.
-package portrayal
-
-import "github.com/beetlebugorg/chartplotter/pkg/geo"
-
-// DefaultPxPerSymbolUnit is screen px per 0.01-mm PresLib symbol unit at 100%
-// zoom — the nominal S-52 feature scale shared by the symbol/linestyle renderers
-// and the tile engine's LC/AP/sector sizing. It MUST use the same reference pixel
-// pitch the rest of the app measures the screen with (web util.mjs
-// DEFAULT_PX_PITCH_MM = 0.26458 mm, the 1/96-inch CSS reference pixel) so a symbol
-// renders at its encoded physical size: the S-52 size-check symbol SY(CHKSYM01),
-// a 5 mm box, then measures 5 mm (500 units × this = 18.9 px × 0.26458 mm = 5 mm).
-// (Previously 0.35278 — the 1/72-inch point — which rendered every symbol ~25% too
-// small against the app's 0.26458 mm pixel.)
-const DefaultPxPerSymbolUnit float32 = 0.01 / 0.26458
-
-// Dash is a simple line-stroke dash style (LS instruction).
-type Dash uint8
-
-const (
- DashSolid Dash = iota
- DashDashed
- DashDotted
-)
-
-// HAlign / VAlign are S-52 text anchor alignments (TX/TE instruction).
-type HAlign uint8
-
-const (
- HAlignLeft HAlign = iota
- HAlignCenter
- HAlignRight
-)
-
-type VAlign uint8
-
-const (
- VAlignTop VAlign = iota
- VAlignMiddle
- VAlignBaseline
- VAlignBottom
-)
-
-// Primitive is one viewport-independent draw step. The concrete types below are
-// the closed set of variants (the Primitive tagged union). Consumers
-// (the bake step) type-switch over them.
-type Primitive interface {
- isPrimitive()
-}
-
-// FillPolygon is a solid area fill (AC instruction). Rings is the outer ring
-// then optional holes; fill rules are even-odd / non-zero, so winding of holes
-// is not relied upon.
-type FillPolygon struct {
- Rings [][]geo.LatLon
- ColorToken string
-}
-
-// StrokeLine is a simple line stroke (LS instruction).
-type StrokeLine struct {
- Points []geo.LatLon
- ColorToken string
- WidthPx float32
- Dash Dash
-}
-
-// SymbolHalo is an optional outline drawn under a stamped symbol (used for
-// soundings against busy fills).
-type SymbolHalo struct {
- ColorToken string
- ExtraWidthPx float32
-}
-
-// SymbolCall stamps a PresLib point symbol (SY instruction). Sounding/danger
-// depths travel with the symbol so the client can do the SNDFRM04 bold/faint
-// split and the OBSTRN/WRECKS shallow/deep swap against the live safety contour
-// without a re-bake. SoundingDepthM / DangerDepthM are NaN for ordinary symbols.
-type SymbolCall struct {
- Anchor geo.LatLon
- // CentreOnArea marks the PRIMARY centred-area symbol of an area feature, so the
- // client centres the glyph on the representative point (S-52 PresLib §8.5.1: the
- // primary centred symbol sits at the pivot "so it is evident which area the symbol
- // applies to"). The "…RES" symbols carry a built-in corner-pivot offset meant to
- // FAN OUT additional symbols ("an offset entry-restricted symbol with a subscript
- // !"); applying it to the primary throws its glyph ~100px off its area. Set on the
- // first centred symbol only — additional symbols keep the offset and fan out.
- CentreOnArea bool
- SymbolName string
- RotationDeg float32
- // RotationTrueNorth marks RotationDeg as referenced to TRUE NORTH (S-52 PresLib
- // Part I §9.2 ROT case 3 — rotation taken from an S-57 attribute like ORIENT), so
- // the mark must rotate WITH the chart as it turns. False means screen-referenced
- // (ROT cases 1 & 2 — no rotation, or a literal angle like a light flare): the mark
- // stays upright to the screen regardless of chart orientation.
- RotationTrueNorth bool
- Scale float32
- OffsetXUnits float32
- OffsetYUnits float32
- Halo *SymbolHalo
- SoundingDepthM float32
- DangerDepthM float32
- DeepSymbolName string
-}
-
-// PatternFill fills an area with a repeating PresLib pattern (AP instruction).
-type PatternFill struct {
- Rings [][]geo.LatLon
- PatternName string
- // Sparse marks a WIDELY-SPACED S-52 "fill pattern" (PresLib §8.5.4) — a
- // pattern of discrete symbols (quality-of-data, aquaculture, fishing…) rather
- // than a dense texture. The baker places it as individual WHOLE symbols on a
- // geographic lattice instead of a tiled fill-pattern, so a symbol is never
- // clipped mid-glyph at the area edge (the "strange looking pattern fill" the
- // spec warns against). When set, the fields below carry the lattice.
- Sparse bool
- SymbolRef string // the pattern's point symbol (sprite-atlas name)
- V1, V2 [2]float64 // lattice basis vectors, millimetres (display)
- Anchor geo.LatLon // area representative point (small-area fallback)
-}
-
-// LinePattern draws a polyline with a complex linestyle (LC instruction).
-// Per-segment direction and repeat-step are computed at projection time, so the
-// cached primitive is purely viewport-independent.
-type LinePattern struct {
- Points []geo.LatLon
- LinestyleName string
- // ColorToken is the linestyle's primary pen colour token (first PD pen,
- // LCRF-resolved). Baked onto the complex_lines feature so the client colours
- // the dash run; "" when the linestyle has no drawn pen.
- ColorToken string
-}
-
-// DrawText is a text label (TX/TE instruction). OffsetXPx/OffsetYPx are the
-// S-52 §8.3.3.2 XOFFS/YOFFS pivot offset pre-multiplied to pixels (+x right,
-// +y down), applied after projection.
-type DrawText struct {
- Anchor geo.LatLon
- Text string
- FontSizePx float32
- ColorToken string
- Halo *TextHalo
- HAlign HAlign
- VAlign VAlign
- OffsetXPx float32
- OffsetYPx float32
- // Group is the S-52 text grouping (the DISPLAY parameter — last argument of
- // TX()/TE()), per PresLib 4.0 §14.4: 11=important (clearances, bearings),
- // 21/26/29=names, 23=light description, 25=seabed, 27=mag variation, etc.
- // Baked as the `tgrp` tag so the client can toggle text groups (§14.5) live
- // without re-baking.
- Group int
-}
-
-// TextHalo is the optional CHWHT outline under chart text (S-52 §10.3.6).
-type TextHalo struct {
- ColorToken string
- WidthPx float32
-}
-
-// AugmentedFigure is one stroked element of a screen-space figure the S-101 rule
-// CONSTRUCTED via AugmentedRay / ArcByRadius (a light-sector leg or arc/ring) —
-// driven by the catalogue's own bearings, radii, colours and widths rather than a
-// Go re-derivation from S-57 attributes. One primitive = one stroked element; a
-// sectored light emits several (two dashed legs, then a black-backed coloured
-// arc). The mm sizes are screen-fixed, so the baker tessellates per-zoom into
-// `sector_lines`; it cannot bake as static geographic geometry.
-type AugmentedFigure struct {
- Anchor geo.LatLon
- Ray bool // true: a straight leg (Bearing/Length); false: an arc/ring
- // Ray params (true-north bearing, already from-seaward-reversed by the rule).
- BearingDeg float64
- LengthMM float64
- // LengthGroundM is a ray leg's length when given as a GROUND distance (metres,
- // from a GeographicCRS sectorLineLength / full-VALNMR leg) rather than display
- // mm — drawn zoom-dependently. 0 ⇒ use LengthMM (display mm). See tessellateFigure.
- LengthGroundM float64
- // Arc params (centred on Anchor); a full 360° sweep is an all-round ring.
- RadiusMM float64
- StartDeg float64
- SweepDeg float64
- // Stroke style, from the rule's LineStyle:_simple_.
- ColorToken string
- WidthMM float64
- Dash Dash
- // FullLengthNM is the LIGHTS nominal range (VALNMR); when set on a ray, the
- // baker also emits the "full light lines" leg variant extended to that range
- // (S-52 LIGHTS06 note 1), tagged for the client's live toggle. 0 = no variant.
- FullLengthNM float64
-}
-
-func (FillPolygon) isPrimitive() {}
-func (StrokeLine) isPrimitive() {}
-func (SymbolCall) isPrimitive() {}
-func (PatternFill) isPrimitive() {}
-func (LinePattern) isPrimitive() {}
-func (DrawText) isPrimitive() {}
-func (AugmentedFigure) isPrimitive() {}
diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go
deleted file mode 100644
index b9e8677..0000000
--- a/internal/engine/portrayal/s101build.go
+++ /dev/null
@@ -1,744 +0,0 @@
-package portrayal
-
-import (
- "fmt"
- "io/fs"
- "math"
- "os"
- "sort"
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/s101"
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/s100/catalog"
- "github.com/beetlebugorg/chartplotter/pkg/s100/fc"
- "github.com/beetlebugorg/chartplotter/pkg/s100/instructions"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// NewS101Builder assembles a builder from an S-101 PortrayalCatalog directory
-// and a FeatureCatalogue.xml path: it loads the feature catalogue (the S-57↔
-// S-101 bridge + Lua introspection) and the drawing catalogue (line styles /
-// area fills / colours). The Lua engine is created fresh per BuildBatch so its
-// per-cell caches (featureCache etc., which are file-local in the catalogue and
-// can't be cleared) are freed each cell — otherwise the shared Lua state grows
-// without bound across a bake.
-func NewS101Builder(portrayalCatalogDir, featureCataloguePath string) (*S101Builder, error) {
- fcBytes, err := os.ReadFile(featureCataloguePath)
- if err != nil {
- return nil, err
- }
- return newS101Builder(os.DirFS(portrayalCatalogDir), fcBytes)
-}
-
-// NewS101BuilderFS assembles a builder from an in-memory PortrayalCatalog FS (e.g.
-// the build-time embedded catalogue, internal/engine/s101catalog) and the
-// FeatureCatalogue.xml bytes — same builder, no on-disk catalogue directory.
-func NewS101BuilderFS(catalogFS fs.FS, featureCatalogueXML []byte) (*S101Builder, error) {
- return newS101Builder(catalogFS, featureCatalogueXML)
-}
-
-func newS101Builder(catalogFS fs.FS, fcBytes []byte) (*S101Builder, error) {
- cat, err := fc.LoadBytes(fcBytes)
- if err != nil {
- return nil, err
- }
- draw, err := catalog.LoadFS(catalogFS)
- if err != nil {
- return nil, err
- }
- rulesFS, err := fs.Sub(catalogFS, "Rules")
- if err != nil {
- return nil, err
- }
- // One shared prototype cache for every engine this builder creates — the
- // framework (and per-class rule chunks) are parsed + compiled once across the
- // whole bake instead of three times per cell. Validating the framework here
- // (fail fast) also warms the cache with the framework prototypes.
- cache := s101.NewProtoCache()
- eng, err := s101.NewEngineFSCached(rulesFS, cat, cache)
- if err != nil {
- return nil, err
- }
- eng.Close()
- return &S101Builder{rulesFS: rulesFS, fcCat: cat, Catalog: draw, protoCache: cache}, nil
-}
-
-// S101Builder is the feature-build seam: it runs the S-101 portrayal rules (via
-// the fc-backed Lua engine) for a batch of features, parses each emitted
-// instruction stream, and emits a primitive for each draw onto the feature
-// geometry to produce the Primitive stream the baker consumes.
-type S101Builder struct {
- rulesFS fs.FS
- fcCat *fc.Catalogue
- Catalog *catalog.Catalog
- protoCache *s101.ProtoCache
-}
-
-// BuildBatch portrays a whole cell's features in ONE engine pass (one chunk
-// compile, one portrayal context) and emits primitives for each onto its
-// geometry. A fresh
-// Lua state is used and closed here so the per-cell caches don't accumulate.
-// Returns featureID → build for every feature.
-func (b *S101Builder) BuildBatch(features []*s57.Feature) (map[int64]FeatureBuild, error) {
- return b.BuildBatchOverrides(features, nil)
-}
-
-// BuildBatchOverrides is BuildBatch with S-101 context-parameter overrides (e.g.
-// {"PlainBoundaries":"true"} or {"SimplifiedSymbols":"true"}), so the baker can
-// portray the plain-boundary / simplified-symbol display variants.
-func (b *S101Builder) BuildBatchOverrides(features []*s57.Feature, overrides map[string]string) (map[int64]FeatureBuild, error) {
- return b.BuildBatchFiltered(features, overrides, nil)
-}
-
-// BuildBatchFiltered is BuildBatchOverrides that portrays only the features for
-// which include returns true (nil = all). Cross-feature context (danger depths,
-// co-located topmarks) is still derived from the FULL feature set, so filtering
-// the portrayed batch never changes a rule's inputs. The override passes use this
-// to portray only the geometry type whose variant they contribute —
-// PlainBoundaries varies area boundaries, SimplifiedSymbols varies point symbols —
-// instead of re-portraying every feature and discarding all but the matching type.
-func (b *S101Builder) BuildBatchFiltered(features []*s57.Feature, overrides map[string]string, include func(*s57.Feature) bool) (map[int64]FeatureBuild, error) {
- eng, err := s101.NewEngineFSCached(b.rulesFS, b.fcCat, b.protoCache)
- if err != nil {
- return nil, err
- }
- eng.SetContextOverrides(overrides)
- defer eng.Close()
-
- depthIdx := BuildDepthIndex(features) // underlying DEPARE/DRGARE for danger depths
- topmarkIdx := buildTopmarkIndex(features)
- batch := make([]s101.Feature, 0, len(features))
- // Memoize the rule run by portrayal input. The S-101 rule produces a
- // geometry-INDEPENDENT instruction stream (the geometry is attached per-feature
- // in buildFeature), so two features with identical inputs — class, primitive,
- // simple/derived/topmark attributes, multipoint vertices — yield the same
- // stream. ENC cells repeat the same inputs across thousands of features
- // (coastline, land regions, depth areas), and the gopher-lua rule run is the
- // dominant, allocation-heavy bake cost, so run it once per distinct input and
- // share the stream. sigRep maps a signature to its representative feature ID;
- // repID maps every portrayed feature to the representative whose stream it uses.
- sigRep := make(map[string]string)
- repID := make(map[int64]string, len(features))
- for _, f := range features {
- if include != nil && !include(f) {
- continue
- }
- g := f.Geometry()
- // Skip non-spatial collection/relationship objects (C_AGGR, C_ASSO) — they
- // group other features and carry no geometry, so there's nothing to
- // portray and the rule would error on the missing primitive.
- if len(g.Coordinates) == 0 && len(g.Rings) == 0 {
- continue
- }
- // TOPMAR is folded into its co-located buoy/beacon as the topmark complex
- // attribute (below); the standalone feature has no S-101 class, so skip it
- // rather than portray it as a magenta unknown mark.
- if f.ObjectClass() == "TOPMAR" {
- continue
- }
- prim := primitiveName(g.Type)
- var points [][3]float64
- // Point features carry their vertices (lon,lat,depth) so the host can
- // resolve a REAL point spatial (HostGetSpatial '#P'/'#M'). SOUNDG is a
- // multipoint (the Sounding rule iterates each point's depth); other point
- // features are a single point. This is required even when the geometry is
- // otherwise attached when the Go side emits primitives: a rule that reads feature.Point /
- // feature.Spatial would otherwise hit the framework's GetSpatial infinite
- // recursion (it reads self['Spatial'] right after assigning it nil, which
- // re-fires __index) — the cause of the OBSTRN/WRECKS stack overflows.
- if g.Type == s57.GeometryTypePoint {
- points = soundingPoints(g)
- if f.ObjectClass() == "SOUNDG" {
- prim = "MultiPoint"
- }
- }
- var topmark map[string]string
- if isTopmarkParent(f.ObjectClass()) {
- if key, ok := pointLocKey(g); ok {
- topmark = topmarkIdx[key]
- }
- }
- sf := s101.Feature{
- ID: strconv.FormatInt(f.ID(), 10),
- ObjectClass: f.ObjectClass(),
- Primitive: prim,
- Attributes: stringAttrs(f.Attributes()),
- Derived: DerivedAttrs(f, depthIdx),
- Points: points,
- Topmark: topmark,
- }
- sig := portrayalSignature(&sf)
- if rep, ok := sigRep[sig]; ok {
- repID[f.ID()] = rep // identical inputs already portrayed; share its stream
- continue
- }
- sigRep[sig] = sf.ID
- repID[f.ID()] = sf.ID
- batch = append(batch, sf)
- }
- streams, err := eng.Portray(batch)
- if err != nil {
- return nil, err
- }
- out := make(map[int64]FeatureBuild, len(features))
- for _, f := range features {
- if include != nil && !include(f) {
- continue
- }
- // repID[f.ID()] is "" for the skipped (TOPMAR / non-spatial) features, and
- // streams[""] is "" — buildFeature then suppresses them, as before.
- out[f.ID()] = b.buildFeature(f, streams[repID[f.ID()]])
- }
- return out, nil
-}
-
-// portrayalSignature serializes everything the S-101 rules read from a feature —
-// class, primitive type, simple + derived + topmark attributes, and any
-// multipoint vertices — into a stable key. Features with equal signatures produce
-// the same instruction stream, so the rule runs once per distinct signature. NUL
-// and unit separators keep distinct field layouts from colliding.
-func portrayalSignature(f *s101.Feature) string {
- var b strings.Builder
- b.WriteString(f.ObjectClass)
- b.WriteByte(0)
- b.WriteString(f.Primitive)
- b.WriteByte(0)
- writeSortedAttrs(&b, f.Attributes)
- b.WriteByte(0)
- writeSortedAttrs(&b, f.Derived)
- b.WriteByte(0)
- writeSortedAttrs(&b, f.Topmark)
- for _, p := range f.Points {
- b.WriteByte(0)
- b.WriteString(strconv.FormatFloat(p[0], 'g', -1, 64))
- b.WriteByte(',')
- b.WriteString(strconv.FormatFloat(p[1], 'g', -1, 64))
- b.WriteByte(',')
- b.WriteString(strconv.FormatFloat(p[2], 'g', -1, 64))
- }
- return b.String()
-}
-
-// writeSortedAttrs appends an attribute map to b in sorted-key order so the
-// signature is stable regardless of map iteration order.
-func writeSortedAttrs(b *strings.Builder, m map[string]string) {
- if len(m) == 0 {
- return
- }
- keys := make([]string, 0, len(m))
- for k := range m {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- for _, k := range keys {
- b.WriteString(k)
- b.WriteByte('=')
- b.WriteString(m[k])
- b.WriteByte('\x1f')
- }
-}
-
-// Build expands one S-57 feature (convenience wrapper over BuildBatch; the bake
-// path uses BuildBatch per cell). ok is false only on engine failure.
-func (b *S101Builder) Build(f *s57.Feature) (FeatureBuild, bool) {
- m, err := b.BuildBatch([]*s57.Feature{f})
- if err != nil {
- return FeatureBuild{}, false
- }
- return m[f.ID()], true
-}
-
-// buildFeature turns one feature's emitted instruction stream into its FeatureBuild,
-// then adds the S-52 §10.6.1.1 additional-information indicator when the object
-// carries it (see addInformSymbol).
-func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild {
- fb := b.buildFeatureBody(f, stream)
- return addInformSymbol(fb, f)
-}
-
-// addInformSymbol appends SY(INFORM01) at the object's position when it carries
-// additional information (INFORM/NINFOM, or TXTDSC/NTXTDS/PICREP) — S-52 §10.6.1.1.
-// INFORM01 is a box-on-a-leader "info available" marker; it's baked display-category
-// Other (the bake routes it so, overriding the host feature's category), so it
-// clears Standard display and only shows when the mariner enables Other. The pivot
-// goes at a point's position / a line's midpoint / an area's centre.
-func addInformSymbol(fb FeatureBuild, f *s57.Feature) FeatureBuild {
- if !hasAdditionalInfo(f.Attributes()) {
- return fb
- }
- anchor, ok := representativePoint(f)
- if !ok {
- return fb
- }
- fb.Primitives = append(fb.Primitives, SymbolCall{
- Anchor: anchor, SymbolName: "INFORM01", Scale: DefaultPxPerSymbolUnit,
- SoundingDepthM: nan32, DangerDepthM: nan32,
- })
- return fb
-}
-
-// hasAdditionalInfo reports whether an object carries S-52 §10.6.1.1 ancillary
-// information (a non-empty INFORM/NINFOM/TXTDSC/NTXTDS/PICREP attribute).
-func hasAdditionalInfo(attrs map[string]any) bool {
- for _, k := range [...]string{"INFORM", "NINFOM", "TXTDSC", "NTXTDS", "PICREP"} {
- if s, _ := attrs[k].(string); strings.TrimSpace(s) != "" {
- return true
- }
- }
- return false
-}
-
-// quaposSolidClass: man-made structures drawn with a definite (solid) line regardless
-// of QUAPOS. The S-52 approximate-position dashing (DEPCNT03 and friends) is for natural
-// features whose position is uncertain — depth contours, coastline, rivers — not
-// engineered structures whose charted extent is definite. Without this, a bridge or road
-// whose edges carry a low-accuracy QUAPOS (often inherited from a shared coastline edge)
-// is wrongly dashed.
-var quaposSolidClass = map[string]bool{
- "BRIDGE": true, "ROADWY": true, "RAILWY": true,
- "CAUSWY": true, "DAMCON": true, "GATCON": true,
-}
-
-// buildFeatureBody turns one feature's emitted instruction stream into its FeatureBuild.
-func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBuild {
- // NEWOBJ with a SYMINS attribute: portray the producer's explicit symbol
- // instruction (S-52 SYMINS02) rather than the S-101 V-AIS alias the engine
- // emitted — SYMINS carries the real symbols, TX/TE labels, boundaries and fills
- // (the bulk of the ECDIS-Chart-1 test content). See parseSYMINS.
- if f.ObjectClass() == "NEWOBJ" {
- if fb, ok := parseSYMINS(f); ok {
- return fb
- }
- // No producer SYMINS, and the V-AIS alias would emit only the generic untyped
- // "default V-AIS" (VATON00) — almost always a plain new object, not a real
- // virtual AIS aid (those carry a type → VATON01-12). Portray the S-52 NEWOBJ
- // "!" instead; typed V-AIS still go through the rule below.
- if strings.Contains(stream, "VATON00") {
- if nb := newObjectBuild(f); len(nb.Primitives) > 0 {
- return nb
- }
- }
- }
- // M_NSYS (navigational system of marks): the S-101 NavigationalSystemOfMarks
- // rule is an UNOFFICIAL stub (NullInstruction only), so draw the S-52 boundary
- // here — MARSYS51 (the A-B system line) or NAVARE51 + a direction-of-buoyage
- // arrow per ORIENT. Bypasses the stub stream entirely.
- if f.ObjectClass() == "M_NSYS" {
- if nb := navSystemBuild(f); len(nb.Primitives) > 0 {
- return nb
- }
- }
- // Genuinely-unknown object class (no S-101 alias) → the magenta "unknown
- // object" mark (S-52 §10.1.1 parity).
- if strings.HasPrefix(stream, "UNMAPPED:") {
- return unknownObjectBuild(f)
- }
- // A rule error (or no stream) → suppress the feature rather than flood the
- // chart with placeholders. (Most current errors are line/area rules needing
- // the S-57 spatial topology the host doesn't model yet — a tracked gap.)
- if stream == "" || strings.HasPrefix(stream, "ERROR:") {
- // NEWOBJ aliases to the POINT-only VirtualAISAidToNavigation rule, so its
- // line/area variants always error here; draw the S-52 dashed magenta new-object
- // boundary instead of dropping them (the missing boxes/lines around things).
- switch f.ObjectClass() {
- case "NEWOBJ":
- if nb := newObjectBuild(f); len(nb.Primitives) > 0 {
- return nb
- }
- case "SWPARE":
- if sb := sweptAreaBuild(f); len(sb.Primitives) > 0 {
- return sb
- }
- }
- return FeatureBuild{DisplayCategory: displayStandard}
- }
-
- g := geometryOf(f.Geometry())
- cmds, _ := instructions.Reduce(instructions.ParseStream(stream))
-
- // The feature anchor (point symbols / text / sector figures) is consumed only
- // by anchored draw ops. For an area it's the polylabel representative point — a
- // pole-of-inaccessibility search over every edge, the single biggest CPU cost
- // in bake portrayal — yet most area features emit only fills and boundary lines
- // that never read it. Compute it only when a command actually needs it.
- var anchor geo.LatLon
- if commandsNeedAnchor(cmds) {
- anchor, _ = textAnchor(g)
- }
- sg := S101Geometry{Anchor: anchor, Rings: g.area, Lines: strokeRunsFor(g)}
- var prims []Primitive
- priority := 0
- cat := 0 // unset; resolved from the viewing groups the rule emits
- var dateStart, dateEnd, timeValid string
- for _, c := range cmds {
- if c.Priority > priority {
- priority = c.Priority
- }
- // Date dependency is feature-level (one Date:/TimeValid: pair the rule emits
- // up front, carried onto every draw): capture it once for the FeatureBuild.
- if timeValid == "" && (c.TimeValid != "" || c.DateStart != "" || c.DateEnd != "") {
- dateStart, dateEnd, timeValid = c.DateStart, c.DateEnd, c.TimeValid
- }
- // The shallow-water pattern (SEABED01 emits AreaFillReference:DIAMOND1 in
- // viewing group 90000 on every depth area shallower than the safety
- // contour) is a MARINER SELECTION, not a fixed portrayal. The client owns
- // it: a dedicated shallow-pattern layer applies DIAMOND1 over the depth
- // areas live from the baked drval1 + the mariner's safety contour, toggled
- // by mariner.shallowPattern. Baking it here too made it (a) always visible
- // and (b) double up beside the client layer when the toggle was on — so
- // drop it and let the client's toggle-aware, live-safety-contour layer win.
- if c.Op == instructions.OpAreaFill && c.Reference == "DIAMOND1" {
- continue
- }
- // Display category is a per-viewing-group property (S-101 partitions
- // viewing groups into Base/Standard/Other/quality bands). A feature can
- // emit draws across bands; take the MOST-VISIBLE (lowest enum) so a
- // safety-critical base-display draw is never hidden because the feature
- // also carries a standard/other label.
- if dc := displayCategoryForViewingGroup(c.ViewingGroup); dc != 0 && (cat == 0 || dc < cat) {
- cat = dc
- }
- prims = append(prims, emitPrimitives(c, sg, b.Catalog)...)
- }
- // Soundings: tag each emitted glyph with its depth so the baker emits the
- // numeric depth + S/G palette variants. Without this the client's depth-unit
- // conversion (synthSounding) and SNDFRM04 safety-depth split fall back to the
- // static metric glyphs and never react to those settings.
- if f.ObjectClass() == "SOUNDG" {
- attachSoundingDepths(prims, soundingPoints(f.Geometry()))
- }
- // Sector / directional lights: the rule constructs the legs + arc as screen-space
- // AugmentedFigure elements (emitted above from the AugmentedRay / ArcByRadius
- // instructions). Tag each leg with the light's nominal range (VALNMR) so the
- // baker can also emit the "full light lines" leg variant for the client's live
- // toggle (S-52 LIGHTS06 note 1).
- if f.ObjectClass() == "LIGHTS" {
- if vnr, ok := floatAttr(f.Attributes(), "VALNMR"); ok && vnr > 0 {
- for i := range prims {
- if fig, ok := prims[i].(AugmentedFigure); ok && fig.Ray {
- fig.FullLengthNM = vnr
- prims[i] = fig
- }
- }
- }
- }
- // Low-accuracy geometry (QUAPOS not surveyed/precise) is drawn DASHED — the S-52
- // approximate-position line style (DEPCNT03 dashes a low-accuracy depth contour;
- // the same applies to coastline, rivers, tracks, …). The S-101 rules read this
- // from a per-edge spatial-quality association we don't model, so apply it here
- // from the parsed per-feature QUAPOS aggregate: switch the feature's solid simple
- // strokes to dashed. Complex line styles and point symbols keep their look.
- if q := f.Geometry().Quapos; q != 0 && q != 1 && q != 10 && q != 11 && !quaposSolidClass[f.ObjectClass()] {
- for i, p := range prims {
- if sl, ok := p.(StrokeLine); ok && sl.Dash == DashSolid {
- sl.Dash = DashDashed
- prims[i] = sl
- }
- }
- }
- // Centred-area symbol placement (S-52 PresLib §8.5.1): the pivot point is the
- // area's representative point (sg.Anchor), where the FIRST/primary centred symbol
- // sits "so it is evident which area the symbol applies to"; ADDITIONAL symbols
- // keep their catalogue pivot offset to fan out and "prevent overwriting" (the
- // spec's "a centred traffic arrow and an offset entry-restricted symbol"). The
- // "…RES" symbols are authored with a corner pivot for that fan-out, which throws
- // the PRIMARY ~100px off its area; centring the first one restores §8.5.1.
- if g.kind == geomArea {
- for i, p := range prims {
- if sc, ok := p.(SymbolCall); ok && sc.Anchor == sg.Anchor {
- sc.CentreOnArea = true
- prims[i] = sc
- break // only the primary sits on the point; the rest fan out
- }
- }
- }
- if cat == 0 {
- cat = displayStandard // no display-category band emitted (e.g. text-only)
- }
- if f.ObjectClass() == "BRIDGE" {
- prims = bridgePostProcess(f, prims)
- }
- switch f.ObjectClass() {
- case "OBSTRN", "WRECKS", "UWTROC":
- prims = obstructionPostProcess(f, prims)
- }
- return FeatureBuild{
- Primitives: prims,
- DisplayPriority: priority,
- DisplayCategory: cat,
- DateStart: dateStart,
- DateEnd: dateEnd,
- TimeValid: timeValid,
- }
-}
-
-// bridgePostProcess fixes two S-101-model gaps the Bridge rule can't, because the
-// S-101 Bridge feature type binds neither a verticalClearance* attribute (the model
-// puts clearance on the related SpanFixed feature) nor a true openingBridge for S-57
-// CATBRG:1 (the framework still resolves openingBridge → true, so the rule stamps the
-// opening-bridge symbol BRIDGE01 on fixed bridges):
-// - drop BRIDGE01 unless CATBRG is an opening category (2–8);
-// - emit the "clr " vertical-clearance label from the S-57 VERCLR directly.
-func bridgePostProcess(f *s57.Feature, prims []Primitive) []Primitive {
- catbrg, _ := floatAttr(f.Attributes(), "CATBRG")
- opening := catbrg >= 2 && catbrg <= 8
- if !opening {
- out := prims[:0]
- for _, p := range prims {
- if sc, ok := p.(SymbolCall); ok && sc.SymbolName == "BRIDGE01" {
- continue
- }
- out = append(out, p)
- }
- prims = out
- }
- if v, ok := floatAttr(f.Attributes(), "VERCLR"); ok && v > 0 {
- if anchor, ok := representativePoint(f); ok {
- prims = append(prims, DrawText{
- Anchor: anchor,
- Text: fmt.Sprintf("clr %.1f", v),
- FontSizePx: 12,
- ColorToken: "CHBLK",
- Halo: &TextHalo{ColorToken: "CHWHT", WidthPx: 1},
- Group: 11, // S-52 text group 11: clearances / important
- OffsetYPx: 11,
- })
- }
- }
- return prims
-}
-
-// commandsNeedAnchor reports whether any reduced draw command consumes the
-// feature anchor — the anchored ops emitPrimitives reads geom.Anchor for: point
-// symbols, text, and sector/augmented figures. Fills and boundary lines don't,
-// so an area emitting only those skips the expensive polylabel anchor. A SPARSE
-// fill pattern (lattice-placed symbols) needs it too, for the small-area
-// fallback (one centred symbol when no lattice point lands inside).
-func commandsNeedAnchor(cmds []instructions.DrawCommand) bool {
- for _, c := range cmds {
- switch c.Op {
- case instructions.OpPoint, instructions.OpText, instructions.OpAugmentedLine:
- return true
- case instructions.OpAreaFill:
- if sparseFillPatterns[c.Reference] {
- return true
- }
- }
- }
- return false
-}
-
-// sparseFillPatterns are the S-52 "fill patterns" (PresLib §8.5.4): WIDELY-SPACED
-// symbol patterns (lattice cell ≳20 mm) placed as discrete whole symbols on a
-// geographic lattice rather than tiled as a texture — so a symbol is never
-// clipped mid-glyph at the area boundary (the "strange looking pattern fill"
-// §8.5.4 warns against) and small areas still get a centred symbol. Dense
-// "textures" (DRGARE dots, DIAMOND1 night-shading, NODATA/PRTSUR dashes, ICEARE,
-// FOULAR, TSSJCT, vegetation) stay tiled fill-patterns.
-var sparseFillPatterns = map[string]bool{
- "DQUALA11": true, "DQUALA21": true, "DQUALB01": true,
- "DQUALC01": true, "DQUALD01": true, "DQUALU01": true,
- "MARCUL02": true, // aquaculture / marine farm
- "FSHFAC03": true, "FSHFAC04": true, "FSHHAV02": true, // fishing facility / fish haven
- "AIRARE02": true, // airport / airfield
- "SNDWAV01": true, // sand waves
-}
-
-// strokeRunsFor returns the drawable polylines an S-101 line draw strokes for a
-// feature, honoring S-52 §8.6.2 masking exactly as the S-52 walker does: a line
-// feature's drawable parts, or — for an area — its drawable boundary, each with
-// coastline-coincident / MASK=1 / data-limit edges already removed by the
-// parser. A non-nil (even empty) lineParts/boundary means masking was computed,
-// so it is used verbatim (empty ⇒ stroke nothing); nil means it wasn't computed
-// (fallback geometry) → stroke the full line / rings. Area FILLS keep the
-// complete rings (g.area) regardless.
-func strokeRunsFor(g geom) [][]geo.LatLon {
- switch g.kind {
- case geomLine:
- if g.lineParts != nil {
- return g.lineParts
- }
- if len(g.line) >= 2 {
- return [][]geo.LatLon{g.line}
- }
- case geomArea:
- if g.boundary != nil {
- return g.boundary
- }
- return g.area
- }
- return nil
-}
-
-// Display-category enum values (DisplayBase/Standard/Other), which the baker's
-// catRank switches on. Defined locally so the S-101 builder needn't import
-// pkg/s52.
-const (
- displayBase = 6
- displayStandard = 7
- displayOther = 8
-)
-
-// displayCategoryForViewingGroup maps an S-101 viewing-group id to its S-52
-// display category. The portrayal catalogue partitions viewing groups into
-// bands by leading digit (portrayal_catalogue.xml ): 1xxxx =
-// Display Base, 2xxxx = Display Standard, 3xxxx = Display Other, 9xxxx =
-// optional quality/CATZOC overlays (Other, hidden by default). The 5xxxx and
-// sub-10000 ids are independent text-group selectors (not display-category
-// bands). Returns 0 for an id that carries no display category.
-func displayCategoryForViewingGroup(vg int) int {
- switch vg / 10000 {
- case 1:
- return displayBase
- case 2:
- return displayStandard
- case 3, 9:
- return displayOther
- default:
- return 0
- }
-}
-
-// soundingPoints extracts a SOUNDG multipoint's vertices as (lon, lat, depth).
-// S-57 encodes soundings as 3-D points [lon, lat, depth]; a point missing its Z
-// is given depth 0 so the rule still places a "0" sounding rather than dropping.
-func soundingPoints(g s57.Geometry) [][3]float64 {
- pts := make([][3]float64, 0, len(g.Coordinates))
- for _, c := range g.Coordinates {
- if len(c) < 2 {
- continue
- }
- var z float64
- if len(c) >= 3 {
- z = c[2]
- }
- pts = append(pts, [3]float64{c[0], c[1], z})
- }
- return pts
-}
-
-// attachSoundingDepths sets SoundingDepthM on each emitted sounding glyph from the
-// SOUNDG multipoint, matching by anchor (the glyphs are placed at their sounding's
-// lon/lat via AugmentedPoint). The depth then reaches the baker, which emits the
-// numeric depth + S/G palette variants the client needs for live depth-unit
-// conversion and the SNDFRM04 safety-depth split.
-func attachSoundingDepths(prims []Primitive, pts [][3]float64) {
- if len(pts) == 0 {
- return
- }
- type key struct{ x, y int64 }
- q := func(v float64) int64 { return int64(math.Round(v * 1e7)) } // ~1cm
- depthAt := make(map[key]float64, len(pts))
- for _, p := range pts {
- depthAt[key{q(p[0]), q(p[1])}] = p[2]
- }
- for i := range prims {
- sc, ok := prims[i].(SymbolCall)
- if !ok {
- continue
- }
- if d, ok := depthAt[key{q(sc.Anchor.Lon), q(sc.Anchor.Lat)}]; ok {
- sc.SoundingDepthM = float32(d)
- prims[i] = sc
- }
- }
-}
-
-// attachDangerDepth tags the isolated-danger symbol (ISODGR01) on an under/awash hazard
-// with the hazard's sounding depth (S-57 VALSOU), which the baker bakes as danger_depth.
-// The client then hides / swaps the mark when the hazard is DEEPER than the mariner's
-// safety contour (S-52 UDWHAZ05: only a sub-safety-contour danger is an isolated danger).
-// Without it DangerDepthM stays NaN and every obstruction shows ISODGR01 regardless of
-// depth (a line obstruction deeper than the safety contour is not an isolated danger).
-func obstructionPostProcess(f *s57.Feature, prims []Primitive) []Primitive {
- d, ok := floatAttr(f.Attributes(), "VALSOU")
- if !ok {
- return prims
- }
- // Tag the isolated-danger mark (ISODGR01) with the sounding so the client shows the
- // ⊗ only when the hazard is shallower than the live safety contour (S-52 UDWHAZ05);
- // a deeper-than-safety obstruction is portrayed by its depth label, not the mark.
- for i := range prims {
- if sc, ok := prims[i].(SymbolCall); ok && sc.SymbolName == "ISODGR01" {
- sc.DangerDepthM = float32(d)
- prims[i] = sc
- }
- }
- // The obstruction carries its VALSOU as a depth (sounding) label. A low-accuracy
- // sounding (unreliable QUAPOS) is parenthesised — the S-52 approximate convention.
- if anchor, ok := representativePoint(f); ok {
- txt := formatSounding(d)
- if q := f.Geometry().Quapos; q != 0 && q != 1 && q != 10 && q != 11 {
- txt = "(" + txt + ")"
- }
- prims = append(prims, DrawText{
- Anchor: anchor,
- Text: txt,
- FontSizePx: 10,
- ColorToken: "CHBLK",
- Halo: &TextHalo{ColorToken: "CHWHT", WidthPx: 1},
- Group: 11,
- })
- }
- return prims
-}
-
-// formatSounding renders a sounding depth: an integer for whole metres, else one decimal.
-func formatSounding(d float64) string {
- if d == math.Trunc(d) {
- return strconv.FormatFloat(d, 'f', 0, 64)
- }
- return strconv.FormatFloat(d, 'f', 1, 64)
-}
-
-func primitiveName(t s57.GeometryType) string {
- switch t {
- case s57.GeometryTypeLineString:
- return "Curve"
- case s57.GeometryTypePolygon:
- return "Surface"
- default:
- return "Point"
- }
-}
-
-// stringAttrs encodes S-57 attribute values as the strings ConvertEncodedValue
-// expects (enumeration/integer → digits, boolean → "1"/"0", text → as-is).
-func stringAttrs(attrs map[string]any) map[string]string {
- out := make(map[string]string, len(attrs))
- for k, v := range attrs {
- if s, ok := encodeAttr(v); ok {
- out[k] = s
- }
- }
- return out
-}
-
-func encodeAttr(v any) (string, bool) {
- switch t := v.(type) {
- case nil:
- return "", false
- case string:
- return t, true
- case bool:
- if t {
- return "1", true
- }
- return "0", true
- case int:
- return strconv.Itoa(t), true
- case int64:
- return strconv.FormatInt(t, 10), true
- case float64:
- if t == math.Trunc(t) && !math.IsInf(t, 0) {
- return strconv.FormatInt(int64(t), 10), true
- }
- return strconv.FormatFloat(t, 'g', -1, 64), true
- case float32:
- return encodeAttr(float64(t))
- default:
- return "", false
- }
-}
diff --git a/internal/engine/portrayal/s101build_test.go b/internal/engine/portrayal/s101build_test.go
deleted file mode 100644
index b7da0dd..0000000
--- a/internal/engine/portrayal/s101build_test.go
+++ /dev/null
@@ -1,528 +0,0 @@
-package portrayal
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-func s101Builder(t *testing.T) *S101Builder {
- t.Helper()
- pc := os.Getenv("S101_CATALOG")
- if pc == "" {
- pc = "/home/jcollins/Projects/s101-portrayal-catalogue/PortrayalCatalog"
- }
- fcPath := os.Getenv("S101_FC")
- if fcPath == "" {
- fcPath = "/home/jcollins/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml"
- }
- if _, err := os.Stat(filepath.Join(pc, "Rules", "main.lua")); err != nil {
- t.Skipf("S-101 catalogue not present; set S101_CATALOG/S101_FC")
- }
- if _, err := os.Stat(fcPath); err != nil {
- t.Skipf("S-101 feature catalogue not present")
- }
- b, err := NewS101Builder(pc, fcPath)
- if err != nil {
- t.Fatal(err)
- }
- return b
-}
-
-// s101BuilderEmbedded builds from the in-repo embedded catalogue (the one the
-// baker ships), so the test runs without an external catalogue checkout.
-func s101BuilderEmbedded(t *testing.T) *S101Builder {
- t.Helper()
- pc := "../s101catalog/catalog/PortrayalCatalog"
- fcPath := "../s101catalog/catalog/FeatureCatalogue.xml"
- if _, err := os.Stat(filepath.Join(pc, "Rules", "main.lua")); err != nil {
- t.Skip("no embedded catalogue")
- }
- b, err := NewS101Builder(pc, fcPath)
- if err != nil {
- t.Fatal(err)
- }
- return b
-}
-
-// TestS101BuildDateDependent: a seasonal buoy (S-57 PERSTA/PEREND) is portrayed
-// date-dependent — buildFeature surfaces the periodic range on the FeatureBuild
-// and the CHDATD01 date-dependent marker symbol is emitted (the S-101
-// ProcessFixedAndPeriodicDates path wired into the engine).
-func TestS101BuildDateDependent(t *testing.T) {
- b := s101BuilderEmbedded(t)
- buoy := s57.NewFeature(1, "BOYLAT",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-76.3, 38.9}}},
- map[string]interface{}{"CATLAM": 2, "BOYSHP": 2, "COLOUR": "3", "PERSTA": "--0301", "PEREND": "--1201"},
- )
- build, ok := b.Build(&buoy)
- if !ok {
- t.Fatal("build failed")
- }
- if build.DateStart != "--0301" || build.DateEnd != "--1201" {
- t.Errorf("date range = %q..%q, want --0301..--1201", build.DateStart, build.DateEnd)
- }
- if build.TimeValid != "closedInterval" {
- t.Errorf("TimeValid = %q, want closedInterval", build.TimeValid)
- }
- hasMarker := false
- for _, p := range build.Primitives {
- if sc, ok := p.(SymbolCall); ok && sc.SymbolName == "CHDATD01" {
- hasMarker = true
- }
- }
- if !hasMarker {
- t.Errorf("no CHDATD01 date-dependent marker emitted; got %#v", build.Primitives)
- }
-}
-
-// TestS101BuildPointSymbol drives a real S-57 feature through the full build
-// seam: S-57 acronyms → S-101 rule → instructions → geometry-placed Primitive.
-func TestS101BuildPointSymbol(t *testing.T) {
- b := s101Builder(t)
-
- pt := s57.NewFeature(1, "SILTNK",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{12.5, 55.7}}},
- map[string]interface{}{"CATSIL": 3, "CONVIS": 1},
- )
- build, ok := b.Build(&pt)
- if !ok {
- t.Fatal("build failed")
- }
- var sym *SymbolCall
- for i := range build.Primitives {
- if sc, ok := build.Primitives[i].(SymbolCall); ok {
- sym = &sc
- break
- }
- }
- if sym == nil {
- t.Fatalf("no SymbolCall emitted; got %#v", build.Primitives)
- }
- if sym.SymbolName != "TOWERS03" {
- t.Errorf("symbol = %q, want TOWERS03", sym.SymbolName)
- }
- if sym.Anchor.Lat != 55.7 || sym.Anchor.Lon != 12.5 {
- t.Errorf("anchor = %+v, want {55.7,12.5}", sym.Anchor)
- }
-}
-
-// TestS101BuildAreaFillAndLine drives a polygon feature; the SiloTank surface
-// branch emits ColorFill:CHBRN + a boundary line, emitted onto the rings.
-func TestS101BuildAreaFillAndLine(t *testing.T) {
- b := s101Builder(t)
- ring := [][]float64{{0, 0}, {0, 1}, {1, 1}, {1, 0}, {0, 0}}
- poly := s57.NewFeature(2, "SILTNK",
- s57.Geometry{Type: s57.GeometryTypePolygon, Coordinates: ring},
- map[string]interface{}{"CATSIL": 1},
- )
- build, ok := b.Build(&poly)
- if !ok {
- t.Fatal("build failed")
- }
- var fill *FillPolygon
- for i := range build.Primitives {
- if fp, ok := build.Primitives[i].(FillPolygon); ok {
- fill = &fp
- break
- }
- }
- if fill == nil || fill.ColorToken != "CHBRN" {
- t.Fatalf("want FillPolygon CHBRN, got %#v", build.Primitives)
- }
- if len(fill.Rings) == 0 || len(fill.Rings[0]) == 0 {
- t.Errorf("fill not emitted onto geometry: %+v", fill.Rings)
- }
-}
-
-// TestS101BuildUnknownClassPlaceholder: an object class with no S-101 alias
-// renders the QUESMRK1 placeholder rather than vanishing.
-func TestS101BuildUnknownClassPlaceholder(t *testing.T) {
- b := s101Builder(t)
- f := s57.NewFeature(3, "ZZZZZZ",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{1, 2}}}, nil)
- build, ok := b.Build(&f)
- if !ok {
- t.Fatal("build should succeed with placeholder")
- }
- if len(build.Primitives) != 1 {
- t.Fatalf("want 1 placeholder primitive, got %d", len(build.Primitives))
- }
- if sc, ok := build.Primitives[0].(SymbolCall); !ok || sc.SymbolName != "QUESMRK1" {
- t.Errorf("want QUESMRK1 placeholder, got %#v", build.Primitives[0])
- }
-}
-
-// TestDisplayCategoryForViewingGroup checks the viewing-group→display-category
-// band mapping (the fix for "everything baked as cat=Other"). 1xxxx=Base,
-// 2xxxx=Standard, 3xxxx/9xxxx=Other, text-selectors (5xxxx/<10000)=unset.
-func TestDisplayCategoryForViewingGroup(t *testing.T) {
- cases := []struct {
- vg int
- want int
- }{
- {11050, displayBase}, // no-data / chart furniture
- {12010, displayBase}, // land area
- {13030, displayBase}, // depth area
- {14010, displayBase}, // isolated underwater danger
- {21010, displayStandard}, // unknown object
- {27070, displayStandard}, // lights
- {32050, displayOther}, // other display element
- {90010, displayOther}, // data-quality overlay
- {11, 0}, // text-group selector (independent)
- {50010, 0}, // 5xxxx text band
- {0, 0}, // unset
- }
- for _, c := range cases {
- if got := displayCategoryForViewingGroup(c.vg); got != c.want {
- t.Errorf("displayCategoryForViewingGroup(%d) = %d, want %d", c.vg, got, c.want)
- }
- }
-}
-
-// TestS101BuildDisplayCategory: a real LANDARE bakes as Display Base (12010),
-// not the old hardcoded Standard — proving the category is read from the rule's
-// emitted viewing group.
-func TestS101BuildDisplayCategory(t *testing.T) {
- b := s101Builder(t)
- ring := [][]float64{{0, 0}, {0, 1}, {1, 1}, {1, 0}, {0, 0}}
- land := s57.NewFeature(7, "LNDARE",
- s57.Geometry{Type: s57.GeometryTypePolygon, Coordinates: ring}, nil)
- build, ok := b.Build(&land)
- if !ok {
- t.Fatal("build failed")
- }
- if build.DisplayCategory != displayBase {
- t.Errorf("LNDARE display category = %d, want displayBase(%d)", build.DisplayCategory, displayBase)
- }
-}
-
-// TestS101LightFlareRotation: a LIGHTS feature's flare symbol is rotated (the
-// catalogue default 135°, screen-referenced). Regression: the CRS-qualified
-// "Rotation:PortrayalCRS,135" parsed to 0°, so flares never rotated.
-func TestS101LightFlareRotation(t *testing.T) {
- b := s101Builder(t)
- f := s57.NewFeature(8, "LIGHTS",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-76.4, 38.6}}},
- map[string]interface{}{"COLOUR": 3, "LITCHR": 2, "SIGGRP": "(1)", "SIGPER": 4},
- )
- build, ok := b.Build(&f)
- if !ok {
- t.Fatal("build failed")
- }
- var rotated bool
- for _, p := range build.Primitives {
- if sc, ok := p.(SymbolCall); ok && sc.RotationDeg != 0 {
- rotated = true
- if sc.RotationTrueNorth {
- t.Errorf("flare should be screen-referenced, got true-north (rot=%v)", sc.RotationDeg)
- }
- }
- }
- if !rotated {
- t.Errorf("no rotated light symbol emitted; prims=%#v", build.Primitives)
- }
-}
-
-// TestS101Soundings: a SOUNDG multipoint portrays one (or more) sounding glyph
-// per point, each placed at its own location. Regression: the bridge sent
-// "Point", so the Sounding rule errored ("Invalid primitive type") and no
-// soundings drew.
-func TestS101Soundings(t *testing.T) {
- b := s101Builder(t)
- f := s57.NewFeature(11, "SOUNDG",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{
- {-76.40, 38.60, 5.0},
- {-76.41, 38.61, 12.0},
- }}, nil)
- build, ok := b.Build(&f)
- if !ok {
- t.Fatal("build failed")
- }
- var anchors []geo.LatLon
- for _, p := range build.Primitives {
- if sc, ok := p.(SymbolCall); ok && (strings.HasPrefix(sc.SymbolName, "SOUNDG") || strings.HasPrefix(sc.SymbolName, "SOUNDS")) {
- anchors = append(anchors, sc.Anchor)
- }
- }
- if len(anchors) == 0 {
- t.Fatalf("no sounding glyphs emitted; prims=%#v", build.Primitives)
- }
- // Each sounding must sit at its own point, not the (zero) feature anchor.
- var sawP1, sawP2 bool
- for _, a := range anchors {
- if approxLL(a, -76.40, 38.60) {
- sawP1 = true
- }
- if approxLL(a, -76.41, 38.61) {
- sawP2 = true
- }
- }
- if !sawP1 || !sawP2 {
- t.Errorf("soundings not placed at their points (p1=%v p2=%v); anchors=%v", sawP1, sawP2, anchors)
- }
-}
-
-func approxLL(a geo.LatLon, lon, lat float64) bool {
- return a.Lon > lon-1e-6 && a.Lon < lon+1e-6 && a.Lat > lat-1e-6 && a.Lat < lat+1e-6
-}
-
-// TestS101DangerDefaultDepth: an OBSTRN with no VALSOU inside a DEPARE portrays
-// (no error) because it inherits defaultClearanceDepth from the depth area's
-// DRVAL1. Regression: OBSTRN07 errored ("Neither valueOfSounding or
-// defaultClearanceDepth have a value") for all depth-less dangers.
-func TestS101DangerDefaultDepth(t *testing.T) {
- b := s101Builder(t)
- // A 0..1° square depth area, DRVAL1 = 8 m.
- depare := s57.NewFeature(30, "DEPARE",
- s57.Geometry{Type: s57.GeometryTypePolygon, Coordinates: [][]float64{
- {0, 0}, {0, 1}, {1, 1}, {1, 0}, {0, 0}}},
- map[string]interface{}{"DRVAL1": 8.0, "DRVAL2": 12.0})
- // An obstruction inside it, NO VALSOU.
- obstrn := s57.NewFeature(31, "OBSTRN",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{0.5, 0.5}}},
- map[string]interface{}{"CATOBS": 6})
- m, err := b.BuildBatch([]*s57.Feature{&depare, &obstrn})
- if err != nil {
- t.Fatal(err)
- }
- if got := m[31]; len(got.Primitives) == 0 {
- t.Fatalf("OBSTRN inside a DEPARE produced no primitives (rule errored on missing depth)")
- }
- // An obstruction OUTSIDE any depth area still has no depth → stays suppressed
- // (genuinely unknown), which is acceptable.
- obstrnOut := s57.NewFeature(32, "OBSTRN",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{5, 5}}},
- map[string]interface{}{"CATOBS": 6})
- m2, _ := b.BuildBatch([]*s57.Feature{&depare, &obstrnOut})
- _ = m2 // no assertion: just must not panic
-}
-
-// TestS101DeepDangerNoOverflow: an obstruction DEEPER than the safety contour
-// (VALSOU > 30) takes the "deep sounding" path, which reads feature.Point.
-// Regression: the spatial glue returned nil for a point's spatial, so the
-// framework's GetSpatial infinitely recursed → stack overflow (101 OBSTRN/WRECKS
-// suppressed). The host now resolves a real Point spatial.
-func TestS101DeepDangerNoOverflow(t *testing.T) {
- b := s101Builder(t)
- for _, depth := range []float64{40, 100, 12.3} {
- f := s57.NewFeature(40, "OBSTRN",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-76.4, 38.6}}},
- map[string]interface{}{"VALSOU": depth, "WATLEV": 3})
- build, ok := b.Build(&f)
- if !ok || len(build.Primitives) == 0 {
- t.Fatalf("deep obstruction VALSOU=%v produced no primitives (stack overflow?); ok=%v", depth, ok)
- }
- }
-}
-
-// TestS101OpeningBridge: an opening bridge (BRIDGE CATBRG=2) portrays via the
-// SpanOpening rule. Regression: the rule's unguarded
-// verticalClearanceClosed.verticalClearanceValue deref crashed because the host
-// never synthesised the clearance complex attribute → all 51 bridges errored.
-func TestS101OpeningBridge(t *testing.T) {
- b := s101Builder(t)
- line := [][]float64{{-76.40, 38.60}, {-76.39, 38.61}}
- // With VERCCL present (closed clearance 5.0 m).
- br := s57.NewFeature(20, "BRIDGE",
- s57.Geometry{Type: s57.GeometryTypeLineString, Coordinates: line},
- map[string]interface{}{"CATBRG": 2, "VERCCL": 5.0})
- build, ok := b.Build(&br)
- if !ok {
- t.Fatal("build failed")
- }
- if len(build.Primitives) == 0 {
- t.Fatalf("opening bridge produced no primitives (rule errored?)")
- }
- // And without VERCCL (the crash case): must still portray, not error out.
- br2 := s57.NewFeature(21, "BRIDGE",
- s57.Geometry{Type: s57.GeometryTypeLineString, Coordinates: line},
- map[string]interface{}{"CATBRG": 2})
- build2, ok := b.Build(&br2)
- if !ok || len(build2.Primitives) == 0 {
- t.Fatalf("opening bridge without VERCCL produced no primitives (rule crashed); ok=%v prims=%d", ok, len(build2.Primitives))
- }
-}
-
-// TestS101NameLabel: a feature with OBJNAM produces a DrawText name label via
-// the PortrayFeatureName wrapper + featureName complex-attr data.
-func TestS101NameLabel(t *testing.T) {
- b := s101Builder(t)
- f := s57.NewFeature(99, "BOYLAT",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-76.4, 38.6}}},
- map[string]interface{}{"OBJNAM": "G C 5", "CATLAM": 1},
- )
- build, ok := b.Build(&f)
- if !ok {
- t.Fatal("build failed")
- }
- var label string
- for _, p := range build.Primitives {
- if dt, ok := p.(DrawText); ok {
- label = dt.Text
- }
- }
- // The LateralBuoy rule formats the name as "by %s" (catalogue's format), so
- // the label contains the OBJNAM — the point is that the name text renders.
- if !strings.Contains(label, "G C 5") {
- t.Errorf("name label = %q, want it to contain \"G C 5\"; prims=%d", label, len(build.Primitives))
- }
-}
-
-// TestS101BuildAllAroundLightCharacteristic: an all-around light's description
-// (LITDSN02) must carry its character + period, not collapse to just the colour.
-// LITDSN02 reads these from the rhythmOfLight complex attribute, which the bridge
-// synthesizes from S-57 LITCHR/SIGGRP/SIGPER — without it the text was e.g. "G".
-func TestS101BuildAllAroundLightCharacteristic(t *testing.T) {
- b := s101BuilderEmbedded(t)
- lt := s57.NewFeature(1, "LIGHTS",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{12.5, 55.7}}},
- map[string]interface{}{"LITCHR": 4, "COLOUR": "4", "SIGPER": "1"}, // Quick, green, 1s
- )
- build, ok := b.Build(<)
- if !ok {
- t.Fatal("build failed")
- }
- var text string
- for _, p := range build.Primitives {
- if dt, ok := p.(DrawText); ok && strings.ContainsAny(dt.Text, "QGFlsm") {
- text = dt.Text
- break
- }
- }
- if !strings.Contains(text, "Q") {
- t.Errorf("light text = %q, want the Quick character 'Q' (rhythmOfLight not synthesized?)", text)
- }
- if !strings.Contains(text, "G") {
- t.Errorf("light text = %q, want the green colour 'G'", text)
- }
- if !strings.Contains(text, "1s") {
- t.Errorf("light text = %q, want the 1s period", text)
- }
-}
-
-// TestS101BuildSectorLight drives an S-57 sectored light through the full build
-// seam and asserts the rule's constructed AugmentedFigure elements come through:
-// the dashed legs (rays, tagged with the nominal range for the full-light-lines
-// toggle) and the coloured arc — driven by the catalogue, not a Go re-derivation.
-func TestS101BuildSectorLight(t *testing.T) {
- b := s101BuilderEmbedded(t)
-
- lt := s57.NewFeature(1, "LIGHTS",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{12.5, 55.7}}},
- map[string]interface{}{
- "SECTR1": "045", "SECTR2": "090",
- "COLOUR": "3", "VALNMR": "9", "LITCHR": 2,
- },
- )
- build, ok := b.Build(<)
- if !ok {
- t.Fatal("build failed")
- }
- var legs, arcs int
- var arcColor string
- for _, p := range build.Primitives {
- fig, ok := p.(AugmentedFigure)
- if !ok {
- continue
- }
- if fig.Ray {
- legs++
- if fig.FullLengthNM != 9 {
- t.Errorf("leg nominal range = %v, want 9 (from VALNMR)", fig.FullLengthNM)
- }
- } else {
- arcs++
- if fig.ColorToken == "LITRD" {
- arcColor = fig.ColorToken
- }
- }
- }
- if legs < 2 {
- t.Errorf("legs = %d, want >=2 (two sector limits)", legs)
- }
- if arcs < 1 {
- t.Errorf("arcs = %d, want >=1 (the sector arc)", arcs)
- }
- if arcColor != "LITRD" {
- t.Errorf("no LITRD (red) arc found; COLOUR=3 should portray red")
- }
-}
-
-// TestS101BuildTopmark proves a co-located S-57 TOPMAR is folded into its parent
-// buoy as the S-101 topmark complex attribute, so the buoy's TOPMAR02 CSP emits
-// a topmark symbol (TMARDEF2 here: floating, no specific shape match yet).
-func TestS101BuildTopmark(t *testing.T) {
- b := s101Builder(t)
-
- at := s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{12.5, 55.7}}}
- buoy := s57.NewFeature(1, "BOYLAT", at, map[string]interface{}{"BOYSHP": 2, "COLOUR": "3"})
- top := s57.NewFeature(2, "TOPMAR", at, map[string]interface{}{"TOPSHP": 1, "COLOUR": "3"})
-
- m, err := b.BuildBatch([]*s57.Feature{&buoy, &top})
- if err != nil {
- t.Fatal(err)
- }
- // The standalone TOPMAR is suppressed (folded into the parent).
- if len(m[2].Primitives) != 0 {
- t.Errorf("standalone TOPMAR should be suppressed, got %#v", m[2].Primitives)
- }
- // The buoy now carries a topmark symbol (TOPMAR02 → a TOPMARxx/TMARDEFx call).
- var topSym bool
- for _, p := range m[1].Primitives {
- if sc, ok := p.(SymbolCall); ok && (strings.HasPrefix(sc.SymbolName, "TOPMAR") || strings.HasPrefix(sc.SymbolName, "TMARDEF")) {
- topSym = true
- }
- }
- if !topSym {
- t.Errorf("buoy should emit a topmark symbol; primitives=%#v", m[1].Primitives)
- }
-}
-
-// TestS101BuildMorfac proves the S-57 MORFAC → S-101 class decomposition by
-// CATMOR: each category routes to its dedicated rule and portrays a symbol
-// (point pilings/bollards/buoys are the bulk of harbour MORFAC features).
-func TestS101BuildMorfac(t *testing.T) {
- b := s101Builder(t)
- at := s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{12.5, 55.7}}}
- cases := []struct{ catmor, wantSym string }{
- {"5", ""}, // pile
- {"3", ""}, // bollard
- {"1", ""}, // dolphin
- {"7", ""}, // mooring buoy
- }
- for _, c := range cases {
- f := s57.NewFeature(1, "MORFAC", at, map[string]interface{}{"CATMOR": c.catmor})
- build, ok := b.Build(&f)
- if !ok {
- t.Fatalf("CATMOR=%s: build failed", c.catmor)
- }
- var sym bool
- for _, p := range build.Primitives {
- if _, ok := p.(SymbolCall); ok {
- sym = true
- }
- }
- // Magenta "unknown object" is the failure we're fixing — assert it's gone.
- if isUnknownBuild(build) {
- t.Errorf("CATMOR=%s: still portrays as unknown object", c.catmor)
- }
- if !sym {
- t.Errorf("CATMOR=%s: want a symbol, got %#v", c.catmor, build.Primitives)
- }
- }
-}
-
-// isUnknownBuild reports whether a build is the magenta unknown-object mark.
-func isUnknownBuild(b FeatureBuild) bool {
- for _, p := range b.Primitives {
- if sc, ok := p.(SymbolCall); ok && (sc.SymbolName == "QUESMRK1" || sc.SymbolName == "ISODGR51") {
- return true
- }
- }
- return false
-}
diff --git a/internal/engine/portrayal/s101depth.go b/internal/engine/portrayal/s101depth.go
deleted file mode 100644
index b6d8880..0000000
--- a/internal/engine/portrayal/s101depth.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package portrayal
-
-import (
- "strconv"
-
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// This file derives the S-101 `defaultClearanceDepth` for under/awash dangers of
-// unknown depth (the S-52 DEPVAL02 "underlying depth area" rule): OBSTRN07 /
-// WRECKS05 hard-require a depth (valueOfSounding OR defaultClearanceDepth) and
-// error if both are nil. S-57 supplies valueOfSounding via VALSOU; when that's
-// absent the danger inherits the shoalest DRVAL1 of the DEPARE/DRGARE it lies in.
-
-// depthArea is one indexed DEPARE/DRGARE polygon plus a bbox for cheap rejection.
-type depthArea struct {
- drval1 float64
- hasDrval1 bool
- rings [][][]float64 // each ring is a list of [lon,lat]
- minLon, minLat, maxLon, maxLat float64
-}
-
-// DepthIndex is the per-cell set of depth/dredged areas, scanned linearly with a
-// bbox pre-filter (areas are bounded per cell, so no grid is needed).
-type DepthIndex struct{ areas []depthArea }
-
-// BuildDepthIndex collects the cell's DEPARE/DRGARE polygons with their DRVAL1.
-func BuildDepthIndex(features []*s57.Feature) *DepthIndex {
- idx := &DepthIndex{}
- for _, f := range features {
- cls := f.ObjectClass()
- if cls != "DEPARE" && cls != "DRGARE" {
- continue
- }
- rings := polygonRings(f.Geometry())
- if len(rings) == 0 {
- continue
- }
- da := depthArea{rings: rings}
- da.drval1, da.hasDrval1 = floatAttr(f.Attributes(), "DRVAL1")
- da.minLon, da.minLat = rings[0][0][0], rings[0][0][1]
- da.maxLon, da.maxLat = da.minLon, da.minLat
- for _, r := range rings {
- for _, c := range r {
- da.minLon, da.maxLon = min(da.minLon, c[0]), max(da.maxLon, c[0])
- da.minLat, da.maxLat = min(da.minLat, c[1]), max(da.maxLat, c[1])
- }
- }
- idx.areas = append(idx.areas, da)
- }
- return idx
-}
-
-// shoalestDRVAL1 returns the smallest (shoalest) DRVAL1 among the depth areas
-// containing (lat, lon). ok is false if the point lies in no depth area with a
-// known DRVAL1 (the danger's depth then stays unknown).
-func (idx *DepthIndex) shoalestDRVAL1(lat, lon float64) (float64, bool) {
- if idx == nil {
- return 0, false
- }
- best, found := 0.0, false
- for i := range idx.areas {
- a := &idx.areas[i]
- if !a.hasDrval1 || lon < a.minLon || lon > a.maxLon || lat < a.minLat || lat > a.maxLat {
- continue
- }
- if pointInRings(lon, lat, a.rings) && (!found || a.drval1 < best) {
- best, found = a.drval1, true
- }
- }
- return best, found
-}
-
-// DerivedAttrs computes the S-101-coded attributes a feature needs but S-57
-// doesn't carry directly. For under/awash dangers it supplies defaultClearanceDepth
-// so OBSTRN07/WRECKS05 (which hard-require valueOfSounding OR defaultClearanceDepth)
-// don't error on a missing depth and drop the hazard.
-//
-// Per S-52 DEPVAL the danger inherits the shoalest DRVAL1 of the depth area it
-// lies in. With no such area the depth is genuinely unknown — default to 0
-// (awash) so UDWHAZ05 treats it as a hazard (an unknown-depth danger is assumed
-// dangerous) rather than the feature being suppressed by the rule error.
-func DerivedAttrs(f *s57.Feature, idx *DepthIndex) map[string]string {
- switch f.ObjectClass() {
- case "OBSTRN", "WRECKS", "UWTROC":
- default:
- return nil
- }
- depth := 0.0
- surrounding := ""
- if pt, ok := representativePoint(f); ok {
- if d, ok := idx.shoalestDRVAL1(pt.Lat, pt.Lon); ok {
- depth = d
- // surroundingDepth is the depth of the area the danger sits in (S-52
- // DEPVAL). UDWHAZ05 reads it: a sub-safety-contour danger whose
- // surrounding water is itself shallow is NOT an isolated danger and
- // must not get ISODGR01 unless "isolated dangers in shallow water" is
- // on. Supply it ONLY when a containing depth area is found — leaving it
- // absent (the no-area case) keeps the rule's conservative "unknown ⇒
- // dangerous" default, so deep/unknown dangers still flag.
- surrounding = strconv.FormatFloat(d, 'f', -1, 64)
- }
- }
- out := map[string]string{"defaultClearanceDepth": strconv.FormatFloat(depth, 'f', -1, 64)}
- if surrounding != "" {
- out["surroundingDepth"] = surrounding
- }
- return out
-}
-
-// polygonRings returns a polygon's rings as [lon,lat] lists (Rings field first,
-// falling back to the flat Coordinates).
-func polygonRings(g s57.Geometry) [][][]float64 {
- if g.Type != s57.GeometryTypePolygon {
- return nil
- }
- if len(g.Rings) > 0 {
- out := make([][][]float64, 0, len(g.Rings))
- for _, r := range g.Rings {
- if len(r.Coordinates) >= 3 {
- out = append(out, r.Coordinates)
- }
- }
- return out
- }
- if len(g.Coordinates) >= 3 {
- return [][][]float64{g.Coordinates}
- }
- return nil
-}
-
-// pointInRings is an even-odd point-in-polygon test across all rings (exterior +
-// holes), so a point inside a hole reads as outside the area.
-func pointInRings(lon, lat float64, rings [][][]float64) bool {
- inside := false
- for _, ring := range rings {
- n := len(ring)
- if n < 3 {
- continue
- }
- j := n - 1
- for i := 0; i < n; i++ {
- xi, yi := ring[i][0], ring[i][1]
- xj, yj := ring[j][0], ring[j][1]
- if (yi > lat) != (yj > lat) {
- if lon < (xj-xi)*(lat-yi)/(yj-yi)+xi {
- inside = !inside
- }
- }
- j = i
- }
- }
- return inside
-}
diff --git a/internal/engine/portrayal/s101emit.go b/internal/engine/portrayal/s101emit.go
deleted file mode 100644
index 927d791..0000000
--- a/internal/engine/portrayal/s101emit.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package portrayal
-
-import (
- "math"
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/s100/catalog"
- "github.com/beetlebugorg/chartplotter/pkg/s100/instructions"
-)
-
-// This file emits primitives from S-101 drawing commands: it turns one resolved
-// S-101 drawing command (from pkg/s100/instructions) plus the feature geometry
-// into a viewport-independent Primitive that everything downstream (projection,
-// MVT bake, client colour resolution) consumes. Colour stays a token; line-style
-// refs resolve against the S-101 catalogue.
-
-// mmPerSymbolUnit-derived conversions. S-101 widths/offsets are millimetres;
-// the engine uses pixels (at DefaultPxPerSymbolUnit) and 0.01-mm symbol units.
-const (
- pxPerMM = float64(DefaultPxPerSymbolUnit) * 100 // px per mm (1mm = 100 symbol units)
- unitsPerMM = 100.0 // 0.01-mm symbol units per mm
-)
-
-// S101Geometry carries the feature geometry a draw command attaches to. The
-// command's Op selects which field is used: Anchor (point symbols/text), Lines
-// (line strokes), Rings (area fills, outer ring first then holes).
-type S101Geometry struct {
- Anchor geo.LatLon
- // Rings are the feature's COMPLETE area rings — area fills (ColorFill/
- // AreaFill) use these unchanged. Empty for non-area features.
- Rings [][]geo.LatLon
- // Lines are the DRAWABLE polylines a line draw strokes, already reduced to
- // the masked / data-limit parts (S-52 §8.6.2): a line feature's drawable
- // parts, or — for an area — its drawable boundary (coastline-coincident and
- // MASK=1/USAG=3 edges removed). One stroke primitive is emitted per run, so a
- // masked area boundary skips its land-shared edges while the fill stays whole.
- Lines [][]geo.LatLon
-}
-
-// emitPrimitives maps one resolved S-101 draw command onto engine Primitives,
-// attaching geometry and resolving line-style references against the catalogue.
-// It returns a slice because an area-boundary line fans into one line primitive
-// per ring; fills/symbols/text are a single primitive.
-// An empty slice means nothing to draw (a no-op, an unhandled draw kind, or a
-// draw whose geometry is missing).
-func emitPrimitives(cmd instructions.DrawCommand, geom S101Geometry, cat *catalog.Catalog) []Primitive {
- switch cmd.Op {
- case instructions.OpColorFill:
- if len(geom.Rings) == 0 {
- return nil
- }
- return []Primitive{FillPolygon{Rings: geom.Rings, ColorToken: cmd.Reference}}
-
- case instructions.OpAreaFill:
- if len(geom.Rings) == 0 {
- return nil
- }
- // Widely-spaced "fill patterns" (§8.5.4) are placed as discrete whole
- // symbols on a geographic lattice by the baker (no mid-glyph edge clip),
- // not tiled as a texture. Carry the lattice (V1/V2 in mm) + a rep point.
- if sparseFillPatterns[cmd.Reference] && cat != nil {
- if af := cat.AreaFills[cmd.Reference]; af != nil {
- return []Primitive{PatternFill{
- Rings: geom.Rings, PatternName: cmd.Reference, Sparse: true,
- SymbolRef: af.SymbolRef,
- V1: [2]float64{af.V1.X, af.V1.Y},
- V2: [2]float64{af.V2.X, af.V2.Y},
- Anchor: geom.Anchor,
- }}
- }
- }
- return []Primitive{PatternFill{Rings: geom.Rings, PatternName: cmd.Reference}}
-
- case instructions.OpLine:
- out := make([]Primitive, 0, len(geom.Lines))
- for _, pts := range geom.Lines {
- if len(pts) < 2 {
- continue // a degenerate ring/run can't be stroked
- }
- if cmd.Reference == "_simple_" && cmd.SimpleLine != nil {
- out = append(out, StrokeLine{
- Points: pts,
- ColorToken: cmd.SimpleLine.Color,
- WidthPx: float32(cmd.SimpleLine.Width * pxPerMM),
- Dash: dashFor(cmd.SimpleLine.DashLength),
- })
- } else {
- out = append(out, LinePattern{
- Points: pts,
- LinestyleName: cmd.Reference,
- ColorToken: linePenColor(cmd.Reference, cat),
- })
- }
- }
- return out
-
- case instructions.OpPoint:
- anchor := geom.Anchor
- if cmd.HasAnchor { // an AugmentedPoint draw (e.g. one SOUNDG sounding)
- anchor = geo.LatLon{Lat: cmd.Anchor[1], Lon: cmd.Anchor[0]}
- } else if p, ok := pointAlongLines(geom.Lines, cmd.LinePlacement); ok {
- // A point symbol placed at a relative position along a line feature
- // (LinePlacement:Relative,) — e.g. a recommended-track / route
- // arrow or a cable/pipeline marker. Without this every such symbol
- // collapsed to the feature's midpoint anchor.
- anchor = p
- }
- return []Primitive{SymbolCall{
- Anchor: anchor,
- SymbolName: cmd.Reference,
- RotationDeg: float32(cmd.Rotation),
- RotationTrueNorth: cmd.RotationTrueNorth,
- OffsetXUnits: float32(cmd.Offset[0] * unitsPerMM),
- OffsetYUnits: float32(cmd.Offset[1] * unitsPerMM),
- Scale: DefaultPxPerSymbolUnit, // matches the sprite atlas px_per_unit
- SoundingDepthM: float32(math.NaN()),
- DangerDepthM: float32(math.NaN()),
- }}
-
- case instructions.OpText:
- if cmd.Reference == "" {
- return nil
- }
- fontPx := float32(cmd.FontSizePx)
- if fontPx <= 0 {
- fontPx = 12 // default body size
- }
- var halo *TextHalo
- if fontPx >= 10 {
- halo = &TextHalo{ColorToken: "CHWHT", WidthPx: 1}
- }
- color := cmd.FontColor
- if color == "" {
- color = "CHBLK"
- }
- return []Primitive{DrawText{
- Anchor: geom.Anchor,
- Text: cmd.Reference,
- FontSizePx: fontPx,
- ColorToken: color,
- Halo: halo,
- HAlign: hAlign(cmd.TextAlignH),
- VAlign: vAlign(cmd.TextAlignV),
- OffsetYPx: float32(cmd.TextVOffset),
- // AddTextInstruction emits "ViewingGroup:,",
- // so for text cmd.ViewingGroup is the S-52 text group (11 important,
- // 21/26/29 names, 23 light, …). Carry it as `tgrp` so the client's
- // §14.5 text-group toggles work. Without it every label is group 0 →
- // "Other", and Important/Names toggle nothing.
- Group: cmd.ViewingGroup,
- }}
-
- case instructions.OpAugmentedLine:
- // A screen-space sector figure element (ray/arc) the rule constructed; carry
- // it with the rule's stroke style for the baker to tessellate per-zoom.
- ag := cmd.Augmented
- if ag == nil {
- return nil
- }
- fig := AugmentedFigure{Anchor: geom.Anchor}
- if cmd.SimpleLine != nil {
- fig.ColorToken = cmd.SimpleLine.Color
- fig.WidthMM = cmd.SimpleLine.Width
- fig.Dash = dashFor(cmd.SimpleLine.DashLength)
- }
- switch ag.Kind {
- case instructions.AugRay:
- fig.Ray = true
- fig.BearingDeg = ag.BearingDeg
- fig.LengthMM = ag.LengthMM
- fig.LengthGroundM = ag.LengthGroundM
- case instructions.AugArc:
- fig.RadiusMM = ag.RadiusMM
- fig.StartDeg = ag.StartDeg
- fig.SweepDeg = ag.SweepDeg
- }
- return []Primitive{fig}
-
- default: // OpNull, OpOther
- return nil
- }
-}
-
-// pointAlongLines returns the point at the LinePlacement position along the line
-// runs, or ok=false when the placement isn't a usable "Relative," spec or
-// there's no line geometry (the caller then keeps the feature anchor). frac is
-// clamped to [0,1] and measured by arc length across all runs, with a cos-lat
-// correction so the fraction tracks ground distance rather than raw degrees.
-func pointAlongLines(lines [][]geo.LatLon, placement string) (geo.LatLon, bool) {
- if placement == "" || len(lines) == 0 {
- return geo.LatLon{}, false
- }
- mode, val, _ := strings.Cut(placement, ",")
- if !strings.EqualFold(strings.TrimSpace(mode), "Relative") {
- return geo.LatLon{}, false // Absolute / unknown placement: keep the anchor
- }
- frac, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
- if err != nil {
- return geo.LatLon{}, false
- }
- frac = math.Max(0, math.Min(1, frac))
-
- // Total arc length of all runs (cos-lat corrected planar approximation —
- // chart line features are short enough that great-circle curvature is
- // negligible at this placement precision).
- seg := func(a, b geo.LatLon) float64 {
- dLat := b.Lat - a.Lat
- dLon := (b.Lon - a.Lon) * math.Cos((a.Lat+b.Lat)*0.5*math.Pi/180)
- return math.Hypot(dLat, dLon)
- }
- var total float64
- for _, run := range lines {
- for i := 1; i < len(run); i++ {
- total += seg(run[i-1], run[i])
- }
- }
- if total == 0 {
- // Degenerate (all coincident): fall back to the first vertex.
- for _, run := range lines {
- if len(run) > 0 {
- return run[0], true
- }
- }
- return geo.LatLon{}, false
- }
-
- target := frac * total
- var acc float64
- for _, run := range lines {
- for i := 1; i < len(run); i++ {
- d := seg(run[i-1], run[i])
- if acc+d >= target {
- t := 0.0
- if d > 0 {
- t = (target - acc) / d
- }
- return geo.LatLon{
- Lat: run[i-1].Lat + t*(run[i].Lat-run[i-1].Lat),
- Lon: run[i-1].Lon + t*(run[i].Lon-run[i-1].Lon),
- }, true
- }
- acc += d
- }
- }
- // frac == 1 (or rounding): the last vertex of the last non-empty run.
- for i := len(lines) - 1; i >= 0; i-- {
- if n := len(lines[i]); n > 0 {
- return lines[i][n-1], true
- }
- }
- return geo.LatLon{}, false
-}
-
-func hAlign(s string) HAlign {
- switch s {
- case "Center":
- return HAlignCenter
- case "Right":
- return HAlignRight
- default:
- return HAlignLeft
- }
-}
-
-func vAlign(s string) VAlign {
- switch s {
- case "Top":
- return VAlignTop
- case "Center":
- return VAlignMiddle
- default:
- return VAlignBottom
- }
-}
-
-func dashFor(dashLength float64) Dash {
- if dashLength > 0 {
- return DashDashed
- }
- return DashSolid
-}
-
-// linePenColor returns a named line style's primary pen colour token, used to
-// tint the complex-line dash run client-side. "" when unknown.
-func linePenColor(ref string, cat *catalog.Catalog) string {
- if cat == nil {
- return ""
- }
- ls, ok := cat.LineStyles[ref]
- if !ok {
- return ""
- }
- if ls.PenColor != "" {
- return ls.PenColor
- }
- for _, c := range ls.Components { // composite: take the first component's pen
- if c.PenColor != "" {
- return c.PenColor
- }
- }
- return ""
-}
diff --git a/internal/engine/portrayal/s101emit_test.go b/internal/engine/portrayal/s101emit_test.go
deleted file mode 100644
index 0267981..0000000
--- a/internal/engine/portrayal/s101emit_test.go
+++ /dev/null
@@ -1,182 +0,0 @@
-package portrayal
-
-import (
- "math"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/s100/catalog"
- "github.com/beetlebugorg/chartplotter/pkg/s100/instructions"
-)
-
-// emitStream parses+reduces an S-101 stream and emits primitives for each draw command.
-func emitStream(t *testing.T, stream string, geom S101Geometry, cat *catalog.Catalog) []Primitive {
- t.Helper()
- cmds, unsup := instructions.Reduce(instructions.ParseStream(stream))
- if len(unsup) != 0 {
- t.Fatalf("unsupported kinds: %v", unsup)
- }
- var out []Primitive
- for _, c := range cmds {
- out = append(out, emitPrimitives(c, geom, cat)...)
- }
- return out
-}
-
-func TestEmitRapidsCurveToStrokeLine(t *testing.T) {
- geom := S101Geometry{Lines: [][]geo.LatLon{{{}, {}}}}
- stream := "ViewingGroup:32050;DrawingPriority:9;DisplayPlane:UnderRadar;LineStyle:_simple_,,0.96,CHGRD;LineInstruction:_simple_"
- prims := emitStream(t, stream, geom, nil)
- if len(prims) != 1 {
- t.Fatalf("want 1 primitive, got %d", len(prims))
- }
- sl, ok := prims[0].(StrokeLine)
- if !ok {
- t.Fatalf("want StrokeLine, got %T", prims[0])
- }
- if sl.ColorToken != "CHGRD" || sl.Dash != DashSolid || len(sl.Points) != 2 {
- t.Errorf("stroke wrong: %+v", sl)
- }
- if want := float32(0.96 * pxPerMM); !approx(sl.WidthPx, want) {
- t.Errorf("WidthPx = %v, want %v", sl.WidthPx, want)
- }
-}
-
-func TestEmitRapidsSurfaceToFillPolygon(t *testing.T) {
- geom := S101Geometry{Rings: [][]geo.LatLon{{{}, {}, {}}}}
- prims := emitStream(t, "ViewingGroup:32050;ColorFill:CHGRD", geom, nil)
- fp, ok := prims[0].(FillPolygon)
- if !ok || fp.ColorToken != "CHGRD" || len(fp.Rings) != 1 {
- t.Fatalf("want FillPolygon CHGRD, got %T %+v", prims[0], prims[0])
- }
-}
-
-func TestEmitRapidsPointNullSuppressed(t *testing.T) {
- prims := emitStream(t, "ViewingGroup:32050;NullInstruction", S101Geometry{}, nil)
- if len(prims) != 0 {
- t.Fatalf("NullInstruction should emit nothing, got %d", len(prims))
- }
-}
-
-func TestEmitPointSymbol(t *testing.T) {
- geom := S101Geometry{Anchor: geo.LatLon{}}
- stream := "ViewingGroup:25010;LocalOffset:1,-2;Rotation:45;PointInstruction:BCNCAR01"
- sc, ok := emitStream(t, stream, geom, nil)[0].(SymbolCall)
- if !ok {
- t.Fatalf("want SymbolCall")
- }
- if sc.SymbolName != "BCNCAR01" || sc.RotationDeg != 45 {
- t.Errorf("symbol wrong: %+v", sc)
- }
- if sc.OffsetXUnits != float32(1*unitsPerMM) || sc.OffsetYUnits != float32(-2*unitsPerMM) {
- t.Errorf("offset wrong: %v,%v", sc.OffsetXUnits, sc.OffsetYUnits)
- }
- if !math.IsNaN(float64(sc.SoundingDepthM)) {
- t.Errorf("ordinary symbol should have NaN sounding depth")
- }
-}
-
-func TestEmitComplexLineResolvesPenColor(t *testing.T) {
- cat := &catalog.Catalog{LineStyles: map[string]*catalog.LineStyle{
- "ACHARE51": {ID: "ACHARE51", PenColor: "CHMGD"},
- }}
- geom := S101Geometry{Lines: [][]geo.LatLon{{{}, {}}}}
- lp, ok := emitStream(t, "LineInstruction:ACHARE51", geom, cat)[0].(LinePattern)
- if !ok {
- t.Fatalf("want LinePattern")
- }
- if lp.LinestyleName != "ACHARE51" || lp.ColorToken != "CHMGD" {
- t.Errorf("line pattern wrong: %+v", lp)
- }
-}
-
-func TestEmitAreaFillReference(t *testing.T) {
- geom := S101Geometry{Rings: [][]geo.LatLon{{{}, {}}}}
- prims := emitStream(t, "AreaFillReference:DRGARE01", geom, nil)
- pf, ok := prims[0].(PatternFill)
- if !ok || pf.PatternName != "DRGARE01" || len(pf.Rings) == 0 {
- t.Fatalf("want PatternFill DRGARE01 on rings, got %#v", prims)
- }
-}
-
-// TestEmitAreaBoundaryLine: a boundary line strokes EACH drawable run, not
-// empty geometry. The regression: an area feature has no Lines unless the
-// builder fills them from its (masked) boundary; emitting onto empty geometry
-// yielded a NaN/Inf bbox the baker dropped ("skipping prim with implausible
-// bbox"). Here two drawable runs ⇒ two LinePatterns.
-func TestEmitAreaBoundaryLine(t *testing.T) {
- run1 := []geo.LatLon{{Lat: 0, Lon: 0}, {Lat: 0, Lon: 1}, {Lat: 1, Lon: 1}}
- run2 := []geo.LatLon{{Lat: 0.2, Lon: 0.2}, {Lat: 0.2, Lon: 0.4}, {Lat: 0.4, Lon: 0.4}}
- geom := S101Geometry{Lines: [][]geo.LatLon{run1, run2}}
- prims := emitStream(t, "LineInstruction:CTNARE51", geom, nil)
- if len(prims) != 2 {
- t.Fatalf("want one line per run (2), got %d: %#v", len(prims), prims)
- }
- for i, p := range prims {
- lp, ok := p.(LinePattern)
- if !ok {
- t.Fatalf("run %d: want LinePattern, got %T", i, p)
- }
- if lp.LinestyleName != "CTNARE51" || len(lp.Points) < 2 {
- t.Errorf("run %d emitted onto empty/wrong geometry: %+v", i, lp)
- }
- }
-}
-
-// TestEmitLineNoGeometry: a line draw with no drawable runs emits nothing
-// rather than a degenerate primitive.
-func TestEmitLineNoGeometry(t *testing.T) {
- if prims := emitStream(t, "LineInstruction:CTNARE51", S101Geometry{}, nil); len(prims) != 0 {
- t.Fatalf("want no primitives for empty geometry, got %d", len(prims))
- }
-}
-
-// TestStrokeRunsForMasking: the builder strokes the MASKED boundary/parts when
-// the parser computed them (coastline-coincident edges removed), and falls back
-// to the full geometry only when masking wasn't computed. Area fills keep the
-// whole rings either way.
-func TestStrokeRunsForMasking(t *testing.T) {
- ring := []geo.LatLon{{Lat: 0, Lon: 0}, {Lat: 0, Lon: 1}, {Lat: 1, Lon: 1}, {Lat: 0, Lon: 0}}
- masked := []geo.LatLon{{Lat: 0, Lon: 0}, {Lat: 0, Lon: 1}} // only the seaward edge
-
- // boundary computed (non-nil) → use it verbatim, NOT the full ring.
- area := geom{kind: geomArea, area: [][]geo.LatLon{ring}, boundary: [][]geo.LatLon{masked}}
- if runs := strokeRunsFor(area); len(runs) != 1 || len(runs[0]) != 2 {
- t.Errorf("masked area boundary = %#v, want the single 2-pt seaward run", runs)
- }
-
- // boundary NOT computed (nil) → fall back to the full rings.
- areaNoMask := geom{kind: geomArea, area: [][]geo.LatLon{ring}}
- if runs := strokeRunsFor(areaNoMask); len(runs) != 1 || len(runs[0]) != len(ring) {
- t.Errorf("unmasked area boundary = %#v, want the full ring", runs)
- }
-
- // empty (non-nil) boundary → stroke nothing (fully coastline-coincident).
- areaAllMasked := geom{kind: geomArea, area: [][]geo.LatLon{ring}, boundary: [][]geo.LatLon{}}
- if runs := strokeRunsFor(areaAllMasked); len(runs) != 0 {
- t.Errorf("fully-masked area boundary = %#v, want no runs", runs)
- }
-
- // a line feature uses its masked parts when present.
- line := geom{kind: geomLine, line: ring, lineParts: [][]geo.LatLon{masked}}
- if runs := strokeRunsFor(line); len(runs) != 1 || len(runs[0]) != 2 {
- t.Errorf("masked line parts = %#v, want the single 2-pt part", runs)
- }
-}
-
-func TestEmitText(t *testing.T) {
- geom := S101Geometry{Anchor: geo.LatLon{}}
- stream := "ViewingGroup:25010;FontColor:CHBLK;TextAlignHorizontal:Center;TextInstruction:Fl.R.4s"
- dt, ok := emitStream(t, stream, geom, nil)[0].(DrawText)
- if !ok {
- t.Fatalf("want DrawText")
- }
- if dt.Text != "Fl.R.4s" || dt.ColorToken != "CHBLK" || dt.HAlign != HAlignCenter {
- t.Errorf("text wrong: %+v", dt)
- }
-}
-
-func approx(a, b float32) bool {
- d := a - b
- return d < 0.001 && d > -0.001
-}
diff --git a/internal/engine/portrayal/s101topmark.go b/internal/engine/portrayal/s101topmark.go
deleted file mode 100644
index fe8139e..0000000
--- a/internal/engine/portrayal/s101topmark.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package portrayal
-
-import (
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// S-57 encodes a buoy/beacon topmark as a SEPARATE TOPMAR point feature
-// co-located with its parent; S-101 instead models the topmark as a complex
-// attribute ON the parent (read by the buoy/beacon rules' TOPMAR02 CSP). This
-// file bridges that: it indexes TOPMAR features by location so the baker can fold
-// each one into its co-located parent (and drop the standalone TOPMAR, which has
-// no S-101 feature class and would otherwise portray as a magenta unknown mark).
-
-// buildTopmarkIndex maps a location key → the topmark data (shape from TOPSHP,
-// colour from COLOUR) of the TOPMAR feature at that point.
-func buildTopmarkIndex(features []*s57.Feature) map[string]map[string]string {
- idx := map[string]map[string]string{}
- for _, f := range features {
- if f.ObjectClass() != "TOPMAR" {
- continue
- }
- key, ok := pointLocKey(f.Geometry())
- if !ok {
- continue
- }
- tm := map[string]string{}
- if s := stringAttr(f.Attributes(), "TOPSHP"); s != "" {
- tm["shape"] = s
- }
- if c := stringAttr(f.Attributes(), "COLOUR"); c != "" {
- tm["colour"] = c
- }
- if len(tm) > 0 {
- idx[key] = tm
- }
- }
- return idx
-}
-
-// isTopmarkParent reports whether an S-57 class is a buoy/beacon (or light float)
-// that carries a topmark — i.e. an S-101 class whose rule reads feature.topmark.
-func isTopmarkParent(cls string) bool {
- return strings.HasPrefix(cls, "BOY") || strings.HasPrefix(cls, "BCN") || cls == "LITFLT"
-}
-
-// pointLocKey is a stable location key for a point feature's first vertex,
-// quantized so a parent and its topmark (which share the vertex) collide.
-func pointLocKey(g s57.Geometry) (string, bool) {
- if g.Type != s57.GeometryTypePoint || len(g.Coordinates) == 0 || len(g.Coordinates[0]) < 2 {
- return "", false
- }
- c := g.Coordinates[0]
- return strconv.FormatFloat(c[0], 'f', 7, 64) + "," + strconv.FormatFloat(c[1], 'f', 7, 64), true
-}
diff --git a/internal/engine/portrayal/symins.go b/internal/engine/portrayal/symins.go
deleted file mode 100644
index 766a936..0000000
--- a/internal/engine/portrayal/symins.go
+++ /dev/null
@@ -1,331 +0,0 @@
-package portrayal
-
-import (
- "fmt"
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/geo"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// SYMINS02 (S-52 PresLib §13.2.18 / §10.3.3.8) — portray an S-57 NEWOBJ from its
-// SYMINS attribute, the producer's explicit "symbol instruction" string. SYMINS is
-// a ';'-separated list of S-52 draw instructions — SY()/TX()/TE()/LS()/LC()/AC()/
-// AP() — that we render verbatim, instead of routing NEWOBJ to the V-AIS alias
-// (the S-101 FeatureCatalogue maps NEWOBJ→VirtualAISAidToNavigation, which would
-// stamp a V-AIS mark and ignore the producer's instruction). This is how the S-52
-// PresLib "ECDIS Chart 1" labels (164 TX), boundaries (LS/LC), fills (AC/AP) and
-// the size-check symbol SY(CHKSYM01) are drawn.
-//
-// Returns ok=false when the feature has no usable SYMINS, so the caller falls back
-// to the default new-object symbology.
-func parseSYMINS(f *s57.Feature) (FeatureBuild, bool) {
- attrs := f.Attributes()
- raw, _ := attrs["SYMINS"].(string)
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return FeatureBuild{}, false
- }
- g := geometryOf(f.Geometry())
- anchor, hasAnchor := representativePoint(f)
-
- var prims []Primitive
- for _, instr := range splitSyminsInstructions(raw) {
- op, params, ok := splitSyminsOp(instr)
- if !ok {
- continue
- }
- switch op {
- case "SY": // point symbol — SY(NAME[,rot])
- if !hasAnchor {
- continue
- }
- args := splitSyminsArgs(params)
- name := strings.TrimSpace(firstOr(args, ""))
- if name == "" {
- continue
- }
- rot := float32(0)
- if len(args) > 1 {
- if v, err := strconv.ParseFloat(strings.TrimSpace(args[1]), 32); err == nil {
- rot = float32(v)
- }
- }
- prims = append(prims, SymbolCall{
- Anchor: anchor, SymbolName: name, RotationDeg: rot,
- Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32,
- })
- case "TX", "TE": // text label
- if !hasAnchor {
- continue
- }
- if t, ok := parseSyminsText(op, params, attrs, anchor); ok {
- prims = append(prims, t)
- }
- case "LS": // simple line — LS(style,width,colour)
- args := splitSyminsArgs(params)
- if len(args) < 3 {
- continue
- }
- w, _ := strconv.Atoi(strings.TrimSpace(args[1]))
- if w <= 0 {
- w = 1
- }
- color := strings.TrimSpace(args[2])
- dash := syminsDash(strings.TrimSpace(args[0]))
- for _, line := range syminsLines(g) {
- prims = append(prims, StrokeLine{Points: line, ColorToken: color, WidthPx: float32(w), Dash: dash})
- }
- case "LC": // complex (symbolised) line — LC(LINESTYLE)
- name := strings.TrimSpace(firstOr(splitSyminsArgs(params), ""))
- if name == "" {
- continue
- }
- for _, line := range syminsLines(g) {
- prims = append(prims, LinePattern{Points: line, LinestyleName: name})
- }
- case "AC": // area colour fill — AC(COLOUR[,transp])
- color := strings.TrimSpace(firstOr(splitSyminsArgs(params), ""))
- if color != "" && len(g.area) > 0 {
- prims = append(prims, FillPolygon{Rings: g.area, ColorToken: color})
- }
- case "AP": // area pattern fill — AP(PATTERN)
- name := strings.TrimSpace(firstOr(splitSyminsArgs(params), ""))
- if name != "" && len(g.area) > 0 {
- prims = append(prims, PatternFill{Rings: g.area, PatternName: name})
- }
- }
- }
- if len(prims) == 0 {
- return FeatureBuild{}, false
- }
- return FeatureBuild{Primitives: prims, DisplayPriority: 6, DisplayCategory: displayStandard}, true
-}
-
-// parseSyminsText parses a SYMINS TX()/TE() instruction into a DrawText.
-//
-// TX(string|attr, hjust, vjust, space, chars, xoffs, yoffs, colour, display)
-// TE(format, attribs, hjust, vjust, space, chars, xoffs, yoffs, colour, display)
-func parseSyminsText(op, params string, attrs map[string]any, anchor geo.LatLon) (DrawText, bool) {
- args := splitSyminsArgs(params)
- var text string
- var hjustIdx, vjustIdx, charsIdx, xoffIdx, yoffIdx, colorIdx, displayIdx int
- if op == "TE" {
- if len(args) < 10 {
- return DrawText{}, false
- }
- format := strings.Trim(args[0], "'\"")
- var names []string
- for _, a := range strings.Split(strings.Trim(args[1], "'\""), ",") {
- if a = strings.TrimSpace(a); a != "" {
- names = append(names, a)
- }
- }
- t, ok := formatSubstitute(attrs, format, names)
- if !ok {
- return DrawText{}, false
- }
- text = t
- hjustIdx, vjustIdx, charsIdx, xoffIdx, yoffIdx, colorIdx, displayIdx = 2, 3, 5, 6, 7, 8, 9
- } else { // TX
- if len(args) < 9 {
- return DrawText{}, false
- }
- rawStr := args[0]
- if strings.HasPrefix(rawStr, "'") || strings.HasPrefix(rawStr, "\"") {
- text = strings.Trim(rawStr, "'\"") // literal
- } else { // attribute reference
- v, ok := attrs[strings.TrimSpace(rawStr)]
- if !ok || v == nil {
- return DrawText{}, false
- }
- text = fmt.Sprintf("%v", v)
- }
- hjustIdx, vjustIdx, charsIdx, xoffIdx, yoffIdx, colorIdx, displayIdx = 1, 2, 4, 5, 6, 7, 8
- }
- if text == "" {
- return DrawText{}, false
- }
- color := strings.TrimSpace(argAt(args, colorIdx))
- if color == "" {
- color = "CHBLK"
- }
- hjust, _ := strconv.Atoi(strings.TrimSpace(argAt(args, hjustIdx)))
- vjust, _ := strconv.Atoi(strings.TrimSpace(argAt(args, vjustIdx)))
- group, _ := strconv.Atoi(strings.TrimSpace(argAt(args, displayIdx)))
- xoff, _ := strconv.Atoi(strings.TrimSpace(argAt(args, xoffIdx)))
- yoff, _ := strconv.Atoi(strings.TrimSpace(argAt(args, yoffIdx)))
- fontPx := syminsFontPx(strings.Trim(argAt(args, charsIdx), "'\""))
- var halo *TextHalo
- if fontPx >= 10 {
- halo = &TextHalo{ColorToken: "CHWHT", WidthPx: 1}
- }
- return DrawText{
- Anchor: anchor, Text: text, FontSizePx: fontPx, ColorToken: color, Halo: halo,
- HAlign: syminsHAlign(hjust), VAlign: syminsVAlign(vjust),
- // S-52 §8.3.3.2 XOFFS/YOFFS are in units of the text body size (+x right, +y down).
- OffsetXPx: float32(xoff) * fontPx, OffsetYPx: float32(yoff) * fontPx,
- Group: group,
- }, true
-}
-
-// syminsFontPx converts a SYMINS CHARS field (e.g. '15110' = style/weight/slant +
-// two-digit body size) to a pixel font size. The body size is in points; one point
-// is 0.351 mm, scaled to px at the app's reference pixel pitch (100·DefaultPxPerSymbolUnit
-// px/mm). Falls back to the engine default (12 px) on a malformed field.
-func syminsFontPx(chars string) float32 {
- if len(chars) >= 5 {
- if body, err := strconv.Atoi(chars[3:5]); err == nil && body > 0 {
- return float32(body) * 0.351 * 100 * float32(DefaultPxPerSymbolUnit)
- }
- }
- return 12
-}
-
-// syminsHAlign maps S-52 HJUST (1 centre, 2 right, 3 left) to HAlign.
-func syminsHAlign(h int) HAlign {
- switch h {
- case 1:
- return HAlignCenter
- case 2:
- return HAlignRight
- default:
- return HAlignLeft
- }
-}
-
-// syminsVAlign maps S-52 VJUST (1 bottom, 2 centre, 3 top) to VAlign.
-func syminsVAlign(v int) VAlign {
- switch v {
- case 1:
- return VAlignBottom
- case 3:
- return VAlignTop
- default:
- return VAlignMiddle
- }
-}
-
-func syminsDash(style string) Dash {
- switch strings.ToUpper(style) {
- case "DASH":
- return DashDashed
- case "DOTT":
- return DashDotted
- default:
- return DashSolid
- }
-}
-
-// syminsLines returns the polyline(s) a line/area instruction (LS/LC) strokes: a
-// line feature's polyline, or each ring of an area feature (closed).
-func syminsLines(g geom) [][]geo.LatLon {
- switch g.kind {
- case geomLine:
- if len(g.line) >= 2 {
- return [][]geo.LatLon{g.line}
- }
- case geomArea:
- var out [][]geo.LatLon
- for _, r := range g.area {
- if len(r) >= 2 {
- if r[0] != r[len(r)-1] {
- r = append(append([]geo.LatLon(nil), r...), r[0])
- }
- out = append(out, r)
- }
- }
- return out
- }
- return nil
-}
-
-// splitSyminsInstructions splits a SYMINS string on ';', honouring quotes and
-// nested parens (so a ';' inside TX('a;b',…) or between parens isn't a split).
-func splitSyminsInstructions(s string) []string {
- var out []string
- var cur strings.Builder
- depth, inQuote := 0, false
- for i := 0; i < len(s); i++ {
- switch c := s[i]; c {
- case '\'', '"':
- inQuote = !inQuote
- cur.WriteByte(c)
- case '(':
- if !inQuote {
- depth++
- }
- cur.WriteByte(c)
- case ')':
- if !inQuote {
- depth--
- }
- cur.WriteByte(c)
- case ';':
- if !inQuote && depth == 0 {
- out = append(out, cur.String())
- cur.Reset()
- } else {
- cur.WriteByte(c)
- }
- default:
- cur.WriteByte(c)
- }
- }
- if cur.Len() > 0 {
- out = append(out, cur.String())
- }
- return out
-}
-
-// splitSyminsOp splits "OP(params)" into the op and the inner params.
-func splitSyminsOp(instr string) (op, params string, ok bool) {
- instr = strings.TrimSpace(instr)
- open := strings.IndexByte(instr, '(')
- closeI := strings.LastIndexByte(instr, ')')
- if open <= 0 || closeI < open {
- return "", "", false
- }
- return strings.TrimSpace(instr[:open]), instr[open+1 : closeI], true
-}
-
-// splitSyminsArgs splits an instruction's params on ',', honouring single/double
-// quotes (so a comma inside a quoted format/string stays in one arg).
-func splitSyminsArgs(params string) []string {
- var out []string
- var cur strings.Builder
- inQuote := false
- for i := 0; i < len(params); i++ {
- switch c := params[i]; c {
- case '\'', '"':
- inQuote = !inQuote
- cur.WriteByte(c)
- case ',':
- if inQuote {
- cur.WriteByte(c)
- } else {
- out = append(out, strings.TrimSpace(cur.String()))
- cur.Reset()
- }
- default:
- cur.WriteByte(c)
- }
- }
- out = append(out, strings.TrimSpace(cur.String()))
- return out
-}
-
-func firstOr(args []string, def string) string {
- if len(args) > 0 {
- return args[0]
- }
- return def
-}
-
-func argAt(args []string, i int) string {
- if i >= 0 && i < len(args) {
- return args[i]
- }
- return ""
-}
diff --git a/internal/engine/portrayal/symins_test.go b/internal/engine/portrayal/symins_test.go
deleted file mode 100644
index ead2158..0000000
--- a/internal/engine/portrayal/symins_test.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package portrayal
-
-import (
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-// TestSYMINSPointSymbol: a NEWOBJ point with SYMINS="SY(INFORM01)" portrays the
-// named symbol (the producer's instruction), NOT the V-AIS alias.
-func TestSYMINSPointSymbol(t *testing.T) {
- f := s57.NewFeature(1, "NEWOBJ",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-5.1, 15.1}}},
- map[string]any{"SYMINS": "SY(INFORM01)"},
- )
- fb, ok := parseSYMINS(&f)
- if !ok {
- t.Fatal("parseSYMINS returned ok=false")
- }
- if len(fb.Primitives) != 1 {
- t.Fatalf("want 1 primitive, got %d", len(fb.Primitives))
- }
- sc, ok := fb.Primitives[0].(SymbolCall)
- if !ok || sc.SymbolName != "INFORM01" {
- t.Fatalf("want SymbolCall INFORM01, got %#v", fb.Primitives[0])
- }
-}
-
-// TestSYMINSTextLabel: a TX literal label is parsed into a DrawText with the text,
-// colour and text group (display field) from the instruction.
-func TestSYMINSTextLabel(t *testing.T) {
- f := s57.NewFeature(2, "NEWOBJ",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-5.1, 15.1}}},
- map[string]any{"SYMINS": "TX('Information about',3,2,2,'14108',0,0,CHBLK,11)"},
- )
- fb, ok := parseSYMINS(&f)
- if !ok {
- t.Fatal("ok=false")
- }
- dt, ok := fb.Primitives[0].(DrawText)
- if !ok {
- t.Fatalf("want DrawText, got %#v", fb.Primitives[0])
- }
- if dt.Text != "Information about" {
- t.Errorf("text = %q, want %q", dt.Text, "Information about")
- }
- if dt.ColorToken != "CHBLK" {
- t.Errorf("colour = %q, want CHBLK", dt.ColorToken)
- }
- if dt.Group != 11 {
- t.Errorf("text group = %d, want 11", dt.Group)
- }
- if dt.HAlign != HAlignLeft { // HJUST 3 = left
- t.Errorf("HAlign = %v, want left", dt.HAlign)
- }
-}
-
-// TestSYMINSAreaBoundaryAndFill: an area NEWOBJ with a dashed boundary + colour
-// fill emits a StrokeLine per ring and a FillPolygon.
-func TestSYMINSAreaBoundaryAndFill(t *testing.T) {
- ring := [][]float64{{-5.1, 15.1}, {-5.0, 15.1}, {-5.0, 15.2}, {-5.1, 15.2}, {-5.1, 15.1}}
- f := s57.NewFeature(3, "NEWOBJ",
- s57.Geometry{Type: s57.GeometryTypePolygon, Coordinates: ring},
- map[string]any{"SYMINS": "AC(CHMGF);LS(DASH,2,CHMGD)"},
- )
- fb, ok := parseSYMINS(&f)
- if !ok {
- t.Fatal("ok=false")
- }
- var hasFill, hasDashedStroke bool
- for _, p := range fb.Primitives {
- switch v := p.(type) {
- case FillPolygon:
- if v.ColorToken == "CHMGF" {
- hasFill = true
- }
- case StrokeLine:
- if v.ColorToken == "CHMGD" && v.Dash == DashDashed {
- hasDashedStroke = true
- }
- }
- }
- if !hasFill || !hasDashedStroke {
- t.Fatalf("want CHMGF fill + dashed CHMGD stroke, got %#v", fb.Primitives)
- }
-}
-
-// TestSYMINSEmptyFallsThrough: no SYMINS ⇒ ok=false so the caller uses the default
-// new-object symbology.
-func TestSYMINSEmptyFallsThrough(t *testing.T) {
- f := s57.NewFeature(4, "NEWOBJ",
- s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-5.1, 15.1}}},
- map[string]any{},
- )
- if _, ok := parseSYMINS(&f); ok {
- t.Fatal("want ok=false for a feature with no SYMINS")
- }
-}
diff --git a/internal/engine/s101/colocation_test.go b/internal/engine/s101/colocation_test.go
deleted file mode 100644
index c245b73..0000000
--- a/internal/engine/s101/colocation_test.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package s101
-
-import (
- "strings"
- "testing"
-)
-
-// TestCoLocatedLightsStack proves HostSpatialGetAssociatedFeatureIDs: two LIGHTS
-// features sharing a node are reported as co-located, so the catalogue's
-// LightFlareAndDescription rule stacks the second description via
-// GetColocatedTextCount -> TextVerticalOffset. A control pair at distinct points
-// must NOT stack — confirming the offset comes from real co-location, not every
-// light.
-func TestCoLocatedLightsStack(t *testing.T) {
- rulesDir, cat := testEnv(t)
-
- light := func(id string, pt [3]float64, colour string) Feature {
- return Feature{
- ID: id, ObjectClass: "LIGHTS", Primitive: "Point",
- Points: [][3]float64{pt},
- Attributes: map[string]string{"COLOUR": colour, "LITCHR": "2", "VALNMR": "4", "SIGPER": "4"},
- }
- }
- countOffsets := func(t *testing.T, feats []Feature) int {
- e, err := NewEngine(rulesDir, cat)
- if err != nil {
- t.Fatal(err)
- }
- defer e.Close()
- res, err := e.Portray(feats)
- if err != nil {
- t.Fatal(err)
- }
- n := 0
- for _, f := range feats {
- if strings.HasPrefix(res[f.ID], "ERROR:") {
- t.Fatalf("rule error for %s: %s", f.ID, res[f.ID])
- }
- if strings.Contains(res[f.ID], "TextVerticalOffset") {
- n++
- }
- }
- return n
- }
-
- same := [3]float64{-122.4, 45.5, 0}
- if n := countOffsets(t, []Feature{light("a", same, "3"), light("b", same, "4")}); n != 1 {
- t.Errorf("co-located pair: want exactly one stacked (offset) description, got %d", n)
- }
-
- apart := [3]float64{-122.3, 45.6, 0}
- if n := countOffsets(t, []Feature{light("a", same, "3"), light("b", apart, "4")}); n != 0 {
- t.Errorf("lights at distinct points must not stack, got %d offset(s)", n)
- }
-}
diff --git a/internal/engine/s101/complex.go b/internal/engine/s101/complex.go
deleted file mode 100644
index 6705b0a..0000000
--- a/internal/engine/s101/complex.go
+++ /dev/null
@@ -1,367 +0,0 @@
-package s101
-
-import (
- "strconv"
- "strings"
-)
-
-// This file builds the synthesized attribute tree a feature's rule reads. S-57
-// stores everything as flat simple attributes on the feature; several S-101
-// rules instead read *complex* (nested) attributes — featureName, the bridge
-// clearances, orientation, and the light sectorCharacteristics structure. The
-// bridge synthesizes those from the S-57 source so the real catalogue rules run
-// unchanged. The host (host.go) serves the tree through the framework's
-// attributePath protocol (HostFeatureGetComplexAttributeCount / -SimpleAttribute).
-
-// cnode is one node of the tree: leaf simple values keyed by S-101 sub-attribute
-// code, and nested complex children keyed by code (each code → its ordered
-// instances). The feature root reuses this shape: its simple map holds the
-// feature's own simple attributes (translated to S-101 codes, list values split)
-// and its children hold the synthesized top-level complex attributes.
-type cnode struct {
- simple map[string][]string
- children map[string][]*cnode
-}
-
-func newCNode() *cnode {
- return &cnode{simple: map[string][]string{}, children: map[string][]*cnode{}}
-}
-
-// addSimple appends non-empty values under code (empties are dropped so the
-// framework reads "attribute absent" rather than an empty string).
-func (n *cnode) addSimple(code string, vals ...string) {
- for _, v := range vals {
- if v != "" {
- n.simple[code] = append(n.simple[code], v)
- }
- }
-}
-
-func (n *cnode) addChild(code string, child *cnode) {
- n.children[code] = append(n.children[code], child)
-}
-
-// resolve walks the framework attributePath ("code:idx;code:idx;…") from this
-// node to the container the framework is querying. An empty path is the node
-// itself. Returns nil if any segment is missing — the framework then reads count
-// 0 / no value, i.e. "attribute absent".
-func (n *cnode) resolve(path string) *cnode {
- cur := n
- if path == "" {
- return cur
- }
- for _, seg := range strings.Split(path, ";") {
- code, idxStr, ok := strings.Cut(seg, ":")
- if !ok {
- return nil
- }
- idx, err := strconv.Atoi(idxStr)
- if err != nil || idx < 1 {
- return nil
- }
- list := cur.children[code]
- if idx > len(list) {
- return nil
- }
- cur = list[idx-1]
- }
- return cur
-}
-
-// buildRoot assembles the synthesized tree for one feature: every S-57 simple
-// attribute (translated to its S-101 code, list values split into array
-// entries), the derived attributes, plus the complex attributes the rules read
-// but S-57 stores flat.
-func (e *Engine) buildRoot(objClass string, s57, derived map[string]string, name string, topmark map[string]string) *cnode {
- root := newCNode()
- for acr, val := range s57 {
- if code, ok := e.cat.AttrCodeForS57(acr); ok {
- root.simple[code] = e.splitValue(code, val)
- }
- }
- // Derived attributes are already in S-101 code form (no S-57 alias).
- for code, val := range derived {
- root.addSimple(code, val)
- }
-
- // featureName (S-57 OBJNAM/NOBJNM → the S-101 featureName complex attribute).
- if name != "" {
- fn := newCNode()
- fn.addSimple("name", name)
- fn.addSimple("language", "eng")
- fn.addSimple("nameUsage", "1") // default → selected even if language differs
- root.addChild("featureName", fn)
- }
-
- // Clearances (bridges): S-57 simple attribute → S-101 complex attribute
- // wrapping a *Value sub-attribute.
- for code, c := range clearances {
- if v := s57[c.s57]; v != "" {
- cl := newCNode()
- cl.addSimple(c.value, v)
- root.addChild(code, cl)
- } else if code == "verticalClearanceClosed" && objClass == "BRIDGE" {
- // An opening bridge's SpanOpening rule dereferences
- // verticalClearanceClosed.verticalClearanceValue unguarded (a DRAFT
- // catalogue bug); present-but-empty reads a nil value (→ hazard alert)
- // instead of crashing on a missing complex attribute.
- root.addChild(code, newCNode())
- }
- }
-
- // Orientation (S-57 ORIENT → the S-101 orientation complex attribute). Read by
- // NavigationLine (leading/clearing lines), RecommendedTrack, directional
- // lights, etc. — same flat-simple→complex shape as the clearances above.
- if v := s57["ORIENT"]; v != "" {
- o := newCNode()
- o.addSimple("orientationValue", v)
- root.addChild("orientation", o)
- }
-
- // Date ranges: S-57 stores DATSTA/DATEND (a one-time fixed window) and
- // PERSTA/PEREND (a recurring seasonal window) as flat simple attributes; S-101
- // wraps each in a complex attribute (fixedDateRange / periodicDateRange) whose
- // dateStart/dateEnd the framework's ProcessFixedAndPeriodicDates reads to mark
- // a feature date-dependent (emit Date:/TimeValid: + the CHDATD01 marker). The
- // dateStart/dateEnd S-101 codes alias BOTH the fixed and periodic S-57 pairs,
- // so the wrapping complex attribute — not the alias — carries the distinction;
- // read the S-57 source pairs directly. (A periodic value is an S-57 partial
- // date, e.g. "--0315" = March 15 every year.)
- if s57["DATSTA"] != "" || s57["DATEND"] != "" {
- fdr := newCNode()
- fdr.addSimple("dateStart", s57["DATSTA"])
- fdr.addSimple("dateEnd", s57["DATEND"])
- root.addChild("fixedDateRange", fdr)
- }
- if s57["PERSTA"] != "" || s57["PEREND"] != "" {
- pdr := newCNode()
- pdr.addSimple("dateStart", s57["PERSTA"])
- pdr.addSimple("dateEnd", s57["PEREND"])
- root.addChild("periodicDateRange", pdr)
- }
-
- // Zone of confidence (S-57 M_QUAL CATZOC → the S-101 zoneOfConfidence complex
- // attribute). QualityOfBathymetricData iterates feature.zoneOfConfidence and reads
- // each instance's categoryOfZoneOfConfidenceInData (+ optional fixedDateRange) to
- // pick the DQUAL data-quality fill; S-57 stores CATZOC as a flat simple, so wrap it
- // in one zoneOfConfidence instance (one M_QUAL feature carries one CATZOC). Without
- // this the rule sees no zones and the data-quality symbology never draws.
- if objClass == "M_QUAL" && s57["CATZOC"] != "" {
- zoc := newCNode()
- zoc.addSimple("categoryOfZoneOfConfidenceInData", s57["CATZOC"])
- if s57["DATSTA"] != "" || s57["DATEND"] != "" {
- fdr := newCNode()
- fdr.addSimple("dateStart", s57["DATSTA"])
- fdr.addSimple("dateEnd", s57["DATEND"])
- zoc.addChild("fixedDateRange", fdr)
- }
- root.addChild("zoneOfConfidence", zoc)
- }
-
- // Topmark (buoys/beacons): a co-located S-57 TOPMAR feature folded in by the
- // baker → the S-101 topmark complex attribute the TOPMAR02 CSP reads.
- if len(topmark) > 0 {
- tm := newCNode()
- tm.addSimple("topmarkDaymarkShape", topmark["shape"])
- if c := topmark["colour"]; c != "" {
- tm.simple["colour"] = e.splitValue("colour", c)
- }
- if len(tm.simple) > 0 {
- root.addChild("topmark", tm)
- }
- }
-
- // Light sectors / directional character (LIGHTS routed to LightSectored).
- e.buildLightSectors(root, objClass, s57)
-
- // rhythmOfLight — the light-description complex (lightCharacteristic /
- // signalGroup / signalPeriod) that LITDSN02 reads to build the characteristic
- // text. Sectored lights carry these inside sectorCharacteristics (above), but
- // LightAllAround reads them from rhythmOfLight; without it an all-around light's
- // description collapses to just its colour ("G" instead of "Q G 1s").
- e.buildRhythmOfLight(root, objClass, s57)
-
- return root
-}
-
-// buildRhythmOfLight synthesizes the rhythmOfLight complex attribute from the S-57
-// LITCHR / SIGGRP / SIGPER simple attributes, so LITDSN02 can render the full
-// light characteristic for lights that read it from rhythmOfLight (not the
-// sectorCharacteristics complex). Built whenever any of the three is present.
-func (e *Engine) buildRhythmOfLight(root *cnode, objClass string, s57 map[string]string) {
- if objClass != "LIGHTS" {
- return
- }
- if s57["LITCHR"] == "" && s57["SIGGRP"] == "" && s57["SIGPER"] == "" {
- return
- }
- rol := newCNode()
- rol.addSimple("lightCharacteristic", s57["LITCHR"])
- if g := s57["SIGGRP"]; g != "" {
- rol.simple["signalGroup"] = e.splitValue("signalGroup", g)
- }
- rol.addSimple("signalPeriod", s57["SIGPER"])
- root.addChild("rhythmOfLight", rol)
-}
-
-// buildLightSectors synthesizes the nested sectorCharacteristics → lightSector →
-// sectorLimit / directionalCharacter structure LightSectored reads, from the S-57
-// LIGHTS simple attributes. One S-57 LIGHTS feature carries exactly one sector
-// (one SECTR1/SECTR2/COLOUR/VALNMR set); multiple sectors at a position are
-// separate co-located features, so each becomes a single-sector LightSectored.
-func (e *Engine) buildLightSectors(root *cnode, objClass string, s57 map[string]string) {
- if objClass != "LIGHTS" {
- return
- }
- sectored := s57["SECTR1"] != "" && s57["SECTR2"] != ""
- directional := hasListVal(s57["CATLIT"], 1) && s57["ORIENT"] != ""
- if !sectored && !directional {
- return
- }
-
- sc := newCNode()
- sc.addSimple("lightCharacteristic", s57["LITCHR"])
- sc.addSimple("signalGroup", s57["SIGGRP"])
- sc.addSimple("signalPeriod", s57["SIGPER"])
-
- ls := newCNode()
- ls.simple["colour"] = e.splitValue("colour", s57["COLOUR"])
- ls.addSimple("valueOfNominalRange", s57["VALNMR"])
- if v := s57["LITVIS"]; v != "" {
- ls.simple["lightVisibility"] = e.splitValue("lightVisibility", v)
- }
-
- if sectored {
- sl := newCNode()
- one := newCNode()
- one.addSimple("sectorBearing", s57["SECTR1"])
- two := newCNode()
- two.addSimple("sectorBearing", s57["SECTR2"])
- sl.addChild("sectorLimitOne", one)
- sl.addChild("sectorLimitTwo", two)
- ls.addChild("sectorLimit", sl)
- } else { // directional
- dc := newCNode()
- o := newCNode()
- o.addSimple("orientationValue", s57["ORIENT"])
- dc.addChild("orientation", o)
- ls.addChild("directionalCharacter", dc)
- }
-
- sc.addChild("lightSector", ls)
- root.addChild("sectorCharacteristics", sc)
-}
-
-// splitValue splits an S-57 attribute value into the array entries the framework
-// expects: enumeration/integer attributes are S-57 lists (comma-separated, e.g.
-// COLOUR "1,3"); text/real/date values are single-valued and returned verbatim.
-func (e *Engine) splitValue(code, val string) []string {
- if sa := e.cat.SimpleAttrs[code]; sa != nil && (sa.ValueType == "enumeration" || sa.ValueType == "integer") {
- parts := strings.Split(val, ",")
- out := make([]string, 0, len(parts))
- for _, p := range parts {
- if p = strings.TrimSpace(p); p != "" {
- out = append(out, p)
- }
- }
- return out
- }
- return []string{val}
-}
-
-// firstListVal returns the first integer of an S-57 comma-separated list value,
-// or 0 if absent/unparseable.
-func firstListVal(csv string) int {
- head, _, _ := strings.Cut(csv, ",")
- n, _ := strconv.Atoi(strings.TrimSpace(head))
- return n
-}
-
-// hasListVal reports whether the S-57 comma-separated list value contains want.
-func hasListVal(csv string, want int) bool {
- for _, p := range strings.Split(csv, ",") {
- if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil && n == want {
- return true
- }
- }
- return false
-}
-
-// resolveCode maps an S-57 object class to its S-101 feature code, disambiguating
-// the classes whose S-57→S-101 mapping is one-to-many (a conversion that depends
-// on the feature's attributes, not a fixed alias). Falls back to the catalogue
-// alias for every other class.
-func (e *Engine) resolveCode(objClass string, attrs map[string]string) (string, bool) {
- switch objClass {
- case "LIGHTS":
- // S-57 LIGHTS aliases to LightAllAround/LightSectored/LightAirObstruction/
- // LightFogDetector; the target depends on the light's attributes.
- return e.resolveLightClass(attrs), true
- case "ADMARE":
- // Aliases to AdministrationArea and VesselTrafficServiceArea; the plain
- // administration area is the correct default (VTS is a distinct S-57 class).
- if _, ok := e.cat.FeatureTypes["AdministrationArea"]; ok {
- return "AdministrationArea", true
- }
- case "MORFAC":
- // S-57 MORFAC (mooring/warping facility) has no single S-101 class; it
- // decomposes by CATMOR into distinct feature classes.
- if code := e.resolveMooringClass(attrs); code != "" {
- return code, true
- }
- case "TSELNE", "TSEZNE":
- // S-101 merged the separation line (TSELNE, a curve) and separation zone
- // (TSEZNE, a surface) into one geometry-distinguished feature with no alias.
- if _, ok := e.cat.FeatureTypes["SeparationZoneOrLine"]; ok {
- return "SeparationZoneOrLine", true
- }
- }
- return e.cat.FeatureCodeForS57(objClass)
-}
-
-// resolveMooringClass maps an S-57 MORFAC to its S-101 feature class by CATMOR
-// (category of mooring/warping facility): dolphin→Dolphin, bollard→Bollard,
-// post/pile→Pile, chain/wire/cable→MooringTrot, mooring buoy→MooringBuoy. An
-// unknown/absent category (incl. tie-up wall, which has no point-feature target)
-// falls back to Pile, the generic mooring post. Returns "" if the resolved class
-// isn't in the catalogue (caller then falls back to the alias lookup).
-func (e *Engine) resolveMooringClass(attrs map[string]string) string {
- var code string
- switch firstListVal(attrs["CATMOR"]) {
- case 1, 2: // dolphin, deviation dolphin
- code = "Dolphin"
- case 3: // bollard
- code = "Bollard"
- case 6: // chain / wire / cable
- code = "MooringTrot"
- case 7: // mooring buoy
- code = "MooringBuoy"
- default: // post or pile (5), tie-up wall (4), unknown
- code = "Pile"
- }
- if _, ok := e.cat.FeatureTypes[code]; ok {
- return code
- }
- return ""
-}
-
-// resolveLightClass picks the S-101 light class for an S-57 LIGHTS feature. A
-// light with two sector limits, or a directional light, is portrayed by
-// LightSectored (which draws sector legs/arcs and directional characters); air
-// obstruction and fog detector lights have dedicated rules; everything else is
-// an all-around light.
-func (e *Engine) resolveLightClass(attrs map[string]string) string {
- if attrs["SECTR1"] != "" && attrs["SECTR2"] != "" {
- return "LightSectored"
- }
- catlit := attrs["CATLIT"]
- switch {
- case hasListVal(catlit, 1): // directional function
- return "LightSectored"
- case hasListVal(catlit, 6): // air obstruction
- return "LightAirObstruction"
- case hasListVal(catlit, 7): // fog detector
- return "LightFogDetector"
- }
- return "LightAllAround"
-}
diff --git a/internal/engine/s101/engine.go b/internal/engine/s101/engine.go
deleted file mode 100644
index 33acfe8..0000000
--- a/internal/engine/s101/engine.go
+++ /dev/null
@@ -1,389 +0,0 @@
-// Package s101 runs the IHO S-101 portrayal rules (gopher-lua) against our
-// S-57 feature data, with the Lua host's type introspection backed by the
-// S-101 Feature Catalogue (pkg/s100/fc) and an S-57→S-101 feature/attribute
-// adapter built from the catalogue's mapping. It is the bridge +
-// host-API integration that connects S-57 features to the S-101 rules.
-//
-// Each feature's rule runs directly (require(Code); the global of that name),
-// exercising the real feature/attribute model — lazy attribute resolution,
-// PrimitiveType from geometry, enum decoding — through the bridge.
-package s101
-
-import (
- "bytes"
- "fmt"
- "io/fs"
- "os"
- "strings"
- "sync"
-
- "github.com/beetlebugorg/chartplotter/pkg/s100/fc"
- lua "github.com/yuin/gopher-lua"
- "github.com/yuin/gopher-lua/parse"
-)
-
-// ProtoCache memoizes compiled Lua chunk prototypes by require name so engines
-// built from the same Rules tree don't re-parse and re-compile the framework
-// (and per-class rule files) from source on every construction. A *FunctionProto
-// is immutable after compilation and safe to instantiate into many independent
-// LStates via NewFunctionFromProto, so one cache is shared across every engine a
-// builder creates. Safe for concurrent use.
-type ProtoCache struct {
- mu sync.Mutex
- protos map[string]*lua.FunctionProto
-}
-
-// NewProtoCache returns an empty prototype cache.
-func NewProtoCache() *ProtoCache { return &ProtoCache{protos: map[string]*lua.FunctionProto{}} }
-
-// proto returns the compiled prototype for .lua under rules, compiling and
-// caching it on first request. The lock is held across compile so a cold cache
-// compiles each chunk once; after warmup it's a single map read.
-func (c *ProtoCache) proto(rules fs.FS, name string) (*lua.FunctionProto, error) {
- c.mu.Lock()
- defer c.mu.Unlock()
- if p, ok := c.protos[name]; ok {
- return p, nil
- }
- data, err := fs.ReadFile(rules, name+".lua")
- if err != nil {
- return nil, err
- }
- chunk, err := parse.Parse(bytes.NewReader(data), name+".lua")
- if err != nil {
- return nil, err
- }
- p, err := lua.Compile(chunk, name+".lua")
- if err != nil {
- return nil, err
- }
- c.protos[name] = p
- return p, nil
-}
-
-// Feature is one S-57 feature to portray, in S-57 terms.
-type Feature struct {
- ID string
- ObjectClass string // S-57 acronym, e.g. SILTNK
- Primitive string // "Point" | "MultiPoint" | "Curve" | "Surface"
- Attributes map[string]string // S-57 attribute acronym -> encoded value, e.g. {"CATSIL":"3"}
- // Derived carries pre-computed S-101 attribute values keyed by their S-101
- // code (NOT an S-57 acronym) — for S-101 attributes with no direct S-57
- // source, e.g. defaultClearanceDepth (the underlying depth-area depth a
- // danger of unknown depth inherits, S-52 DEPVAL02).
- Derived map[string]string
- // Points carries the multipoint vertices (lon, lat, depth) for a MultiPoint
- // primitive (SOUNDG): the rule (Sounding→SOUNDG03) iterates them, reading each
- // point's X/Y and ScaledZ (depth). Empty for non-multipoint features.
- Points [][3]float64
- // Topmark carries a co-located S-57 TOPMAR's data ("shape" from TOPSHP,
- // "colour" from COLOUR) for a buoy/beacon. In S-101 the topmark is a complex
- // attribute on the parent, not a separate feature, so the baker folds the
- // co-located TOPMAR in here and the bridge synthesizes the topmark complex
- // attribute the buoy/beacon rules' TOPMAR02 CSP reads. Empty otherwise.
- Topmark map[string]string
-}
-
-// contextParameters is the full S-101 mariner-setting set the rules read
-// (rules error on reading an unregistered setting); name, S-100 value type, and
-// default. Depth contours default to S-52-typical metres.
-var contextParameters = []struct{ name, typ, def string }{
- {"RadarOverlay", "boolean", "false"},
- {"PlainBoundaries", "boolean", "false"},
- {"SimplifiedSymbols", "boolean", "false"},
- {"FourShades", "boolean", "true"},
- {"FullLightLines", "boolean", "false"},
- {"IgnoreScaleMinimum", "boolean", "false"},
- {"ShallowWaterDangers", "boolean", "false"},
- {"SafetyContour", "real", "30"},
- {"SafetyDepth", "real", "30"},
- {"ShallowContour", "real", "2"},
- {"DeepContour", "real", "30"},
- {"SafetyHeight", "real", "0"},
- {"PreferredLanguage", "text", "eng"},
-}
-
-// Engine holds a Lua state with the S-101 framework loaded and the host
-// callbacks bound to a Feature Catalogue.
-type Engine struct {
- L *lua.LState
- cat *fc.Catalogue
-
- // overrides replaces a context parameter's default for this engine (e.g.
- // PlainBoundaries=true), so the baker can portray boundary-style / point-style
- // variants. Empty = the defaults in contextParameters.
- overrides map[string]string
-
- // per-run feature data (set by Portray); keyed by feature ID.
- adapted map[string]*adapted
- order []string
- // coLocated indexes mapped point features by their exact position, so the
- // host can answer HostSpatialGetAssociatedFeatureIDs — i.e. which features
- // share a node. Co-located lights use it (S-101 LightFlareAndDescription) to
- // stack their descriptions (GetColocatedTextCount) and fan their flares.
- coLocated map[[2]float64][]string
-}
-
-// coLocatedFeatureIDs returns the IDs of mapped point features that share the
-// given feature's node (including the feature itself; the caller filters self).
-// It backs HostSpatialGetAssociatedFeatureIDs for point spatials — the data the
-// S-101 LightFlareAndDescription rule needs to stack co-located light
-// descriptions and fan their flares. Non-point / unknown features yield nil.
-func (e *Engine) coLocatedFeatureIDs(featureID string) []string {
- a := e.adapted[featureID]
- if a == nil || a.primitive != "Point" || len(a.points) == 0 {
- return nil
- }
- k := [2]float64{a.points[0][0], a.points[0][1]}
- ids := e.coLocated[k]
- if len(ids) < 2 {
- return nil // only itself: no co-located neighbours
- }
- return ids
-}
-
-// SetContextOverrides overrides context-parameter defaults (e.g.
-// {"PlainBoundaries": "true"}) before Portray, so a variant pass renders plain
-// boundaries / simplified symbols. Call right after construction.
-func (e *Engine) SetContextOverrides(o map[string]string) { e.overrides = o }
-
-// adapted is an S-57 feature rewritten into S-101 terms via the bridge. root is
-// the synthesized attribute tree (simple attributes + featureName / clearance /
-// orientation / sector complex attributes) the host serves to the rule.
-type adapted struct {
- id, code, primitive string
- root *cnode // synthesized attribute tree (see complex.go)
- points [][3]float64 // multipoint vertices (lon,lat,depth) for SOUNDG
-}
-
-// NewEngine loads the S-101 framework from a Rules directory (path) and binds
-// host callbacks to cat.
-func NewEngine(rulesDir string, cat *fc.Catalogue) (*Engine, error) {
- return NewEngineFS(os.DirFS(rulesDir), cat)
-}
-
-// NewEngineFS loads the S-101 framework from an fs.FS rooted at the Rules
-// directory (e.g. an embed.FS sub-tree) and binds host callbacks to cat. It
-// compiles the framework fresh; reuse a ProtoCache via NewEngineFSCached to share
-// compiled chunks across engines.
-func NewEngineFS(rules fs.FS, cat *fc.Catalogue) (*Engine, error) {
- return NewEngineFSCached(rules, cat, NewProtoCache())
-}
-
-// NewEngineFSCached is NewEngineFS sharing a caller-owned ProtoCache, so a batch
-// of engines built from the same Rules tree parse + compile each chunk once
-// instead of per engine. Each engine still gets a fresh LState (its per-cell
-// catalogue caches are freed on Close); only the immutable compiled prototypes
-// are shared.
-func NewEngineFSCached(rules fs.FS, cat *fc.Catalogue, cache *ProtoCache) (*Engine, error) {
- // A growable registry: a single SOUNDG can carry thousands of soundings, and
- // resolving its multipoint builds one Lua object per point (plus SOUNDG03's
- // per-point instructions). The default fixed registry overflows on such a
- // feature, so allow it to grow well past the default ceiling.
- e := &Engine{L: lua.NewState(lua.Options{
- RegistrySize: 1024 * 64,
- RegistryMaxSize: 1024 * 1024,
- RegistryGrowStep: 1024 * 32,
- IncludeGoStackTrace: false,
- }), cat: cat}
- L := e.L
-
- noop := L.NewFunction(func(*lua.LState) int { return 0 })
- dbg := L.NewTable()
- for _, m := range []string{"StartPerformance", "StopPerformance", "ResetPerformance", "Break", "FirstChanceError", "Trace"} {
- L.SetField(dbg, m, noop)
- }
- L.SetGlobal("Debug", dbg)
-
- loaded := map[string]bool{}
- L.SetGlobal("require", L.NewFunction(func(s *lua.LState) int {
- name := s.CheckString(1)
- if loaded[name] {
- return 0
- }
- loaded[name] = true
- proto, err := cache.proto(rules, name)
- if err != nil {
- s.RaiseError("require(%q): %v", name, err)
- }
- // A top-level chunk has no upvalues and binds globals via ls.Env, so
- // NewFunctionFromProto is equivalent to Load here — just without the
- // re-parse/compile.
- s.Push(s.NewFunctionFromProto(proto))
- if err := s.PCall(0, 0, nil); err != nil {
- s.RaiseError("require(%q): %v", name, err)
- }
- return 0
- }))
-
- e.bindHost()
-
- // main.lua defines globals the rules rely on (sqParams, unknownValue,
- // nilMarker, scaminInfinite, …) in addition to PortrayalMain, so it must be
- // loaded even though we dispatch rules directly rather than via PortrayalMain.
- if err := L.DoString(`require 'S100Scripting'; require 'PortrayalModel'; require 'PortrayalAPI'; require 'Default'; require 'main'`); err != nil {
- L.Close()
- return nil, fmt.Errorf("load framework: %w", err)
- }
- if err := L.DoString(spatialGlue); err != nil {
- L.Close()
- return nil, fmt.Errorf("install spatial glue: %w", err)
- }
- return e, nil
-}
-
-// spatialGlue installs HostFeatureGetSpatialAssociations + HostGetSpatial in
-// Lua (after the framework loads) so they can build proper SpatialAssociation /
-// Surface objects via the framework constructors. We model each feature's
-// geometry as ONE association of its primitive type — enough for the line/area
-// rules to iterate GetFlattenedSpatialAssociations without erroring; the actual
-// boundary/fill geometry is attached by the Go side when it emits primitives, not read
-// from Lua. Surfaces resolve (HostGetSpatial) to a surface with a single
-// exterior-ring curve. _HostFeaturePrimitive (Go) gives the primitive type.
-const spatialGlue = `
-function HostFeatureGetSpatialAssociations(featureID)
- local pt = _HostFeaturePrimitive(featureID)
- if pt == '' then return nil end
- local arr = { Type = 'array:SpatialAssociation' }
- arr[1] = CreateSpatialAssociation(pt, featureID .. '#' .. string.sub(pt, 1, 1), Orientation.Forward)
- return arr
-end
-
-function HostGetSpatial(spatialID)
- -- Surfaces (id suffix '#S') resolve a Spatial: a surface whose single
- -- exterior ring is one curve (so GetFlattenedSpatialAssociations yields it).
- if string.sub(spatialID, -2) == '#S' then
- local fid = string.sub(spatialID, 1, -3)
- local ext = CreateSpatialAssociation('Curve', fid .. '#exterior', Orientation.Forward)
- return CreateSurface(ext, {})
- end
- -- MultiPoints (id suffix '#M', SOUNDG) resolve to a real multipoint built
- -- from the feature's vertices, so SOUNDG03 can iterate point.X/Y/ScaledZ.
- if string.sub(spatialID, -2) == '#M' then
- local fid = string.sub(spatialID, 1, -3)
- local pts = _HostFeaturePoints(fid)
- local spatials = { Type = 'array:Spatial' }
- for _, p in ipairs(pts) do
- spatials[#spatials + 1] = CreatePoint(p[1], p[2], p[3])
- end
- return CreateMultiPoint(spatials)
- end
- -- Points (id suffix '#P') MUST resolve to a real Point, never nil: the
- -- framework's GetSpatial does self['Spatial'] = sa.Spatial then re-reads
- -- self['Spatial'] — assigning nil leaves the field absent, so the re-read
- -- re-enters GetSpatial forever (the OBSTRN/WRECKS deep-sounding overflow).
- if string.sub(spatialID, -2) == '#P' then
- local fid = string.sub(spatialID, 1, -3)
- local pts = _HostFeaturePoints(fid)
- if pts[1] then
- return CreatePoint(pts[1][1], pts[1][2], pts[1][3])
- end
- return CreatePoint('0', '0', nil)
- end
- return nil
-end
-`
-
-// Close releases the Lua state.
-func (e *Engine) Close() { e.L.Close() }
-
-// Portray runs each feature's rule and returns featureID -> drawing-instruction
-// stream. Unmapped object classes (no S-101 alias) yield an "UNMAPPED:" marker
-// so gaps are visible rather than silent.
-func (e *Engine) Portray(features []Feature) (map[string]string, error) {
- e.adapted = map[string]*adapted{}
- e.order = nil
- e.coLocated = map[[2]float64][]string{}
- results := map[string]string{}
-
- for _, f := range features {
- code, ok := e.resolveCode(f.ObjectClass, f.Attributes)
- if !ok {
- results[f.ID] = "UNMAPPED:" + f.ObjectClass
- continue
- }
- // S-57 OBJNAM/NOBJNM → the S-101 featureName complex attribute (served by
- // the host) so PortrayFeatureName emits a label.
- name := f.Attributes["OBJNAM"]
- if name == "" {
- name = f.Attributes["NOBJNM"]
- }
- a := &adapted{
- id: f.ID,
- code: code,
- primitive: f.Primitive,
- points: f.Points,
- root: e.buildRoot(f.ObjectClass, f.Attributes, f.Derived, name, f.Topmark),
- }
- e.adapted[f.ID] = a
- e.order = append(e.order, f.ID)
- // Index point features by exact position for co-location queries. Only
- // point primitives carry a single resolvable node; lines/areas don't
- // participate in the LIGHTS06 co-located stacking/fanning.
- if f.Primitive == "Point" && len(f.Points) > 0 {
- k := [2]float64{f.Points[0][0], f.Points[0][1]}
- e.coLocated[k] = append(e.coLocated[k], f.ID)
- }
- }
-
- if len(e.order) > 0 {
- if err := e.run(); err != nil {
- return nil, err
- }
- res := e.L.GetGlobal("_RESULTS")
- if t, ok := res.(*lua.LTable); ok {
- for _, id := range e.order {
- if v := t.RawGetString(id); v != lua.LNil {
- results[id] = v.String()
- }
- }
- }
- }
- return results, nil
-}
-
-// run builds the portrayal context from the host callbacks and dispatches each
-// feature's rule, collecting joined instruction streams into _RESULTS.
-func (e *Engine) run() error {
- var b strings.Builder
- b.WriteString("local cps = { Type = 'array:ContextParameter' }\n")
- for _, p := range contextParameters {
- def := p.def
- if v, ok := e.overrides[p.name]; ok {
- def = v
- }
- fmt.Fprintf(&b, "table.insert(cps, PortrayalCreateContextParameter(%q, %q, %q))\n", p.name, p.typ, def)
- }
- // Mirror main.lua's ProcessFeaturePortrayalItem success path (date ranges +
- // rule + feature name + nautical info + date-dependent marker), but on error
- // we suppress the feature rather than fall back to Default (which would stamp
- // QUESMRK1 everywhere).
- b.WriteString(`PortrayalInitializeContextParameters(cps)
-_RESULTS = {}
-local ctx = portrayalContext.ContextParameters
-for _, item in ipairs(portrayalContext.FeaturePortrayalItems) do
- local feature = item.Feature
- local fp = item:NewFeaturePortrayal()
- local ok, err = pcall(function()
- -- Fixed/periodic date ranges (synthesized from S-57 DATSTA/DATEND +
- -- PERSTA/PEREND): emit Date:/TimeValid: annotations and report whether the
- -- feature is date-dependent so the CHDATD01 marker is added below.
- local dateDependent = ProcessFixedAndPeriodicDates(feature, fp)
- require(feature.Code)
- local vg = _G[feature.Code](feature, fp, ctx)
- if not fp.GetFeatureNameCalled then
- PortrayFeatureName(feature, fp, ctx, 32, 24, vg, nil, 'TextAlignHorizontal:Center;TextAlignVertical:Top;LocalOffset:0,-3.51;FontColor:CHBLK')
- end
- ProcessNauticalInformation(feature, fp, ctx, vg)
- if dateDependent then
- AddDateDependentSymbol(feature, fp, ctx, vg)
- end
- end)
- if ok then
- _RESULTS[feature.ID] = table.concat(fp.DrawingInstructions, ';')
- else
- _RESULTS[feature.ID] = 'ERROR:' .. tostring(err)
- end
-end`)
- return e.L.DoString(b.String())
-}
diff --git a/internal/engine/s101/engine_test.go b/internal/engine/s101/engine_test.go
deleted file mode 100644
index 1c16e75..0000000
--- a/internal/engine/s101/engine_test.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package s101
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/s100/fc"
-)
-
-// testEnv resolves the vendored catalogue + feature catalogue, or skips.
-func testEnv(t *testing.T) (rulesDir string, cat *fc.Catalogue) {
- t.Helper()
- pc := os.Getenv("S101_CATALOG")
- if pc == "" {
- pc = "/home/jcollins/Projects/s101-portrayal-catalogue/PortrayalCatalog"
- }
- fcPath := os.Getenv("S101_FC")
- if fcPath == "" {
- fcPath = "/home/jcollins/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml"
- }
- rulesDir = filepath.Join(pc, "Rules")
- if _, err := os.Stat(filepath.Join(rulesDir, "main.lua")); err != nil {
- t.Skipf("S-101 rules not present (%s); set S101_CATALOG/S101_FC to run", rulesDir)
- }
- if _, err := os.Stat(fcPath); err != nil {
- t.Skipf("S-101 feature catalogue not present (%s)", fcPath)
- }
- c, err := fc.Load(fcPath)
- if err != nil {
- t.Fatal(err)
- }
- return rulesDir, c
-}
-
-func portrayOne(t *testing.T, e *Engine, f Feature) string {
- t.Helper()
- res, err := e.Portray([]Feature{f})
- if err != nil {
- t.Fatal(err)
- }
- out := res[f.ID]
- if strings.HasPrefix(out, "ERROR:") {
- t.Fatalf("rule error: %s", out)
- }
- return out
-}
-
-// TestSiloTankAttributeDrivenSymbology proves the whole bridge + host path:
-// an S-57 feature (acronyms) is adapted to S-101 and its rule branches on the
-// translated attribute values to pick the right point symbol.
-func TestSiloTankAttributeDrivenSymbology(t *testing.T) {
- rulesDir, cat := testEnv(t)
- e, err := NewEngine(rulesDir, cat)
- if err != nil {
- t.Fatal(err)
- }
- defer e.Close()
-
- // CATSIL=3 (categoryOfSiloTank=tower), CONVIS=1 (visualProminence=prominent)
- // -> SiloTank rule emits PointInstruction:TOWERS03.
- out := portrayOne(t, e, Feature{
- ID: "f1", ObjectClass: "SILTNK", Primitive: "Point",
- Attributes: map[string]string{"CATSIL": "3", "CONVIS": "1"},
- })
- if !strings.Contains(out, "PointInstruction:TOWERS03") {
- t.Errorf("CATSIL=3,CONVIS=1: want TOWERS03, got %q", out)
- }
-
- // CATSIL=2, no CONVIS -> categoryOfSiloTank==2 branch -> TNKCON02.
- out = portrayOne(t, e, Feature{
- ID: "f2", ObjectClass: "SILTNK", Primitive: "Point",
- Attributes: map[string]string{"CATSIL": "2"},
- })
- if !strings.Contains(out, "PointInstruction:TNKCON02") {
- t.Errorf("CATSIL=2: want TNKCON02, got %q", out)
- }
-
- // Surface primitive -> ColorFill:CHBRN + a boundary line.
- out = portrayOne(t, e, Feature{
- ID: "f3", ObjectClass: "SILTNK", Primitive: "Surface",
- Attributes: map[string]string{"CATSIL": "1"},
- })
- if !strings.Contains(out, "ColorFill:CHBRN") {
- t.Errorf("surface: want ColorFill:CHBRN, got %q", out)
- }
-}
-
-func TestUnmappedObjectClassMarked(t *testing.T) {
- rulesDir, cat := testEnv(t)
- e, err := NewEngine(rulesDir, cat)
- if err != nil {
- t.Fatal(err)
- }
- defer e.Close()
-
- res, err := e.Portray([]Feature{{ID: "x", ObjectClass: "ZZZZZZ", Primitive: "Point"}})
- if err != nil {
- t.Fatal(err)
- }
- if res["x"] != "UNMAPPED:ZZZZZZ" {
- t.Errorf("want UNMAPPED marker, got %q", res["x"])
- }
-}
diff --git a/internal/engine/s101/host.go b/internal/engine/s101/host.go
deleted file mode 100644
index e695024..0000000
--- a/internal/engine/s101/host.go
+++ /dev/null
@@ -1,284 +0,0 @@
-package s101
-
-import (
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/s100/fc"
- lua "github.com/yuin/gopher-lua"
-)
-
-// bindHost registers every Host* callback the S-101 framework calls. Type
-// introspection is backed by the Feature Catalogue (e.cat); per-feature data is
-// backed by the adapted features set in Portray.
-func (e *Engine) bindHost() {
- L := e.L
-
- set := func(name string, fn lua.LGFunction) { L.SetGlobal(name, L.NewFunction(fn)) }
- emptyArray := func(s *lua.LState) int { s.Push(s.NewTable()); return 1 }
-
- // --- type code lists (GetTypeInfo iterates these) ---
- set("HostGetFeatureTypeCodes", func(s *lua.LState) int {
- s.Push(keysOfFeatureTypes(s, e.cat))
- return 1
- })
- set("HostGetSimpleAttributeTypeCodes", func(s *lua.LState) int {
- s.Push(keysOfSimpleAttrs(s, e.cat))
- return 1
- })
- set("HostGetInformationTypeCodes", func(s *lua.LState) int {
- t := s.NewTable()
- for code := range e.cat.InformationTypes {
- t.Append(lua.LString(code))
- }
- s.Push(t)
- return 1
- })
- set("HostGetComplexAttributeTypeCodes", func(s *lua.LState) int {
- t := s.NewTable()
- for code := range e.cat.ComplexAttrs {
- t.Append(lua.LString(code))
- }
- s.Push(t)
- return 1
- })
- set("HostGetRoleTypeCodes", emptyArray)
- set("HostGetInformationAssociationTypeCodes", emptyArray)
- set("HostGetFeatureAssociationTypeCodes", emptyArray)
-
- // --- per-code type info (fetched lazily) ---
- typeInfo := func(s *lua.LState, kind, code string, binds []fc.AttributeBinding) *lua.LTable {
- info := s.NewTable()
- s.SetField(info, "Type", lua.LString(kind))
- s.SetField(info, "Code", lua.LString(code))
- s.SetField(info, "AttributeBindings", bindingsTable(s, binds))
- return info
- }
- set("HostGetFeatureTypeInfo", func(s *lua.LState) int {
- code := s.CheckString(1)
- var binds []fc.AttributeBinding
- if ft := e.cat.FeatureTypes[code]; ft != nil {
- binds = ft.Bindings
- }
- binds = withGuaranteed(binds)
- s.Push(typeInfo(s, "FeatureTypeInfo", code, binds))
- return 1
- })
- set("HostGetSimpleAttributeTypeInfo", func(s *lua.LState) int {
- code := s.CheckString(1)
- info := s.NewTable()
- s.SetField(info, "Type", lua.LString("SimpleAttributeInfo"))
- s.SetField(info, "Code", lua.LString(code))
- if sa := e.cat.SimpleAttrs[code]; sa != nil {
- s.SetField(info, "ValueType", lua.LString(sa.ValueType))
- }
- s.Push(info)
- return 1
- })
- set("HostGetInformationTypeInfo", func(s *lua.LState) int {
- code := s.CheckString(1)
- var binds []fc.AttributeBinding
- if it := e.cat.InformationTypes[code]; it != nil {
- binds = it.Bindings
- }
- s.Push(typeInfo(s, "InformationTypeInfo", code, binds))
- return 1
- })
- set("HostGetComplexAttributeTypeInfo", func(s *lua.LState) int {
- code := s.CheckString(1)
- var binds []fc.AttributeBinding
- if ca := e.cat.ComplexAttrs[code]; ca != nil {
- binds = ca.Bindings
- }
- s.Push(typeInfo(s, "ComplexAttributeInfo", code, binds))
- return 1
- })
-
- // --- dataset / feature data ---
- set("HostGetFeatureIDs", func(s *lua.LState) int {
- t := s.NewTable()
- for _, id := range e.order {
- t.Append(lua.LString(id))
- }
- s.Push(t)
- return 1
- })
- set("HostFeatureGetCode", func(s *lua.LState) int {
- if a := e.adapted[s.CheckString(1)]; a != nil {
- s.Push(lua.LString(a.code))
- } else {
- s.Push(lua.LString(""))
- }
- return 1
- })
- // _HostFeaturePrimitive backs the Lua-side HostFeatureGetSpatialAssociations
- // glue (installed after the framework loads), which builds proper
- // SpatialAssociation objects via the framework constructors. Returns the
- // feature's primitive ("Point"|"Curve"|"Surface"), or "" if unknown.
- set("_HostFeaturePrimitive", func(s *lua.LState) int {
- if a := e.adapted[s.CheckString(1)]; a != nil {
- s.Push(lua.LString(a.primitive))
- } else {
- s.Push(lua.LString(""))
- }
- return 1
- })
- // _HostFeaturePoints backs the MultiPoint spatial glue (HostGetSpatial '#M'):
- // returns the feature's multipoint vertices as an array of {x,y,z} string
- // triples (CreatePoint takes strings), x=lon y=lat z=depth. Used for SOUNDG.
- set("_HostFeaturePoints", func(s *lua.LState) int {
- t := s.NewTable()
- if a := e.adapted[s.CheckString(1)]; a != nil {
- for _, p := range a.points {
- row := s.NewTable()
- row.Append(lua.LString(strconv.FormatFloat(p[0], 'f', -1, 64)))
- row.Append(lua.LString(strconv.FormatFloat(p[1], 'f', -1, 64)))
- row.Append(lua.LString(strconv.FormatFloat(p[2], 'f', -1, 64)))
- t.Append(row)
- }
- }
- s.Push(t)
- return 1
- })
- set("HostFeatureGetSimpleAttribute", func(s *lua.LState) int {
- // (featureID, attributePath, attributeCode) -> array of value strings.
- // Served from the synthesized attribute tree: resolve the container the
- // path points at, then return that node's simple values for the code.
- id := s.CheckString(1)
- path := s.CheckString(2)
- code := s.CheckString(3)
- t := s.NewTable()
- if a := e.adapted[id]; a != nil && a.root != nil {
- if node := a.root.resolve(path); node != nil {
- for _, v := range node.simple[code] {
- t.Append(lua.LString(v))
- }
- }
- }
- s.Push(t)
- return 1
- })
- set("HostFeatureGetComplexAttributeCount", func(s *lua.LState) int {
- // (featureID, attributePath, attributeCode) -> count of that complex
- // attribute's instances at the container the path points at.
- id := s.CheckString(1)
- path := s.CheckString(2)
- code := s.CheckString(3)
- n := 0
- if a := e.adapted[id]; a != nil && a.root != nil {
- if node := a.root.resolve(path); node != nil {
- n = len(node.children[code])
- }
- }
- s.Push(lua.LNumber(n))
- return 1
- })
- set("HostFeatureGetAssociatedFeatureIDs", emptyArray)
- set("HostFeatureGetAssociatedInformationIDs", emptyArray)
- // HostFeatureGetSpatialAssociations + HostGetSpatial are defined in Lua glue
- // (installSpatialGlue) after the framework loads, so they can use the
- // framework's CreateSpatialAssociation/CreateSurface constructors.
- //
- // HostSpatialGetAssociatedFeatureIDs answers "which features share this
- // spatial?" The glue's point-spatial IDs are "#P", so resolve the
- // feature, then return everything sharing its node — the co-located set the
- // LIGHTS06 rule needs to stack descriptions + fan flares. Curve/surface
- // spatials carry no node here, so they fall through to empty.
- set("HostSpatialGetAssociatedFeatureIDs", func(s *lua.LState) int {
- t := s.NewTable()
- spatialID := s.CheckString(1)
- fid := spatialID
- if i := strings.LastIndexByte(spatialID, '#'); i >= 0 {
- fid = spatialID[:i]
- }
- for _, id := range e.coLocatedFeatureIDs(fid) {
- t.Append(lua.LString(id))
- }
- s.Push(t)
- return 1
- })
- set("HostSpatialGetAssociatedInformationIDs", emptyArray)
- set("HostInformationTypeGetCode", func(s *lua.LState) int { s.Push(lua.LString("")); return 1 })
- set("HostInformationTypeGetSimpleAttribute", emptyArray)
- set("HostInformationTypeGetComplexAttributeCount", func(s *lua.LState) int { s.Push(lua.LNumber(0)); return 1 })
-
- set("HostPortrayalEmit", func(s *lua.LState) int { s.Push(lua.LTrue); return 1 })
- set("HostDebuggerEntry", func(*lua.LState) int { return 0 })
-}
-
-// clearances maps each S-101 clearance complex attribute to the S-57 simple
-// attribute that backs it and the value sub-attribute the rules read. Bridges and
-// overhead cables/pipes carry clearances as S-57 simple attributes
-// (VERCCL/VERCLR/VERCSA/HORCLR/VERCOP); the S-101 catalogue models them as complex
-// attributes wrapping a *Value field.
-var clearances = map[string]struct{ s57, value string }{
- "verticalClearanceClosed": {"VERCCL", "verticalClearanceValue"},
- "verticalClearanceFixed": {"VERCLR", "verticalClearanceValue"},
- "verticalClearanceSafe": {"VERCSA", "verticalClearanceValue"},
- "verticalClearanceOpen": {"VERCOP", "verticalClearanceValue"},
- "horizontalClearanceFixed": {"HORCLR", "horizontalClearanceValue"},
-}
-
-// guaranteedAttrs are attributes some DRAFT-catalogue rules read WITHOUT the
-// nil-safe `!` prefix on feature types the catalogue doesn't bind them to — so
-// the framework raises "Invalid attribute code". Binding them on every feature
-// type makes such a read return nil (attribute absent) instead of erroring:
-// - inTheWater: read by Building, SlopeTopline, … (boolean).
-// - orientationValue: read by route/traffic rules (RadarLine, RecommendedTrack,
-// TwoWayRoutePart, …) that don't all bind it (the S-57 ORIENT alias).
-// - topmark: the TOPMAR02 CSP reads feature.topmark, but some classes that call
-// it (e.g. MooringBuoy) don't bind it. It's a complex attribute; the injected
-// Upper:1 binding resolves it as a single-valued complex (see LookupAttributeValue).
-var guaranteedAttrs = []string{"inTheWater", "orientationValue", "topmark"}
-
-// withGuaranteed appends any guaranteedAttrs binding the feature type is missing.
-func withGuaranteed(binds []fc.AttributeBinding) []fc.AttributeBinding {
- out := binds
- for _, name := range guaranteedAttrs {
- found := false
- for _, b := range out {
- if b.AttributeRef == name {
- found = true
- break
- }
- }
- if !found {
- out = append(out[:len(out):len(out)], fc.AttributeBinding{AttributeRef: name, Lower: 0, Upper: 1})
- }
- }
- return out
-}
-
-// bindingsTable builds the AttributeBindings table (attr code → {Upper,Lower
-// Multiplicity}) the framework reads to validate attribute access + decide
-// single-valued vs array. Infinite upper is mapped to a large number.
-func bindingsTable(s *lua.LState, binds []fc.AttributeBinding) *lua.LTable {
- t := s.NewTable()
- for _, b := range binds {
- upper := b.Upper
- if upper < 0 {
- upper = 1 << 30
- }
- bt := s.NewTable()
- s.SetField(bt, "UpperMultiplicity", lua.LNumber(upper))
- s.SetField(bt, "LowerMultiplicity", lua.LNumber(b.Lower))
- s.SetField(t, b.AttributeRef, bt)
- }
- return t
-}
-
-func keysOfFeatureTypes(s *lua.LState, cat *fc.Catalogue) *lua.LTable {
- t := s.NewTable()
- for code := range cat.FeatureTypes {
- t.Append(lua.LString(code))
- }
- return t
-}
-
-func keysOfSimpleAttrs(s *lua.LState, cat *fc.Catalogue) *lua.LTable {
- t := s.NewTable()
- for code := range cat.SimpleAttrs {
- t.Append(lua.LString(code))
- }
- return t
-}
diff --git a/internal/engine/s101/sector_test.go b/internal/engine/s101/sector_test.go
deleted file mode 100644
index f00d4fb..0000000
--- a/internal/engine/s101/sector_test.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package s101
-
-import (
- "strings"
- "testing"
-)
-
-// TestSectoredLightRoutesAndDrawsArcs proves the ambiguous-alias resolution
-// (LIGHTS→LightSectored) plus the synthesized sectorCharacteristics structure:
-// an S-57 sectored light emits the sector legs (AugmentedRay) and arc
-// (ArcByRadius) the LightSectored rule draws — not the all-around flare.
-func TestSectoredLightRoutesAndDrawsArcs(t *testing.T) {
- rulesDir, cat := testEnv(t)
- e, err := NewEngine(rulesDir, cat)
- if err != nil {
- t.Fatal(err)
- }
- defer e.Close()
-
- out := portrayOne(t, e, Feature{
- ID: "f1", ObjectClass: "LIGHTS", Primitive: "Point",
- Attributes: map[string]string{
- "SECTR1": "045", "SECTR2": "090",
- "COLOUR": "3", "VALNMR": "9", "LITCHR": "2", "CATLIT": "0",
- },
- })
- if !strings.Contains(out, "AugmentedRay") {
- t.Errorf("sectored light: want sector legs (AugmentedRay), got %q", out)
- }
- if !strings.Contains(out, "ArcByRadius") {
- t.Errorf("sectored light: want sector arc (ArcByRadius), got %q", out)
- }
-}
-
-// TestDirectionalLightRoutesToSectored proves a directional light (CATLIT=1 with
-// an orientation) reaches LightSectored and exercises its directionalCharacter
-// branch without error.
-func TestDirectionalLightRoutesToSectored(t *testing.T) {
- rulesDir, cat := testEnv(t)
- e, err := NewEngine(rulesDir, cat)
- if err != nil {
- t.Fatal(err)
- }
- defer e.Close()
-
- out := portrayOne(t, e, Feature{
- ID: "f1", ObjectClass: "LIGHTS", Primitive: "Point",
- Attributes: map[string]string{
- "CATLIT": "1", "ORIENT": "135", "COLOUR": "1", "VALNMR": "12", "LITCHR": "2",
- },
- })
- if out == "" {
- t.Errorf("directional light: want a non-empty stream, got empty")
- }
-}
-
-// TestNavigationLineOrientation proves the orientation complex attribute is
-// synthesized from S-57 ORIENT: a leading line (categoryOfNavigationLine=1)
-// portrays its bearing label instead of erroring on a nil orientation.
-func TestNavigationLineOrientation(t *testing.T) {
- rulesDir, cat := testEnv(t)
- e, err := NewEngine(rulesDir, cat)
- if err != nil {
- t.Fatal(err)
- }
- defer e.Close()
-
- out := portrayOne(t, e, Feature{
- ID: "f1", ObjectClass: "NAVLNE", Primitive: "Curve",
- Attributes: map[string]string{"CATNAV": "1", "ORIENT": "043.5"},
- })
- if !strings.Contains(out, "deg") {
- t.Errorf("leading line: want a '%%03.0f deg' bearing label, got %q", out)
- }
-}
-
-// TestAmbiguousAliasDefaults pins the disambiguation choices for the one-to-many
-// S-57→S-101 classes (a plain light is all-around; air-obstruction and fog
-// detector get their dedicated classes; ADMARE is an administration area).
-func TestAmbiguousAliasDefaults(t *testing.T) {
- _, cat := testEnv(t)
- e := &Engine{cat: cat}
- cases := []struct {
- class string
- attrs map[string]string
- want string
- }{
- {"LIGHTS", nil, "LightAllAround"},
- {"LIGHTS", map[string]string{"SECTR1": "1", "SECTR2": "2"}, "LightSectored"},
- {"LIGHTS", map[string]string{"CATLIT": "1"}, "LightSectored"},
- {"LIGHTS", map[string]string{"CATLIT": "6"}, "LightAirObstruction"},
- {"LIGHTS", map[string]string{"CATLIT": "7"}, "LightFogDetector"},
- {"ADMARE", nil, "AdministrationArea"},
- }
- for _, c := range cases {
- got, ok := e.resolveCode(c.class, c.attrs)
- if !ok || got != c.want {
- t.Errorf("resolveCode(%s, %v) = %q,%v; want %q", c.class, c.attrs, got, ok, c.want)
- }
- }
-}
-
-// TestSeparationZoneOrLineMapping pins the TSELNE/TSEZNE → SeparationZoneOrLine
-// conversion (S-101 merged the separation line and zone into one geometry-
-// distinguished feature with no alias).
-func TestSeparationZoneOrLineMapping(t *testing.T) {
- _, cat := testEnv(t)
- e := &Engine{cat: cat}
- for _, cls := range []string{"TSELNE", "TSEZNE"} {
- got, ok := e.resolveCode(cls, nil)
- if !ok || got != "SeparationZoneOrLine" {
- t.Errorf("resolveCode(%s) = %q,%v; want SeparationZoneOrLine", cls, got, ok)
- }
- }
-}
diff --git a/internal/engine/s101catalog/custom-overlay/LineStyles/NEWOBJ01.xml b/internal/engine/s101catalog/custom-overlay/LineStyles/NEWOBJ01.xml
deleted file mode 100644
index 0015a88..0000000
--- a/internal/engine/s101catalog/custom-overlay/LineStyles/NEWOBJ01.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
- 10
-
- CHMGD
-
-
- 0
- 3.5
-
-
- 7
-
-
diff --git a/internal/engine/s101catalog/custom-overlay/Symbols/NEWOBJ01.svg b/internal/engine/s101catalog/custom-overlay/Symbols/NEWOBJ01.svg
deleted file mode 100644
index 69cc269..0000000
--- a/internal/engine/s101catalog/custom-overlay/Symbols/NEWOBJ01.svg
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
- NEWOBJ01
- default symbol for a new object (NEWOBJ) — S-52 §10.3.3.8; original artwork (not derived from the S-52 PresLib digital files)
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/internal/engine/s101catalog/embed_off.go b/internal/engine/s101catalog/embed_off.go
deleted file mode 100644
index a19f397..0000000
--- a/internal/engine/s101catalog/embed_off.go
+++ /dev/null
@@ -1,17 +0,0 @@
-//go:build !embed_s101
-
-// Stub for plain (untagged) builds: no S-101 catalogue is embedded, so the
-// baker must be given an external one via --s101. Build with `make` (which syncs
-// the catalogue and sets -tags embed_s101) for a self-contained binary.
-package s101catalog
-
-import "io/fs"
-
-// Available reports whether an S-101 catalogue is embedded — false here.
-func Available() bool { return false }
-
-// PortrayalFS returns nil (no embedded catalogue in an untagged build).
-func PortrayalFS() (fs.FS, error) { return nil, nil }
-
-// FeatureCatalogue returns nil (no embedded catalogue in an untagged build).
-func FeatureCatalogue() ([]byte, error) { return nil, nil }
diff --git a/internal/engine/s101catalog/embed_on.go b/internal/engine/s101catalog/embed_on.go
deleted file mode 100644
index 3a04666..0000000
--- a/internal/engine/s101catalog/embed_on.go
+++ /dev/null
@@ -1,29 +0,0 @@
-//go:build embed_s101
-
-// Package s101catalog provides the S-101 PortrayalCatalog + FeatureCatalogue
-// EMBEDDED into the binary at build time.
-//
-// The catalogue is NOT vendored into the repo. `make` rsyncs it from the
-// external $(S101_PC)/$(S101_FC) into the gitignored catalog/ dir, then builds
-// with -tags embed_s101 so go:embed bakes it into the binary. A plain
-// `go build` (no tag) compiles the embed_off stub instead — Available() is
-// false and the baker falls back to an external --s101 dir.
-package s101catalog
-
-import (
- "embed"
- "io/fs"
-)
-
-//go:embed all:catalog
-var files embed.FS
-
-// Available reports whether an S-101 catalogue is embedded in this binary.
-func Available() bool { return true }
-
-// PortrayalFS returns the embedded PortrayalCatalog tree (Rules/, Symbols/,
-// ColorProfiles/, LineStyles/, AreaFills/, …).
-func PortrayalFS() (fs.FS, error) { return fs.Sub(files, "catalog/PortrayalCatalog") }
-
-// FeatureCatalogue returns the embedded FeatureCatalogue.xml bytes.
-func FeatureCatalogue() ([]byte, error) { return files.ReadFile("catalog/FeatureCatalogue.xml") }
diff --git a/internal/engine/server/aux_test.go b/internal/engine/server/aux_test.go
index fac4299..2033f47 100644
--- a/internal/engine/server/aux_test.go
+++ b/internal/engine/server/aux_test.go
@@ -1,6 +1,7 @@
package server
import (
+ "archive/zip"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -11,8 +12,18 @@ import (
"github.com/beetlebugorg/chartplotter/internal/engine/auxfiles"
)
-// writeAux writes a companion aux.zip with the given files into dir/name.aux.zip.
-func writeAux(t *testing.T, dir, name string, files map[string][]byte) {
+// writeAuxDirT writes a loose aux/ dir (the current layout) under dir.
+func writeAuxDirT(t *testing.T, dir string, files map[string][]byte) {
+ t.Helper()
+ if _, err := auxfiles.WriteDir(filepath.Join(dir, "aux"), files); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// writeLegacyAuxZip writes a legacy companion ".aux.zip" (loose files + an
+// index.json in the auxfiles.Manifest shape) so the back-compat reader stays covered
+// — pre-loose bakes (e.g. the user's existing noaa.aux.zip) must keep resolving.
+func writeLegacyAuxZip(t *testing.T, dir, name string, files map[string][]byte) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
@@ -22,44 +33,63 @@ func writeAux(t *testing.T, dir, name string, files map[string][]byte) {
t.Fatal(err)
}
defer f.Close()
- if _, err := auxfiles.WriteZip(f, files); err != nil {
+ zw := zip.NewWriter(f)
+ index := map[string]auxfiles.Entry{}
+ for key, data := range files {
+ stored := filepath.Base(key)
+ w, err := zw.Create(stored)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := w.Write(data); err != nil {
+ t.Fatal(err)
+ }
+ index[key] = auxfiles.Entry{Stored: stored, Type: "text/plain"}
+ }
+ iw, err := zw.Create(auxfiles.IndexName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.NewEncoder(iw).Encode(auxfiles.Manifest{Version: 1, Files: index}); err != nil {
+ t.Fatal(err)
+ }
+ if err := zw.Close(); err != nil {
t.Fatal(err)
}
}
func TestServeAux(t *testing.T) {
cache := t.TempDir()
- // Two districts, each with its own companion aux.zip somewhere under the cache.
- writeAux(t, filepath.Join(cache, "NOAA", "D5-OVERVIEW"), "noaa-d5-overview", map[string][]byte{
+ // One district as a loose aux/ dir (current layout), one as a legacy companion
+ // zip — both must resolve through the same /api/aux.
+ writeAuxDirT(t, filepath.Join(cache, "NOAA", "D5-OVERVIEW"), map[string][]byte{
"US5MD1MC.TXT": []byte("Channel maintained to 35ft"),
})
- writeAux(t, filepath.Join(cache, "NOAA", "D17-OVERVIEW"), "noaa-d17-overview", map[string][]byte{
+ writeLegacyAuxZip(t, filepath.Join(cache, "NOAA", "D17-OVERVIEW"), "noaa-d17-overview", map[string][]byte{
"NOTE17.TXT": []byte("Caution: ice"),
})
s := New("", cache, cache, true)
- // Manifest lists every referenced filename (upper-cased) → MIME, across districts.
+ // Manifest lists every referenced filename → {stored,type}, across both layouts.
rec := httptest.NewRecorder()
- s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/aux", nil))
+ s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/aux/index.json", nil))
if rec.Code != http.StatusOK {
t.Fatalf("manifest status = %d", rec.Code)
}
- var man struct {
- Files map[string]string `json:"files"`
- }
+ var man auxfiles.Manifest
if err := json.Unmarshal(rec.Body.Bytes(), &man); err != nil {
t.Fatal(err)
}
- if man.Files["US5MD1MC.TXT"] != "text/plain" {
- t.Errorf("manifest US5MD1MC.TXT = %q, want text/plain", man.Files["US5MD1MC.TXT"])
+ if man.Files["US5MD1MC.TXT"].Type != "text/plain" || man.Files["US5MD1MC.TXT"].Stored != "US5MD1MC.TXT" {
+ t.Errorf("manifest US5MD1MC.TXT = %+v, want stored=US5MD1MC.TXT type=text/plain", man.Files["US5MD1MC.TXT"])
}
- if man.Files["NOTE17.TXT"] != "text/plain" {
- t.Errorf("manifest missing the second district's file: %v", man.Files)
+ if man.Files["NOTE17.TXT"].Type != "text/plain" { // the legacy-zip district
+ t.Errorf("manifest missing the legacy-zip district's file: %v", man.Files)
}
- // One file by name (case-insensitive) → its bytes + content type, not the zip.
+ // One file by its stored name (case-insensitive) → its bytes + content type.
rec = httptest.NewRecorder()
- s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/aux/us5md1mc.txt", nil))
+ s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/aux/us5md1mc.txt", nil))
if rec.Code != http.StatusOK {
t.Fatalf("file status = %d", rec.Code)
}
@@ -70,9 +100,16 @@ func TestServeAux(t *testing.T) {
t.Errorf("file content-type = %q", ct)
}
- // Unknown name → 404 (and the raw zip is never reachable).
+ // The legacy-zip district's file resolves through the same /aux/ path.
+ rec = httptest.NewRecorder()
+ s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/aux/NOTE17.TXT", nil))
+ if rec.Code != http.StatusOK || rec.Body.String() != "Caution: ice" {
+ t.Errorf("legacy-zip file: status=%d body=%q", rec.Code, rec.Body.String())
+ }
+
+ // Unknown name → 404.
rec = httptest.NewRecorder()
- s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/aux/NOPE.TXT", nil))
+ s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/aux/NOPE.TXT", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("unknown file status = %d, want 404", rec.Code)
}
@@ -84,10 +121,10 @@ func TestAuxIndexInvalidate(t *testing.T) {
if got := s.auxIdx.manifest(cache); len(got) != 0 {
t.Fatalf("empty cache manifest = %v", got)
}
- // Add a companion zip after the first (empty) build, then invalidate.
- writeAux(t, filepath.Join(cache, "import"), "import", map[string][]byte{"A.TXT": []byte("x")})
+ // Add an aux dir after the first (empty) build, then invalidate.
+ writeAuxDirT(t, filepath.Join(cache, "import"), map[string][]byte{"A.TXT": []byte("x")})
s.auxIdx.invalidate()
- if _, ok := s.auxIdx.lookup(cache, "A.TXT"); !ok {
+ if _, ok := s.auxIdx.lookupStored(cache, "A.TXT"); !ok {
t.Errorf("A.TXT not found after invalidate + re-index")
}
}
diff --git a/internal/engine/server/auxfiles.go b/internal/engine/server/auxfiles.go
index 9a5b3ea..eb22183 100644
--- a/internal/engine/server/auxfiles.go
+++ b/internal/engine/server/auxfiles.go
@@ -11,37 +11,44 @@ import (
"path/filepath"
"strings"
"sync"
+
+ "github.com/beetlebugorg/chartplotter/internal/engine/auxfiles"
)
// aux.go serves an ENC's auxiliary content — the external resources a feature
// points at by filename: TXTDSC/NTXTDS textual descriptions (.TXT) and PICREP
-// pictures (PNG-transcoded). The baker bundles them per district into a companion
-// ".aux.zip" beside the pmtiles. Rather than expose that raw archive to the
-// client (which would force a whole-zip download just to render one pick), the
-// server indexes every companion zip and serves a SINGLE file per request on
-// demand. The pick report fetches GET /api/aux/ only when a picked feature
-// actually references it.
+// pictures (PNG-transcoded). The baker writes them per provider into a companion
+// "aux/" dir beside the pmtiles: loose static files + an index.json. The server
+// indexes every aux dir and serves a SINGLE file per request on demand (GET
+// /aux/), so a pick never downloads more than the one file it shows —
+// and, because the files are loose, the same dir resolves OFFLINE as plain static
+// files with no server at all. Legacy ".aux.zip" companions (baked before the
+// loose layout) are still indexed so their attachments keep resolving until re-bake.
-// auxLoc points at one aux file: the companion zip that holds it, the entry name
-// inside that zip (TIFF pictures are stored transcoded to .png), and the MIME type
-// to serve it with.
+// auxLoc points at one aux file: EITHER the loose aux dir that holds it (dir) or the
+// legacy companion zip (zip); the stored filename (TIFF pictures are stored transcoded
+// to .png); and the MIME type to serve it with.
type auxLoc struct {
- zip string
+ dir string // loose aux dir holding the file (preferred); "" if it's in a zip
+ zip string // legacy companion aux.zip holding the file; "" if it's loose
stored string
typ string
}
-// auxIndex aggregates every set's companion ".aux.zip" into one lookup keyed
-// by the upper-cased referenced filename (the form S-57 stores TXTDSC/PICREP values
-// in). Built lazily from the cache and invalidated whenever a set is (re)baked or
-// removed, so a freshly imported district's attachments resolve without a restart.
+// auxIndex aggregates every provider's aux content into one lookup keyed by the
+// upper-cased referenced filename (the form S-57 stores TXTDSC/PICREP values in).
+// Built lazily from the cache and invalidated whenever a set is (re)baked or removed,
+// so a freshly imported district's attachments resolve without a restart.
type auxIndex struct {
- mu sync.RWMutex
- loaded bool
- entries map[string]auxLoc
+ mu sync.RWMutex
+ loaded bool
+ entries map[string]auxLoc // referenced name (UPPER) → loc — drives the manifest
+ byStored map[string]auxLoc // stored filename (UPPER) → loc — resolves GET /aux/
}
-func newAuxIndex() *auxIndex { return &auxIndex{entries: map[string]auxLoc{}} }
+func newAuxIndex() *auxIndex {
+ return &auxIndex{entries: map[string]auxLoc{}, byStored: map[string]auxLoc{}}
+}
// invalidate marks the index stale; the next lookup rebuilds it.
func (a *auxIndex) invalidate() {
@@ -50,10 +57,10 @@ func (a *auxIndex) invalidate() {
a.mu.Unlock()
}
-// ensure (re)builds the index from cacheDir if it is stale. It walks for every
-// "*.aux.zip" companion and reads each one's index.json. Best-effort: a broken or
-// missing zip is skipped, leaving those files unresolvable (the pick report then
-// shows the bare filename).
+// ensure (re)builds the index from cacheDir if it is stale. It walks for every loose
+// "aux/index.json" (the current layout) and every legacy "*.aux.zip" companion,
+// reading each manifest. Best-effort: a broken or missing manifest is skipped,
+// leaving those files unresolvable (the pick report then shows the bare filename).
func (a *auxIndex) ensure(cacheDir string) {
a.mu.RLock()
loaded := a.loaded
@@ -63,27 +70,48 @@ func (a *auxIndex) ensure(cacheDir string) {
}
entries := map[string]auxLoc{}
_ = filepath.WalkDir(cacheDir, func(path string, d os.DirEntry, err error) error {
- if err != nil || d.IsDir() || !strings.HasSuffix(path, ".aux.zip") {
+ if err != nil || d.IsDir() {
return nil
}
- readAuxZipIndex(path, entries)
+ switch {
+ case filepath.Base(path) == auxfiles.IndexName && filepath.Base(filepath.Dir(path)) == "aux":
+ readAuxDirIndex(path, entries) // loose aux/ dir (current layout)
+ case strings.HasSuffix(path, ".aux.zip"):
+ readAuxZipIndex(path, entries) // legacy companion zip
+ }
return nil
})
+ byStored := make(map[string]auxLoc, len(entries))
+ for _, loc := range entries {
+ byStored[strings.ToUpper(loc.stored)] = loc
+ }
a.mu.Lock()
a.entries = entries
+ a.byStored = byStored
a.loaded = true
a.mu.Unlock()
}
-// auxIndexEntry mirrors one record of a companion zip's index.json (auxfiles.entry).
-type auxIndexEntry struct {
- Stored string `json:"stored"`
- Type string `json:"type"`
+// readAuxDirIndex decodes a loose aux dir's index.json and records each referenced
+// filename as a loose-file location under that dir. Later manifests win on a (rare)
+// name clash — aux names are basenames and effectively provider-unique.
+func readAuxDirIndex(indexPath string, out map[string]auxLoc) {
+ b, err := os.ReadFile(indexPath)
+ if err != nil {
+ return
+ }
+ var man auxfiles.Manifest
+ if json.Unmarshal(b, &man) != nil {
+ return
+ }
+ dir := filepath.Dir(indexPath)
+ for name, e := range man.Files {
+ out[strings.ToUpper(name)] = auxLoc{dir: dir, stored: e.Stored, typ: e.Type}
+ }
}
-// readAuxZipIndex opens one companion aux.zip, decodes its index.json, and adds each
-// referenced filename to out. Later zips win on a (rare) name clash — aux names are
-// basenames and effectively district-unique.
+// readAuxZipIndex opens one legacy companion aux.zip, decodes its index.json, and
+// adds each referenced filename to out.
func readAuxZipIndex(path string, out map[string]auxLoc) {
zr, err := zip.OpenReader(path)
if err != nil {
@@ -91,69 +119,75 @@ func readAuxZipIndex(path string, out map[string]auxLoc) {
}
defer zr.Close()
for _, f := range zr.File {
- if f.Name != "index.json" {
+ if f.Name != auxfiles.IndexName {
continue
}
rc, err := f.Open()
if err != nil {
return
}
- var meta struct {
- Files map[string]auxIndexEntry `json:"files"`
- }
- err = json.NewDecoder(rc).Decode(&meta)
+ var man auxfiles.Manifest
+ err = json.NewDecoder(rc).Decode(&man)
rc.Close()
if err != nil {
return
}
- for name, e := range meta.Files {
+ for name, e := range man.Files {
out[strings.ToUpper(name)] = auxLoc{zip: path, stored: e.Stored, typ: e.Type}
}
return
}
}
-// lookup resolves a referenced aux filename to its location, rebuilding first if stale.
-func (a *auxIndex) lookup(cacheDir, name string) (auxLoc, bool) {
+// lookupStored resolves a STORED aux filename (the name the manifest points the
+// client at, and the client requests) to its location, rebuilding first if stale.
+func (a *auxIndex) lookupStored(cacheDir, stored string) (auxLoc, bool) {
a.ensure(cacheDir)
a.mu.RLock()
- loc, ok := a.entries[strings.ToUpper(name)]
+ loc, ok := a.byStored[strings.ToUpper(stored)]
a.mu.RUnlock()
return loc, ok
}
-// manifest returns NAME→MIME for every available aux file, rebuilding if stale. The
-// client loads this once so the pick report knows which TXTDSC/PICREP refs resolve.
-func (a *auxIndex) manifest(cacheDir string) map[string]string {
+// manifest returns referencedName→{stored,type} for every available aux file,
+// rebuilding if stale — the same index.json shape the offline aux dir carries, so
+// the client loads it identically online or off.
+func (a *auxIndex) manifest(cacheDir string) map[string]auxfiles.Entry {
a.ensure(cacheDir)
a.mu.RLock()
- out := make(map[string]string, len(a.entries))
+ out := make(map[string]auxfiles.Entry, len(a.entries))
for name, loc := range a.entries {
- out[name] = loc.typ
+ out[name] = auxfiles.Entry{Stored: loc.stored, Type: loc.typ}
}
a.mu.RUnlock()
return out
}
-// serveAux serves ENC auxiliary content the pick report references by filename.
-// GET /api/aux → JSON {"files":{NAME:MIME,…}} of everything resolvable.
-// GET /api/aux/ → that one file's bytes, extracted on demand from the cached
-// companion aux.zip. The raw archive itself is never exposed.
+// serveAux serves ENC feature attachments (TXTDSC/PICREP) as loose static files — the
+// SAME layout the offline bundle carries, so the client loads aux identically whether
+// a server is running or not:
+//
+// GET /aux/index.json → {"version":1,"files":{REF:{stored,type},…}} of everything resolvable
+// GET /aux/ → that one file's bytes
+//
+// CORS-open so a static-hosted client can fetch from a different origin.
func (s *Server) serveAux(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
if r.Method != http.MethodGet {
apiErr(w, http.StatusMethodNotAllowed, "GET only")
return
}
- name := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/api/aux"), "/")
- if name == "" { // the manifest
+ name := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/aux"), "/")
+ if name == "" || name == "index.json" { // the manifest
w.Header().Set("Content-Type", jsonCT)
- _ = json.NewEncoder(w).Encode(map[string]any{"files": s.auxIdx.manifest(s.cacheDir)})
+ w.Header().Set("Cache-Control", "no-cache")
+ _ = json.NewEncoder(w).Encode(auxfiles.Manifest{Version: 1, Files: s.auxIdx.manifest(s.cacheDir)})
return
}
if dec, err := url.PathUnescape(name); err == nil {
name = dec
}
- loc, ok := s.auxIdx.lookup(s.cacheDir, name)
+ loc, ok := s.auxIdx.lookupStored(s.cacheDir, name)
if !ok {
apiErr(w, http.StatusNotFound, "no such aux file")
return
@@ -163,9 +197,20 @@ func (s *Server) serveAux(w http.ResponseWriter, r *http.Request) {
}
}
-// writeAuxEntry streams one stored entry out of its companion zip to w with the
-// indexed MIME type.
+// writeAuxEntry streams one stored aux file to w with the indexed MIME type — a loose
+// file read straight off disk (current layout), or, for a legacy companion, the entry
+// extracted from its zip.
func writeAuxEntry(w http.ResponseWriter, loc auxLoc) error {
+ if loc.dir != "" {
+ b, err := os.ReadFile(filepath.Join(loc.dir, loc.stored))
+ if err != nil {
+ return err
+ }
+ w.Header().Set("Content-Type", loc.typ)
+ w.Header().Set("Cache-Control", "max-age=86400")
+ _, err = w.Write(b)
+ return err
+ }
zr, err := zip.OpenReader(loc.zip)
if err != nil {
return err
diff --git a/internal/engine/server/cellindex.go b/internal/engine/server/cellindex.go
index 9c3ce39..854397e 100644
--- a/internal/engine/server/cellindex.go
+++ b/internal/engine/server/cellindex.go
@@ -2,20 +2,22 @@ package server
import (
"encoding/json"
+ "io/fs"
"log"
"os"
"path/filepath"
"sort"
+ "strings"
"sync"
- "github.com/beetlebugorg/chartplotter/internal/engine/baker"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
)
// cellIndex is a small, persistent name→bounding-box index over the cached source
-// cells (/ENC_ROOT//.000). It lets the server answer "where
-// is cell X" and "which installed cells are active" without re-parsing thousands
-// of cells on every request: each cell's header is read ONCE (the bbox cached to
-// /cells-index.json), then queries hit the in-memory map. Kept
+// cells — every .000 under a per-pack (or loose) cells/ dir in the data dir. It lets
+// the server answer "where is cell X" and "which installed cells are active" without
+// re-parsing thousands of cells on every request: each cell's header is read ONCE (the
+// bbox cached to /cells-index.json), then queries hit the in-memory map. Kept
// deliberately simple — a flat JSON map, not a database; the data is tiny (a few
// floats per cell) and read-mostly.
type cellIndex struct {
@@ -23,16 +25,16 @@ type cellIndex struct {
cond *sync.Cond // broadcast when a scan finishes (for wait())
bbox map[string][4]float64 // cell stem → [W,S,E,N]
path string // cells-index.json
- encRoot string // /ENC_ROOT
+ root string // — walked for cells under any .../cells/ dir
scanning bool // a scan goroutine is running
dirty bool // a (re)build was requested during a scan → scan again
}
func newCellIndex(dataDir string) *cellIndex {
ci := &cellIndex{
- bbox: map[string][4]float64{},
- path: filepath.Join(dataDir, "cells-index.json"),
- encRoot: filepath.Join(dataDir, "ENC_ROOT"),
+ bbox: map[string][4]float64{},
+ path: filepath.Join(dataDir, "cells-index.json"),
+ root: dataDir,
}
ci.cond = sync.NewCond(&ci.mu)
if data, err := os.ReadFile(ci.path); err == nil {
@@ -126,41 +128,67 @@ func (ci *cellIndex) wait() {
ci.mu.Unlock()
}
+// isSourceCellFile reports whether a walked path is an installed SOURCE cell — a .000
+// under a provider ENC_ROOT tree (/ENC_ROOT//…) or directly in a
+// loose cells/ dir (the /api/cell proxy + share-published cells). It keys the cell
+// index + the installed-cell listing.
+func isSourceCellFile(path, name string) bool {
+ if !isBaseCell(name) {
+ return false
+ }
+ if filepath.Base(filepath.Dir(path)) == "cells" {
+ return true
+ }
+ sep := string(filepath.Separator)
+ return strings.Contains(path, sep+"ENC_ROOT"+sep)
+}
+
// scan reads every cached cell's header once (bbox cached so repeat scans skip the
// already-indexed) and reconciles: drops index entries for cells no longer on disk.
+// Source cells live under each provider's ENC_ROOT tree (/ENC_ROOT//)
+// plus loose/cells/, so the scan walks the data dir for every such .000.
func (ci *cellIndex) scan() {
- entries, err := os.ReadDir(ci.encRoot)
- if err != nil {
- return
- }
- present := make(map[string]bool, len(entries))
+ present := map[string]bool{}
added := 0
- for _, e := range entries {
- if !e.IsDir() || !isCellName(e.Name()) {
- continue
+ _ = filepath.WalkDir(ci.root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if d.IsDir() {
+ if n := d.Name(); n == "tiles" || n == "assets" {
+ return fs.SkipDir // regenerable bundle output — never holds source cells
+ }
+ return nil
+ }
+ if !isSourceCellFile(path, d.Name()) {
+ return nil // only source .000 files (ENC_ROOT trees + loose cells/)
+ }
+ name := strings.TrimSuffix(d.Name(), ".000")
+ if !isCellName(name) {
+ return nil
}
- name := e.Name()
present[name] = true
if _, ok := ci.get(name); ok {
- continue // already indexed (forget() drops a re-imported cell so it re-parses)
+ return nil // already indexed (forget() drops a re-imported cell so it re-parses)
}
- data, err := os.ReadFile(filepath.Join(ci.encRoot, name, name+".000"))
+ src, err := tile57.Open(path)
if err != nil {
- continue
+ return nil
}
- chart, err := baker.ParseCellBytes(name, data)
- if err != nil {
- continue
+ infos, err := src.Cells()
+ src.Close()
+ if err != nil || len(infos) == 0 {
+ return nil
}
- b := chart.Bounds()
ci.mu.Lock()
- ci.bbox[name] = [4]float64{b.MinLon, b.MinLat, b.MaxLon, b.MaxLat}
+ ci.bbox[name] = infos[0].BBox
ci.mu.Unlock()
added++
if added%200 == 0 {
ci.save() // periodic checkpoint for a long backfill
}
- }
+ return nil
+ })
// Reconcile: drop entries for cells no longer on disk (removed packs/cells), so
// the index never reports a chart that isn't installed anymore.
removed := 0
@@ -178,6 +206,150 @@ func (ci *cellIndex) scan() {
}
}
+// cachedCellStems returns the de-duplicated stems of every cell cached on disk (any
+// .000 under a .../cells/ dir in the data dir) — the installed cell list for /api/cells,
+// independent of whether the background index has read each cell's bbox yet.
+func (s *Server) cachedCellStems() []string {
+ seen := map[string]bool{}
+ _ = filepath.WalkDir(s.dataDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if d.IsDir() {
+ if n := d.Name(); n == "tiles" || n == "assets" {
+ return fs.SkipDir // regenerable bundle output — never holds source cells
+ }
+ return nil
+ }
+ if !isSourceCellFile(path, d.Name()) {
+ return nil
+ }
+ if stem := strings.TrimSuffix(d.Name(), ".000"); isCellName(stem) {
+ seen[stem] = true
+ }
+ return nil
+ })
+ out := make([]string, 0, len(seen))
+ for n := range seen {
+ out = append(out, n)
+ }
+ return out
+}
+
+// migrateLegacyENCRoot moves cells from the RETIRED flat /ENC_ROOT cache into
+// the loose-cell dir (flattened) on the first start after the per-pack-cells migration,
+// so an existing install's downloaded cells aren't orphaned — they stay searchable
+// (/api/cells) and serveable (/api/cell). One-time: ENC_ROOT is removed afterwards.
+// Best-effort; per-pack cells now live under //cells/, not here.
+func migrateLegacyENCRoot(dataDir string) {
+ root := filepath.Join(dataDir, "ENC_ROOT")
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return // no legacy cache
+ }
+ dst := filepath.Join(dataDir, "loose", "cells")
+ if err := os.MkdirAll(dst, 0o755); err != nil {
+ return
+ }
+ moved := 0
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ files, _ := os.ReadDir(filepath.Join(root, e.Name()))
+ for _, f := range files {
+ if f.IsDir() {
+ continue
+ }
+ to := filepath.Join(dst, f.Name())
+ if _, err := os.Stat(to); err == nil {
+ continue // already present (a same-named loose cell / prior run)
+ }
+ if os.Rename(filepath.Join(root, e.Name(), f.Name()), to) == nil {
+ moved++
+ }
+ }
+ }
+ _ = os.RemoveAll(root)
+ if moved > 0 {
+ log.Printf("migrated %d legacy ENC_ROOT cell file(s) → %s", moved, dst)
+ }
+}
+
+// migrateProviderEncRoot upgrades the RETIRED per-district-pack layout to the
+// provider-enc-root model on first start: each ///cells/ becomes
+// a district subfolder //ENC_ROOT//, and the stale per-pack baked
+// bundles under the cache are dropped so scanPacks doesn't mis-register them (the
+// provider re-bakes from its migrated ENC_ROOT — see rebakeMissingProviders). One-time,
+// best-effort: a provider already on the new layout (an ENC_ROOT present, no cells/
+// packs) is left untouched, so this is a no-op on a fresh or already-migrated install.
+func migrateProviderEncRoot(dataDir, cacheDir string) {
+ providers, err := os.ReadDir(dataDir)
+ if err != nil {
+ return
+ }
+ for _, pe := range providers {
+ if !pe.IsDir() {
+ continue
+ }
+ provider := pe.Name()
+ if provider == "loose" || provider == "ENC_ROOT" || provider == "tiles" || provider == "assets" {
+ continue // not a provider tree
+ }
+ provDir := filepath.Join(dataDir, provider)
+ packs, err := os.ReadDir(provDir)
+ if err != nil {
+ continue
+ }
+ moved := 0
+ for _, pk := range packs {
+ if !pk.IsDir() || pk.Name() == "ENC_ROOT" {
+ continue
+ }
+ cellsDir := filepath.Join(provDir, pk.Name(), "cells")
+ if fi, err := os.Stat(cellsDir); err != nil || !fi.IsDir() {
+ continue // not an old per-pack cells/ dir
+ }
+ dst := filepath.Join(provDir, "ENC_ROOT", strings.ToLower(pk.Name()))
+ if os.MkdirAll(dst, 0o755) != nil {
+ continue
+ }
+ files, _ := os.ReadDir(cellsDir)
+ for _, f := range files {
+ if f.IsDir() {
+ continue
+ }
+ to := filepath.Join(dst, f.Name())
+ if _, err := os.Stat(to); err == nil {
+ continue // already present (a shared boundary cell from another district)
+ }
+ if os.Rename(filepath.Join(cellsDir, f.Name()), to) == nil {
+ moved++
+ }
+ }
+ _ = os.RemoveAll(filepath.Join(provDir, pk.Name())) // drop the old pack tree (data side)
+ }
+ if moved == 0 {
+ continue
+ }
+ // Drop stale per-pack baked bundles so scanPacks can't mis-derive a provider from
+ // ///tiles/chart.pmtiles. In single-dir mode the RemoveAll
+ // above already took them (they sat inside the pack tree). In split mode, clear the
+ // old-layout cache tree here; the current-layout //tiles bundle, if
+ // any, is left (a re-bake overwrites it).
+ if cacheDir != dataDir {
+ if cpacks, err := os.ReadDir(filepath.Join(cacheDir, provider)); err == nil {
+ for _, cp := range cpacks {
+ if cp.IsDir() && cp.Name() != "tiles" && cp.Name() != "assets" {
+ _ = os.RemoveAll(filepath.Join(cacheDir, provider, cp.Name()))
+ }
+ }
+ }
+ }
+ log.Printf("migrated provider %q to ENC_ROOT layout (%d cell file(s))", provider, moved)
+ }
+}
+
// forget drops cells from the index so the next build re-parses them — used when
// an import re-caches a cell whose bounds may have changed.
func (ci *cellIndex) forget(names []string) {
diff --git a/internal/engine/server/cellindex_test.go b/internal/engine/server/cellindex_test.go
index e082a43..5d9136c 100644
--- a/internal/engine/server/cellindex_test.go
+++ b/internal/engine/server/cellindex_test.go
@@ -31,7 +31,7 @@ func TestCellIndexBuild(t *testing.T) {
t.Skipf("testdata cell absent: %v", err)
}
dir := t.TempDir()
- cdir := filepath.Join(dir, "ENC_ROOT", cell)
+ cdir := filepath.Join(dir, "loose", "cells")
if err := os.MkdirAll(cdir, 0o755); err != nil {
t.Fatal(err)
}
@@ -64,7 +64,7 @@ func TestCellIndexFreshness(t *testing.T) {
t.Skipf("testdata cell absent: %v", err)
}
dir := t.TempDir()
- cdir := filepath.Join(dir, "ENC_ROOT", cell)
+ cdir := filepath.Join(dir, "loose", "cells")
if err := os.MkdirAll(cdir, 0o755); err != nil {
t.Fatal(err)
}
diff --git a/internal/engine/server/cells.go b/internal/engine/server/cells.go
index fc27dc4..0e6ed2a 100644
--- a/internal/engine/server/cells.go
+++ b/internal/engine/server/cells.go
@@ -5,6 +5,7 @@ import (
"bytes"
"fmt"
"io"
+ "io/fs"
"net/http"
"os"
"path/filepath"
@@ -15,20 +16,30 @@ import (
// isBaseCell reports whether a zip entry is an S-57 base cell (…/.000).
func isBaseCell(name string) bool { return strings.HasSuffix(name, ".000") }
-// ClearCache removes only the REGENERABLE baked tile sets under cacheDir (the
-// per-district NOAA/, import/, and flat tiles/ trees). It deliberately leaves the
-// SOURCE ENC (ENC_ROOT/, in the data dir) untouched — clearing the cache must not
-// delete downloaded charts; they rebake from source. Returns how many trees removed.
+// ClearCache removes only the REGENERABLE baked bundle parts under cacheDir — each
+// provider's tiles/ and assets/ dirs — while sparing every provider's ENC_ROOT source
+// tree (the downloaded SOURCE ENC) and any loose cells/ dir. In single-dir mode
+// (dataDir == cacheDir) the ENC_ROOT sits under the SAME tree as the bundle, so a
+// blanket RemoveAll of the provider trees would delete downloaded charts. Clearing the
+// cache must not do that — providers rebake from their kept ENC_ROOT. Returns how many
+// regenerable dirs were removed.
func ClearCache(cacheDir string) (int, error) {
n := 0
- for _, sub := range []string{"NOAA", "import", "tiles"} {
- p := filepath.Join(cacheDir, sub)
- if _, err := os.Stat(p); err == nil {
- if os.RemoveAll(p) == nil {
+ _ = filepath.WalkDir(cacheDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || !d.IsDir() {
+ return nil
+ }
+ switch d.Name() {
+ case "ENC_ROOT", "cells":
+ return filepath.SkipDir // NEVER remove or descend into source cells
+ case "tiles", "assets":
+ if os.RemoveAll(path) == nil {
n++
}
+ return filepath.SkipDir
}
- }
+ return nil
+ })
return n, nil
}
@@ -67,21 +78,15 @@ func fetchCellBase(client *http.Client, url string) ([]byte, error) {
return nil, fmt.Errorf("no base cell in zip")
}
-// loadCellCached returns the base-cell bytes for name, cached in NOAA's
-// ALL_ENCs.zip layout at dir/ENC_ROOT//.000 so re-baking doesn't
-// re-download — and so an ALL_ENCs.zip the user extracted into the cache dir is
-// recognised as-is (a standard, interoperable ENC root). name is already
-// validated (validCell), so it's a safe path component.
-func loadCellCached(client *http.Client, dir, name, url string) (data []byte, hit bool, err error) {
- cpath := filepath.Join(dir, "ENC_ROOT", name, name+".000")
+// loadCellCached returns the base-cell bytes for name, cached FLAT at
+// destDir/.000 so re-baking doesn't re-download. destDir is the target cells/
+// dir — a pack's cells/ for a district fetch, or the loose-cell dir for the /api/cell
+// download proxy. name is already validated (validCell), so it's a safe path component.
+func loadCellCached(client *http.Client, destDir, name, url string) (data []byte, hit bool, err error) {
+ cpath := filepath.Join(destDir, name+".000")
if b, e := os.ReadFile(cpath); e == nil {
return b, true, nil
}
- // Also honour a flat cache location (dir/.cellcache-.000), so cells
- // already on disk there are reused instead of re-downloaded.
- if b, e := os.ReadFile(filepath.Join(dir, ".cellcache-"+name+".000")); e == nil {
- return b, true, nil
- }
// Retry transient download failures (NOAA occasionally 5xx / resets under
// load) so a single hiccup doesn't drop a cell from the region.
var b []byte
@@ -95,9 +100,8 @@ func loadCellCached(client *http.Client, dir, name, url string) (data []byte, hi
}
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
- // Cache best-effort (ENC_ROOT//.000); a write failure just means
- // we re-fetch next time.
- if e := os.MkdirAll(filepath.Dir(cpath), 0o755); e == nil {
+ // Cache best-effort (destDir/.000); a write failure just means we re-fetch.
+ if e := os.MkdirAll(destDir, 0o755); e == nil {
_ = os.WriteFile(cpath, b, 0o644)
}
return b, false, nil
diff --git a/internal/engine/server/cells_active_test.go b/internal/engine/server/cells_active_test.go
index 2455515..78de08a 100644
--- a/internal/engine/server/cells_active_test.go
+++ b/internal/engine/server/cells_active_test.go
@@ -33,42 +33,46 @@ func activeCells(t *testing.T, base string) map[string]bool {
return set
}
-// TestActiveCellsDropOnDelete: a manifest-tracked pack's cells appear under
-// ?active=1, and DELETEing the pack drops them AND removes the lingering
-// .cells.json manifest. This is the "search shows uninstalled cells" / "stale
-// after remove" regression: the active set is driven by the live pack list + its
-// manifest, so removing the pack (packDel) and its manifest clears the cells, while
-// the source stays in ENC_ROOT for a future re-bake.
+// TestActiveCellsDropOnDelete: a provider's manifest-tracked cells appear under
+// ?active=1, and DELETEing the provider drops them AND removes the lingering
+// .cells.json manifest + the ENC_ROOT source tree. This is the "search shows
+// uninstalled cells" / "stale after remove" regression: the active set is driven by the
+// live pack list + its manifest, so dropping the provider set clears the cells, and
+// delete also reclaims the provider's source ENC_ROOT.
func TestActiveCellsDropOnDelete(t *testing.T) {
dir := t.TempDir()
s := New(dir, dir, dir, false)
const cell = "US5MD11M"
- // The cell's source dir must exist in ENC_ROOT (serveCells lists it from there).
- if err := os.MkdirAll(filepath.Join(dir, "ENC_ROOT", cell), 0o755); err != nil {
+ // The cell's source .000 must exist in the provider ENC_ROOT (serveCells lists
+ // installed cells from the ENC_ROOT trees; delete reclaims them).
+ scell := filepath.Join(s.districtDir("noaa", "d5"), cell+".000")
+ if err := os.MkdirAll(filepath.Dir(scell), 0o755); err != nil {
t.Fatal(err)
}
- // Register a band-set with an exact cell manifest, and add it as an enabled pack.
- const set = "noaa-d5-harbor"
- if err := s.writeSetCells(set, map[string]baker.CellData{cell + ".000": {}}); err != nil {
+ if err := os.WriteFile(scell, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
- manifest := filepath.Join(s.setDir(set), set+".cells.json")
+ // Register the provider set with an exact cell manifest, and add it as an enabled pack.
+ const provider = "noaa"
+ if err := s.writeSetCells(provider, map[string]baker.CellData{cell + ".000": {}}); err != nil {
+ t.Fatal(err)
+ }
+ manifest := filepath.Join(s.setDir(provider), provider+".cells.json")
if _, err := os.Stat(manifest); err != nil {
t.Fatalf("manifest not written: %v", err)
}
- s.packAdd(set, filepath.Join(s.setDir(set), set+".pmtiles"))
+ s.packAdd(provider, filepath.Join(s.setDir(provider), "tiles", "chart.pmtiles"))
ts := httptest.NewServer(s)
defer ts.Close()
if !activeCells(t, ts.URL)[cell] {
- t.Fatalf("cell %s should be active while its pack is installed", cell)
+ t.Fatalf("cell %s should be active while its provider is installed", cell)
}
- // DELETE the district → its band-sets are unregistered and their baked files +
- // manifests removed.
- req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/set?set=noaa-d5", nil)
+ // DELETE the provider → unregistered, baked bundle + manifest + ENC_ROOT removed.
+ req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/set?set=noaa", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
@@ -80,13 +84,14 @@ func TestActiveCellsDropOnDelete(t *testing.T) {
}
if activeCells(t, ts.URL)[cell] {
- t.Errorf("cell %s still active after its pack was deleted (stale search)", cell)
+ t.Errorf("cell %s still active after its provider was deleted (stale search)", cell)
}
if _, err := os.Stat(manifest); !os.IsNotExist(err) {
t.Errorf("manifest %s not removed on delete (err=%v)", manifest, err)
}
- // The source cell is intentionally kept for a future re-bake.
- if _, err := os.Stat(filepath.Join(dir, "ENC_ROOT", cell)); err != nil {
- t.Errorf("source cell should be kept in ENC_ROOT: %v", err)
+ // Delete reclaims disk: the provider's source ENC_ROOT is removed too (re-download to
+ // restore); disable, by contrast, keeps the cells + bundle and only hides the set.
+ if _, err := os.Stat(scell); !os.IsNotExist(err) {
+ t.Errorf("source cell should be removed on delete (err=%v)", err)
}
}
diff --git a/internal/engine/server/http.go b/internal/engine/server/http.go
index 481338d..0bde538 100644
--- a/internal/engine/server/http.go
+++ b/internal/engine/server/http.go
@@ -38,9 +38,11 @@ type Server struct {
share shareStore // latest "share my view" snapshot (camera + cell list)
settings settingsStore // persisted client display settings (/client-settings.json)
Version string // build version
+ EngineCommit string // tile57 engine commit built into this binary ("unknown" without the ldflags stamp)
sets *tileSets // registry of ENABLED tile sets served at /tiles/{set}/…
imports *importJobs // background server-side bake jobs (POST /api/import)
+ bakeMu sync.Mutex // serializes bakes: two imports must not interleave cross-pack peer rewrites / shared context
packsMu sync.Mutex // guards packs
packs map[string]string // ALL baked packs on disk: set name → pmtiles path
prefs *prefs // persisted enable/disable state (/prefs.json)
@@ -64,6 +66,8 @@ func New(assetsDir, cacheDir, dataDir string, allowRemote bool) *Server {
if dataDir == "" {
dataDir = cacheDir
}
+ migrateLegacyENCRoot(dataDir) // one-time: retired flat ENC_ROOT → loose/cells (before indexing)
+ migrateProviderEncRoot(dataDir, cacheDir) // one-time: per-district-pack layout → per-provider ENC_ROOT
s := &Server{assetsDir: assetsDir, cacheDir: cacheDir, dataDir: dataDir, allowRemote: allowRemote, sets: newTileSets(), imports: newImportJobs(), auxIdx: newAuxIndex(), cellIdx: newCellIndex(dataDir)}
s.cellIdx.build() // backfill cell bounds in the background (kick spawns its own goroutine)
// Discover every baked pack on disk (provider trees + flat tiles/), then
@@ -87,9 +91,37 @@ func New(assetsDir, cacheDir, dataDir string, allowRemote bool) *Server {
if len(s.packs) > 0 {
log.Printf("tilesets: %d pack(s) on disk, %d enabled (from %s)", len(s.packs), n, cacheDir)
}
+ s.rebakeMissingProviders() // self-heal: bake any provider with an ENC_ROOT but no bundle (post-migration)
return s
}
+// rebakeMissingProviders bakes, in the background, any provider that has an ENC_ROOT on
+// disk but no baked bundle registered — e.g. after migrateProviderEncRoot dropped the
+// old per-pack bundles, or a download that never finished baking. The provider's charts
+// reappear without a re-download (the ENC_ROOT is preserved). A no-op when nothing is
+// missing (the common case), so it's cheap on a normal start.
+func (s *Server) rebakeMissingProviders() {
+ var missing []string
+ for _, prov := range s.installedProviders() {
+ if _, ok := s.packPath(prov); !ok {
+ missing = append(missing, prov)
+ }
+ }
+ if len(missing) == 0 {
+ return
+ }
+ go func() {
+ for _, prov := range missing {
+ job := s.imports.create(prov)
+ s.bakeMu.Lock()
+ if s.bakeProvider(job.ID, prov) {
+ s.imports.update(job.ID, func(j *importJob) { j.State = "done" })
+ }
+ s.bakeMu.Unlock()
+ }
+ }()
+}
+
// Close releases server-held resources (open tile-set archives). Safe to call once
// at shutdown.
func (s *Server) Close() error {
@@ -105,10 +137,29 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
lw := &logResponseWriter{ResponseWriter: w, status: http.StatusOK}
setSecurityHeaders(lw)
switch {
+ case r.URL.Path == "/api/style.json":
+ // A complete MapLibre style document generated by the native tile57 engine
+ s.serveTile57Style(lw, r)
+ case r.URL.Path == "/api/style-diff":
+ // Minimal MapLibre mutation ops between two mariner selections, for flicker-
+ // free tile57 toggles (build-tagged; 501 stub in the default build).
+ s.serveTile57StyleDiff(lw, r)
case strings.HasPrefix(r.URL.Path, "/api/"):
s.handleAPI(lw, r)
case strings.HasPrefix(r.URL.Path, "/tiles/"):
s.serveTileSet(lw, r)
+ case r.URL.Path == "/aux/index.json" || strings.HasPrefix(r.URL.Path, "/aux/"):
+ // Feature attachments (TXTDSC/PICREP) as loose static files: GET /aux/index.json
+ // (the manifest) + GET /aux/ (one file). The SAME path the offline bundle
+ // serves, so the client loads aux identically online or off — no zip, no /api.
+ s.serveAux(lw, r)
+ case strings.HasPrefix(r.URL.Path, "/osm/"):
+ // Proxy the OSM raster basemap through the server: a browser can't set the
+ // User-Agent that OSM's tile policy requires (it's a forbidden request
+ // header), so direct tile.openstreetmap.org fetches get 403'd. The server
+ // fetches them with a compliant, app-identifying UA and streams them back
+ // from same-origin.
+ s.serveOSM(lw, r)
default:
s.serveAsset(lw, r)
}
@@ -227,7 +278,7 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/health":
w.Header().Set("Content-Type", jsonCT)
- io.WriteString(w, `{"ok":true}`)
+ fmt.Fprintf(w, `{"ok":true,"version":%q}`, s.Version)
case r.URL.Path == "/api/cells":
s.serveCells(w, r) // GET: names of cells currently in the server's ENC_ROOT cache
case r.URL.Path == "/api/ienc/catalog":
@@ -256,10 +307,6 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
s.serveConnectionsStream(w, r) // SSE: live connection-status badges
case strings.HasPrefix(r.URL.Path, "/api/connections/"):
s.serveConnection(w, r) // GET/PUT/DELETE /, or SSE //raw (sniffer)
- case strings.HasPrefix(r.URL.Path, "/api/tile/"):
- s.serveTile(w, r) // GET one MVT tile baked from cached cells (tile-debugger inspect)
- case r.URL.Path == "/api/aux" || strings.HasPrefix(r.URL.Path, "/api/aux/"):
- s.serveAux(w, r) // GET aux manifest, or one TXTDSC/PICREP file on demand (not the raw zip)
case strings.HasPrefix(r.URL.Path, "/api/import"):
s.handleImport(w, r) // POST: server-side native bake → register a tile set; status polling
case r.URL.Path == "/api/packs":
@@ -269,7 +316,9 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
case r.URL.Path == "/api/set/enable" || r.URL.Path == "/api/set/disable":
s.handleSetEnabled(w, r) // POST: show/hide a pack on the map (data kept)
case r.URL.Path == "/api/set":
- s.handleDeleteSet(w, r) // DELETE: unregister a tile set + remove its baked files
+ s.handleDeleteSet(w, r) // DELETE: uninstall a whole provider (baked bundle + ENC_ROOT)
+ case r.URL.Path == "/api/district":
+ s.handleDeleteDistrict(w, r) // DELETE: remove one district + re-bake the provider
case r.URL.Path == "/api/proxy":
s.serveProxy(w, r) // dumb CORS/Range passthrough for a NOAA URL (e.g. All_ENCs.zip)
default:
@@ -298,7 +347,7 @@ func (s *Server) serveCell(w http.ResponseWriter, r *http.Request) {
apiErr(w, http.StatusBadRequest, "url must be from a known chart provider")
return
}
- data, _, err := loadCellCached(chartHTTPClient, s.dataDir, name, rawURL)
+ data, _, err := loadCellCached(chartHTTPClient, s.looseCellsDir(), name, rawURL)
if err != nil {
apiErr(w, http.StatusBadGateway, err.Error())
return
diff --git a/internal/engine/server/import.go b/internal/engine/server/import.go
index a98db07..33d5259 100644
--- a/internal/engine/server/import.go
+++ b/internal/engine/server/import.go
@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"log"
- "maps"
"net/http"
"os"
"path/filepath"
@@ -16,11 +15,8 @@ import (
"sync"
"time"
- "github.com/beetlebugorg/chartplotter/internal/engine/auxfiles"
"github.com/beetlebugorg/chartplotter/internal/engine/baker"
- "github.com/beetlebugorg/chartplotter/internal/engine/pmtiles"
- "github.com/beetlebugorg/chartplotter/internal/engine/tilesource"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
)
// Server-side import/bake. POST /api/import takes ENC input — an uploaded
@@ -35,18 +31,21 @@ import (
// importJob is a single background bake's state.
type importJob struct {
- ID string `json:"id"`
- Set string `json:"set"`
- State string `json:"state"` // "running" | "done" | "error"
- Phase string `json:"phase"` // "download" | "extract" | "bake"
- Band string `json:"band"` // usage band being baked (e.g. "coastal"); "" outside the bake phase
- Note string `json:"note"` // human-readable current step (e.g. "downloading US5MD1MC")
- Done int `json:"done"` // phase units done (bytes/cells downloaded, then tiles emitted)
- Total int `json:"total"` // phase total (0 until known)
- Unit string `json:"unit"` // what done/total count: "bytes" | "cells" | "tiles"
- Cells int `json:"cells"` // cells successfully parsed
- Err string `json:"error,omitempty"`
- Started string `json:"started"`
+ ID string `json:"id"`
+ Set string `json:"set"`
+ State string `json:"state"` // "running" | "done" | "error"
+ Phase string `json:"phase"` // "download" | "extract" | "bake"
+ Band string `json:"band"` // usage band being baked (e.g. "coastal"); "" outside the bake phase
+ Pack string `json:"pack"` // set key of the pack being processed now (multi-pack import); "" for a single set
+ PackNum int `json:"packNum"` // 1-based position of the current pack in the batch (0 = n/a)
+ PackTotal int `json:"packTotal"` // packs in the batch (0/1 = single, no "N of M" shown)
+ Note string `json:"note"` // human-readable current step (e.g. "downloading US5MD1MC")
+ Done int `json:"done"` // phase units done (bytes/cells downloaded, then tiles emitted)
+ Total int `json:"total"` // phase total (0 until known)
+ Unit string `json:"unit"` // what done/total count: "bytes" | "cells" | "tiles"
+ Cells int `json:"cells"` // cells successfully parsed
+ Err string `json:"error,omitempty"`
+ Started string `json:"started"`
}
// importJobs is the (in-memory) registry of import jobs.
@@ -125,6 +124,10 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
s.importEvents(w, r)
return
}
+ if r.URL.Path == "/api/import/packs" {
+ s.handleImportPacks(w, r)
+ return
+ }
if r.URL.Path != "/api/import" || r.Method != http.MethodPost {
apiErr(w, http.StatusMethodNotAllowed, "POST /api/import")
return
@@ -138,14 +141,13 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
set := r.URL.Query().Get("set")
// "auto" (or empty) means "name this upload from its CATALOG identity" — the
- // one-pack-per-upload path. The real name is derived below, after the zip is
- // parsed (we need its catalogue / cell names first).
+ // one-district-per-upload path (under the "user" provider). The real name is
+ // derived below, after the zip is parsed (we need its catalogue / cell names first).
autoName := set == "" || set == "auto"
if !autoName && !isSetName(set) {
apiErr(w, http.StatusBadRequest, "set must be a valid name")
return
}
- overzoom := r.URL.Query().Get("overzoom") == "1"
applyUpdates := r.URL.Query().Get("updates") != "0" // default: apply .001+ (NtM corrections)
cells, aux, cat, err := s.importInputs(r)
@@ -153,31 +155,49 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
apiErr(w, http.StatusBadRequest, err.Error())
return
}
+ // No cells supplied in the request → re-bake the provider from its cached ENC_ROOT
+ // (a cache re-bake; ?set names the provider, e.g. "noaa").
if len(cells) == 0 {
- apiErr(w, http.StatusBadRequest, "no ENC base cells (.000) in input")
+ provider := providerOf(set)
+ if autoName || len(s.providerDistricts(provider)) == 0 {
+ apiErr(w, http.StatusBadRequest, "no ENC base cells (.000) in input")
+ return
+ }
+ job := s.imports.create(provider)
+ go s.runImport(job.ID, provider)
+ w.Header().Set("Content-Type", jsonCT)
+ w.WriteHeader(http.StatusAccepted)
+ fmt.Fprintf(w, `{"ok":true,"job":%q,"set":%q}`, job.ID, provider)
return
}
if autoName {
set = s.deriveUploadSet(cat, cells)
}
+ if !applyUpdates { // bake the base .000 edition — persist base-only so the disk-read bake matches
+ cells = baseOnly(cells)
+ }
+ // Persist the cells into the district's ENC_ROOT subfolder now that the name is
+ // known, then bake the whole provider (all districts) into its one archive.
+ provider, district := providerOf(set), districtOf(set)
+ if district == "" {
+ district = provider // a bare-provider upload → one district named for the provider
+ }
+ s.cacheDistrict(provider, district, cells, aux, cat)
- job := s.imports.create(set)
- go s.runImport(job.ID, set, cells, aux, cat, overzoom, applyUpdates)
+ job := s.imports.create(provider)
+ go s.runImport(job.ID, provider)
w.Header().Set("Content-Type", jsonCT)
w.WriteHeader(http.StatusAccepted)
- fmt.Fprintf(w, `{"ok":true,"job":%q,"set":%q}`, job.ID, set)
+ fmt.Fprintf(w, `{"ok":true,"job":%q,"set":%q}`, job.ID, provider)
}
-// deriveUploadSet picks a stable, friendly pack name for an uploaded exchange set
-// from its CATALOG identity (longest common cell-name prefix), falling back to the
-// cells' shared prefix when there's no catalogue, then to "upload". Namespaced
-// under the "user" provider and uniquified against existing packs.
-func (s *Server) deriveUploadSet(cat *s57.Catalog, cells map[string]baker.CellData) string {
- id := ""
- if cat != nil {
- id = catalogPackIdentity(cat)
- }
+// deriveUploadSet picks a stable, friendly pack key for an uploaded exchange set from
+// its CATALOG identity (longest common cell-name prefix), falling back to the cells'
+// shared prefix when there's no catalogue, then to "upload". Every upload is a DISTRICT
+// under the "user" provider ("user-"), uniquified against existing user districts.
+func (s *Server) deriveUploadSet(cat []tile57.CatalogEntry, cells map[string]baker.CellData) string {
+ id := catalogPackIdentity(cat)
if id == "" {
stems := make([]string, 0, len(cells))
for n := range cells {
@@ -188,20 +208,19 @@ func (s *Server) deriveUploadSet(cat *s57.Catalog, cells map[string]baker.CellDa
if id == "" {
id = "upload"
}
- return s.uniqueSet("user-" + id)
+ return "user-" + s.uniqueDistrict("user", id)
}
-// uniqueSet returns base, or base-2/base-3/… if a pack (any band-set of that
-// district) already exists, so a second upload of the same area doesn't clobber
-// the first.
-func (s *Server) uniqueSet(base string) string {
- taken := func(name string) bool { return len(s.setsForDistrict(name)) > 0 }
- if !taken(base) {
+// uniqueDistrict returns base, or base-2/base-3/… if a district folder of that name
+// already exists under the provider's ENC_ROOT, so a second upload of the same area
+// doesn't clobber the first.
+func (s *Server) uniqueDistrict(provider, base string) string {
+ if _, err := os.Stat(s.districtDir(provider, base)); err != nil {
return base
}
for i := 2; i < 1000; i++ {
cand := base + "-" + itoa(i)
- if !taken(cand) {
+ if _, err := os.Stat(s.districtDir(provider, cand)); err != nil {
return cand
}
}
@@ -271,18 +290,23 @@ func (s *Server) handleImportFetch(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"ok":true,"job":%q,"set":%q}`, job.ID, req.Set)
}
-// runImportFetch downloads the requested cells from NOAA into the server cache
-// (reporting download progress on the job), then bakes + registers the set.
+// runImportFetch downloads the requested cells from NOAA into the district's ENC_ROOT
+// subfolder (reporting download progress on the job), then bakes + registers the
+// provider (its whole ENC_ROOT) as one archive.
func (s *Server) runImportFetch(jobID string, req importFetchReq) {
fail := func(err error) {
log.Printf("import %s (%s): %v", jobID, req.Set, err)
s.imports.update(jobID, func(j *importJob) { j.State = "error"; j.Err = err.Error() })
}
applyUpdates := req.Updates == nil || *req.Updates // default: apply .001+
+ provider, district := providerOf(req.Set), districtOf(req.Set)
+ if district == "" {
+ district = provider
+ }
var cells map[string]baker.CellData
var aux map[string][]byte
- var cat *s57.Catalog
+ var cat []tile57.CatalogEntry
if req.ZipURL != "" {
// Bulk: stream the one zip (byte progress), then extract + cache its cells.
@@ -308,11 +332,13 @@ func (s *Server) runImportFetch(jobID string, req importFetchReq) {
if len(req.Names) > 0 {
cells = filterCells(cells, req.Names)
}
- // Persist the extracted cells to the ENC_ROOT cache so a later rebake of the
- // installed union (req.Bake) finds them (per-cell downloads cache themselves).
- s.cacheCells(cells)
+ if !applyUpdates {
+ cells = baseOnly(cells)
+ }
+ // Persist the extracted cells to the district's ENC_ROOT subfolder (the bake reads them there).
+ s.cacheDistrict(provider, district, cells, aux, cat)
} else {
- // Per-cell: download each into the ENC_ROOT cache, then bake from there.
+ // Per-cell: download each into the district's ENC_ROOT subfolder, then bake from there.
cells = map[string]baker.CellData{}
total := len(req.Cells)
s.imports.update(jobID, func(j *importJob) { j.Phase, j.Unit, j.Total = "download", "cells", total })
@@ -326,7 +352,7 @@ func (s *Server) runImportFetch(jobID string, req importFetchReq) {
continue
}
s.imports.update(jobID, func(j *importJob) { j.Note = "Downloading " + c.Name; j.Done = i })
- base, _, err := loadCellCached(chartHTTPClient, s.dataDir, c.Name, c.URL)
+ base, _, err := loadCellCached(chartHTTPClient, s.districtDir(provider, district), c.Name, c.URL)
if err != nil {
log.Printf("import %s: download %s: %v", jobID, c.Name, err) // skip, keep going
} else {
@@ -340,52 +366,25 @@ func (s *Server) runImportFetch(jobID string, req importFetchReq) {
fail(fmt.Errorf("no cells downloaded"))
return
}
- // Download-only: the cells are now in the XDG cache (ENC_ROOT/); the client
- // triggers the union bake separately. Done.
+ // Download-only: the cells are now cached in the district's ENC_ROOT subfolder; the
+ // client triggers the bake separately (e.g. via /api/import/packs). Done.
if req.DownloadOnly {
- log.Printf("import %s: downloaded %d cell(s) into the cache", jobID, len(cells))
+ log.Printf("import %s: downloaded %d cell(s) into %s", jobID, len(cells), s.districtDir(provider, district))
s.imports.update(jobID, func(j *importJob) { j.Cells = len(cells); j.State = "done" })
return
}
- // Bake the full installed union (req.Bake) from the cache, with the freshly
- // downloaded cells merged in; or just the downloaded set when Bake is empty.
- bakeMap := cells
- if len(req.Bake) > 0 {
- bakeMap = s.cachedCellData(strings.Join(req.Bake, ","))
- maps.Copy(bakeMap, cells)
- }
- s.bakeAndRegister(jobID, req.Set, bakeMap, aux, cat, req.Overzoom, applyUpdates)
+ s.bakeAndRegister(jobID, provider)
}
-// cacheCells writes each cell's base (+updates) into the ENC_ROOT cache layout so
-// a later cache bake (cachedCellData) finds it. Best-effort; write errors are logged.
-func (s *Server) cacheCells(cells map[string]baker.CellData) {
- root := filepath.Join(s.dataDir, "ENC_ROOT")
- for name, cd := range cells {
- stem := strings.TrimSuffix(name, ".000")
- if !isCellName(stem) {
- continue
- }
- dir := filepath.Join(root, stem)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- log.Printf("cache %s: %v", stem, err)
- continue
- }
- if err := os.WriteFile(filepath.Join(dir, stem+".000"), cd.Base, 0o644); err != nil {
- log.Printf("cache %s: %v", stem, err)
- }
- for un, ub := range cd.Updates {
- _ = os.WriteFile(filepath.Join(dir, filepath.Base(un)), ub, 0o644)
- }
- }
- if s.cellIdx != nil {
- stems := make([]string, 0, len(cells))
- for name := range cells {
- stems = append(stems, strings.TrimSuffix(name, ".000"))
- }
- s.cellIdx.forget(stems) // re-imported cells: drop stale bounds so the rebuild re-parses
- s.cellIdx.rebuild() // re-index in the background (kick spawns its own goroutine; dirty re-run picks up a reindex that lands mid-scan)
+// baseOnly returns cells with their .001+ updates dropped — for a base-.000-edition
+// bake (the ?updates=0 mode). Applied BEFORE caching, so the persisted ENC_ROOT holds
+// base-only and the disk-read bake matches the choice.
+func baseOnly(cells map[string]baker.CellData) map[string]baker.CellData {
+ out := make(map[string]baker.CellData, len(cells))
+ for n, cd := range cells {
+ out[n] = baker.CellData{Base: cd.Base}
}
+ return out
}
// filterCells keeps only the cells whose stem (name sans .000) is in names.
@@ -443,10 +442,11 @@ func fetchURLProgress(raw string, onProgress func(done, total int)) ([]byte, err
return out.Bytes(), nil
}
-// importInputs gathers the cells to bake: from an uploaded zip (raw zip body or a
-// multipart "file" field) when one is present, else from the ENC_ROOT cache
-// (optionally narrowed by ?cells=A,B,C).
-func (s *Server) importInputs(r *http.Request) (map[string]baker.CellData, map[string][]byte, *s57.Catalog, error) {
+// importInputs gathers the cells to bake from the request itself: an uploaded zip (raw
+// body or a multipart "file" field), or specific named LOOSE cells (?cells=csv). It
+// returns nil cells when the request carries none — the signal for handleImport to
+// re-bake the provider from its already-cached ENC_ROOT.
+func (s *Server) importInputs(r *http.Request) (map[string]baker.CellData, map[string][]byte, []tile57.CatalogEntry, error) {
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
f, _, err := r.FormFile("file")
@@ -458,7 +458,7 @@ func (s *Server) importInputs(r *http.Request) (map[string]baker.CellData, map[s
if err != nil {
return nil, nil, nil, err
}
- return s.cacheExtracted(extractZipCells(data))
+ return extractZipCells(data)
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxImportBytes))
@@ -466,59 +466,44 @@ func (s *Server) importInputs(r *http.Request) (map[string]baker.CellData, map[s
return nil, nil, nil, err
}
if isZip(body) {
- return s.cacheExtracted(extractZipCells(body))
+ return extractZipCells(body)
}
- // No (zip) body → bake from the cached cells (already on disk).
- return s.cachedCellData(r.URL.Query().Get("cells")), nil, nil, nil
-}
-
-// cacheExtracted persists freshly-extracted upload cells to the ENC_ROOT source
-// cache before baking, so the ORIGINAL cell files are always kept (re-bakeable
-// after a tile-cache wipe) rather than discarded after an in-memory bake. Passes
-// the (cells, aux, err) triple straight through.
-func (s *Server) cacheExtracted(cells map[string]baker.CellData, aux map[string][]byte, cat *s57.Catalog, err error) (map[string]baker.CellData, map[string][]byte, *s57.Catalog, error) {
- if err == nil && len(cells) > 0 {
- s.cacheCells(cells)
+ // No (zip) body → bake specific named LOOSE cells (a lone .000 drop PUT via /api/cell,
+ // or a hand-picked list), which handleImport then writes into the district's ENC_ROOT
+ // subfolder; or, with no ?cells list, return nil → re-bake the provider's cached ENC_ROOT.
+ if csv := r.URL.Query().Get("cells"); csv != "" {
+ return s.looseCellData(csv), nil, nil, nil
}
- return cells, aux, cat, err
+ return nil, nil, nil, nil
}
-// maxImportBytes caps an uploaded exchange set (a single NOAA district zip is well
-// under this; the whole-nation All_ENCs.zip is multi-GB and is not an upload case).
-const maxImportBytes = 2 << 30 // 2 GiB
-
-// cachedCellData builds CellData (base + updates) from the ENC_ROOT cache, for the
-// no-upload import mode. csv, when non-empty, narrows to those cell names.
-func (s *Server) cachedCellData(csv string) map[string]baker.CellData {
- want := map[string]bool{}
- for n := range strings.SplitSeq(csv, ",") {
- if n = strings.TrimSpace(n); n != "" {
- want[n] = true
- }
- }
- root := filepath.Join(s.dataDir, "ENC_ROOT")
- entries, err := os.ReadDir(root)
+// looseCellData reads the named base cells (+ their .001… updates) from the loose-cell
+// dir — where /api/cell uploads/proxies land — for the "bake these specific cells into a
+// pack" import (a lone .000 drop, or a re-bake of a hand-picked cell list).
+func (s *Server) looseCellData(csv string) map[string]baker.CellData {
+ dir := s.looseCellsDir()
+ entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
cells := map[string]baker.CellData{}
- for _, e := range entries {
- name := e.Name()
- if !e.IsDir() || !isCellName(name) || (len(want) > 0 && !want[name]) {
+ for name := range strings.SplitSeq(csv, ",") {
+ name = strings.TrimSpace(name)
+ if name == "" || !isCellName(name) {
continue
}
- base, err := os.ReadFile(filepath.Join(root, name, name+".000"))
+ base, err := os.ReadFile(filepath.Join(dir, name+".000"))
if err != nil {
continue
}
cd := baker.CellData{Base: base, Updates: map[string][]byte{}}
- // Pick up any update files (.001…) sitting beside the base.
- if files, _ := os.ReadDir(filepath.Join(root, name)); files != nil {
- for _, uf := range files {
- if ext := encExtServer(uf.Name()); ext != "" && ext != ".000" {
- if b, e := os.ReadFile(filepath.Join(root, name, uf.Name())); e == nil {
- cd.Updates[uf.Name()] = b
- }
+ for _, e := range entries { // pick up this stem's updates sitting flat beside the base
+ if e.IsDir() || strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) != name {
+ continue
+ }
+ if ext := encExtServer(e.Name()); ext != "" && ext != ".000" {
+ if b, e2 := os.ReadFile(filepath.Join(dir, e.Name())); e2 == nil {
+ cd.Updates[e.Name()] = b
}
}
}
@@ -527,136 +512,41 @@ func (s *Server) cachedCellData(csv string) map[string]baker.CellData {
return cells
}
-// runImport bakes cells into /tiles/.pmtiles and registers the set.
-func (s *Server) runImport(jobID, set string, cells map[string]baker.CellData, aux map[string][]byte, cat *s57.Catalog, overzoom, applyUpdates bool) {
- s.bakeAndRegister(jobID, set, cells, aux, cat, overzoom, applyUpdates)
-}
-
-// bakeAndRegister is the shared bake → write → register tail for every import
-// path (upload, cached, server-fetch). The district `set` is baked into ONE archive
-// PER navigational-purpose band (set-overview, set-general, …), so a coarse-band-only
-// offshore area keeps tiles above the old merged archive's single maxzoom (no more
-// no-data hatch holes). Each band that produced tiles is written + registered as its
-// own set; the district aux.zip is written once (with the first band). Progress and
-// the terminal state are recorded on the job.
-func (s *Server) bakeAndRegister(jobID, set string, cells map[string]baker.CellData, aux map[string][]byte, cat *s57.Catalog, overzoom, applyUpdates bool) {
- fail := func(err error) {
- log.Printf("import %s (%s): %v", jobID, set, err)
- s.imports.update(jobID, func(j *importJob) { j.State = "error"; j.Err = err.Error() })
- }
-
- if !applyUpdates { // bake at the base .000 edition — strip updates first
- base := make(map[string]baker.CellData, len(cells))
- for n, cd := range cells {
- base[n] = baker.CellData{Base: cd.Base}
- }
- cells = base
- }
- _ = overzoom // the per-band streaming bake has no all-bands-to-z0 overzoom mode
- s.imports.update(jobID, func(j *importJob) {
- // Open on the "prepare" stage (unit "cells"): the bake starts by parsing
- // cells for coverage, well before the first tile emits.
- j.Phase, j.Unit, j.Band, j.Note, j.Done, j.Total = "bake", "cells", "", fmt.Sprintf("Baking %d cell(s)", len(cells)), 0, 0
- })
-
- // Drop any STALE merged archive named exactly `set` from a prior (pre-per-band)
- // bake, so the old single-maxzoom set isn't left serving alongside the new bands.
- s.removeMergedSet(set)
-
- // ONE bake path: the exact streaming per-band bake the CLI (`chartplotter bake
- // --bands`) uses — same cross-band suppression, same zoom ranges. No server-only
- // baker variant to drift out of sync.
- bands, tiles, first := 0, 0, true
- _, nCells, err := baker.BakeToPMTilesBandsStreaming(cells, 0,
- func(name string, e error) { log.Printf("import %s: skip %s: %v", jobID, name, e) },
- func(stage string, done, total int, band string) {
- // "prepare" = parsing + portraying a band's cells (the gap before any
- // tile emits); "tiles" = emitting that band's tiles. The unit lets the
- // client name the stage (Preparing … charts vs Generating … tiles).
- unit := "tiles"
- if stage == "prepare" {
- unit = "cells"
- }
- s.imports.update(jobID, func(j *importJob) { j.Done, j.Total, j.Band, j.Unit = done, total, band, unit })
- },
- func(slug string, pb *pmtiles.Builder) error {
- bandSet := set + "-" + slug
- bandAux := aux
- if !first { // ship the district aux.zip ONCE, with the first band
- bandAux = nil
- }
- if err := s.writeAndRegister(bandSet, pb, bandAux); err != nil {
- return err
- }
- // Record which cells went into this pack (beside its pmtiles), so
- // /api/cells?active returns exactly the installed cells — not every
- // cached cell that overlaps the pack's (often global) bounding box.
- if err := s.writeSetCells(bandSet, cells); err != nil {
- log.Printf("import %s: cell manifest %q: %v", jobID, bandSet, err)
- }
- first = false
- bands++
- tiles += pb.Count()
- log.Printf("import %s: baked %q (%d tiles)", jobID, bandSet, pb.Count())
- return nil
- })
- if err != nil {
- fail(err)
- return
- }
- if bands == 0 {
- fail(fmt.Errorf("no bands produced tiles"))
- return
- }
- s.imports.update(jobID, func(j *importJob) { j.Cells = nCells })
- s.auxIdx.invalidate() // the district's companion aux.zip changed — re-index /api/aux
-
- // Per-pack metadata sidecar for the chart library: per-cell scale/edition/date/
- // agency/coverage (cheap coverage-only parse) overlaid with the catalogue's chart
- // titles + coverage. Best-effort — a write failure only costs the extracted detail.
- s.imports.update(jobID, func(j *importJob) { j.Phase, j.Note = "meta", "Reading chart metadata" })
- cellMeta := baker.ExtractCellMeta(cells, func(name string, e error) {
- log.Printf("import %s: meta skip %s: %v", jobID, name, e)
- })
- meta := buildSetMeta(set, cellMeta, cat)
- meta.Imported = time.Now().UTC().Format(time.RFC3339)
- if err := s.writeSetMeta(set, meta); err != nil {
- log.Printf("import %s: write meta %q: %v", jobID, set, err)
- }
+// maxImportBytes caps an uploaded exchange set (a single NOAA district zip is well
+// under this; the whole-nation All_ENCs.zip is multi-GB and is not an upload case).
+const maxImportBytes = 2 << 30 // 2 GiB
- log.Printf("import %s: baked district %q (%d cells, %d bands, %d tiles)", jobID, set, nCells, bands, tiles)
- s.imports.update(jobID, func(j *importJob) { j.State = "done" })
+// runImport (re-)bakes the provider's whole ENC_ROOT into its one archive and registers
+// it — the shared tail for every single-set import path (upload, loose cells, cache
+// re-bake). The cells are already persisted under the provider's ENC_ROOT.
+func (s *Server) runImport(jobID, provider string) {
+ s.bakeAndRegister(jobID, provider)
}
-// removeMergedSet drops a stale MERGED archive named exactly `set` (the pre-per-band
-// layout) if one is still registered: unregister, untrack, and delete its
-// .pmtiles/.aux.zip. The per-band sets ("set-") are left alone. Best-effort.
-func (s *Server) removeMergedSet(set string) {
- if _, ok := s.packPath(set); !ok {
- if _, live := s.sets.get(set); !live {
- return // no merged set on disk or registered
- }
+// bakeAndRegister bakes a provider (its whole ENC_ROOT) into ONE archive and records
+// the terminal job state. Serializes with the packs path — a bake rewrites bundle
+// output in place, which concurrent bakes must not interleave.
+func (s *Server) bakeAndRegister(jobID, provider string) {
+ s.bakeMu.Lock()
+ defer s.bakeMu.Unlock()
+ if s.bakeProvider(jobID, provider) {
+ s.imports.update(jobID, func(j *importJob) { j.State = "done" })
}
- s.sets.remove(set)
- s.packDel(set)
- s.prefs.setDisabled(set, false)
- dir := s.setDir(set)
- _ = os.Remove(filepath.Join(dir, set+".pmtiles"))
- _ = os.Remove(filepath.Join(dir, set+".aux.zip"))
}
-// setDir is the per-set output directory under the (regenerable) cache. A set name
-// is "-" (e.g. "noaa-d17", "ienc-overview"), which maps to
-// /// so packs from different providers (NOAA districts, IENC
-// waterways, …) live in their own trees. A name with no provider prefix (a local
-// import, e.g. "import") goes to /import/. The set's pmtiles + aux.zip live
-// together there: /.{pmtiles,aux.zip}.
+// setDir is the provider's baked-bundle output dir under the (regenerable) cache:
+// // holding tiles/chart.pmtiles + assets + manifest + the
+// sidecars (.aux.zip, .cells.json, .meta.json). ONE archive per provider
+// (provider-enc-root); `set` is the provider name.
func (s *Server) setDir(set string) string {
- if i := strings.IndexByte(set, '-'); i > 0 && i < len(set)-1 {
- provider, pack := strings.ToUpper(set[:i]), strings.ToUpper(set[i+1:])
- return filepath.Join(s.cacheDir, provider, pack)
- }
- return filepath.Join(s.cacheDir, "import")
+ return filepath.Join(s.cacheDir, strings.ToUpper(set))
+}
+
+// looseCellsDir holds cells not tied to any pack — the /api/cell download proxy's
+// cache and share-published hand-imported cells. A scoped replacement for the old flat
+// ENC_ROOT's loose-cell role, still a cells/ dir so the cell index picks it up.
+func (s *Server) looseCellsDir() string {
+ return filepath.Join(s.dataDir, "loose", "cells")
}
// writeSetCells records the cell stems baked into `set` beside its pmtiles
@@ -695,59 +585,6 @@ func (s *Server) setCells(set string) ([]string, bool) {
return stems, true
}
-// writeAndRegister writes the baked archive to /.pmtiles atomically
-// (temp + rename), writes the companion .aux.zip beside it (TXTDSC/PICREP, via
-// the auxfiles package), and registers the set (replacing any prior one).
-func (s *Server) writeAndRegister(set string, pb *pmtiles.Builder, aux map[string][]byte) error {
- dir := s.setDir(set)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
- final := filepath.Join(dir, set+".pmtiles")
- tmp, err := os.CreateTemp(dir, set+".*.tmp")
- if err != nil {
- return err
- }
- tmpName := tmp.Name()
- if err := pb.WriteArchive(tmp); err != nil {
- tmp.Close()
- os.Remove(tmpName)
- return err
- }
- if err := tmp.Close(); err != nil {
- os.Remove(tmpName)
- return err
- }
- if err := os.Rename(tmpName, final); err != nil {
- os.Remove(tmpName)
- return err
- }
- // Stamp the build version beside the pack (.bakever) so startup can flag a
- // cache baked by an OLDER binary — the stale-tile trap where the server serves
- // tiles from before a baker/portrayal change. Best-effort; absence reads as stale.
- if s.Version != "" {
- _ = os.WriteFile(final+bakeVerExt, []byte(s.Version), 0o644)
- }
- // Companion aux.zip (best-effort — a missing aux archive only disables pictures
- // in the pick report, it doesn't break tiles).
- if len(aux) > 0 {
- if f, e := os.Create(filepath.Join(dir, set+".aux.zip")); e == nil {
- if _, e := auxfiles.WriteZip(f, aux); e != nil {
- log.Printf("aux %s: %v", set, e)
- }
- f.Close()
- }
- }
- src, err := tilesource.Open(final)
- if err != nil {
- return err
- }
- s.sets.register(set, src)
- s.packAdd(set, final) // track for /api/packs + enable/disable
- s.prefs.setDisabled(set, false) // a freshly baked pack is enabled
- return nil
-}
-
// statusJSON renders a job snapshot as the status JSON line (shared by the polling
// endpoint and the SSE stream).
func (j importJob) statusJSON() string {
@@ -756,8 +593,8 @@ func (j importJob) statusJSON() string {
pct = j.Done * 100 / j.Total
}
return fmt.Sprintf(
- `{"ok":true,"id":%q,"set":%q,"state":%q,"phase":%q,"band":%q,"note":%q,"done":%d,"total":%d,"unit":%q,"percent":%d,"cells":%d,"error":%q}`,
- j.ID, j.Set, j.State, j.Phase, j.Band, j.Note, j.Done, j.Total, j.Unit, pct, j.Cells, j.Err)
+ `{"ok":true,"id":%q,"set":%q,"state":%q,"phase":%q,"band":%q,"pack":%q,"packNum":%d,"packTotal":%d,"note":%q,"done":%d,"total":%d,"unit":%q,"percent":%d,"cells":%d,"error":%q}`,
+ j.ID, j.Set, j.State, j.Phase, j.Band, j.Pack, j.PackNum, j.PackTotal, j.Note, j.Done, j.Total, j.Unit, pct, j.Cells, j.Err)
}
// importStatus returns a job's state as JSON (one-shot poll).
@@ -831,7 +668,7 @@ func (s *Server) importEvents(w http.ResponseWriter, r *http.Request) {
// extractZipCells reads an exchange-set zip held in memory, grouping each cell's
// base (.000) + updates (.001…) by cell stem and collecting referenced aux files.
// It mirrors the CLI's collectCells/addZipCells for an in-memory archive.
-func extractZipCells(data []byte) (map[string]baker.CellData, map[string][]byte, *s57.Catalog, error) {
+func extractZipCells(data []byte) (map[string]baker.CellData, map[string][]byte, []tile57.CatalogEntry, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, nil, nil, fmt.Errorf("not a valid zip: %w", err)
@@ -898,10 +735,10 @@ func extractZipCells(data []byte) (map[string]baker.CellData, map[string][]byte,
}
cells[stem+".000"] = baker.CellData{Base: a.base, Updates: a.updates}
}
- var cat *s57.Catalog
+ var cat []tile57.CatalogEntry
if catalogBytes != nil {
- if c, err := s57.ParseCatalog(catalogBytes); err == nil {
- cat = c
+ if entries, err := tile57.CatalogEntries(catalogBytes); err == nil {
+ cat = entries
} else {
log.Printf("import: CATALOG.031 parse failed (ignored): %v", err)
}
diff --git a/internal/engine/server/import_meta_test.go b/internal/engine/server/import_meta_test.go
index ced80c8..35dceb0 100644
--- a/internal/engine/server/import_meta_test.go
+++ b/internal/engine/server/import_meta_test.go
@@ -20,7 +20,7 @@ func buildExchangeZip(t *testing.T) []byte {
if err != nil {
t.Fatal(err)
}
- cat, err := os.ReadFile("../../../pkg/s57/testdata/US5MD1MC_CATALOG.031")
+ cat, err := os.ReadFile("testdata/US5MD1MC_CATALOG.031")
if err != nil {
t.Fatal(err)
}
@@ -90,8 +90,8 @@ func TestImport_NoCatalog(t *testing.T) {
// TestImport_AutoNameAndMeta exercises the upload metadata wiring (minus HTTP and
// the bake): extract → derive a CATALOG-identity pack name → extract per-cell
// metadata → write the sidecar → surface it on /api/packs and /api/pack/.
-// The bake itself needs the S-101 portrayer (-tags embed_s101) and is covered by
-// the baker tests; this replicates the post-bake metadata tail of bakeAndRegister.
+// The bake itself (native libtile57) is exercised elsewhere; this replicates the
+// post-bake metadata tail (ExtractCellMeta → sidecar) independently of a real bake.
func TestImport_AutoNameAndMeta(t *testing.T) {
cacheDir, dataDir := t.TempDir(), t.TempDir()
s := New(t.TempDir(), cacheDir, dataDir, false)
@@ -108,25 +108,31 @@ func TestImport_AutoNameAndMeta(t *testing.T) {
t.Fatalf("cells = %d, want 1", len(cells))
}
- // CATALOG identity → single cell → "user-us5md1mc".
+ // CATALOG identity → single cell → pack key "user-us5md1mc" (provider "user",
+ // district "us5md1mc").
set := s.deriveUploadSet(cat, cells)
if set != "user-us5md1mc" {
t.Fatalf("deriveUploadSet = %q, want user-us5md1mc", set)
}
+ provider, district := providerOf(set), districtOf(set)
- // The post-bake metadata tail (bakeAndRegister does exactly this after baking).
+ // The post-bake metadata tail (bakeProvider → registerBakedSet does exactly this):
+ // the sidecar is keyed by the PROVIDER, one archive per provider.
cellMeta := baker.ExtractCellMeta(cells, nil)
- meta := buildSetMeta(set, cellMeta, cat)
+ meta := buildSetMeta(provider, cellMeta, cat)
meta.Imported = "2026-06-25T00:00:00Z"
- if err := s.writeSetMeta(set, meta); err != nil {
+ if err := s.writeSetMeta(provider, meta); err != nil {
t.Fatal(err)
}
- // Register a band-set so the district lists on /api/packs (a real bake does this
- // via packAdd; the empty path makes the bounds-open skip gracefully).
- s.packAdd(set+"-harbor", "")
+ // Create the district ENC_ROOT folder so /api/packs lists it, and register the
+ // provider set (empty path → the bounds-open skips gracefully).
+ if err := os.MkdirAll(s.districtDir(provider, district), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ s.packAdd(provider, "")
// The metadata sidecar carries the catalogue title + extracted header fields.
- m, ok := s.readSetMeta(set)
+ m, ok := s.readSetMeta(provider)
if !ok {
t.Fatal("no metadata sidecar written")
}
@@ -146,17 +152,20 @@ func TestImport_AutoNameAndMeta(t *testing.T) {
t.Errorf("per-cell detail wrong: %+v", m.Cells)
}
- // /api/packs lists the pack with its merged metadata.
+ // /api/packs lists the PROVIDER with its merged metadata + its installed district.
rec := httptest.NewRecorder()
s.handlePacks(rec, httptest.NewRequest("GET", "/api/packs", nil))
body := rec.Body.String()
- if !strings.Contains(body, `"name":"user-us5md1mc"`) || !strings.Contains(body, `"title":"Annapolis Harbor"`) {
+ if !strings.Contains(body, `"name":"user"`) || !strings.Contains(body, `"title":"Annapolis Harbor"`) {
t.Errorf("/api/packs missing pack or title: %s", body)
}
+ if !strings.Contains(body, `"districts":["us5md1mc"]`) {
+ t.Errorf("/api/packs missing district listing: %s", body)
+ }
- // /api/pack/ returns the full detail incl. per-cell list.
+ // /api/pack/ returns the full detail incl. per-cell list.
rec = httptest.NewRecorder()
- s.handlePackDetail(rec, httptest.NewRequest("GET", "/api/pack/"+set, nil))
+ s.handlePackDetail(rec, httptest.NewRequest("GET", "/api/pack/"+provider, nil))
if rec.Code != 200 {
t.Fatalf("pack detail status %d: %s", rec.Code, rec.Body.String())
}
@@ -164,7 +173,7 @@ func TestImport_AutoNameAndMeta(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("pack detail JSON: %v", err)
}
- if got.Set != set || len(got.Cells) != 1 {
+ if got.Set != provider || len(got.Cells) != 1 {
t.Errorf("pack detail = %+v", got)
}
}
diff --git a/internal/engine/server/import_packs.go b/internal/engine/server/import_packs.go
new file mode 100644
index 0000000..6ede9ea
--- /dev/null
+++ b/internal/engine/server/import_packs.go
@@ -0,0 +1,206 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+
+ "github.com/beetlebugorg/chartplotter/internal/engine/baker"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
+)
+
+// Multi-district import (POST /api/import/packs). The chart-library selects SEVERAL
+// districts and downloads them together; this endpoint fetches each district's cells
+// into its ENC_ROOT subfolder (//ENC_ROOT//) and then bakes
+// each touched PROVIDER as ONE archive from its whole ENC_ROOT. Best-available across
+// districts is a per-feature decision inside that single archive (the baker's
+// finestCsclAt) — no cross-pack peer context, no repacks. This is the primary download
+// path; the single-set POST /api/import stays for uploads + legacy.
+
+// importPacksReq is the JSON body: the set of district packs to download + bake.
+type importPacksReq struct {
+ Packs []importPackSpec `json:"packs"`
+}
+
+// importPackSpec is one selected district: its pack key ("-", e.g.
+// "noaa-d5") plus how to fetch its cells — a bulk district zip (zipUrl, optionally
+// narrowed to names) or a list of per-cell NOAA zips.
+type importPackSpec struct {
+ Set string `json:"set"`
+ ZipURL string `json:"zipUrl"`
+ Names []string `json:"names"`
+ Updates *bool `json:"updates"` // nil → apply .001+ (default)
+ Cells []struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ } `json:"cells"`
+}
+
+// handleImportPacks validates a multi-district fetch spec and starts a single
+// background job that downloads every district then re-bakes each touched provider.
+func (s *Server) handleImportPacks(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ apiErr(w, http.StatusMethodNotAllowed, "POST /api/import/packs")
+ return
+ }
+ var req importPacksReq
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
+ apiErr(w, http.StatusBadRequest, "bad JSON: "+err.Error())
+ return
+ }
+ if len(req.Packs) == 0 {
+ apiErr(w, http.StatusBadRequest, "no packs")
+ return
+ }
+ for _, p := range req.Packs {
+ if !isSetName(p.Set) || districtOf(p.Set) == "" {
+ apiErr(w, http.StatusBadRequest, "each pack needs a valid - key")
+ return
+ }
+ if p.ZipURL == "" && len(p.Cells) == 0 {
+ apiErr(w, http.StatusBadRequest, "pack "+p.Set+" needs zipUrl or cells")
+ return
+ }
+ if p.ZipURL != "" && !isChartURL(p.ZipURL) {
+ apiErr(w, http.StatusBadRequest, "zipUrl must be a charts.noaa.gov or ienccloud.us URL")
+ return
+ }
+ for _, c := range p.Cells {
+ if c.URL != "" && !isChartURL(c.URL) {
+ apiErr(w, http.StatusBadRequest, "cell url must be from a known chart provider")
+ return
+ }
+ }
+ }
+
+ label := req.Packs[0].Set
+ if len(req.Packs) > 1 {
+ label = fmt.Sprintf("%d packs", len(req.Packs))
+ }
+ job := s.imports.create(label)
+ go s.runImportPacks(job.ID, req)
+
+ w.Header().Set("Content-Type", jsonCT)
+ w.WriteHeader(http.StatusAccepted)
+ fmt.Fprintf(w, `{"ok":true,"job":%q,"packs":%d}`, job.ID, len(req.Packs))
+}
+
+// runImportPacks downloads each selected district's cells into its ENC_ROOT subfolder,
+// then bakes each touched provider ONCE from its whole ENC_ROOT. A district whose
+// download fails is skipped, not fatal.
+func (s *Server) runImportPacks(jobID string, req importPacksReq) {
+ fail := func(err error) {
+ log.Printf("import %s (packs): %v", jobID, err)
+ s.imports.update(jobID, func(j *importJob) { j.State = "error"; j.Err = err.Error() })
+ }
+
+ // 1. Download each district's cells into //ENC_ROOT//.
+ providers := map[string]bool{}
+ for i, p := range req.Packs {
+ provider, district := providerOf(p.Set), districtOf(p.Set)
+ s.imports.update(jobID, func(j *importJob) { j.Pack, j.PackNum, j.PackTotal = p.Set, i+1, len(req.Packs) })
+ cells, aux, cat, err := s.fetchPackCells(jobID, p)
+ if err != nil {
+ log.Printf("import %s: pack %s download: %v", jobID, p.Set, err) // skip, keep going
+ continue
+ }
+ if len(cells) == 0 {
+ log.Printf("import %s: pack %s: no cells", jobID, p.Set)
+ continue
+ }
+ if p.Updates != nil && !*p.Updates {
+ cells = baseOnly(cells)
+ }
+ s.cacheDistrict(provider, district, cells, aux, cat)
+ providers[provider] = true
+ }
+ if len(providers) == 0 {
+ fail(fmt.Errorf("no cells downloaded for any pack"))
+ return
+ }
+
+ // 2. Bake each touched provider once (serialized: a bake rewrites bundle output in
+ // place, which concurrent bakes must not interleave). Downloads above run unlocked.
+ s.bakeMu.Lock()
+ defer s.bakeMu.Unlock()
+ s.imports.update(jobID, func(j *importJob) {
+ j.Phase, j.Unit, j.Note, j.Done, j.Total = "bake", "cells", "Preparing charts", 0, 0
+ })
+ names := make([]string, 0, len(providers))
+ for prov := range providers {
+ names = append(names, prov)
+ }
+ sort.Strings(names)
+ baked := 0
+ for _, prov := range names {
+ if s.bakeProvider(jobID, prov) {
+ baked++
+ }
+ }
+ if baked == 0 {
+ return // bakeProvider recorded the error
+ }
+ s.imports.update(jobID, func(j *importJob) {
+ j.Pack, j.Band, j.Phase, j.State, j.Note = "", "", "done", "done", ""
+ j.PackNum, j.PackTotal = 0, 0
+ })
+ log.Printf("import %s: baked %d provider(s) from %d district(s)", jobID, baked, len(req.Packs))
+}
+
+// fetchPackCells downloads ONE district's cells (bulk zipUrl or per-cell) into memory,
+// reporting download progress on the job. It does not persist or bake — the caller
+// writes them to the district's ENC_ROOT subfolder (cacheDistrict) and bakes the
+// provider. The per-cell path streams each cell straight into the district dir as it
+// goes (via loadCellCached) so a re-run resumes from what's already on disk.
+func (s *Server) fetchPackCells(jobID string, p importPackSpec) (map[string]baker.CellData, map[string][]byte, []tile57.CatalogEntry, error) {
+ if p.ZipURL != "" {
+ name := p.ZipURL[strings.LastIndexByte(p.ZipURL, '/')+1:]
+ s.imports.update(jobID, func(j *importJob) {
+ j.Pack, j.Phase, j.Unit, j.Note, j.Done, j.Total = p.Set, "download", "bytes", "Downloading "+name, 0, 0
+ })
+ data, err := fetchURLProgress(p.ZipURL, func(done, total int) {
+ s.imports.update(jobID, func(j *importJob) { j.Done, j.Total = done, total })
+ })
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("download %s: %w", p.ZipURL, err)
+ }
+ s.imports.update(jobID, func(j *importJob) {
+ j.Phase, j.Unit, j.Note, j.Done, j.Total = "extract", "cells", "Extracting "+name, 0, 0
+ })
+ cells, aux, cat, err := extractZipCells(data)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ if len(p.Names) > 0 {
+ cells = filterCells(cells, p.Names)
+ }
+ return cells, aux, cat, nil
+ }
+
+ // Per-cell: download each into the district's ENC_ROOT subfolder, then bake from there.
+ cells := map[string]baker.CellData{}
+ dir := s.districtDir(providerOf(p.Set), districtOf(p.Set))
+ total := len(p.Cells)
+ s.imports.update(jobID, func(j *importJob) { j.Pack, j.Phase, j.Unit, j.Total = p.Set, "download", "cells", total })
+ for i, c := range p.Cells {
+ if !isCellName(c.Name) {
+ continue
+ }
+ if c.URL != "" && !allowedChartURL(c.URL) {
+ continue
+ }
+ s.imports.update(jobID, func(j *importJob) { j.Note = "Downloading " + c.Name; j.Done = i })
+ base, _, err := loadCellCached(chartHTTPClient, dir, c.Name, c.URL)
+ if err != nil {
+ log.Printf("import %s: download %s: %v", jobID, c.Name, err) // skip, keep going
+ } else {
+ cells[c.Name+".000"] = baker.CellData{Base: base}
+ }
+ s.imports.update(jobID, func(j *importJob) { j.Done = i + 1 })
+ }
+ return cells, nil, nil, nil
+}
diff --git a/internal/engine/server/import_packs_test.go b/internal/engine/server/import_packs_test.go
new file mode 100644
index 0000000..87c6e0c
--- /dev/null
+++ b/internal/engine/server/import_packs_test.go
@@ -0,0 +1,100 @@
+package server
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestImportPacks is a real end-to-end of POST /api/import/packs under the provider-
+// enc-root model: two districts (d5, d7) of the SAME provider are downloaded into their
+// ENC_ROOT subfolders and baked into ONE provider archive in a single job. The same cell
+// is placed in both district folders, so this also exercises the engine's stem de-dup
+// (it must bake once, not double-draw). Exercises handleImportPacks → runImportPacks →
+// fetchPackCells → cacheDistrict → bakeProvider → registerBakedSet with the native
+// libtile57 engine and a committed real S-57 cell.
+func TestImportPacks(t *testing.T) {
+ cell, err := os.ReadFile("../../../testdata/US5MD1MC.000")
+ if err != nil {
+ t.Skipf("testdata cell absent: %v", err)
+ }
+ cacheDir, dataDir := t.TempDir(), t.TempDir()
+ s := New(t.TempDir(), cacheDir, dataDir, false)
+ ts := httptest.NewServer(s)
+ defer ts.Close()
+
+ // Pre-place the real cell into two NOAA district folders under the ENC_ROOT so the
+ // per-cell fetch with an empty URL finds them without hitting NOAA.
+ for _, dist := range []string{"d5", "d7"} {
+ p := filepath.Join(s.districtDir("noaa", dist), "US5MD1MC.000")
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, cell, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ body := `{"packs":[
+ {"set":"noaa-d5","cells":[{"name":"US5MD1MC","url":""}]},
+ {"set":"noaa-d7","cells":[{"name":"US5MD1MC","url":""}]}
+ ]}`
+ resp, err := http.Post(ts.URL+"/api/import/packs", jsonCT, strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusAccepted {
+ t.Fatalf("accept: %d %s", resp.StatusCode, b)
+ }
+ var acc struct{ Job string }
+ if err := json.Unmarshal(b, &acc); err != nil || acc.Job == "" {
+ t.Fatalf("bad accept body %q: %v", b, err)
+ }
+
+ // Poll the job to completion (a single small harbor cell bakes in ~1-2s).
+ var st struct {
+ State, Error string
+ Cells int
+ }
+ deadline := time.Now().Add(60 * time.Second)
+ for time.Now().Before(deadline) {
+ r, _ := http.Get(ts.URL + "/api/import/status?job=" + acc.Job)
+ bb, _ := io.ReadAll(r.Body)
+ r.Body.Close()
+ json.Unmarshal(bb, &st)
+ if st.State != "running" {
+ break
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ if st.State != "done" {
+ t.Fatalf("job state=%q error=%q", st.State, st.Error)
+ }
+
+ // ONE provider set ("noaa") registered, with a baked chart.pmtiles under the cache
+ // dir, and both districts' source cells kept under the data dir.
+ if _, ok := s.sets.get("noaa"); !ok {
+ t.Errorf("provider set %q not registered", "noaa")
+ }
+ chart := filepath.Join(s.setDir("noaa"), "tiles", "chart.pmtiles")
+ if fi, err := os.Stat(chart); err != nil || fi.Size() == 0 {
+ t.Errorf("provider %q: no baked pmtiles (%v)", "noaa", err)
+ }
+ for _, dist := range []string{"d5", "d7"} {
+ if _, err := os.Stat(filepath.Join(s.districtDir("noaa", dist), "US5MD1MC.000")); err != nil {
+ t.Errorf("district %q: source cell missing after bake: %v", dist, err)
+ }
+ }
+ // The shared cell bakes ONCE (stem de-dup): the provider cell manifest has one entry.
+ if stems, ok := s.setCells("noaa"); !ok || len(stems) != 1 || stems[0] != "US5MD1MC" {
+ t.Errorf("provider cell manifest = %v (ok=%t), want [US5MD1MC]", stems, ok)
+ }
+}
diff --git a/internal/engine/server/import_test.go b/internal/engine/server/import_test.go
index 887513f..ea6180a 100644
--- a/internal/engine/server/import_test.go
+++ b/internal/engine/server/import_test.go
@@ -197,12 +197,13 @@ func TestImportFetchValidation(t *testing.T) {
}
}
-// TestImportFetchDownloadOnly places a cell in the cache, then a per-cell
-// download-only fetch with an empty URL finds it cached (no NOAA) and finishes
-// "done" without baking — verifying the server-side download path + cache.
+// TestImportFetchDownloadOnly places a cell in the provider's ENC_ROOT, then a
+// per-cell download-only fetch with an empty URL finds it cached (no NOAA) and
+// finishes "done" without baking — verifying the server-side download path + cache.
+// A bare-provider set ("user") lands in a district named for the provider.
func TestImportFetchDownloadOnly(t *testing.T) {
dir := t.TempDir()
- cp := filepath.Join(dir, "ENC_ROOT", "US5MD1MC", "US5MD1MC.000")
+ cp := filepath.Join(dir, "USER", "ENC_ROOT", "user", "US5MD1MC.000")
if err := os.MkdirAll(filepath.Dir(cp), 0o755); err != nil {
t.Fatal(err)
}
@@ -255,7 +256,7 @@ func TestImportFetchDownloadOnly(t *testing.T) {
func TestServeCells(t *testing.T) {
dir := t.TempDir()
for _, n := range []string{"US5MD1MC", "U37AG001"} {
- p := filepath.Join(dir, "ENC_ROOT", n, n+".000")
+ p := filepath.Join(dir, "loose", "cells", n+".000")
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
diff --git a/internal/engine/server/osm.go b/internal/engine/server/osm.go
new file mode 100644
index 0000000..c50a7cd
--- /dev/null
+++ b/internal/engine/server/osm.go
@@ -0,0 +1,92 @@
+package server
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// serveOSM proxies the OpenStreetMap raster basemap through this server.
+//
+// The browser can't set the User-Agent that the OSM tile usage policy requires
+// (User-Agent is a forbidden header for fetch/XHR), so a direct
+// tile.openstreetmap.org request from the page gets a 403. The server fetches
+// the tile with a compliant, app-identifying UA — which is also the correct
+// way to be a good OSM citizen: one identified client, server-side cached.
+//
+// Route: GET /osm/{z}/{x}/{y}.png → https://tile.openstreetmap.org/{z}/{x}/{y}.png
+func (s *Server) serveOSM(w http.ResponseWriter, r *http.Request) {
+ rest := strings.TrimPrefix(r.URL.Path, "/osm/")
+ z, x, y, ok := parseOSMPath(rest)
+ if !ok {
+ apiErr(w, http.StatusBadRequest, "path must be /osm/{z}/{x}/{y}.png")
+ return
+ }
+
+ upstream := fmt.Sprintf("https://tile.openstreetmap.org/%d/%d/%d.png", z, x, y)
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstream, nil)
+ if err != nil {
+ apiErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ // OSM's policy: a valid, identifying User-Agent (an app name + contact/URL).
+ req.Header.Set("User-Agent", s.osmUserAgent())
+ req.Header.Set("Referer", "https://github.com/beetlebugorg/chartplotter")
+
+ resp, err := osmClient.Do(req)
+ if err != nil {
+ apiErr(w, http.StatusBadGateway, "osm fetch: "+err.Error())
+ return
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ // Surface the upstream status (403/429/…) so the cause is visible.
+ apiErr(w, http.StatusBadGateway, "osm upstream "+strconv.Itoa(resp.StatusCode))
+ return
+ }
+ w.Header().Set("Content-Type", "image/png")
+ // The z/x/y URL is immutable content; let the browser cache it a day.
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ w.WriteHeader(http.StatusOK)
+ io.Copy(w, resp.Body)
+}
+
+// osmUserAgent identifies this app to OSM per their tile usage policy.
+func (s *Server) osmUserAgent() string {
+ v := s.Version
+ if v == "" {
+ v = "dev"
+ }
+ return "chartplotter/" + v + " (+https://github.com/beetlebugorg/chartplotter)"
+}
+
+var osmClient = &http.Client{Timeout: 15 * time.Second}
+
+// parseOSMPath pulls z/x/y out of "{z}/{x}/{y}[.png]".
+func parseOSMPath(rest string) (z, x, y int, ok bool) {
+ if i := strings.IndexByte(rest, '?'); i >= 0 {
+ rest = rest[:i]
+ }
+ parts := strings.Split(rest, "/")
+ if len(parts) != 3 {
+ return 0, 0, 0, false
+ }
+ last := parts[2]
+ if i := strings.IndexByte(last, '.'); i >= 0 {
+ if ext := last[i:]; ext != ".png" {
+ return 0, 0, 0, false
+ }
+ last = last[:i]
+ }
+ var err1, err2, err3 error
+ z, err1 = strconv.Atoi(parts[0])
+ x, err2 = strconv.Atoi(parts[1])
+ y, err3 = strconv.Atoi(last)
+ if err1 != nil || err2 != nil || err3 != nil || z < 0 || z > 22 {
+ return 0, 0, 0, false
+ }
+ return z, x, y, true
+}
diff --git a/internal/engine/server/packs_test.go b/internal/engine/server/packs_test.go
index 99c13f7..9aa59ac 100644
--- a/internal/engine/server/packs_test.go
+++ b/internal/engine/server/packs_test.go
@@ -5,49 +5,56 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"reflect"
"testing"
)
-// TestSplitSet covers the district/band split: a "-" suffix splits off as
-// the band; anything else is a whole-name district with band "all".
-func TestSplitSet(t *testing.T) {
- cases := []struct{ in, district, band string }{
- {"noaa-d5-general", "noaa-d5", "general"},
- {"noaa-d5-overview", "noaa-d5", "overview"},
- {"ienc-allegheny-berthing", "ienc-allegheny", "berthing"},
- {"noaa-d5", "noaa-d5", "all"}, // no band suffix → whole name, "all"
- {"user", "user", "all"}, // merged / local import
- {"import-harbor", "import", "harbor"}, // a band slug is still split off
- {"general", "general", "all"}, // bare slug (len == suffix) is NOT split
- {"x-coastalish", "x-coastalish", "all"}, // "-coastalish" is not "-coastal"
+// TestProviderKeys covers the provider/district split of a client pack key: the
+// provider is the part before the first "-" (the baked SET name), the district is
+// everything after it (the ENC_ROOT subfolder).
+func TestProviderKeys(t *testing.T) {
+ cases := []struct{ in, provider, district string }{
+ {"noaa-d5", "noaa", "d5"},
+ {"ienc-allegheny", "ienc", "allegheny"},
+ {"user-us5md1mc", "user", "us5md1mc"},
+ {"user-us5md1mc-2", "user", "us5md1mc-2"}, // district keeps everything after the first "-"
+ {"noaa", "noaa", ""}, // bare provider (no district)
}
for _, c := range cases {
- d, b := splitSet(c.in)
- if d != c.district || b != c.band {
- t.Errorf("splitSet(%q) = (%q,%q), want (%q,%q)", c.in, d, b, c.district, c.band)
+ if p := providerOf(c.in); p != c.provider {
+ t.Errorf("providerOf(%q) = %q, want %q", c.in, p, c.provider)
+ }
+ if d := districtOf(c.in); d != c.district {
+ t.Errorf("districtOf(%q) = %q, want %q", c.in, d, c.district)
}
}
}
-// TestHandlePacksGrouping checks /api/packs groups a district's band-sets into one
-// entry, bands sorted coarse→fine, enabled iff any band is enabled.
-func TestHandlePacksGrouping(t *testing.T) {
+// TestHandlePacksProviders checks /api/packs lists ONE entry per provider with its
+// installed districts read from the ENC_ROOT folder listing (not per-district sets).
+func TestHandlePacksProviders(t *testing.T) {
dir := t.TempDir()
s := New(dir, dir, dir, false)
- // Register band-sets out of order across two districts plus a merged set.
- for _, n := range []string{
- "noaa-d5-harbor", "noaa-d5-overview", "noaa-d5-general", "noaa-d5-coastal",
- "ienc-x-berthing",
- "legacy", // merged set → band "all"
+ // Create ENC_ROOT district folders (each with a placeholder .000) for two providers.
+ for _, dd := range []struct{ prov, dist string }{
+ {"noaa", "d5"}, {"noaa", "d7"}, {"ienc", "allegheny"},
} {
- s.packAdd(n, dir+"/"+n+".pmtiles")
+ p := filepath.Join(s.districtDir(dd.prov, dd.dist), "US5XX000.000")
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
}
- // Disable only ONE of noaa-d5's bands — the district stays enabled (any enabled).
- s.prefs.setDisabled("noaa-d5-harbor", true)
- // Disable BOTH of ienc-x's sole band → district disabled.
- s.prefs.setDisabled("ienc-x-berthing", true)
+ // Register baked provider sets (fake paths — bounds/meta are just skipped when the
+ // archive can't be opened). Disable ienc so it reports enabled=false.
+ s.packAdd("noaa", dir+"/noaa.pmtiles")
+ s.packAdd("ienc", dir+"/ienc.pmtiles")
+ s.prefs.setDisabled("ienc", true)
ts := httptest.NewServer(s)
defer ts.Close()
@@ -60,31 +67,30 @@ func TestHandlePacksGrouping(t *testing.T) {
var got struct {
Packs []struct {
- Name string `json:"name"`
- Enabled bool `json:"enabled"`
- Bands []string `json:"bands"`
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ Districts []string `json:"districts"`
} `json:"packs"`
}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("bad JSON %s: %v", body, err)
}
- // Districts sorted: ienc-x, legacy, noaa-d5.
+ // Providers sorted: ienc, noaa; districts sorted within each.
want := []struct {
- name string
- enabled bool
- bands []string
+ name string
+ enabled bool
+ districts []string
}{
- {"ienc-x", false, []string{"berthing"}},
- {"legacy", true, []string{"all"}},
- {"noaa-d5", true, []string{"overview", "general", "coastal", "harbor"}}, // coarse→fine
+ {"ienc", false, []string{"allegheny"}},
+ {"noaa", true, []string{"d5", "d7"}},
}
if len(got.Packs) != len(want) {
t.Fatalf("got %d packs, want %d: %s", len(got.Packs), len(want), body)
}
for i, w := range want {
p := got.Packs[i]
- if p.Name != w.name || p.Enabled != w.enabled || !reflect.DeepEqual(p.Bands, w.bands) {
- t.Errorf("pack[%d] = {%q,%t,%v}, want {%q,%t,%v}", i, p.Name, p.Enabled, p.Bands, w.name, w.enabled, w.bands)
+ if p.Name != w.name || p.Enabled != w.enabled || !reflect.DeepEqual(p.Districts, w.districts) {
+ t.Errorf("pack[%d] = {%q,%t,%v}, want {%q,%t,%v}", i, p.Name, p.Enabled, p.Districts, w.name, w.enabled, w.districts)
}
}
}
diff --git a/internal/engine/server/prefs.go b/internal/engine/server/prefs.go
index fa1c11d..4cc63ab 100644
--- a/internal/engine/server/prefs.go
+++ b/internal/engine/server/prefs.go
@@ -2,6 +2,7 @@ package server
import (
"encoding/json"
+ "fmt"
"log"
"os"
"path/filepath"
@@ -14,6 +15,13 @@ import (
// (.pmtiles.bakever), so startup can flag a cache baked by an older binary.
const bakeVerExt = ".bakever"
+// engineVerExt is the sidecar that records the tile57 ENGINE commit that baked a
+// pack (.pmtiles.enginever) — bake-time truth, distinct from the running
+// binary's own engine commit. The set's TileJSON reports it so the client can
+// stamp the map with the engine behind the visible tiles (and flag a mixed-bake
+// cache). A pack without the sidecar predates stamping → "pre-stamp".
+const engineVerExt = ".enginever"
+
// ReportStaleCache logs a loud warning for any served pack whose recorded build
// version (its .bakever sidecar) differs from the running binary — the
// stale-cache trap, where `make serve` keeps serving tiles baked by older
@@ -87,8 +95,11 @@ func (p *prefs) setDisabled(set string, off bool) {
}
}
-// scanPacks walks the cache and returns every baked pack file keyed by set name
-// (basename sans extension) → path. Includes the provider trees plus flat tiles/.
+// scanPacks walks the cache and returns every baked provider bundle keyed by set name
+// (= provider) → path. The tile57 bundle writes //tiles/chart.pmtiles
+// (one archive per provider, provider-enc-root), so the set name is the provider dir —
+// otherwise every bundle collapses to "chart" and the chart library can't list them
+// after a restart.
func scanPacks(cacheDir string) map[string]string {
out := map[string]string{}
_ = filepath.WalkDir(cacheDir, func(path string, d os.DirEntry, err error) error {
@@ -99,6 +110,13 @@ func scanPacks(cacheDir string) map[string]string {
return nil
}
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
+ // tile57 bundle: //tiles/chart.pmtiles → the provider name.
+ if name == "chart" && filepath.Base(filepath.Dir(path)) == "tiles" {
+ provider := filepath.Base(filepath.Dir(filepath.Dir(path)))
+ if provider != "" && provider != "." {
+ name = strings.ToLower(provider)
+ }
+ }
if isSetName(name) {
out[name] = path
}
@@ -128,6 +146,30 @@ func (s *Server) packPath(set string) (string, bool) {
return p, ok
}
+// packGen is a baked pack's cache-bust generation token — its archive mtime in
+// unix-nanos, which changes every time the set is re-baked (a fresh file is
+// renamed into place). 0 for a live/dynamic set (no pack file). Both the
+// TileJSON and the engine-style source URL stamp this as ?g so a given tile URL
+// is content-addressed and safe to cache immutably (see serveTile).
+func (s *Server) packGen(set string) int64 {
+ if p, ok := s.packPath(set); ok {
+ if fi, err := os.Stat(p); err == nil {
+ return fi.ModTime().UnixNano()
+ }
+ }
+ return 0
+}
+
+// genQuery renders a packGen token as a tile-URL query suffix: "?g=" for a
+// real (nonzero) generation, "" for a live set (so its URL stays token-free and
+// serveTile keeps it no-cache).
+func genQuery(gen int64) string {
+ if gen == 0 {
+ return ""
+ }
+ return fmt.Sprintf("?g=%d", gen)
+}
+
func (s *Server) packNames() []string {
s.packsMu.Lock()
defer s.packsMu.Unlock()
diff --git a/internal/engine/server/provider.go b/internal/engine/server/provider.go
new file mode 100644
index 0000000..97259c4
--- /dev/null
+++ b/internal/engine/server/provider.go
@@ -0,0 +1,272 @@
+package server
+
+import (
+ "encoding/json"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/beetlebugorg/chartplotter/internal/engine/baker"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
+)
+
+// provider-enc-root storage model (specs/provider-enc-root.md): one baked archive
+// per PROVIDER (noaa / ienc / user); DISTRICTS (d5, a river, an upload id) are
+// download/delete subfolders under the provider's ENC_ROOT, NOT separate sets. A
+// provider is baked as ONE tile57 bundle from its whole ENC_ROOT tree, so
+// best-available across cells/districts is a per-feature decision inside a single
+// archive (the baker's finestCsclAt) instead of a fragile cross-source composition.
+//
+// A client-facing pack key stays "-" (e.g. "noaa-d5"); the
+// server splits it into the provider (the baked SET name) + the district (the
+// ENC_ROOT subfolder). A bare key with no "-" is already a provider.
+
+// providerOf returns the provider component of a pack key ("noaa" from "noaa-d5"),
+// lowercased — this is the baked SET name (one archive per provider). A key with no
+// "-" is returned lowercased as-is (already a bare provider).
+func providerOf(key string) string {
+ if i := strings.IndexByte(key, '-'); i > 0 {
+ return strings.ToLower(key[:i])
+ }
+ return strings.ToLower(key)
+}
+
+// districtOf returns the district component of a pack key ("d5" from "noaa-d5"), or
+// "" when the key is a bare provider. The district is a management/delete label —
+// the ENC_ROOT subfolder name.
+func districtOf(key string) string {
+ if i := strings.IndexByte(key, '-'); i > 0 && i < len(key)-1 {
+ return key[i+1:]
+ }
+ return ""
+}
+
+// isDistrictName accepts a safe single path component for a district subfolder (same
+// rules as a set name: letters, digits, '-', '_', '.', no separators or traversal).
+func isDistrictName(d string) bool { return isSetName(d) }
+
+// providerDataDir is // — the provider's persistent home (holding its
+// ENC_ROOT source tree). Uppercased to match the pre-existing per-provider layout.
+func (s *Server) providerDataDir(provider string) string {
+ return filepath.Join(s.dataDir, strings.ToUpper(provider))
+}
+
+// encRootDir is //ENC_ROOT/ — the bake input, walked recursively by
+// tile57.BakeBundle for every *.000 across all installed district subfolders. It
+// lives under the DATA dir (persistent, survives a cache wipe: it is the downloads'
+// home + the bake input), so ClearCache never touches it.
+func (s *Server) encRootDir(provider string) string {
+ return filepath.Join(s.providerDataDir(provider), "ENC_ROOT")
+}
+
+// districtDir is //ENC_ROOT// — one district's cells; the
+// download/delete unit. Downloads write cells (+ updates + aux + a _catalog.json
+// title sidecar) here; delete removes the whole subfolder and re-bakes the provider.
+func (s *Server) districtDir(provider, district string) string {
+ return filepath.Join(s.encRootDir(provider), district)
+}
+
+// providerDistricts lists the installed district subfolders under a provider's
+// ENC_ROOT (sorted) — the district→cell map is the folder listing itself, so the UI
+// reads "which districts are installed" straight from disk.
+func (s *Server) providerDistricts(provider string) []string {
+ entries, err := os.ReadDir(s.encRootDir(provider))
+ if err != nil {
+ return nil
+ }
+ var out []string
+ for _, e := range entries {
+ if e.IsDir() && isDistrictName(e.Name()) {
+ out = append(out, e.Name())
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// installedProviders lists every provider that currently has a non-empty ENC_ROOT
+// (at least one district folder on disk), sorted — the set of bakeable providers.
+func (s *Server) installedProviders() []string {
+ entries, err := os.ReadDir(s.dataDir)
+ if err != nil {
+ return nil
+ }
+ var out []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ provider := strings.ToLower(e.Name())
+ if !isSetName(provider) {
+ continue
+ }
+ if len(s.providerDistricts(provider)) > 0 {
+ out = append(out, provider)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// districtCatFile is the per-district sidecar of parsed CATALOG.031 titles (the raw
+// catalogue isn't kept at the ENC_ROOT root — that would flip BakeBundle into
+// catalog-only mode — so titles are stashed here and re-gathered per provider bake).
+const districtCatFile = "_catalog.json"
+
+// cacheDistrict writes one district's downloaded exchange-set content into its
+// ENC_ROOT subfolder: each cell FLAT as .000 (+ .001… updates), aux content
+// files (TXTDSC/PICREP) flat beside them, and a _catalog.json of parsed titles. The
+// bake reads the whole provider ENC_ROOT from here (no temp staging); the flat layout
+// is transparent to BakeBundle's recursive *.000 walk. Best-effort; errors logged.
+func (s *Server) cacheDistrict(provider, district string, cells map[string]baker.CellData, aux map[string][]byte, cat []tile57.CatalogEntry) {
+ dir := s.districtDir(provider, district)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return
+ }
+ // Drop any previously-cached updates for the stems we're about to (re)write, so
+ // the persisted source matches EXACTLY the edition written now (a ?updates=0
+ // re-import must not leave stale .001+ on disk for the disk-read bake to apply).
+ stems := make(map[string]bool, len(cells))
+ for name := range cells {
+ stems[strings.TrimSuffix(name, ".000")] = true
+ }
+ if entries, err := os.ReadDir(dir); err == nil {
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ if ext := encExtServer(e.Name()); ext != "" && ext != ".000" && stems[strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))] {
+ _ = os.Remove(filepath.Join(dir, e.Name()))
+ }
+ }
+ }
+ for name, cd := range cells {
+ stem := strings.TrimSuffix(name, ".000")
+ if !isCellName(stem) {
+ continue
+ }
+ if err := os.WriteFile(filepath.Join(dir, stem+".000"), cd.Base, 0o644); err != nil {
+ continue
+ }
+ for un, ub := range cd.Updates {
+ _ = os.WriteFile(filepath.Join(dir, filepath.Base(un)), ub, 0o644)
+ }
+ }
+ for name, b := range aux {
+ if isAuxContentServer(name) {
+ _ = os.WriteFile(filepath.Join(dir, filepath.Base(name)), b, 0o644)
+ }
+ }
+ if len(cat) > 0 {
+ if b, err := json.Marshal(cat); err == nil {
+ _ = os.WriteFile(filepath.Join(dir, districtCatFile), b, 0o644)
+ }
+ }
+ if s.cellIdx != nil {
+ stems := make([]string, 0, len(cells))
+ for name := range cells {
+ stems = append(stems, strings.TrimSuffix(name, ".000"))
+ }
+ s.cellIdx.forget(stems) // re-imported cells: drop stale bounds so the rebuild re-parses
+ s.cellIdx.rebuild() // re-index in the background (single-flight; a mid-scan rebuild re-runs)
+ }
+}
+
+// providerCellData reads every base cell (+ its .001… updates) under a provider's
+// ENC_ROOT, DE-DUPLICATED by stem (a boundary cell shared by two districts is read
+// once), for the bake's metadata sidecar + cell manifest. The bake itself reads the
+// tree directly (BakeBundle); this is only the in-memory view the register tail needs.
+func (s *Server) providerCellData(provider string) map[string]baker.CellData {
+ root := s.encRootDir(provider)
+ cells := map[string]baker.CellData{} // stem+".000" → base
+ updates := map[string]map[string][]byte{} // stem → update-name → bytes
+ _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return nil
+ }
+ ext := encExtServer(d.Name())
+ if ext == "" {
+ return nil
+ }
+ stem := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name()))
+ if !isCellName(stem) {
+ return nil
+ }
+ if ext == ".000" {
+ if _, seen := cells[stem+".000"]; seen {
+ return nil // dedup: first district's copy wins
+ }
+ if b, e := os.ReadFile(path); e == nil {
+ cells[stem+".000"] = baker.CellData{Base: b}
+ }
+ return nil
+ }
+ if updates[stem] == nil {
+ updates[stem] = map[string][]byte{}
+ }
+ if _, seen := updates[stem][d.Name()]; !seen {
+ if b, e := os.ReadFile(path); e == nil {
+ updates[stem][d.Name()] = b
+ }
+ }
+ return nil
+ })
+ for name, cd := range cells {
+ if u := updates[strings.TrimSuffix(name, ".000")]; len(u) > 0 {
+ cd.Updates = u
+ cells[name] = cd
+ }
+ }
+ return cells
+}
+
+// providerAux gathers the aux content files (TXTDSC/PICREP text + pictures) across a
+// provider's ENC_ROOT, de-duplicated by upper-cased basename, for the provider's one
+// companion aux.zip.
+func (s *Server) providerAux(provider string) map[string][]byte {
+ root := s.encRootDir(provider)
+ aux := map[string][]byte{}
+ _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return nil
+ }
+ if encExtServer(d.Name()) != "" || isCatalogFile(d.Name()) || !isAuxContentServer(d.Name()) {
+ return nil
+ }
+ k := strings.ToUpper(d.Name())
+ if _, ok := aux[k]; !ok {
+ if b, e := os.ReadFile(path); e == nil {
+ aux[k] = b
+ }
+ }
+ return nil
+ })
+ return aux
+}
+
+// providerCatalog merges the parsed CATALOG.031 title entries stashed per district
+// (_catalog.json) across a provider's ENC_ROOT, for the provider's metadata sidecar
+// (per-cell titles). Missing/broken sidecars are skipped.
+func (s *Server) providerCatalog(provider string) []tile57.CatalogEntry {
+ root := s.encRootDir(provider)
+ var cat []tile57.CatalogEntry
+ _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || d.Name() != districtCatFile {
+ return nil
+ }
+ if b, e := os.ReadFile(path); e == nil {
+ var entries []tile57.CatalogEntry
+ if json.Unmarshal(b, &entries) == nil {
+ for i := range entries {
+ // HasBBox is derived (json:"-"), so reconstitute it from a non-zero bbox.
+ entries[i].HasBBox = entries[i].BBox != [4]float64{}
+ }
+ cat = append(cat, entries...)
+ }
+ }
+ return nil
+ })
+ return cat
+}
diff --git a/internal/engine/server/server_test.go b/internal/engine/server/server_test.go
index d7de2a7..b43c4e1 100644
--- a/internal/engine/server/server_test.go
+++ b/internal/engine/server/server_test.go
@@ -55,7 +55,7 @@ func TestServeStaticAndRange(t *testing.T) {
func TestServeCell(t *testing.T) {
dir := t.TempDir()
cell := []byte("S57-CELL-BYTES")
- cp := filepath.Join(dir, "ENC_ROOT", "US5MD1MC", "US5MD1MC.000")
+ cp := filepath.Join(dir, "loose", "cells", "US5MD1MC.000")
if err := os.MkdirAll(filepath.Dir(cp), 0o755); err != nil {
t.Fatal(err)
}
@@ -93,7 +93,9 @@ func TestAPIHealthAndHostCheck(t *testing.T) {
resp, _ := http.Get(ts.URL + "/api/health")
got, _ := io.ReadAll(resp.Body)
resp.Body.Close()
- if strings.TrimSpace(string(got)) != `{"ok":true}` {
+ // /api/health also advertises the server version, so match the liveness
+ // marker rather than an exact body.
+ if !strings.Contains(string(got), `"ok":true`) {
t.Errorf("health: got %q", got)
}
@@ -145,7 +147,7 @@ func TestShareAndUpload(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Fatalf("put cell: got %d", resp.StatusCode)
}
- if _, err := os.Stat(filepath.Join(dir, "ENC_ROOT", "US5MD1MC", "US5MD1MC.000")); err != nil {
+ if _, err := os.Stat(filepath.Join(dir, "loose", "cells", "US5MD1MC.000")); err != nil {
t.Errorf("uploaded cell not cached: %v", err)
}
resp, _ = http.Get(ts.URL + "/api/cell/US5MD1MC")
diff --git a/internal/engine/server/setmeta.go b/internal/engine/server/setmeta.go
index a711052..ba13057 100644
--- a/internal/engine/server/setmeta.go
+++ b/internal/engine/server/setmeta.go
@@ -3,12 +3,13 @@ package server
import (
"encoding/json"
"os"
+ "path"
"path/filepath"
"sort"
"strings"
"github.com/beetlebugorg/chartplotter/internal/engine/baker"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
)
// setMetaExt is the per-pack metadata sidecar written beside the band archives
@@ -66,15 +67,27 @@ func itoa(n int) string {
return string(b[i:])
}
+// catCellStem returns the cell name without extension (e.g. "US5MD1MC") for a
+// base-cell catalogue entry (IMPL "BIN", a .000 file), or "" for updates, text
+// descriptions, and other auxiliary rows. The engine normalises separators to
+// '/', but NOAA records backslashes, so both are handled.
+func catCellStem(e tile57.CatalogEntry) string {
+ base := path.Base(strings.ReplaceAll(e.File, "\\", "/"))
+ if e.Impl != "BIN" || !strings.HasSuffix(strings.ToUpper(base), ".000") {
+ return ""
+ }
+ return base[:len(base)-4]
+}
+
// catalogPackIdentity derives a stable, friendly pack identity from an
// exchange-set catalogue: the longest common (alphanumeric) prefix of the base
// cell names, lowercased — e.g. cells US5MD1MC/US5MD2NW → "us5md". Returns ""
// when there's no usable shared prefix (≥3 chars) so the caller can fall back to
// the upload filename. A single cell yields that cell's full stem.
-func catalogPackIdentity(cat *s57.Catalog) string {
+func catalogPackIdentity(cat []tile57.CatalogEntry) string {
stems := make([]string, 0)
- for _, c := range cat.Cells() {
- if s := c.CellStem(); s != "" {
+ for _, e := range cat {
+ if s := catCellStem(e); s != "" {
stems = append(stems, s)
}
}
@@ -120,19 +133,20 @@ func slug(s string) string {
// titles are left empty and the CLIENT resolves them from the NOAA master index it
// already holds (chart-library's _byName); cells stay fully described by their own
// header (scale/edition/date/agency/coverage) regardless.
-func buildSetMeta(set string, cellMeta map[string]baker.CellMeta, cat *s57.Catalog) SetMeta {
+func buildSetMeta(set string, cellMeta map[string]baker.CellMeta, cat []tile57.CatalogEntry) SetMeta {
// Catalogue overlay: stem → long name, stem → bbox.
catTitle := map[string]string{}
catBox := map[string][4]float64{}
- if cat != nil {
- for _, e := range cat.Cells() {
- stem := e.CellStem()
- if e.LongName != "" {
- catTitle[stem] = e.LongName
- }
- if e.HasBBox {
- catBox[stem] = [4]float64{e.West, e.South, e.East, e.North}
- }
+ for _, e := range cat {
+ stem := catCellStem(e)
+ if stem == "" {
+ continue
+ }
+ if e.LongName != "" {
+ catTitle[stem] = e.LongName
+ }
+ if e.HasBBox {
+ catBox[stem] = e.BBox
}
}
diff --git a/internal/engine/server/setmeta_test.go b/internal/engine/server/setmeta_test.go
index 892ee32..81a3654 100644
--- a/internal/engine/server/setmeta_test.go
+++ b/internal/engine/server/setmeta_test.go
@@ -4,7 +4,7 @@ import (
"testing"
"github.com/beetlebugorg/chartplotter/internal/engine/baker"
- "github.com/beetlebugorg/chartplotter/pkg/s57"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
)
func TestCommonPrefixIdentity(t *testing.T) {
@@ -29,10 +29,10 @@ func TestBuildSetMeta_CatalogOverlay(t *testing.T) {
"US5MD1MC": {Name: "US5MD1MC", Title: "US5MD1MC", Scale: 12000, Agency: 550, IssueDate: "20251030", BBox: [4]float64{-76.5, 38.9, -76.4, 39.0}, HasBBox: true},
"US5MD2NW": {Name: "US5MD2NW", Title: "US5MD2NW", Scale: 20000, Agency: 550},
}
- cat := &s57.Catalog{Entries: []s57.CatalogEntry{
- {File: "US5MD1MC\\US5MD1MC.000", Impl: "BIN", LongName: "Annapolis Harbor", HasBBox: true, West: -76.5, South: 38.9, East: -76.4, North: 39.0},
- {File: "US5MD2NW\\US5MD2NW.000", Impl: "BIN", LongName: "Chesapeake Bay", HasBBox: true, West: -76.6, South: 39.0, East: -76.4, North: 39.2},
- }}
+ cat := []tile57.CatalogEntry{
+ {File: "US5MD1MC\\US5MD1MC.000", Impl: "BIN", LongName: "Annapolis Harbor", HasBBox: true, BBox: [4]float64{-76.5, 38.9, -76.4, 39.0}},
+ {File: "US5MD2NW\\US5MD2NW.000", Impl: "BIN", LongName: "Chesapeake Bay", HasBBox: true, BBox: [4]float64{-76.6, 39.0, -76.4, 39.2}},
+ }
m := buildSetMeta("user-us5md", cellMeta, cat)
diff --git a/internal/engine/server/share.go b/internal/engine/server/share.go
index 2dd21d7..3dea00c 100644
--- a/internal/engine/server/share.go
+++ b/internal/engine/server/share.go
@@ -116,7 +116,7 @@ func (s *Server) uploadCell(w http.ResponseWriter, r *http.Request) {
apiErr(w, http.StatusRequestEntityTooLarge, "cell too large")
return
}
- cpath := filepath.Join(s.dataDir, "ENC_ROOT", name, name+".000")
+ cpath := filepath.Join(s.looseCellsDir(), name+".000")
if err := os.MkdirAll(filepath.Dir(cpath), 0o755); err != nil {
apiErr(w, http.StatusInternalServerError, err.Error())
return
diff --git a/pkg/s57/testdata/US5MD1MC_CATALOG.031 b/internal/engine/server/testdata/US5MD1MC_CATALOG.031
similarity index 100%
rename from pkg/s57/testdata/US5MD1MC_CATALOG.031
rename to internal/engine/server/testdata/US5MD1MC_CATALOG.031
diff --git a/internal/engine/server/tile.go b/internal/engine/server/tile.go
deleted file mode 100644
index 7521c9b..0000000
--- a/internal/engine/server/tile.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package server
-
-import (
- "bytes"
- "net/http"
- "os"
- "path/filepath"
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/internal/engine/bake"
- "github.com/beetlebugorg/chartplotter/internal/engine/baker"
- "github.com/beetlebugorg/chartplotter/internal/engine/pmtiles"
- "github.com/beetlebugorg/chartplotter/internal/engine/tile"
-)
-
-// serveTile bakes ONE MVT tile on demand from cached raw cells — the hittable URL
-// behind the tile-debugger's "inspect this tile" button. It re-bakes server-side
-// with the SAME baker the browser runs (NewSession → AddCellBytes → EmitTileInto,
-// no prebuilt index — exactly the wasm cpBakeTile path), so the precise bytes for
-// a z/x/y can be pulled with curl and fed to any MVT inspector.
-//
-// GET /api/tile/{z}/{x}/{y}[?cells=US2EC02M,US5MD1MC]
-//
-// With ?cells it bakes from just those cached cells (what the app loaded for the
-// tile); without it, from every cell in the ENC_ROOT cache. By default it returns
-// the raw (un-gzipped) MVT body; with ?format=pmtiles it wraps that single tile in
-// a valid PMTiles v3 archive (so it loads in pmtiles.io / any PMTiles viewer — a
-// bare .pbf trips "wrong magic number"). 204 when the tile is empty, 400/404 on
-// bad input / nothing cached. CORS-open so a remote viewer can fetch it.
-func (s *Server) serveTile(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Access-Control-Allow-Origin", "*")
- if r.Method != http.MethodGet {
- apiErr(w, http.StatusMethodNotAllowed, "GET only")
- return
- }
- z, x, y, ok := parseTilePath(r.URL.Path)
- if !ok {
- apiErr(w, http.StatusBadRequest, "path must be /api/tile/{z}/{x}/{y}")
- return
- }
- names := s.tileCells(r.URL.Query().Get("cells"))
- if len(names) == 0 {
- apiErr(w, http.StatusNotFound, "no cached cells to bake (PUT cells first, or pass ?cells=)")
- return
- }
- sess, err := baker.NewSession()
- if err != nil {
- apiErr(w, http.StatusInternalServerError, err.Error())
- return
- }
- added := 0
- for _, name := range names {
- data, err := os.ReadFile(filepath.Join(s.dataDir, "ENC_ROOT", name, name+".000"))
- if err != nil {
- continue // skip cells that aren't in the cache
- }
- if _, err := sess.AddCellBytes(name, data); err == nil {
- added++
- }
- }
- if added == 0 {
- apiErr(w, http.StatusNotFound, "none of the requested cells are cached")
- return
- }
- var scratch bake.TileScratch
- body := sess.Baker.EmitTileInto(tile.TileCoord{Z: z, X: x, Y: y}, baker.MVTExtent, baker.MVTBuffer, &scratch)
- if len(body) == 0 {
- w.WriteHeader(http.StatusNoContent) // baked clean: no features in this tile
- return
- }
- base := strconv.FormatUint(uint64(z), 10) + "-" + strconv.FormatUint(uint64(x), 10) + "-" + strconv.FormatUint(uint64(y), 10)
- w.Header().Set("Cache-Control", "no-cache")
- if r.URL.Query().Get("format") == "pmtiles" {
- // Wrap the one tile in a minimal PMTiles archive so PMTiles viewers accept
- // it. Buffer first so a write error surfaces before the status is sent.
- pb := pmtiles.New()
- pb.AddTile(uint8(z), x, y, body)
- var buf bytes.Buffer
- if err := pb.WriteArchive(&buf); err != nil {
- apiErr(w, http.StatusInternalServerError, err.Error())
- return
- }
- w.Header().Set("Content-Type", "application/octet-stream")
- w.Header().Set("Content-Disposition", "attachment; filename=\""+base+".pmtiles\"")
- w.Write(buf.Bytes())
- return
- }
- w.Header().Set("Content-Type", "application/vnd.mapbox-vector-tile")
- w.Header().Set("Content-Disposition", "inline; filename=\""+base+".pbf\"")
- w.Write(body)
-}
-
-// parseTilePath pulls z/x/y out of /api/tile/{z}/{x}/{y}.
-func parseTilePath(p string) (z, x, y uint32, ok bool) {
- parts := strings.Split(strings.Trim(strings.TrimPrefix(p, "/api/tile/"), "/"), "/")
- if len(parts) != 3 {
- return 0, 0, 0, false
- }
- zi, e1 := strconv.ParseUint(parts[0], 10, 32)
- xi, e2 := strconv.ParseUint(parts[1], 10, 32)
- yi, e3 := strconv.ParseUint(parts[2], 10, 32)
- if e1 != nil || e2 != nil || e3 != nil || zi > 24 {
- return 0, 0, 0, false
- }
- return uint32(zi), uint32(xi), uint32(yi), true
-}
-
-// tileCells resolves which cells to bake: the explicit ?cells=A,B,C list (filtered
-// to valid cell names), or — when empty — every cell in the ENC_ROOT cache.
-func (s *Server) tileCells(csv string) []string {
- if strings.TrimSpace(csv) != "" {
- var out []string
- for _, n := range strings.Split(csv, ",") {
- if n = strings.TrimSpace(n); isCellName(n) {
- out = append(out, n)
- }
- }
- return out
- }
- entries, err := os.ReadDir(filepath.Join(s.dataDir, "ENC_ROOT"))
- if err != nil {
- return nil
- }
- var out []string
- for _, e := range entries {
- if e.IsDir() && isCellName(e.Name()) {
- out = append(out, e.Name())
- }
- }
- return out
-}
diff --git a/internal/engine/server/tile57_bake.go b/internal/engine/server/tile57_bake.go
new file mode 100644
index 0000000..37b2069
--- /dev/null
+++ b/internal/engine/server/tile57_bake.go
@@ -0,0 +1,161 @@
+package server
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/beetlebugorg/chartplotter/internal/engine/auxfiles"
+ "github.com/beetlebugorg/chartplotter/internal/engine/baker"
+ "github.com/beetlebugorg/chartplotter/internal/engine/tilesource"
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
+)
+
+// bakeProgress returns a per-band progress callback for a provider bake. BakeBundle
+// fires stage-0 (portraying a band's cells) then stage-1 (writing that band's tiles)
+// events per navigational-purpose band. A calm per-band bar: it fills 0→100% per band
+// and resets at each band — clamped monotonic WITHIN a band (the engine double-counts a
+// band's tiles across its parallel-gen + serial-write phases, which would otherwise
+// rewind). The multi-district DOWNLOAD phase sets pack "N of M" on the job separately.
+func (s *Server) bakeProgress(jobID string) func(tile57.BakeProgress) {
+ curBand, bandDoneMax := -1, 0
+ return func(p tile57.BakeProgress) {
+ s.imports.update(jobID, func(j *importJob) {
+ j.Phase, j.Band = "bake", p.BandName
+ if p.Stage == 0 { // portraying this band's cells
+ j.Unit, j.Note, j.Done, j.Total = "cells", "Preparing charts", p.Done, p.Total
+ return
+ }
+ if p.BandIndex != curBand { // new band → reset the within-band floor
+ curBand, bandDoneMax = p.BandIndex, 0
+ }
+ if p.Done > bandDoneMax {
+ bandDoneMax = p.Done
+ }
+ j.Unit, j.Note, j.Done, j.Total = "tiles", "Generating tiles", bandDoneMax, p.Total
+ })
+ }
+}
+
+// registerBakedSet registers a freshly-baked PROVIDER's chart.pmtiles as the live tile
+// set and writes its tail — bake stamps, cell manifest, companion aux.zip, metadata
+// sidecar — so a tile57 provider is as complete in the chart library as any pack.
+// Returns false (recording a job error) if the bundle can't be opened. `set` is the
+// provider name (one archive per provider).
+func (s *Server) registerBakedSet(jobID, set string, cells map[string]baker.CellData, aux map[string][]byte, cat []tile57.CatalogEntry, created string) bool {
+ outDir := s.setDir(set)
+ chart := filepath.Join(outDir, "tiles", "chart.pmtiles")
+ src, err := tilesource.Open(chart)
+ if err != nil {
+ log.Printf("import %s (%s): open baked bundle: %v", jobID, set, err)
+ return false
+ }
+ s.sets.register(set, src)
+ s.packAdd(set, chart)
+ s.prefs.setDisabled(set, false)
+ if s.Version != "" {
+ _ = os.WriteFile(chart+bakeVerExt, []byte(s.Version), 0o644)
+ }
+ // Bake-time engine stamp (.enginever): the tile57 commit THIS binary links,
+ // recorded beside the pack so the set's TileJSON can report which engine baked
+ // these tiles even after the binary is upgraded. Best-effort, like .bakever.
+ if s.EngineCommit != "" {
+ _ = os.WriteFile(chart+engineVerExt, []byte(s.EngineCommit), 0o644)
+ }
+ if err := s.writeSetCells(set, cells); err != nil {
+ log.Printf("import %s: cell manifest %q: %v", jobID, set, err)
+ }
+
+ // Companion aux/ dir (TXTDSC/PICREP) beside the set: loose static files + an
+ // index.json, so feature attachments serve via /aux AND resolve offline as
+ // plain files (no zip to unpack, no server needed) — one aux dir per provider.
+ if len(aux) > 0 {
+ if _, e := auxfiles.WriteDir(filepath.Join(outDir, "aux"), aux); e != nil {
+ log.Printf("import %s: aux %q: %v", jobID, set, e)
+ }
+ _ = os.Remove(filepath.Join(outDir, set+".aux.zip")) // drop a stale legacy zip from a pre-loose bake
+ s.auxIdx.invalidate()
+ }
+
+ // Per-pack metadata sidecar for the chart library (per-cell scale/edition/date/
+ // agency/coverage + catalogue titles).
+ cellMeta := baker.ExtractCellMeta(cells, func(name string, e error) {
+ log.Printf("import %s: meta skip %s: %v", jobID, name, e)
+ })
+ meta := buildSetMeta(set, cellMeta, cat)
+ meta.Imported = created
+ if err := s.writeSetMeta(set, meta); err != nil {
+ log.Printf("import %s: write meta %q: %v", jobID, set, err)
+ }
+ return true
+}
+
+// bakeProvider bakes a provider's WHOLE ENC_ROOT (all installed district subfolders)
+// into its ONE self-contained tile57 chart bundle under the provider's cache dir
+// (tiles/chart.pmtiles + per-scheme style-*.json + assets + manifest.json) and
+// registers it. The baker's within-archive best-available (finestCsclAt: finest
+// M_COVR-covering cell wins per point; coarser shows only in holes; per-cell oscl
+// overscale hatch) does all cross-cell / cross-district composition — there is no
+// cross-pack context, no peer folding. Any provider change (download/delete a
+// district) triggers a full re-bake: the archive is a pure function of the ENC_ROOT.
+// Returns true on success; on failure it records the job error and returns false. The
+// caller sets the terminal "done" state (a multi-provider batch bakes several first).
+func (s *Server) bakeProvider(jobID, provider string) bool {
+ fail := func(err error) bool {
+ log.Printf("import %s (%s): provider bake: %v", jobID, provider, err)
+ s.imports.update(jobID, func(j *importJob) { j.State = "error"; j.Err = err.Error() })
+ return false
+ }
+ // No districts left (e.g. the last was just deleted) → drop the provider set.
+ if len(s.providerDistricts(provider)) == 0 {
+ s.dropProviderSet(provider)
+ return true
+ }
+ encRoot := s.encRootDir(provider)
+ outDir := s.setDir(provider)
+ if err := os.MkdirAll(outDir, 0o755); err != nil {
+ return fail(err)
+ }
+ s.imports.update(jobID, func(j *importJob) {
+ j.Phase, j.Band, j.Unit, j.Note, j.Done, j.Total = "bake", "", "cells", "Preparing charts", 0, 0
+ })
+ created := time.Now().UTC().Format(time.RFC3339)
+ // MaxZoom 24 = the ABI's "no clamp" (each cell's full native band); MaxZoom 0 would
+ // clamp every band down to z0 — an EMPTY archive.
+ n, bbox, err := tile57.BakeBundle(encRoot, outDir, tile57.BakeOpts{Created: created, MaxZoom: 24}, s.bakeProgress(jobID))
+ if err != nil {
+ return fail(err)
+ }
+ // An inverted/empty bbox (or zero cells) means nothing valid parsed. Treat it as a
+ // failed import (don't register an empty pack) and drop the stub bundle it wrote.
+ if n == 0 || bbox[2] <= bbox[0] || bbox[3] <= bbox[1] {
+ os.RemoveAll(outDir)
+ return fail(fmt.Errorf("import produced no coverage (%d cell(s), no valid S-57 data)", n))
+ }
+
+ s.imports.update(jobID, func(j *importJob) { j.Phase, j.Note = "meta", "Reading chart metadata" })
+ cells := s.providerCellData(provider)
+ aux := s.providerAux(provider)
+ cat := s.providerCatalog(provider)
+ if !s.registerBakedSet(jobID, provider, cells, aux, cat, created) {
+ return fail(fmt.Errorf("could not register baked bundle for %q", provider))
+ }
+ s.imports.update(jobID, func(j *importJob) { j.Cells = n })
+ log.Printf("import %s: baked provider %q (%d cell(s)) → %s", jobID, provider, n, outDir)
+ return true
+}
+
+// dropProviderSet unregisters a provider whose ENC_ROOT is now empty and removes its
+// (regenerable) baked bundle + its (now-empty) provider data tree. The cell index is
+// rebuilt so its cells stop counting as installed.
+func (s *Server) dropProviderSet(provider string) {
+ s.sets.remove(provider)
+ s.packDel(provider)
+ s.prefs.setDisabled(provider, false)
+ _ = os.RemoveAll(s.setDir(provider)) // baked bundle (cache)
+ _ = os.RemoveAll(s.providerDataDir(provider)) // ENC_ROOT source tree (now empty)
+ s.auxIdx.invalidate()
+ s.cellIdx.rebuild()
+}
diff --git a/internal/engine/server/tile57_style.go b/internal/engine/server/tile57_style.go
new file mode 100644
index 0000000..9566921
--- /dev/null
+++ b/internal/engine/server/tile57_style.go
@@ -0,0 +1,412 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+
+ tile57 "github.com/beetlebugorg/tile57/bindings/go"
+)
+
+// serveTile57Style returns a complete MapLibre style document generated by the
+// native tile57 engine (tile57_style_template + tile57_build_style over the S-52
+// colortables baked into libtile57), so the style itself — not just the tiles and
+// symbology assets — comes from the engine. Query params (all optional):
+//
+// scheme day|dusk|night (default day)
+// tiles chart {z}/{x}/{y} URL (default /tiles/tile57/{z}/{x}/{y}.mvt)
+// sprite sprite base URL (default /sprite)
+// glyphs glyphs URL (default /glyphs/{fontstack}/{range}.pbf)
+// bands CSV of band ranks to show (default all)
+//
+// The FULL S-52 mariner selection is read from the query too (marinerFromQuery) —
+// the client's DEFAULT_MARINER keys (display categories, contours, boundary/point
+// style, text groups, dates, viewingGroupsOff, sizeScale, …) — so the engine bakes
+// the live display state into the style and the client re-fetches on a toggle.
+//
+// CORS-open like the tile + asset routes so a static-hosted client can fetch it.
+func (s *Server) serveTile57Style(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ if r.Method != http.MethodGet {
+ apiErr(w, http.StatusMethodNotAllowed, "GET only")
+ return
+ }
+ q := r.URL.Query()
+ // libtile57 is the sole engine, so EVERY registered provider renders from the engine
+ // style. ?set is a CSV of PROVIDERS (default: all registered) — one for a single-
+ // provider install, several across providers (each a self-contained best-available
+ // provider archive; districts live inside one archive, not as separate sources). 404
+ // only when nothing is registered.
+ sets := s.resolveStyleSets(q.Get("set"))
+ if len(sets) == 0 {
+ apiErr(w, http.StatusNotFound, "no renderable tile57 set")
+ return
+ }
+ m := marinerFromQuery(q)
+ var style []byte
+ var err error
+ if len(sets) == 1 {
+ style, err = s.styleCtxForSet(r, q, sets[0]).build(m)
+ } else {
+ style, err = s.buildMultiStyle(r, q, sets, m)
+ }
+ if err != nil {
+ apiErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeMaybeGzip(w, r, jsonCT, style) // the engine style is multi-MB — gzip on the wire
+}
+
+// buildMultiStyle composes ONE MapLibre style spanning several PROVIDERS: it builds the
+// engine style for each provider set (each with its own SCAMIN ladder + top zoom) and
+// merges them, namespacing every set's "chart" source + layers so they coexist. The
+// sets are ordered coarse→fine, so a finer provider's layers draw over a coarser one
+// where they overlap. Within a provider, best-available across cells/districts is
+// already resolved inside its single archive (the baker's finestCsclAt), so this cross-
+// source merge now only spans the handful of installed providers (noaa/ienc/user),
+// which are normally geographically disjoint — the per-district explosion is gone.
+func (s *Server) buildMultiStyle(r *http.Request, q url.Values, sets []string, m tile57.Mariner) ([]byte, error) {
+ parts := make([]setStyle, 0, len(sets))
+ for _, set := range sets {
+ b, err := s.styleCtxForSet(r, q, set).build(m)
+ if err != nil {
+ continue // a set that fails to build is skipped, not fatal
+ }
+ parts = append(parts, setStyle{set: set, style: b})
+ }
+ if len(parts) == 0 {
+ return nil, fmt.Errorf("no set produced a style")
+ }
+ return mergeStyles(parts)
+}
+
+// setStyle is one pack's single-source engine style, tagged with its set name.
+type setStyle struct {
+ set string
+ style []byte
+}
+
+// mergeStyles merges per-set single-source styles into one multi-source style. Each
+// set's "chart" source becomes "chart-" and each layer's id gets a "--"
+// suffix (source ref rewritten to match); backgrounds are dropped (the client's
+// chrome supplies one) and sprite/glyphs are taken from the first part.
+func mergeStyles(parts []setStyle) ([]byte, error) {
+ out := map[string]any{"version": 8}
+ sources := map[string]any{}
+ layers := make([]any, 0, len(parts)*40)
+ for i, p := range parts {
+ var st map[string]any
+ if err := json.Unmarshal(p.style, &st); err != nil {
+ return nil, err
+ }
+ if i == 0 {
+ for _, k := range []string{"sprite", "glyphs"} {
+ if v, ok := st[k]; ok {
+ out[k] = v
+ }
+ }
+ }
+ if srcs, ok := st["sources"].(map[string]any); ok {
+ for k, v := range srcs {
+ sources[chartSrcKey(k, p.set)] = v
+ }
+ }
+ if lyrs, ok := st["layers"].([]any); ok {
+ for _, l := range lyrs {
+ lm, ok := l.(map[string]any)
+ if !ok || lm["type"] == "background" {
+ continue
+ }
+ if src, ok := lm["source"].(string); ok {
+ lm["source"] = chartSrcKey(src, p.set)
+ }
+ if id, ok := lm["id"].(string); ok {
+ lm["id"] = id + "--" + p.set
+ }
+ layers = append(layers, lm)
+ }
+ }
+ }
+ out["sources"] = sources
+ out["layers"] = layers
+ return json.Marshal(out)
+}
+
+// chartSrcKey namespaces the engine's single "chart" source per set; any other
+// source name (there are none today) passes through.
+func chartSrcKey(name, set string) string {
+ if name == "chart" {
+ return "chart-" + set
+ }
+ return name
+}
+
+// serveTile57StyleDiff returns the minimal MapLibre mutation ops to move the style from
+// one mariner selection to another, so the client applies a toggle IN PLACE
+// (setFilter/setPaintProperty/setLayoutProperty) with no setStyle — no tile reload, no
+// overlay churn, no flicker. This is the host-side stand-in for the engine's future
+// tile57_style_diff (../tile57 specs/style-diff.md): it builds both full styles here and
+// structurally diffs them (style_diff.go). When the ABI lands, only this body changes;
+// the op-array contract to the client is identical.
+//
+// POST /api/style-diff body: {"from":"","to":""}
+//
+// `from`/`to` are url-encoded mariner queries — the same string the client builds for
+// /api/style.json — so the two styles share this request's set/urls/scamin/lat context
+// and differ ONLY in the mariner.
+func (s *Server) serveTile57StyleDiff(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ if r.Method != http.MethodPost {
+ apiErr(w, http.StatusMethodNotAllowed, "POST only")
+ return
+ }
+ var body struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ apiErr(w, http.StatusBadRequest, "body must be JSON {from, to}")
+ return
+ }
+ fromQ, err1 := url.ParseQuery(body.From)
+ toQ, err2 := url.ParseQuery(body.To)
+ if err1 != nil || err2 != nil {
+ apiErr(w, http.StatusBadRequest, "from/to must be url-encoded mariner queries")
+ return
+ }
+ // The in-place diff is single-source only. A multi-pack ?set CSV won't resolve to
+ // one set, so it 404s here and the client falls back to a full style re-fetch.
+ set, ok := s.resolveStyleSet(toQ.Get("set"))
+ if !ok {
+ apiErr(w, http.StatusNotFound, "style-diff is single-set only (multi-pack → full re-fetch)")
+ return
+ }
+ // Shared style context comes from the "to" (new) query — set/urls/scamin/lat/bands.
+ // The engine diffs the two mariners over one structural template (colours resolve
+ // per-mariner from the colortables, so scheme changes ride the diff too).
+ ctx := s.styleCtxForSet(r, toQ, set)
+ fromM, toM := marinerFromQuery(fromQ), marinerFromQuery(toQ)
+ tmpl, err := tile57.StyleTemplate(toM.Scheme, ctx.tiles, ctx.sprite, ctx.glyphs, ctx.minZoom, ctx.maxZoom, ctx.encoding)
+ if err != nil {
+ apiErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ ct, err := tile57.ColortablesDefault()
+ if err != nil {
+ apiErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ ops, err := tile57.StyleDiff(tmpl, fromM, toM, ct, ctx.bands, ctx.scamin, ctx.lat)
+ if err != nil {
+ apiErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeMaybeGzip(w, r, jsonCT, ops)
+}
+
+// tile57StyleCtx is the per-request, mariner-INDEPENDENT style input (URLs, zoom
+// span, SCAMIN manifest, latitude, band filter, tile encoding). Shared by
+// /api/style.json and /api/style-diff so the two styles a diff compares differ
+// ONLY in the mariner.
+type tile57StyleCtx struct {
+ tiles, sprite, glyphs string
+ minZoom, maxZoom uint32
+ scamin []int32
+ lat float64
+ bands []int32
+ // The set's tile encoding (FormatMLT for an MLT-default bake) — the engine
+ // emits `"encoding":"mlt"` on the chart source so maplibre-gl decodes MLT.
+ encoding tile57.TileFormat
+}
+
+// resolveStyleSet maps a requested ?set to a real registered set for the engine
+// style: the requested set if it's registered, else the sole registered set (the
+// common single-pack case). With the Go engine gone, EVERY pack renders from the
+// engine style — so this no longer refuses a baked pack just because it isn't the
+// live "tile57" set. ok=false only when there's nothing to render or an ambiguous
+// multi-pack install with no matching set (Phase 5: multi-source composition).
+func (s *Server) resolveStyleSet(requested string) (string, bool) {
+ if requested != "" {
+ if _, ok := s.lookupSet(requested); ok {
+ return requested, true
+ }
+ }
+ if names := s.sets.names(); len(names) == 1 {
+ return names[0], true
+ }
+ return requested, false
+}
+
+// resolveStyleSets resolves the ?set CSV to registered packs for the engine style,
+// ordered coarse→fine by top zoom (so a finer pack's layers draw over a coarser one).
+// An empty or all-unregistered request falls back to EVERY registered pack. Returns
+// nil only when nothing is registered.
+func (s *Server) resolveStyleSets(requested string) []string {
+ var want []string
+ if requested != "" {
+ for _, name := range strings.Split(requested, ",") {
+ if name = strings.TrimSpace(name); name == "" {
+ continue
+ }
+ if _, ok := s.lookupSet(name); ok {
+ want = append(want, name)
+ }
+ }
+ }
+ if len(want) == 0 {
+ want = append(want, s.sets.names()...) // no valid explicit set → all registered
+ }
+ sort.SliceStable(want, func(i, j int) bool { return s.setMaxZoom(want[i]) < s.setMaxZoom(want[j]) })
+ return want
+}
+
+// setMaxZoom is a registered set's top zoom (0 if unknown), for coarse→fine ordering.
+func (s *Server) setMaxZoom(name string) uint8 {
+ if src, ok := s.lookupSet(name); ok {
+ return src.Meta().MaxZoom
+ }
+ return 0
+}
+
+// styleCtxForSet builds the shared style context for a SPECIFIC registered set: the
+// tile/sprite/glyph URLs, the band filter, and (from that set) the top zoom, SCAMIN
+// manifest, and centre latitude. ?scamin/?lat/?tiles override (single-set only).
+func (s *Server) styleCtxForSet(r *http.Request, q url.Values, set string) tile57StyleCtx {
+ base := requestOrigin(r)
+ ctx := tile57StyleCtx{
+ // Stamp the bake-generation token onto the default tile URL so engine-mode
+ // tiles are content-addressed and cacheable exactly like the TileJSON path
+ // (serveTile keys immutable caching on ?g). A ?tiles override is used verbatim.
+ tiles: orDefault(q.Get("tiles"), fmt.Sprintf("%s/tiles/%s/{z}/{x}/{y}.mvt%s", base, set, genQuery(s.packGen(set)))),
+ sprite: orDefault(q.Get("sprite"), base+"/sprite"),
+ glyphs: orDefault(q.Get("glyphs"), base+"/glyphs/{fontstack}/{range}.pbf"),
+ bands: parseBands(q.Get("bands")),
+ }
+ // The style is band-independent — the merged zoom-gate by default (one self-gating
+ // zoom-expression layer per render-type), or the exact live filter-gate under
+ // ?scaminexact. The engine no longer turns a SCAMIN manifest into per-value #sm
+ // bucket layers, so ctx.scamin stays empty here. The manifest still rides the
+ // TileJSON (served by the tile route) as the filter-gate client's ladder crossings.
+ if src, ok := s.lookupSet(set); ok {
+ meta := src.Meta()
+ ctx.minZoom = uint32(meta.MinZoom) // the set's real tile floor — MapLibre requests nothing below the source minzoom
+ ctx.maxZoom = uint32(meta.MaxZoom) // live ENC = z18 (berthing); don't clamp to the engine's z16 default
+ ctx.encoding = tile57.EncodingFormat(meta.TileType)
+ ctx.lat = (meta.S + meta.N) / 2 // bounds-centre latitude for the scale→zoom map
+ }
+ if v := q.Get("lat"); v != "" {
+ if f, err := strconv.ParseFloat(v, 64); err == nil {
+ ctx.lat = f
+ }
+ }
+ return ctx
+}
+
+// build generates the full MapLibre style for one mariner over the shared context. The
+// chart source's zoom span is pinned to the set's real one: minzoom is emitted verbatim
+// (an inflated floor blanks every zoom below it — MapLibre never under-zooms a vector
+// source), and maxzoom 0 would clamp to the engine's z16 default and drop the finest
+// band's z17–18 (MapLibre overzooms for free).
+func (c tile57StyleCtx) build(m tile57.Mariner) ([]byte, error) {
+ return tile57.Style(m.Scheme, c.tiles, c.sprite, c.glyphs, c.minZoom, c.maxZoom, c.encoding, m, c.bands, c.scamin, c.lat)
+}
+
+func orDefault(v, def string) string {
+ if v == "" {
+ return def
+ }
+ return v
+}
+
+// requestOrigin reconstructs the scheme://host the client reached us on, for the
+// absolute URLs MapLibre needs in the style document.
+func requestOrigin(r *http.Request) string {
+ scheme := "http"
+ if r.TLS != nil {
+ scheme = "https"
+ }
+ return scheme + "://" + r.Host
+}
+
+// parseBands parses a CSV of band ranks ("0,1,2") into a slice; nil for "" (all).
+func parseBands(csv string) []int32 {
+ if csv == "" {
+ return nil
+ }
+ var out []int32
+ for _, p := range strings.Split(csv, ",") {
+ if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
+ out = append(out, int32(n))
+ }
+ }
+ return out
+}
+
+// marinerFromQuery builds the S-52 mariner selection from the request query, starting
+// from the engine defaults so any omitted param keeps its default — the client sends
+// its DEFAULT_MARINER state as query params (same key names) so the engine bakes the
+// live display into the style. Bools accept "1"/"true"; floats parse leniently.
+func marinerFromQuery(q url.Values) tile57.Mariner {
+ m := tile57.MarinerDefaults()
+ m.Scheme = tile57.SchemeFromString(q.Get("scheme"))
+ boolP := func(key string, dst *bool) {
+ if v := q.Get(key); v != "" {
+ *dst = v == "1" || v == "true"
+ }
+ }
+ floatP := func(key string, dst *float64) {
+ if f, err := strconv.ParseFloat(q.Get(key), 64); err == nil {
+ *dst = f
+ }
+ }
+ floatP("shallowContour", &m.ShallowContour)
+ floatP("safetyContour", &m.SafetyContour)
+ floatP("deepContour", &m.DeepContour)
+ floatP("safetyDepth", &m.SafetyDepth)
+ boolP("fourShadeWater", &m.FourShadeWater)
+ switch q.Get("depthUnit") {
+ case "ft":
+ m.DepthUnit = tile57.DepthFeet
+ case "m":
+ m.DepthUnit = tile57.DepthMeters
+ }
+ boolP("displayBase", &m.DisplayBase)
+ boolP("displayStandard", &m.DisplayStandard)
+ boolP("displayOther", &m.DisplayOther)
+ boolP("dataQuality", &m.DataQuality)
+ boolP("showInformCallouts", &m.ShowInformCallouts)
+ boolP("showMetaBounds", &m.ShowMetaBounds)
+ boolP("showIsolatedDangersShallow", &m.ShowIsolatedDangersShallow)
+ // S-52 §10.1.10 overscale indication (AP(OVERSC01)) — engine default true.
+ boolP("showOverscale", &m.ShowOverscale)
+ switch q.Get("boundaryStyle") {
+ case "plain":
+ m.BoundaryStyle = tile57.BoundaryPlain
+ case "symbolized":
+ m.BoundaryStyle = tile57.BoundarySymbolized
+ }
+ boolP("simplifiedPoints", &m.SimplifiedPoints)
+ boolP("showFullSectorLines", &m.ShowFullSectorLines)
+ boolP("textNames", &m.TextNames)
+ boolP("showLightDescriptions", &m.ShowLightDescriptions)
+ boolP("textOther", &m.TextOther)
+ boolP("dateDependent", &m.DateDependent)
+ boolP("highlightDateDependent", &m.HighlightDateDependent)
+ if v := q.Get("dateView"); v != "" {
+ m.DateView = v
+ }
+ boolP("ignoreScamin", &m.IgnoreScamin)
+ // The merged zoom-gate is the DEFAULT (ScaminFilterGate off → the engine's
+ // self-gating zoom-expression, one layer per render-type). ?scaminexact opts INTO
+ // the exact live filter-gate (the client rewrites curDenom via setFilter on ladder
+ // crossings — scamin-layers.md). The legacy ?scaminMerge param is now a no-op: its
+ // behavior (empty manifest, filter-gate off) is exactly the default.
+ boolP("scaminFilterGate", &m.ScaminFilterGate)
+ floatP("sizeScale", &m.SizeScale)
+ m.ViewingGroupsOff = parseBands(q.Get("viewingGroupsOff")) // CSV of vg ids turned off
+ return m
+}
diff --git a/internal/engine/server/tileserve.go b/internal/engine/server/tileserve.go
index 1c8a05c..4b0c171 100644
--- a/internal/engine/server/tileserve.go
+++ b/internal/engine/server/tileserve.go
@@ -65,13 +65,34 @@ func (s *Server) serveTileSet(w http.ResponseWriter, r *http.Request) {
apiErr(w, http.StatusInternalServerError, err.Error())
return
}
+ // Cache policy first, so it rides the 204 too: an EMPTY tile at a given ?g is
+ // as content-addressed as a full one (emptiness is baked into that generation),
+ // and empty ocean tiles are the MAJORITY of a viewport's grid — caching those
+ // 204s saves the most round-trips. Tiles are immutable per bake generation: the
+ // ?g token in the URL (the pack archive's mtime) changes on every re-bake, so a
+ // given tile URL always maps to identical bytes/emptiness and can cache forever.
+ // The live/dynamic set carries no generation (?g absent or 0) and regenerates on
+ // demand, so it stays no-cache. Keying off the token — not pack-vs-live plumbing
+ // — ties the policy exactly to the content-addressing guarantee.
+ if g := r.URL.Query().Get("g"); g != "" && g != "0" {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "no-cache")
+ }
if len(body) == 0 {
- w.WriteHeader(http.StatusNoContent) // blank/missing tile
+ w.WriteHeader(http.StatusNoContent) // blank/missing tile (still cacheable per ?g)
return
}
- w.Header().Set("Content-Type", "application/vnd.mapbox-vector-tile")
- w.Header().Set("Cache-Control", "no-cache")
- // The backend returns decompressed MVT; gzip on the wire when the client asks,
+ // Tiles serve BYTES-VERBATIM in the set's stored encoding (no transcode).
+ // MLT has no registered media type, so an .mvt URL carrying MLT bytes goes
+ // out as application/octet-stream — MapLibre keys its decoder off the source
+ // `encoding` hint (style/TileJSON), never the content type.
+ if src.Meta().TileType == "mlt" {
+ w.Header().Set("Content-Type", "application/octet-stream")
+ } else {
+ w.Header().Set("Content-Type", "application/vnd.mapbox-vector-tile")
+ }
+ // The backend returns decompressed tiles; gzip on the wire when the client asks,
// to claw back the size advantage prebaked archives store the tiles with.
if acceptsGzip(r) {
w.Header().Set("Content-Encoding", "gzip")
@@ -89,6 +110,26 @@ func (s *Server) lookupSet(name string) (tilesource.TileSource, bool) {
return s.sets.get(name)
}
+// engineForSet reports the tile57 engine commit behind a set's tiles. A baked
+// pack answers with BAKE-TIME truth — the commit stamped into its .enginever
+// sidecar when it was baked ("pre-stamp" for packs baked before stamping) —
+// while a dynamic set (plugin tiles, no pack path) generates tiles on demand
+// inside the RUNNING binary, so it answers with the build's own engine commit.
+func (s *Server) engineForSet(name string) string {
+ if p, ok := s.packPath(name); ok {
+ if b, err := os.ReadFile(p + engineVerExt); err == nil {
+ if v := strings.TrimSpace(string(b)); v != "" {
+ return v
+ }
+ }
+ return "pre-stamp"
+ }
+ if s.EngineCommit != "" {
+ return s.EngineCommit
+ }
+ return "unknown"
+}
+
// serveTileJSON returns a minimal TileJSON 3.0 descriptor for a set, so a client
// can configure its MapLibre source with `url: "/tiles/{set}.json"`.
func (s *Server) serveTileJSON(w http.ResponseWriter, r *http.Request, name string) {
@@ -108,13 +149,7 @@ func (s *Server) serveTileJSON(w http.ResponseWriter, r *http.Request, name stri
// re-bake, gets a new ?g, and its tile URLs change — so the browser/MapLibre tile
// caches are bypassed by content, not a fragile client-side counter. serveTile
// ignores the query, so ?g is purely a cache key.
- gen := int64(0)
- if p, ok := s.packPath(name); ok {
- if fi, err := os.Stat(p); err == nil {
- gen = fi.ModTime().UnixNano()
- }
- }
- tilesURL := fmt.Sprintf("%s://%s/tiles/%s/{z}/{x}/{y}.mvt?g=%d", scheme, r.Host, name, gen)
+ tilesURL := fmt.Sprintf("%s://%s/tiles/%s/{z}/{x}/{y}.mvt%s", scheme, r.Host, name, genQuery(s.packGen(name)))
// SCAMIN manifest (from the archive metadata): the client builds one native-
// minzoom bucket layer per value at load — no runtime probe/collect/setStyle.
scaminJSON := ""
@@ -125,11 +160,22 @@ func (s *Server) serveTileJSON(w http.ResponseWriter, r *http.Request, name stri
}
scaminJSON = `,"scamin":[` + strings.Join(parts, ",") + `]`
}
+ // Tile-encoding hint: an MLT set (the tile57 default bake format) advertises
+ // `"encoding":"mlt"` — maplibre-gl (>=5.12) propagates the TileJSON field onto
+ // the vector source and switches its worker to the native MLT decoder. MVT
+ // sets emit nothing extra (the MapLibre default), keeping their TileJSON as-is.
+ format, encodingJSON := "pbf", ""
+ if m.TileType == "mlt" {
+ format, encodingJSON = "mlt", `,"encoding":"mlt"`
+ }
w.Header().Set("Content-Type", jsonCT)
w.Header().Set("Cache-Control", "no-cache")
+ // `engine` is the tile57 engine commit behind this set's tiles (bake-time for
+ // packs, the running binary for live sets — see engineForSet). The client's
+ // attribution stamp reads it from this TileJSON, its one per-set metadata fetch.
fmt.Fprintf(w,
- `{"tilejson":"3.0.0","scheme":"xyz","format":"pbf","tiles":[%q],"minzoom":%d,"maxzoom":%d,"bounds":[%g,%g,%g,%g],"center":[%g,%g,%d]%s}`,
- tilesURL, m.MinZoom, m.MaxZoom, m.W, m.S, m.E, m.N,
+ `{"tilejson":"3.0.0","scheme":"xyz","format":%q%s,"engine":%q,"tiles":[%q],"minzoom":%d,"maxzoom":%d,"bounds":[%g,%g,%g,%g],"center":[%g,%g,%d]%s}`,
+ format, encodingJSON, s.engineForSet(name), tilesURL, m.MinZoom, m.MaxZoom, m.W, m.S, m.E, m.N,
(m.W+m.E)/2, (m.S+m.N)/2, m.MinZoom, scaminJSON,
)
}
@@ -166,3 +212,21 @@ func parseTileSetPath(rest string) (set string, z, x, y uint32, ok bool) {
func acceptsGzip(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
}
+
+// writeMaybeGzip writes body with contentType, gzip-compressed on the wire when the
+// client accepts it. Large JSON (the engine style is multi-MB) compresses ~10×, so this
+// is worth it for /api/style.json and /api/style-diff. Sets Cache-Control:no-cache; the
+// caller sets any CORS headers first.
+func writeMaybeGzip(w http.ResponseWriter, r *http.Request, contentType string, body []byte) {
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Cache-Control", "no-cache")
+ if acceptsGzip(r) {
+ w.Header().Set("Content-Encoding", "gzip")
+ w.WriteHeader(http.StatusOK)
+ zw := gzip.NewWriter(w)
+ zw.Write(body)
+ zw.Close()
+ return
+ }
+ w.Write(body)
+}
diff --git a/internal/engine/server/tileserve_test.go b/internal/engine/server/tileserve_test.go
index 7ffc34b..fe11cb5 100644
--- a/internal/engine/server/tileserve_test.go
+++ b/internal/engine/server/tileserve_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"github.com/beetlebugorg/chartplotter/internal/engine/pmtiles"
+ "github.com/beetlebugorg/chartplotter/internal/engine/tilesource"
)
// writeTestPMTiles drops a prebaked archive with one tile at z/x/y into
@@ -59,6 +60,28 @@ func TestServeTileSet(t *testing.T) {
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
t.Errorf("missing CORS header")
}
+ // No ?g generation token → revalidate-always (live/dynamic set semantics).
+ if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
+ t.Errorf("tile without ?g: cache-control got %q, want no-cache", cc)
+ }
+
+ // A ?g generation token → the tile URL is content-addressed, so cache it
+ // immutably (baked-pack semantics; the client busts by a new ?g on re-bake).
+ req, _ = http.NewRequest("GET", ts.URL+"/tiles/charts/8/10/20.mvt?g=1699999999", nil)
+ resp, err = http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if cc := resp.Header.Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
+ t.Errorf("tile with ?g: cache-control got %q, want immutable", cc)
+ }
+ // A zero token is treated as no generation (not immutable).
+ resp, _ = http.Get(ts.URL + "/tiles/charts/8/10/20.mvt?g=0")
+ resp.Body.Close()
+ if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
+ t.Errorf("tile with ?g=0: cache-control got %q, want no-cache", cc)
+ }
// The .mvt suffix is optional.
resp, _ = http.Get(ts.URL + "/tiles/charts/8/10/20")
@@ -67,12 +90,16 @@ func TestServeTileSet(t *testing.T) {
t.Errorf("no-suffix tile: got %d, want 200", resp.StatusCode)
}
- // A blank/missing tile → 204.
- resp, _ = http.Get(ts.URL + "/tiles/charts/8/0/0.mvt")
+ // A blank/missing tile → 204, and it still carries the cache header (an empty
+ // tile is content-addressed per ?g just like a full one — cache the ocean).
+ resp, _ = http.Get(ts.URL + "/tiles/charts/8/0/0.mvt?g=1699999999")
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("missing tile: got %d, want 204", resp.StatusCode)
}
+ if cc := resp.Header.Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
+ t.Errorf("empty tile with ?g: cache-control got %q, want immutable", cc)
+ }
// An unknown set → 404.
resp, _ = http.Get(ts.URL + "/tiles/nope/8/10/20.mvt")
@@ -162,3 +189,112 @@ func TestServeTileJSONAndList(t *testing.T) {
t.Errorf("set list: %v", list.Sets)
}
}
+
+// The TileJSON `engine` field distinguishes BAKE-TIME truth from the running
+// binary: a pack reports the engine commit stamped into its .enginever sidecar
+// when it was baked; a pack without the sidecar (baked before stamping) reports
+// "pre-stamp"; a DYNAMIC set (no pack path — plugin tiles) generates tiles in
+// the running binary, so it reports the build's own EngineCommit.
+func TestServeTileJSONEngineStamp(t *testing.T) {
+ dir := t.TempDir()
+ writeTestPMTiles(t, dir, "stamped", 8, 10, 20)
+ writeTestPMTiles(t, dir, "legacy", 8, 10, 20)
+ // Bake-time stamp for one pack only (what writeAndRegister/bakeBundleTile57 write).
+ stamped := filepath.Join(tilesDir(dir), "stamped.pmtiles")
+ if err := os.WriteFile(stamped+engineVerExt, []byte("abc123def\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ srv := New(dir, dir, dir, false)
+ srv.EngineCommit = "fff999000" // the RUNNING binary's engine
+ // A dynamic set: registered without a pack path (plugin tiles).
+ live, err := tilesource.Open(filepath.Join(tilesDir(dir), "legacy.pmtiles"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv.RegisterTileSet("live57", live)
+ ts := httptest.NewServer(srv)
+ defer ts.Close()
+
+ engineOf := func(set string) string {
+ t.Helper()
+ resp, err := http.Get(ts.URL + "/tiles/" + set + ".json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ var tj struct {
+ Engine string `json:"engine"`
+ }
+ if err := json.Unmarshal(got, &tj); err != nil {
+ t.Fatalf("tilejson parse: %v (%s)", err, got)
+ }
+ return tj.Engine
+ }
+ if got := engineOf("stamped"); got != "abc123def" {
+ t.Errorf("stamped pack engine: got %q, want the bake-time sidecar commit", got)
+ }
+ if got := engineOf("legacy"); got != "pre-stamp" {
+ t.Errorf("legacy pack engine: got %q, want \"pre-stamp\"", got)
+ }
+ if got := engineOf("live57"); got != "fff999000" {
+ t.Errorf("live set engine: got %q, want the running binary's EngineCommit", got)
+ }
+}
+
+// An MLT archive (the tile57 default bake format; PMTiles header tile_type 6)
+// must advertise `"encoding":"mlt"` in its TileJSON — the hint maplibre-gl
+// propagates onto the vector source to select its native MLT decoder — and its
+// tiles (bytes-verbatim, no transcode) go out as application/octet-stream since
+// MLT has no registered media type.
+func TestServeTileSetMLT(t *testing.T) {
+ dir := t.TempDir()
+ body := writeTestPMTiles(t, dir, "mltcharts", 8, 10, 20)
+ // The Go pmtiles Builder always writes tile_type MVT (it only bakes legacy
+ // archives); flip header byte 99 to 6 (MLT) — what a tile57 MLT bake stores.
+ path := filepath.Join(tilesDir(dir), "mltcharts.pmtiles")
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw[99] = 6
+ if err := os.WriteFile(path, raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ ts := httptest.NewServer(New(dir, dir, dir, false))
+ defer ts.Close()
+
+ resp, _ := http.Get(ts.URL + "/tiles/mltcharts.json")
+ got, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ var tj struct {
+ Format string `json:"format"`
+ Encoding string `json:"encoding"`
+ }
+ if err := json.Unmarshal(got, &tj); err != nil {
+ t.Fatalf("tilejson parse: %v (%s)", err, got)
+ }
+ if tj.Encoding != "mlt" || tj.Format != "mlt" {
+ t.Errorf(`tilejson encoding/format: got %q/%q, want "mlt"/"mlt" (%s)`, tj.Encoding, tj.Format, got)
+ }
+
+ req, _ := http.NewRequest("GET", ts.URL+"/tiles/mltcharts/8/10/20.mvt", nil)
+ req.Header.Set("Accept-Encoding", "identity")
+ resp, err = http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tile, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("tile status: got %d, want 200", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); ct != "application/octet-stream" {
+ t.Errorf("MLT tile content-type: got %q, want application/octet-stream", ct)
+ }
+ if !bytes.Equal(tile, body) {
+ t.Errorf("MLT tile body must serve verbatim: got %q, want %q", tile, body)
+ }
+}
diff --git a/internal/engine/server/tilesets.go b/internal/engine/server/tilesets.go
index 2620609..0f49c23 100644
--- a/internal/engine/server/tilesets.go
+++ b/internal/engine/server/tilesets.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
- "math"
"net/http"
"os"
"path/filepath"
@@ -38,6 +37,15 @@ func (ts *tileSets) register(name string, src tilesource.TileSource) {
ts.m[name] = src
}
+// RegisterTileSet registers (or replaces) a tile set under name, served at
+// /tiles/{name}/… exactly like a prebaked archive. It is exposed so an alternate
+// backend — e.g. the optional libtile57 live source compiled in under
+// -tags tile57 — can publish a set the same way discovery registers .pmtiles
+// packs. A replaced backend is closed.
+func (s *Server) RegisterTileSet(name string, src tilesource.TileSource) {
+ s.sets.register(name, src)
+}
+
// remove unregisters and closes the set named name. Reports whether it existed.
func (ts *tileSets) remove(name string) bool {
ts.mu.Lock()
@@ -80,60 +88,6 @@ func (ts *tileSets) closeAll() {
ts.m = map[string]tilesource.TileSource{}
}
-// bandSlugs is the fixed COARSE→FINE band order. A registered tile SET is named
-// "-" (one archive per nav-purpose band, so coarse-band-only areas
-// keep tiles above the merged archive's single maxzoom). "all" is the catch-all for
-// a name that doesn't end in a known band (a merged set, or a non-banded
-// import). bandOrder ranks a slug for the sorted /api/packs listing.
-var bandSlugs = []string{"overview", "general", "coastal", "approach", "harbor", "berthing"}
-
-func bandOrder(slug string) int {
- for i, s := range bandSlugs {
- if s == slug {
- return i
- }
- }
- return len(bandSlugs) // "all" sorts last
-}
-
-// splitSet splits a registered set name into its logical district + band. If the
-// name ends in "-", district = the prefix and band = that slug; otherwise
-// the whole name is the district and band = "all" (a merged set or a non-banded
-// local import). The district is the API-facing pack name (/api/packs, the
-// enable/disable/delete ?set=); band is the per-archive suffix.
-func splitSet(name string) (district, band string) {
- for _, slug := range bandSlugs {
- if suf := "-" + slug; strings.HasSuffix(name, suf) && len(name) > len(suf) {
- return name[:len(name)-len(suf)], slug
- }
- }
- return name, "all"
-}
-
-// setsForDistrict returns every registered set name belonging to district d (its
-// merged form "d" plus each band-set "d-"), so enable/disable/delete can fan
-// out across a district's per-band archives. Looks at both the live registry and the
-// on-disk pack list (a disabled band-set isn't registered but still has a pack).
-func (s *Server) setsForDistrict(d string) []string {
- seen := map[string]bool{}
- for _, n := range s.packNames() {
- if dist, _ := splitSet(n); dist == d {
- seen[n] = true
- }
- }
- for _, n := range s.sets.names() {
- if dist, _ := splitSet(n); dist == d {
- seen[n] = true
- }
- }
- out := make([]string, 0, len(seen))
- for n := range seen {
- out = append(out, n)
- }
- sort.Strings(out)
- return out
-}
-
// isSetName accepts a safe single path component for a set name: letters, digits,
// '-', '_', '.' (but no separators or traversal).
func isSetName(s string) bool {
@@ -162,96 +116,91 @@ func (s *Server) handleDeleteSet(w http.ResponseWriter, r *http.Request) {
apiErr(w, http.StatusBadRequest, "bad set name")
return
}
- // ?set= is the DISTRICT — fan out the delete across every one of its band-sets
- // (the merged "set" form plus each "set-" archive).
- for _, name := range s.setsForDistrict(set) {
- s.sets.remove(name)
- s.packDel(name)
- s.prefs.setDisabled(name, false) // drop any stale disabled flag
- dir := s.setDir(name)
- _ = os.Remove(filepath.Join(dir, name+".pmtiles"))
- _ = os.Remove(filepath.Join(dir, name+".aux.zip"))
- _ = os.Remove(filepath.Join(dir, name+".cells.json")) // the per-set cell manifest
- _ = os.Remove(dir) // best-effort: drop the pack dir if now empty
- }
- // Drop the district's metadata sidecar (.meta.json), which lives in
- // the district's own setDir, separate from the per-band dirs above.
- mdir := s.setDir(set)
- _ = os.Remove(filepath.Join(mdir, set+setMetaExt))
- _ = os.Remove(mdir)
- s.auxIdx.invalidate() // a district's companion aux.zip is gone — re-index /api/aux
- // The active search (?active=1) now drops these cells: packDel removed the pack and
- // we deleted its manifest, so enabledPackCells() no longer counts them. The source
- // cells stay in ENC_ROOT, so the raw index keeps their bounds for a future re-bake;
- // reconcile here only prunes cells whose source is actually gone.
- s.cellIdx.rebuild()
+ // ?set= is a PROVIDER — a full uninstall drops the baked bundle (cache) AND the
+ // whole ENC_ROOT source tree (data), every district. To remove a single district
+ // (keeping the rest), the client calls DELETE /api/district instead.
+ s.dropProviderSet(providerOf(set))
w.Header().Set("Content-Type", jsonCT)
io.WriteString(w, `{"ok":true}`)
}
-// handlePacks lists every installed pack with its enabled state, so the client can
-// show disabled packs (kept on disk, hidden from the map) for management. A pack is a
-// logical DISTRICT (e.g. "noaa-d5"); its per-band archives ("noaa-d5-general", …) are
-// grouped into ONE entry with the bands it produced, coarse→fine. A district is
-// enabled iff ANY of its band-sets is enabled (enable/disable fan out to all bands,
-// so they move together — see handleSetEnabled).
-func (s *Server) handlePacks(w http.ResponseWriter, r *http.Request) {
- // Group registered band-sets by district, collecting each district's bands and
- // whether any band is enabled (not disabled).
- type pack struct {
- bands []string
- enabled bool
- w, s, e, n float64
- hasBounds bool
+// handleDeleteDistrict removes ONE district from a provider (DELETE
+// /api/district?provider=&district=): it deletes the district's ENC_ROOT subfolder
+// and re-bakes the provider from what remains (dropping the provider set entirely if
+// that was its last district). Delete reclaims disk; re-download to restore. The
+// re-bake runs as a background job the client follows via /api/import/status.
+func (s *Server) handleDeleteDistrict(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete {
+ apiErr(w, http.StatusMethodNotAllowed, "DELETE only")
+ return
}
- byDistrict := map[string]*pack{}
- var order []string // first-seen district order, then re-sorted below
- for _, name := range s.packNames() {
- d, band := splitSet(name)
- p := byDistrict[d]
- if p == nil {
- p = &pack{}
- byDistrict[d] = p
- order = append(order, d)
- }
- p.bands = append(p.bands, band)
- if !s.prefs.isDisabled(name) {
- p.enabled = true
+ provider := providerOf(r.URL.Query().Get("provider"))
+ district := r.URL.Query().Get("district")
+ if !isSetName(provider) || !isDistrictName(district) {
+ apiErr(w, http.StatusBadRequest, "need provider + district")
+ return
+ }
+ if _, err := os.Stat(s.districtDir(provider, district)); err != nil {
+ apiErr(w, http.StatusNotFound, "no such district")
+ return
+ }
+ job := s.imports.create(provider)
+ go func() {
+ s.bakeMu.Lock()
+ defer s.bakeMu.Unlock()
+ if err := os.RemoveAll(s.districtDir(provider, district)); err != nil {
+ s.imports.update(job.ID, func(j *importJob) { j.State = "error"; j.Err = err.Error() })
+ return
}
- // Union each band-set's geographic bounds so the client can outline a pack's
- // coverage even while it's DISABLED (its tiles aren't served, but the boundary
- // still marks "you have this chart here, currently off"). Read straight from
- // the archive on disk — disabled packs aren't in the live set registry.
- if path, ok := s.packPath(name); ok {
- if src, err := tilesource.Open(path); err == nil {
- m := src.Meta()
- _ = tilesource.Close(src)
- if !p.hasBounds {
- p.w, p.s, p.e, p.n, p.hasBounds = m.W, m.S, m.E, m.N, true
- } else {
- p.w, p.s = math.Min(p.w, m.W), math.Min(p.s, m.S)
- p.e, p.n = math.Max(p.e, m.E), math.Max(p.n, m.N)
- }
- }
+ s.auxIdx.invalidate() // the district's aux content is gone — re-index /aux
+ if s.bakeProvider(job.ID, provider) {
+ s.imports.update(job.ID, func(j *importJob) { j.State = "done" })
}
+ }()
+ w.Header().Set("Content-Type", jsonCT)
+ w.WriteHeader(http.StatusAccepted)
+ fmt.Fprintf(w, `{"ok":true,"job":%q}`, job.ID)
+}
+
+// handlePacks lists every installed PROVIDER (GET /api/packs) with its enabled state,
+// geographic bounds, extracted metadata, and its installed DISTRICTS (the ENC_ROOT
+// subfolder names — the district→cell map is the folder listing itself). One entry per
+// provider; districts are download/delete units under it, not separately toggled.
+func (s *Server) handlePacks(w http.ResponseWriter, r *http.Request) {
+ // The installable unit is the provider: union of providers with a baked bundle and
+ // providers with an ENC_ROOT on disk (a download that hasn't finished baking yet).
+ seen := map[string]bool{}
+ for _, name := range s.packNames() {
+ seen[providerOf(name)] = true
+ }
+ for _, prov := range s.installedProviders() {
+ seen[prov] = true
}
- sort.Strings(order)
+ providers := make([]string, 0, len(seen))
+ for p := range seen {
+ providers = append(providers, p)
+ }
+ sort.Strings(providers)
+
w.Header().Set("Content-Type", jsonCT)
fmt.Fprint(w, `{"packs":[`)
- for i, d := range order {
+ for i, prov := range providers {
if i > 0 {
fmt.Fprint(w, ",")
}
- p := byDistrict[d]
- sort.Slice(p.bands, func(a, b int) bool { return bandOrder(p.bands[a]) < bandOrder(p.bands[b]) })
- fmt.Fprintf(w, `{"name":%q,"enabled":%t`, d, p.enabled)
- if p.hasBounds {
- fmt.Fprintf(w, `,"bounds":[%g,%g,%g,%g]`, p.w, p.s, p.e, p.n)
+ fmt.Fprintf(w, `{"name":%q,"enabled":%t`, prov, !s.prefs.isDisabled(prov))
+ // Bounds straight from the baked archive on disk — works even while DISABLED (its
+ // tiles aren't served, but the boundary still marks "you have this chart here").
+ if path, ok := s.packPath(prov); ok {
+ if src, err := tilesource.Open(path); err == nil {
+ m := src.Meta()
+ _ = tilesource.Close(src)
+ fmt.Fprintf(w, `,"bounds":[%g,%g,%g,%g]`, m.W, m.S, m.E, m.N)
+ }
}
- // Extracted per-pack metadata (title/agency/scale range/counts/imported date),
- // from the .meta.json sidecar written at import. Cells are omitted from
- // the list view — fetch GET /api/pack/ for the full per-cell detail.
- if m, ok := s.readSetMeta(d); ok {
+ // Extracted metadata (title/agency/scale range/counts/imported date), from the
+ // .meta.json sidecar. Cells omitted here — GET /api/pack/.
+ if m, ok := s.readSetMeta(prov); ok {
if m.Title != "" {
fmt.Fprintf(w, `,"title":%q`, m.Title)
}
@@ -268,21 +217,23 @@ func (s *Server) handlePacks(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `,"imported":%q`, m.Imported)
}
}
- fmt.Fprint(w, `,"bands":[`)
- for j, band := range p.bands {
+ // Installed districts (ENC_ROOT subfolder names) so the client can mark which of
+ // a provider's districts are present and offer per-district download/delete.
+ fmt.Fprint(w, `,"districts":[`)
+ for j, d := range s.providerDistricts(prov) {
if j > 0 {
fmt.Fprint(w, ",")
}
- fmt.Fprintf(w, "%q", band)
+ fmt.Fprintf(w, "%q", d)
}
fmt.Fprint(w, "]}")
}
fmt.Fprint(w, "]}")
}
-// handlePackDetail returns the full extracted metadata for one pack, including the
-// per-cell list (GET /api/pack/). 404s when the pack has no metadata sidecar
-// (e.g. baked before metadata extraction existed, or a built-in pack).
+// handlePackDetail returns the full extracted metadata for one provider, including the
+// per-cell list (GET /api/pack/). 404s when the provider has no metadata
+// sidecar (e.g. baked before metadata extraction existed).
func (s *Server) handlePackDetail(w http.ResponseWriter, r *http.Request) {
const prefix = "/api/pack/"
name := strings.TrimPrefix(r.URL.Path, prefix)
@@ -290,7 +241,7 @@ func (s *Server) handlePackDetail(w http.ResponseWriter, r *http.Request) {
apiErr(w, http.StatusBadRequest, "bad pack name")
return
}
- m, ok := s.readSetMeta(name)
+ m, ok := s.readSetMeta(providerOf(name))
if !ok {
apiErr(w, http.StatusNotFound, "no metadata for pack")
return
@@ -299,9 +250,9 @@ func (s *Server) handlePackDetail(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(m)
}
-// handleSetEnabled shows or hides a pack on the map (POST /api/set/enable|disable
-// ?set=NAME). The baked data is kept; disabling just unregisters it so /tiles/{set}
-// stops serving + the client stops rendering it. Persists to prefs.
+// handleSetEnabled shows or hides a PROVIDER on the map (POST /api/set/enable|disable
+// ?set=). The baked data is kept; disabling just unregisters it so
+// /tiles/{provider} stops serving + the client stops rendering it. Persists to prefs.
func (s *Server) handleSetEnabled(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
apiErr(w, http.StatusMethodNotAllowed, "POST only")
@@ -312,41 +263,36 @@ func (s *Server) handleSetEnabled(w http.ResponseWriter, r *http.Request) {
apiErr(w, http.StatusBadRequest, "bad set name")
return
}
+ provider := providerOf(set)
enable := strings.HasSuffix(r.URL.Path, "/enable")
- // ?set= is the DISTRICT — fan out to every one of its band-sets so a district's
- // per-band archives toggle together. Disabled state persists per band-set (so it
- // survives a restart), keyed by the registered set name, not the district.
- for _, name := range s.setsForDistrict(set) {
- s.prefs.setDisabled(name, !enable)
- if enable {
- if path, ok := s.packPath(name); ok {
- if _, live := s.sets.get(name); !live {
- if src, err := tilesource.Open(path); err == nil {
- s.sets.register(name, src)
- } else {
- apiErr(w, http.StatusInternalServerError, err.Error())
- return
- }
+ s.prefs.setDisabled(provider, !enable)
+ if enable {
+ if path, ok := s.packPath(provider); ok {
+ if _, live := s.sets.get(provider); !live {
+ if src, err := tilesource.Open(path); err == nil {
+ s.sets.register(provider, src)
+ } else {
+ apiErr(w, http.StatusInternalServerError, err.Error())
+ return
}
}
- } else {
- s.sets.remove(name)
}
+ } else {
+ s.sets.remove(provider)
}
w.Header().Set("Content-Type", jsonCT)
- fmt.Fprintf(w, `{"ok":true,"set":%q,"enabled":%t}`, set, enable)
+ fmt.Fprintf(w, `{"ok":true,"set":%q,"enabled":%t}`, provider, enable)
}
-// serveCells returns the names of cells currently in the server's ENC_ROOT source
-// store. The client uses this so its installed-set (and the persisted baked sets)
-// survive a page reload — the cells live server-side in the XDG data dir.
-// serveCells returns the installed source cells. The "cells" array is every
-// cached cell name (back-compat: the installed list). "bbox" maps each INDEXED
-// cell to its [W,S,E,N] footprint (fills in as the background index backfills),
-// so the client can search a cell by name and fly to it. With ?active=1 the
-// result is restricted to cells whose footprint overlaps an ENABLED pack — i.e.
-// charts actually on the map right now (and only those that are indexed, since an
-// un-indexed cell has no footprint to test or fly to).
+// serveCells returns the names of cells currently in the server's per-pack cells/
+// source store. The client uses this so its installed-set (and the persisted baked
+// sets) survive a page reload — the cells live server-side in the XDG data dir.
+// The "cells" array is every cached cell name (back-compat: the installed list).
+// "bbox" maps each INDEXED cell to its [W,S,E,N] footprint (fills in as the background
+// index backfills), so the client can search a cell by name and fly to it. With
+// ?active=1 the result is restricted to cells whose footprint overlaps an ENABLED
+// pack — i.e. charts actually on the map right now (and only those that are indexed,
+// since an un-indexed cell has no footprint to test or fly to).
func (s *Server) serveCells(w http.ResponseWriter, r *http.Request) {
active := r.URL.Query().Get("active") == "1"
var inPack map[string]bool // cells baked into enabled packs (exact, from manifests)
@@ -355,14 +301,10 @@ func (s *Server) serveCells(w http.ResponseWriter, r *http.Request) {
inPack, legacy = s.enabledPackCells()
}
_, idx := s.cellIdx.snapshot()
- entries, _ := os.ReadDir(filepath.Join(s.dataDir, "ENC_ROOT"))
- names := make([]string, 0, len(entries))
+ stems := s.cachedCellStems()
+ names := make([]string, 0, len(stems))
boxes := make(map[string][4]float64)
- for _, e := range entries {
- if !e.IsDir() || !isCellName(e.Name()) {
- continue
- }
- n := e.Name()
+ for _, n := range stems {
box, has := idx[n]
if active {
// Active = actually baked into an enabled pack. Prefer the exact per-pack
diff --git a/internal/engine/tile/tile.go b/internal/engine/tile/tile.go
deleted file mode 100644
index 29d8653..0000000
--- a/internal/engine/tile/tile.go
+++ /dev/null
@@ -1,440 +0,0 @@
-// Package tile holds tile geometry: Web-Mercator (z,x,y) <-> tile-local extent
-// coordinates, plus the clipping the MVT encoder needs (Sutherland-Hodgman for
-// polygons, Liang-Barsky for polylines).
-//
-// The whole world at zoom z is extent*2^z units wide; tile (x,y) owns the
-// [x*extent, (x+1)*extent) window, and a lat/lon maps to a tile-local coordinate
-// by subtracting that origin. Geometry is then clipped to [-buffer, extent+buffer]
-// (the MVT render buffer) so a mark just off the tile edge still rasterises.
-package tile
-
-import (
- "math"
-
- "github.com/beetlebugorg/chartplotter/pkg/geo"
-)
-
-// FPoint is a tile-local coordinate before quantization.
-type FPoint struct{ X, Y float64 }
-
-// UPoint is a normalized-world coordinate stored as 32-bit fixed point: the [0,1)
-// Web-Mercator value × 2³². Half the size of an FPoint (8 vs 16 bytes), which
-// matters because the baker holds every cell's pre-projected geometry in memory
-// at once. Precision is 2⁻³² of the world ≈ a few mm at the equator — far finer
-// than a z18 / 4096-extent tile pixel, so there is no visible loss. Project with
-// Projector.ProjectNormU.
-type UPoint struct{ X, Y uint32 }
-
-// uScale is 2³² — the fixed-point scale for UPoint.
-const uScale = 4294967296.0
-
-// NormU converts a [0,1] normalized-world value to 32-bit fixed point (clamped).
-func NormU(n float64) uint32 {
- v := n * uScale
- if v <= 0 {
- return 0
- }
- if v >= uScale-1 {
- return uScale - 1
- }
- return uint32(v)
-}
-
-// IPoint is a quantized integer MVT vertex.
-type IPoint struct{ X, Y int32 }
-
-// TileCoord is a (z,x,y) tile address.
-type TileCoord struct{ Z, X, Y uint32 }
-
-// WorldSize is the world width in tile-local units at zoom z (extent*2^z).
-func WorldSize(z, extent uint32) float64 {
- return float64(extent) * math.Pow(2.0, float64(z))
-}
-
-func worldXpx(lon, worldSize float64) float64 {
- return (lon + 180.0) / 360.0 * worldSize
-}
-
-// latToWebMercatorPx is the portrayal projector's Web-Mercator y (origin at the
-// north edge), matching the viewport projection so tiles align with the renderer.
-func latToWebMercatorPx(latDeg, worldPx float64) float64 {
- latRad := latDeg * math.Pi / 180.0
- sinLat := math.Sin(latRad)
- y := 0.5 - math.Log((1.0+sinLat)/(1.0-sinLat))/(4.0*math.Pi)
- return y * worldPx
-}
-
-// Projector is a per-tile lat/lon -> tile-local transform. Build once per tile.
-type Projector struct {
- worldSize float64
- worldSizeU float64 // worldSize / 2³², for projecting UPoint fixed-point coords
- originX float64
- originY float64
-}
-
-// NewProjector builds the transform for a tile of the given extent.
-func NewProjector(coord TileCoord, extent uint32) Projector {
- ws := WorldSize(coord.Z, extent)
- return Projector{
- worldSize: ws,
- worldSizeU: ws / uScale,
- originX: float64(coord.X) * float64(extent),
- originY: float64(coord.Y) * float64(extent),
- }
-}
-
-// ProjectNormU projects a UPoint (32-bit fixed-point normalized world) into this
-// tile's pixel space — the same affine transform as ProjectNorm, on stored
-// geometry that's half the size.
-func (p Projector) ProjectNormU(n UPoint) FPoint {
- return FPoint{
- X: float64(n.X)*p.worldSizeU - p.originX,
- Y: float64(n.Y)*p.worldSizeU - p.originY,
- }
-}
-
-// Project maps a lat/lon to this tile's local coordinate.
-func (p Projector) Project(ll geo.LatLon) FPoint {
- return FPoint{
- X: worldXpx(ll.Lon, p.worldSize) - p.originX,
- Y: latToWebMercatorPx(ll.Lat, p.worldSize) - p.originY,
- }
-}
-
-// ProjectNorm projects a point already in normalized-world coordinates (X,Y in
-// [0,1], Web-Mercator) into this tile's pixel space. This is a cheap affine
-// transform — no log/sin/tan — so callers that project the same geometry into
-// many tiles should normalize once (lon/lat → [0,1]) and use this per tile.
-func (p Projector) ProjectNorm(n FPoint) FPoint {
- return FPoint{
- X: n.X*p.worldSize - p.originX,
- Y: n.Y*p.worldSize - p.originY,
- }
-}
-
-// Quantize rounds a clipped tile-local coordinate to an integer MVT vertex.
-func Quantize(p FPoint) IPoint {
- return IPoint{X: int32(math.Round(p.X)), Y: int32(math.Round(p.Y))}
-}
-
-// TileRange is an inclusive tile index range covering a bbox at zoom z.
-type TileRange struct {
- Z uint32
- XMin, XMax, YMin, YMax uint32
-}
-
-// Count is the number of tiles in the range.
-func (r TileRange) Count() uint64 {
- return uint64(r.XMax-r.XMin+1) * uint64(r.YMax-r.YMin+1)
-}
-
-// RangeForBbox is the inclusive tile range covering bbox at zoom z. Y is clamped
-// to the valid [0, 2^z) band; X is not wrapped (ENC cells don't cross the
-// antimeridian in this corpus).
-func RangeForBbox(z uint32, bbox geo.BoundingBox, extent uint32) TileRange {
- ws := WorldSize(z, extent)
- extF := float64(extent)
- nTiles := int64(math.Pow(2.0, float64(z)))
- last := nTiles - 1
-
- clampI := func(v, lo, hi int64) int64 {
- if v < lo {
- return lo
- }
- if v > hi {
- return hi
- }
- return v
- }
-
- tx0 := clampI(int64(math.Floor(worldXpx(bbox.MinLon, ws)/extF)), 0, last)
- tx1 := clampI(int64(math.Floor(worldXpx(bbox.MaxLon, ws)/extF)), 0, last)
- // Y grows southward: MaxLat is the smaller (northern) pixel value.
- ty0 := clampI(int64(math.Floor(latToWebMercatorPx(bbox.MaxLat, ws)/extF)), 0, last)
- ty1 := clampI(int64(math.Floor(latToWebMercatorPx(bbox.MinLat, ws)/extF)), 0, last)
-
- return TileRange{
- Z: z,
- XMin: uint32(min64(tx0, tx1)),
- XMax: uint32(max64(tx0, tx1)),
- YMin: uint32(min64(ty0, ty1)),
- YMax: uint32(max64(ty0, ty1)),
- }
-}
-
-// -- clipping ----------------------------------------------------------------
-
-// Rect is a clip rectangle in tile-local coordinates.
-type Rect struct {
- MinX, MinY, MaxX, MaxY float64
-}
-
-// RectForTile is the clip rect for a tile of extent with buffer units of bleed.
-func RectForTile(extent uint32, buffer float64) Rect {
- e := float64(extent)
- return Rect{MinX: -buffer, MinY: -buffer, MaxX: e + buffer, MaxY: e + buffer}
-}
-
-// ContainsF reports whether p is inside the rect (inclusive).
-func (r Rect) ContainsF(p FPoint) bool {
- return p.X >= r.MinX && p.X <= r.MaxX && p.Y >= r.MinY && p.Y <= r.MaxY
-}
-
-type edge uint8
-
-const (
- edgeLeft edge = iota
- edgeRight
- edgeTop
- edgeBottom
-)
-
-var fourEdges = [4]edge{edgeLeft, edgeRight, edgeTop, edgeBottom}
-
-func inside(e edge, p FPoint, r Rect) bool {
- switch e {
- case edgeLeft:
- return p.X >= r.MinX
- case edgeRight:
- return p.X <= r.MaxX
- case edgeTop:
- return p.Y >= r.MinY
- default: // bottom
- return p.Y <= r.MaxY
- }
-}
-
-func intersectEdge(e edge, a, b FPoint, r Rect) FPoint {
- switch e {
- case edgeLeft:
- return lerpX(a, b, r.MinX)
- case edgeRight:
- return lerpX(a, b, r.MaxX)
- case edgeTop:
- return lerpY(a, b, r.MinY)
- default: // bottom
- return lerpY(a, b, r.MaxY)
- }
-}
-
-func lerpX(a, b FPoint, x float64) FPoint {
- t := (x - a.X) / (b.X - a.X)
- return FPoint{X: x, Y: a.Y + t*(b.Y-a.Y)}
-}
-
-func lerpY(a, b FPoint, y float64) FPoint {
- t := (y - a.Y) / (b.Y - a.Y)
- return FPoint{X: a.X + t*(b.X-a.X), Y: y}
-}
-
-// Clipper is a reusable polygon clipper: it holds the two Sutherland-Hodgman
-// ping-pong buffers so a whole bake reuses one pair of allocations. The returned
-// slice aliases internal storage and is valid only until the next Polygon call —
-// callers quantize/copy it out immediately.
-type Clipper struct {
- a, b []FPoint
-}
-
-// Polygon clips ring to r and returns the (possibly empty) result.
-func (c *Clipper) Polygon(ring []FPoint, r Rect) []FPoint {
- // Fast path: a ring fully inside the rect clips to itself.
- allIn := true
- for _, p := range ring {
- if p.X < r.MinX || p.X > r.MaxX || p.Y < r.MinY || p.Y > r.MaxY {
- allIn = false
- break
- }
- }
- if allIn {
- c.a = append(c.a[:0], ring...)
- return c.a
- }
-
- // Ping-pong between c.a and c.b. src reads, scratch is written, then swap.
- src := append(c.a[:0], ring...)
- scratch := c.b
- for _, e := range fourEdges {
- scratch = scratch[:0]
- if len(src) != 0 {
- j := len(src) - 1
- for i := 0; i < len(src); i++ {
- cur, prev := src[i], src[j]
- ci, pi := inside(e, cur, r), inside(e, prev, r)
- if ci {
- if !pi {
- scratch = append(scratch, intersectEdge(e, prev, cur, r))
- }
- scratch = append(scratch, cur)
- } else if pi {
- scratch = append(scratch, intersectEdge(e, prev, cur, r))
- }
- j = i
- }
- }
- src, scratch = scratch, src
- }
- // After four swaps, src holds the result. Persist both backing arrays.
- c.a, c.b = src, scratch
- return src
-}
-
-// ClipPolygon clips a single ring against r with throwaway buffers.
-func ClipPolygon(ring []FPoint, r Rect) []FPoint {
- var c Clipper
- out := c.Polygon(ring, r)
- // Detach from the clipper's buffer since c is discarded.
- cp := make([]FPoint, len(out))
- copy(cp, out)
- return cp
-}
-
-// clipSegment is the Liang-Barsky clip of segment a->b to r. ok=false if the
-// segment misses the rect entirely.
-type clippedSeg struct {
- a, b FPoint
- entered, exited bool
- t0, t1 float64
-}
-
-func clipSegment(a, b FPoint, r Rect) (clippedSeg, bool) {
- dx := b.X - a.X
- dy := b.Y - a.Y
- t0, t1 := 0.0, 1.0
- p := [4]float64{-dx, dx, -dy, dy}
- q := [4]float64{a.X - r.MinX, r.MaxX - a.X, a.Y - r.MinY, r.MaxY - a.Y}
- for i := 0; i < 4; i++ {
- if p[i] == 0 {
- if q[i] < 0 {
- return clippedSeg{}, false // parallel and outside
- }
- continue
- }
- t := q[i] / p[i]
- if p[i] < 0 {
- if t > t1 {
- return clippedSeg{}, false
- }
- if t > t0 {
- t0 = t
- }
- } else {
- if t < t0 {
- return clippedSeg{}, false
- }
- if t < t1 {
- t1 = t
- }
- }
- }
- return clippedSeg{
- a: FPoint{X: a.X + t0*dx, Y: a.Y + t0*dy},
- b: FPoint{X: a.X + t1*dx, Y: a.Y + t1*dy},
- entered: t0 > 0.0,
- exited: t1 < 1.0,
- t0: t0,
- t1: t1,
- }, true
-}
-
-func approxEq(a, b FPoint) bool {
- return math.Abs(a.X-b.X) < 1e-6 && math.Abs(a.Y-b.Y) < 1e-6
-}
-
-// ClipLine clips a polyline to r, returning the contiguous in-rect runs. A line
-// that leaves and re-enters yields multiple runs.
-func ClipLine(pts []FPoint, r Rect) [][]FPoint {
- var runs [][]FPoint
- if len(pts) < 2 {
- return runs
- }
- var cur []FPoint
- for i := 0; i+1 < len(pts); i++ {
- seg, ok := clipSegment(pts[i], pts[i+1], r)
- if !ok {
- if len(cur) > 0 {
- runs = append(runs, cur)
- cur = nil
- }
- continue
- }
- if len(cur) == 0 {
- cur = append(cur, seg.a, seg.b)
- } else if approxEq(cur[len(cur)-1], seg.a) {
- cur = append(cur, seg.b)
- } else {
- runs = append(runs, cur)
- cur = []FPoint{seg.a, seg.b}
- }
- if seg.exited {
- runs = append(runs, cur)
- cur = nil
- }
- }
- if len(cur) > 0 {
- runs = append(runs, cur)
- }
- return runs
-}
-
-// PhasedRun is a clipped polyline run plus the cumulative arc length at its first
-// vertex (arc0), so a dashed line's pattern lines up across a tile boundary.
-type PhasedRun struct {
- Points []FPoint
- Arc0 float64
-}
-
-// ClipLinePhased is like ClipLine, but each run carries arc0 — the arc length
-// from the polyline's first vertex to the run's first vertex. arc[i] is the
-// cumulative arc length at pts[i] and must have the same length as pts.
-func ClipLinePhased(pts []FPoint, arc []float64, r Rect) []PhasedRun {
- var runs []PhasedRun
- if len(pts) < 2 {
- return runs
- }
- var cur []FPoint
- var curArc0 float64
- for i := 0; i+1 < len(pts); i++ {
- da := arc[i+1] - arc[i]
- seg, ok := clipSegment(pts[i], pts[i+1], r)
- if !ok {
- if len(cur) > 0 {
- runs = append(runs, PhasedRun{Points: cur, Arc0: curArc0})
- cur = nil
- }
- continue
- }
- arcA := arc[i] + seg.t0*da
- if len(cur) == 0 {
- curArc0 = arcA
- cur = append(cur, seg.a, seg.b)
- } else if approxEq(cur[len(cur)-1], seg.a) {
- cur = append(cur, seg.b)
- } else {
- runs = append(runs, PhasedRun{Points: cur, Arc0: curArc0})
- curArc0 = arcA
- cur = []FPoint{seg.a, seg.b}
- }
- if seg.exited {
- runs = append(runs, PhasedRun{Points: cur, Arc0: curArc0})
- cur = nil
- }
- }
- if len(cur) > 0 {
- runs = append(runs, PhasedRun{Points: cur, Arc0: curArc0})
- }
- return runs
-}
-
-func min64(a, b int64) int64 {
- if a < b {
- return a
- }
- return b
-}
-
-func max64(a, b int64) int64 {
- if a > b {
- return a
- }
- return b
-}
diff --git a/internal/engine/tile/tile_test.go b/internal/engine/tile/tile_test.go
deleted file mode 100644
index aa9c30c..0000000
--- a/internal/engine/tile/tile_test.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package tile
-
-import (
- "math"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/geo"
-)
-
-func TestProjectorInsideTileMonotonic(t *testing.T) {
- pt := geo.LatLon{Lat: 38.978, Lon: -76.49}
- rng := RangeForBbox(14, geo.BoundingBox{MinLat: pt.Lat, MinLon: pt.Lon, MaxLat: pt.Lat, MaxLon: pt.Lon}, 4096)
- proj := NewProjector(TileCoord{Z: 14, X: rng.XMin, Y: rng.YMin}, 4096)
- in := proj.Project(pt)
- if in.X < 0 || in.X > 4096 || in.Y < 0 || in.Y > 4096 {
- t.Fatalf("point not inside its tile: %+v", in)
- }
- east := proj.Project(geo.LatLon{Lat: 38.978, Lon: -76.40})
- north := proj.Project(geo.LatLon{Lat: 39.05, Lon: -76.49})
- if east.X <= in.X {
- t.Error("increasing lon should increase x")
- }
- if north.Y >= in.Y {
- t.Error("increasing lat should decrease y (north up)")
- }
-}
-
-func TestRangeForBbox(t *testing.T) {
- bbox := geo.BoundingBox{MinLat: 38.9, MinLon: -76.55, MaxLat: 39.05, MaxLon: -76.40}
- rng := RangeForBbox(14, bbox, 4096)
- if rng.Count() < 1 {
- t.Fatal("expected >= 1 tile")
- }
- if rng.XMin > rng.XMax || rng.YMin > rng.YMax {
- t.Fatal("range inverted")
- }
- if rng.XMax >= (uint32(1) << 14) {
- t.Fatal("x out of band")
- }
-}
-
-func TestClipPolygonStraddlingEdge(t *testing.T) {
- r := Rect{MinX: 0, MinY: 0, MaxX: 100, MaxY: 100}
- ring := []FPoint{{50, 50}, {150, 50}, {150, 80}, {50, 80}}
- out := ClipPolygon(ring, r)
- if len(out) < 4 {
- t.Fatalf("expected >=4 vertices, got %d", len(out))
- }
- for _, p := range out {
- if p.X > 100+1e-6 {
- t.Errorf("vertex past clip edge: %+v", p)
- }
- }
-}
-
-func TestClipPolygonAllFourEdges(t *testing.T) {
- r := Rect{MinX: 0, MinY: 0, MaxX: 100, MaxY: 100}
- ring := []FPoint{{-10, -10}, {110, -10}, {110, 110}, {-10, 110}}
- out := ClipPolygon(ring, r)
- if len(out) < 4 {
- t.Fatalf("expected >=4 vertices, got %d", len(out))
- }
- for _, p := range out {
- if p.X < -1e-6 || p.X > 100+1e-6 || p.Y < -1e-6 || p.Y > 100+1e-6 {
- t.Errorf("vertex outside rect: %+v", p)
- }
- }
-}
-
-func TestClipperReuse(t *testing.T) {
- // Reusing one Clipper across rings must not leak the prior ring's data.
- r := Rect{MinX: 0, MinY: 0, MaxX: 100, MaxY: 100}
- var c Clipper
- big := []FPoint{{-10, -10}, {110, -10}, {110, 110}, {-10, 110}}
- _ = c.Polygon(big, r)
- insideRing := []FPoint{{10, 10}, {40, 10}, {40, 40}, {10, 40}}
- got := c.Polygon(insideRing, r)
- if len(got) != 4 {
- t.Fatalf("expected 4 vertices, got %d", len(got))
- }
- for i, g := range got {
- if g != insideRing[i] {
- t.Errorf("vertex %d = %+v, want %+v", i, g, insideRing[i])
- }
- }
-}
-
-func TestClipLineSplits(t *testing.T) {
- r := Rect{MinX: 0, MinY: 0, MaxX: 100, MaxY: 100}
- pts := []FPoint{{10, 10}, {200, 10}, {200, 50}, {10, 50}} // in -> out -> in
- runs := ClipLine(pts, r)
- if len(runs) != 2 {
- t.Fatalf("expected 2 runs, got %d", len(runs))
- }
- for _, run := range runs {
- for _, p := range run {
- if p.X > 100+1e-6 {
- t.Errorf("vertex past edge: %+v", p)
- }
- }
- }
-}
-
-func TestClipLineFullyInside(t *testing.T) {
- r := Rect{MinX: 0, MinY: 0, MaxX: 100, MaxY: 100}
- pts := []FPoint{{10, 10}, {20, 20}, {30, 40}}
- runs := ClipLine(pts, r)
- if len(runs) != 1 || len(runs[0]) != 3 {
- t.Fatalf("expected one 3-vertex run, got %d runs", len(runs))
- }
-}
-
-func TestClipLinePhasedArc0(t *testing.T) {
- r := Rect{MinX: 0, MinY: 0, MaxX: 100, MaxY: 100}
- pts := []FPoint{{-50, 50}, {150, 50}}
- arc := []float64{0, 200}
- runs := ClipLinePhased(pts, arc, r)
- if len(runs) != 1 {
- t.Fatalf("expected 1 run, got %d", len(runs))
- }
- if math.Abs(runs[0].Arc0-50) > 1e-9 {
- t.Errorf("arc0 = %v, want 50", runs[0].Arc0)
- }
- if math.Abs(runs[0].Points[0].X-0) > 1e-9 {
- t.Errorf("run should start at left edge, got x=%v", runs[0].Points[0].X)
- }
-}
-
-func TestClipLinePhasedSeamPhase(t *testing.T) {
- r := Rect{MinX: 0, MinY: 0, MaxX: 100, MaxY: 100}
- pts := []FPoint{{10, 10}, {200, 10}, {200, 50}, {10, 50}}
- arc := []float64{0, 190, 230, 420}
- runs := ClipLinePhased(pts, arc, r)
- if len(runs) != 2 {
- t.Fatalf("expected 2 runs, got %d", len(runs))
- }
- if math.Abs(runs[0].Arc0-0) > 1e-9 {
- t.Errorf("run0 arc0 = %v, want 0", runs[0].Arc0)
- }
- if math.Abs(runs[1].Arc0-330) > 1e-6 {
- t.Errorf("run1 arc0 = %v, want 330", runs[1].Arc0)
- }
-}
diff --git a/internal/engine/tilesource/mbtiles.go b/internal/engine/tilesource/mbtiles.go
index 3674ae9..63979bd 100644
--- a/internal/engine/tilesource/mbtiles.go
+++ b/internal/engine/tilesource/mbtiles.go
@@ -74,8 +74,14 @@ func (m *MBTiles) loadMeta() error {
if strings.EqualFold(value, "pbf") {
m.meta.Gzipped = true // pbf in mbtiles is gzipped by convention
}
+ if strings.EqualFold(value, "mlt") {
+ m.meta.TileType = "mlt" // MLT tiles: hint the client decoder
+ }
}
}
+ if m.meta.TileType == "" {
+ m.meta.TileType = "mvt" // mbtiles archives here are legacy Go-baked MVT
+ }
return rows.Err()
}
diff --git a/internal/s57/parser/chart.go b/internal/s57/parser/chart.go
deleted file mode 100644
index be8efd6..0000000
--- a/internal/s57/parser/chart.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package parser
-
-// Chart represents a complete S-57 Electronic Navigational Chart.
-// This is the top-level structure returned by the parser.
-//
-// Reference: S-57 Part 3 §7 (31Main.pdf p94): Structure implementation
-// showing how datasets are composed of metadata and feature records.
-type Chart struct {
- metadata *datasetMetadata // Private - use accessor methods
- params datasetParams // Private - DSPM record data
- Features []Feature // Public - array of extracted features
- spatialRecords map[spatialKey]*spatialRecord // Private - for update merging
- warnings []ConformanceWarning // Private - non-fatal spec deviations
-}
-
-// Warnings returns the non-fatal S-57 / ISO-8211 spec-conformance deviations
-// detected while parsing this chart (see conformance.go). Empty when the cell is
-// fully conformant. In strict mode (ParseOptions.ValidateConformance) these are
-// returned as a parse error instead.
-func (c *Chart) Warnings() []ConformanceWarning {
- return c.warnings
-}
-
-// DatasetName returns the chart's dataset name (cell identifier).
-func (c *Chart) DatasetName() string {
- if c.metadata == nil {
- return ""
- }
- return c.metadata.DatasetName()
-}
-
-// Edition returns the chart's edition number.
-func (c *Chart) Edition() string {
- if c.metadata == nil {
- return ""
- }
- return c.metadata.Edition()
-}
-
-// UpdateNumber returns the chart's update number.
-func (c *Chart) UpdateNumber() string {
- if c.metadata == nil {
- return ""
- }
- return c.metadata.UpdateNumber()
-}
-
-// UpdateDate returns the update application date (YYYYMMDD).
-func (c *Chart) UpdateDate() string {
- if c.metadata == nil {
- return ""
- }
- return c.metadata.UpdateDate()
-}
-
-// IssueDate returns the issue date (YYYYMMDD).
-func (c *Chart) IssueDate() string {
- if c.metadata == nil {
- return ""
- }
- return c.metadata.IssueDate()
-}
-
-// S57Edition returns the S-57 standard edition used (e.g., "03.1").
-func (c *Chart) S57Edition() string {
- if c.metadata == nil {
- return ""
- }
- return c.metadata.S57Edition()
-}
-
-// ProducingAgency returns the producing agency code.
-func (c *Chart) ProducingAgency() int {
- if c.metadata == nil {
- return 0
- }
- return c.metadata.ProducingAgency()
-}
-
-// Comment returns the metadata comment field.
-func (c *Chart) Comment() string {
- if c.metadata == nil {
- return ""
- }
- return c.metadata.Comment()
-}
-
-// ExchangePurpose returns human-readable exchange purpose ("New" or "Revision").
-func (c *Chart) ExchangePurpose() string {
- if c.metadata == nil {
- return "Unknown"
- }
- return c.metadata.ExchangePurpose()
-}
-
-// ProductSpecification returns human-readable product spec ("ENC" or "ODD").
-func (c *Chart) ProductSpecification() string {
- if c.metadata == nil {
- return "Unknown"
- }
- return c.metadata.ProductSpecification()
-}
-
-// ApplicationProfile returns human-readable application profile.
-func (c *Chart) ApplicationProfile() string {
- if c.metadata == nil {
- return "Unknown"
- }
- return c.metadata.ApplicationProfile()
-}
-
-// IntendedUsage returns the intended usage (navigational purpose) code.
-//
-// Values per S-57 specification:
-//
-// 1 = Overview, 2 = General, 3 = Coastal, 4 = Approach, 5 = Harbour, 6 = Berthing
-func (c *Chart) IntendedUsage() int {
- if c.metadata == nil {
- return 0
- }
- return c.metadata.intu
-}
-
-// CoordinateUnits returns the coordinate units from the DSPM record.
-// S-57 §7.3.2.1 (31Main.pdf p66) COUN field: 1=lat/lon, 2=eastings/northings.
-func (c *Chart) CoordinateUnits() int {
- return c.params.COUN
-}
-
-// HorizontalDatum returns the horizontal datum code from the DSPM record.
-// S-57 §7.3.2.1 (31Main.pdf p66) HDAT field: 2=WGS-84 (most common).
-func (c *Chart) HorizontalDatum() int {
- return c.params.HDAT
-}
-
-// CompilationScale returns the compilation scale from the DSPM record.
-// S-57 §7.3.2.1 (31Main.pdf p66) CSCL field: scale denominator (e.g., 50000 for 1:50,000).
-func (c *Chart) CompilationScale() int32 {
- return c.params.CSCL
-}
-
-// DepthUnits returns the depth units from the DSPM record.
-// S-57 §7.3.2.1 (31Main.pdf p66) DUNI field: 1=meters, 2=fathoms+feet, 3=feet, 4=fathoms+fractions.
-func (c *Chart) DepthUnits() int {
- return c.params.DUNI
-}
diff --git a/internal/s57/parser/coastline_masking_realdata_test.go b/internal/s57/parser/coastline_masking_realdata_test.go
deleted file mode 100644
index 465c90e..0000000
--- a/internal/s57/parser/coastline_masking_realdata_test.go
+++ /dev/null
@@ -1,207 +0,0 @@
-package parser
-
-import (
- "sort"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-// Real-data verification of DERIVED coastline-coincident boundary masking on NOAA
-// cells. This parses two real ENC cells twice (option OFF vs ON) and reports, per
-// area object class, how many coastline-coincident boundary edges are DRAWN before
-// vs after. Expected: DEPARE/SEAARE/RESARE/CTNARE coincident drawn-edge counts drop
-// to ~0 with the option on, while the exempt LNDARE is unchanged.
-//
-// Run: go test ./internal/s57/parser/ -run TestRealDataCoastlineMasking -v
-
-var realDataCells = []string{
- "../../../testdata/US5MD1MC.000",
- "../../../testdata/US4MD81M.000",
-}
-
-// areaEdgeRefs reproduces constructPolygonGeometry's edge-collection for an AREA
-// feature: direct edge refs (RCNM=130) plus edges pulled from face records (RCNM=140)
-// via VRPT. Returns the edge RCIDs that make up the feature's boundary.
-func areaEdgeRefs(fr *featureRecord, spatialRecords map[spatialKey]*spatialRecord) []int64 {
- var rcids []int64
- for _, fsptRef := range fr.SpatialRefs {
- var spatial *spatialRecord
- if fsptRef.RCNM != 0 {
- spatial = spatialRecords[spatialKey{RCNM: fsptRef.RCNM, RCID: fsptRef.RCID}]
- } else {
- for _, rcnm := range []int{int(spatialTypeFace), int(spatialTypeEdge), int(spatialTypeConnectedNode), int(spatialTypeIsolatedNode)} {
- if sp, ok := spatialRecords[spatialKey{RCNM: rcnm, RCID: fsptRef.RCID}]; ok {
- spatial = sp
- break
- }
- }
- }
- if spatial == nil {
- continue
- }
- switch spatial.RecordType {
- case spatialTypeFace:
- for _, ptr := range spatial.VectorPointers {
- if ptr.TargetRCNM == int(spatialTypeEdge) {
- rcids = append(rcids, ptr.TargetRCID)
- }
- }
- case spatialTypeEdge:
- rcids = append(rcids, fsptRef.RCID)
- }
- }
- return rcids
-}
-
-func TestRealDataCoastlineMasking(t *testing.T) {
- // Area object classes of interest. LNDARE is the exempt coast-definer.
- track := []string{"DEPARE", "SEAARE", "RESARE", "CTNARE", "LNDARE"}
- inTrack := map[string]bool{}
- for _, c := range track {
- inTrack[c] = true
- }
-
- for _, cell := range realDataCells {
- fsys := iso8211.OSFS()
- opts := DefaultParseOptions()
- opts.ApplyUpdates = false // base .000 only, both runs identical input
-
- data, _, _, err := parseBaseFile(fsys, cell, opts, &conformance{})
- if err != nil {
- t.Fatalf("%s: parseBaseFile: %v", cell, err)
- }
-
- // Build the COALNE edge RCID set exactly as buildChart does.
- coalneEdges := map[int64]bool{}
- for _, fr := range data.features {
- if oc, _ := ObjectClassToString(fr.ObjectClass); oc != "COALNE" {
- continue
- }
- for _, ref := range fr.SpatialRefs {
- if ref.RCNM == 0 || ref.RCNM == int(spatialTypeEdge) {
- coalneEdges[ref.RCID] = true
- }
- }
- }
-
- // Per-object-class counters.
- type counts struct{ off, on int }
- drawn := map[string]*counts{} // coincident edges actually DRAWN (in BoundaryLines)
-
- // To decide "drawn", we re-run drawableBoundaryLines for each area feature
- // off vs on, and count how many of its COINCIDENT edges survive. Since one
- // drawable line ↔ one edge, we instead count the coincident edges present in
- // the feature's edge refs that are NOT masked/data-limit (off run) vs the
- // same minus coalne (on run). This matches drawableBoundaryLines' filter.
- for _, fr := range data.features {
- if fr.GeomPrim != 3 {
- continue
- }
- oc, _ := ObjectClassToString(fr.ObjectClass)
- if !inTrack[oc] {
- continue
- }
- if drawn[oc] == nil {
- drawn[oc] = &counts{}
- }
- exempt := isCoastlineMaskExempt(oc)
- for _, rcid := range areaEdgeRefs(fr, data.spatialRecords) {
- if !coalneEdges[rcid] {
- continue // not a coastline-coincident edge
- }
- // OFF run: derived masking disabled → coincident edge is drawn.
- drawn[oc].off++
- // ON run: drawn only if the class is exempt (LNDARE keeps it).
- if exempt {
- drawn[oc].on++
- }
- }
- }
-
- t.Logf("=== %s : coastline-coincident boundary edges DRAWN (off → on) ===", cell)
- names := make([]string, 0, len(drawn))
- for k := range drawn {
- names = append(names, k)
- }
- sort.Strings(names)
- for _, oc := range names {
- c := drawn[oc]
- t.Logf(" %-7s off=%-5d on=%-5d", oc, c.off, c.on)
- }
-
- // Assertions: non-exempt classes must drop coincident drawn edges to 0 with
- // the option on; LNDARE (exempt) must be unchanged.
- for _, oc := range names {
- c := drawn[oc]
- if isCoastlineMaskExempt(oc) {
- if c.on != c.off {
- t.Errorf("%s %s (exempt): expected unchanged, off=%d on=%d", cell, oc, c.off, c.on)
- }
- } else if c.off > 0 && c.on != 0 {
- t.Errorf("%s %s: expected coincident drawn edges → 0 with masking on, got off=%d on=%d", cell, oc, c.off, c.on)
- }
- }
- }
-}
-
-// TestRealDataCoastlineMaskingEndToEnd cross-checks the above edge-ref accounting
-// against the actual buildChart output: it parses the cell through the full pipeline
-// off vs on and confirms total BoundaryLines per tracked area class drops by exactly
-// the coincident-edge count (and LNDARE is unchanged). This proves the geometry the
-// renderer receives really has the coastline edges removed.
-func TestRealDataCoastlineMaskingEndToEnd(t *testing.T) {
- track := map[string]bool{"DEPARE": true, "SEAARE": true, "RESARE": true, "CTNARE": true, "LNDARE": true}
-
- for _, cell := range realDataCells {
- fsys := iso8211.OSFS()
-
- parseOnce := func(mask bool) map[string]int {
- opts := DefaultParseOptions()
- opts.ApplyUpdates = false
- opts.MaskCoastlineCoincidentBoundaries = mask
- data, params, meta, err := parseBaseFile(fsys, cell, opts, &conformance{})
- if err != nil {
- t.Fatalf("%s: parseBaseFile: %v", cell, err)
- }
- chart, err := buildChart(data, meta, params, opts)
- if err != nil {
- t.Fatalf("%s: buildChart: %v", cell, err)
- }
- total := map[string]int{}
- for i := range chart.Features {
- f := &chart.Features[i]
- if !track[f.ObjectClass] {
- continue
- }
- total[f.ObjectClass] += len(f.Geometry.BoundaryLines)
- }
- return total
- }
-
- off := parseOnce(false)
- on := parseOnce(true)
-
- t.Logf("=== %s : total drawable BoundaryLines per area class (off → on) ===", cell)
- names := make([]string, 0, len(off))
- for k := range off {
- names = append(names, k)
- }
- sort.Strings(names)
- for _, oc := range names {
- t.Logf(" %-7s off=%-6d on=%-6d dropped=%d", oc, off[oc], on[oc], off[oc]-on[oc])
- }
-
- // LNDARE (exempt) total boundary lines must be unchanged.
- if off["LNDARE"] != on["LNDARE"] {
- t.Errorf("%s LNDARE total BoundaryLines changed: off=%d on=%d", cell, off["LNDARE"], on["LNDARE"])
- }
- // Non-exempt classes that had any boundary lines must drop at least one
- // (proves coincident edges were removed on real data).
- for _, oc := range []string{"DEPARE", "SEAARE", "RESARE", "CTNARE"} {
- if off[oc] > 0 && on[oc] >= off[oc] {
- t.Errorf("%s %s: expected fewer drawable boundary lines with masking on, off=%d on=%d", cell, oc, off[oc], on[oc])
- }
- }
- }
-}
diff --git a/internal/s57/parser/coastline_masking_test.go b/internal/s57/parser/coastline_masking_test.go
deleted file mode 100644
index bf3447b..0000000
--- a/internal/s57/parser/coastline_masking_test.go
+++ /dev/null
@@ -1,220 +0,0 @@
-package parser
-
-import "testing"
-
-// Derived coastline-coincident boundary masking
-// (S-57 Appendix B.1 Annex A §17 scenario 2).
-//
-// These tests build synthetic spatial + feature records where a COALNE line and an
-// area (DEPARE / LNDARE) are built from the SAME edge RCID — exactly the situation
-// in real NOAA cells where the producer never sets FSPT MASK=1. The area's drawn
-// boundary edge that shares a COALNE edge RCID must be dropped from BoundaryLines
-// when masking is on, while the fill (Rings) and flat Coordinates keep it.
-
-// squareAreaRecords returns the spatial records for a unit square boundary made of
-// four direct edges (RCIDs 10..13). Edge 10 is the SHARED coastline edge
-// (bottom: (0,0)->(1,0)); the other three close the ring. Connected nodes carry
-// the corner coordinates so getFullEdgeCoordinates yields full edge geometry.
-func squareAreaRecords() map[spatialKey]*spatialRecord {
- node := func(id int64, lon, lat float64) *spatialRecord {
- return &spatialRecord{ID: id, RecordType: spatialTypeConnectedNode, Coordinates: [][]float64{{lon, lat}}}
- }
- return map[spatialKey]*spatialRecord{
- // Corner nodes of the unit square.
- {RCNM: int(spatialTypeConnectedNode), RCID: 100}: node(100, 0, 0),
- {RCNM: int(spatialTypeConnectedNode), RCID: 101}: node(101, 1, 0),
- {RCNM: int(spatialTypeConnectedNode), RCID: 102}: node(102, 1, 1),
- {RCNM: int(spatialTypeConnectedNode), RCID: 103}: node(103, 0, 1),
- // Four boundary edges. Edge 10 is the shared coastline edge (bottom).
- {RCNM: int(spatialTypeEdge), RCID: 10}: edgeRecord(10, nil, 100, 101), // (0,0)->(1,0) SHARED
- {RCNM: int(spatialTypeEdge), RCID: 11}: edgeRecord(11, nil, 101, 102), // (1,0)->(1,1)
- {RCNM: int(spatialTypeEdge), RCID: 12}: edgeRecord(12, nil, 102, 103), // (1,1)->(0,1)
- {RCNM: int(spatialTypeEdge), RCID: 13}: edgeRecord(13, nil, 103, 100), // (0,1)->(0,0)
- }
-}
-
-// squareAreaFeature builds an area (PRIM=3) feature whose boundary is the four
-// direct edges of the unit square. The object-class code is cosmetic here:
-// constructPolygonGeometry decides masking from its maskCoast argument, not the
-// object class (buildChart applies the LNDARE exemption before calling it).
-func squareAreaFeature(objClass int) *featureRecord {
- return &featureRecord{
- ObjectClass: objClass,
- GeomPrim: 3, // area
- SpatialRefs: []spatialRef{
- {RCNM: int(spatialTypeEdge), RCID: 10, Orientation: 1, Usage: 1},
- {RCNM: int(spatialTypeEdge), RCID: 11, Orientation: 1, Usage: 1},
- {RCNM: int(spatialTypeEdge), RCID: 12, Orientation: 1, Usage: 1},
- {RCNM: int(spatialTypeEdge), RCID: 13, Orientation: 1, Usage: 1},
- },
- }
-}
-
-// ringsHavePoint reports whether any ring's coordinates contain [lon,lat].
-func ringsHavePoint(rings []Ring, lon, lat float64) bool {
- for _, ring := range rings {
- if flatHasPoint(ring.Coordinates, lon, lat) {
- return true
- }
- }
- return false
-}
-
-// TestCoastlineMaskingDropsSharedEdgeFromBoundaryOnly verifies that, with derived
-// masking ON, a DEPARE area sharing edge 10 with COALNE drops that edge from its
-// drawn BoundaryLines, but keeps the shared edge in its fill Rings (and flat
-// Coordinates). The three non-shared edges remain drawn.
-func TestCoastlineMaskingDropsSharedEdgeFromBoundaryOnly(t *testing.T) {
- spatialRecords := squareAreaRecords()
- feat := squareAreaFeature(42)
-
- coalneEdges := map[int64]bool{10: true} // edge 10 is referenced by COALNE
-
- g, err := constructPolygonGeometry(feat, spatialRecords, coalneEdges, true)
- if err != nil {
- t.Fatal(err)
- }
-
- // The shared edge 10 spans (0,0)->(1,0); its UNIQUE midpoint-distinguishing
- // endpoint relative to the other edges is (1,0)... but (1,0) is shared with
- // edge 11's start. The unambiguous test is: the bottom segment (0,0)->(1,0)
- // must not appear as a drawn boundary part. Check that no boundary part
- // contains BOTH (0,0) and (1,0).
- for i, part := range g.BoundaryLines {
- var has00, has10 bool
- for _, p := range part {
- if p[0] == 0 && p[1] == 0 {
- has00 = true
- }
- if p[0] == 1 && p[1] == 0 {
- has10 = true
- }
- }
- if has00 && has10 {
- t.Fatalf("boundary part %d still draws the shared coastline edge (0,0)->(1,0): %v", i, part)
- }
- }
-
- // Exactly the three non-shared edges remain drawn.
- if len(g.BoundaryLines) != 3 {
- t.Fatalf("expected 3 drawable boundary edges (shared one dropped), got %d: %v", len(g.BoundaryLines), g.BoundaryLines)
- }
-
- // The three non-shared edges' distinctive coordinates are still drawn.
- if !hasPoint(g.BoundaryLines, 1, 1) {
- t.Errorf("non-shared edge corner (1,1) missing from BoundaryLines")
- }
- if !hasPoint(g.BoundaryLines, 0, 1) {
- t.Errorf("non-shared edge corner (0,1) missing from BoundaryLines")
- }
-
- // The fill (Rings) MUST still include the shared edge's geometry — both its
- // endpoints (0,0) and (1,0) appear in the ring.
- if !ringsHavePoint(g.Rings, 0, 0) || !ringsHavePoint(g.Rings, 1, 0) {
- t.Errorf("fill Rings dropped the shared edge geometry; Rings=%v", g.Rings)
- }
- // Flat Coordinates (backward-compat fill) likewise keep the shared edge.
- if !flatHasPoint(g.Coordinates, 0, 0) || !flatHasPoint(g.Coordinates, 1, 0) {
- t.Errorf("flat Coordinates dropped the shared edge geometry; Coordinates=%v", g.Coordinates)
- }
-}
-
-// TestCoastlineMaskingOffKeepsSharedEdge verifies that with the option OFF the
-// DEPARE keeps the shared edge in BoundaryLines (all four edges drawn).
-func TestCoastlineMaskingOffKeepsSharedEdge(t *testing.T) {
- spatialRecords := squareAreaRecords()
- feat := squareAreaFeature(42)
- coalneEdges := map[int64]bool{10: true}
-
- g, err := constructPolygonGeometry(feat, spatialRecords, coalneEdges, false)
- if err != nil {
- t.Fatal(err)
- }
-
- if len(g.BoundaryLines) != 4 {
- t.Fatalf("masking OFF: expected 4 drawable boundary edges, got %d: %v", len(g.BoundaryLines), g.BoundaryLines)
- }
- // The shared bottom edge (0,0)->(1,0) must still be drawn as one part.
- var found bool
- for _, part := range g.BoundaryLines {
- var has00, has10 bool
- for _, p := range part {
- if p[0] == 0 && p[1] == 0 {
- has00 = true
- }
- if p[0] == 1 && p[1] == 0 {
- has10 = true
- }
- }
- if has00 && has10 {
- found = true
- }
- }
- if !found {
- t.Errorf("masking OFF: shared coastline edge (0,0)->(1,0) should still be drawn; BoundaryLines=%v", g.BoundaryLines)
- }
-}
-
-// TestCoastDefinerSet locks in which classes define the coast. They are BOTH the
-// edge-set contributors (buildChart) AND exempt from masking. COALNE alone left
-// stray boundary "chevrons" where the NOAA land/water line is encoded only as an
-// LNDARE or SLCONS edge, so the set was widened to all three.
-func TestCoastDefinerSet(t *testing.T) {
- for _, c := range []string{"COALNE", "LNDARE", "SLCONS"} {
- if !isCoastlineMaskExempt(c) {
- t.Errorf("%s must be a coast-definer (exempt + edge-set contributor)", c)
- }
- }
- for _, c := range []string{"DEPARE", "SEAARE", "RESARE", "CTNARE"} {
- if isCoastlineMaskExempt(c) {
- t.Errorf("%s must NOT be a coast-definer", c)
- }
- }
-}
-
-// TestCoastlineMaskingExemptsLNDARE verifies the coast-definer exemption: an LNDARE
-// area sharing the same edge KEEPS it in BoundaryLines even when masking is on. The
-// exemption is enforced by buildChart (which sets maskCoast=false for LNDARE), so we
-// call constructPolygonGeometry with maskCoast=false to mirror that path AND assert
-// isCoastlineMaskExempt classifies LNDARE.
-func TestCoastlineMaskingExemptsLNDARE(t *testing.T) {
- if !isCoastlineMaskExempt("LNDARE") {
- t.Fatalf("LNDARE must be exempt from coastline masking")
- }
- if isCoastlineMaskExempt("DEPARE") {
- t.Fatalf("DEPARE must NOT be exempt from coastline masking")
- }
-
- spatialRecords := squareAreaRecords()
- feat := squareAreaFeature(71)
- coalneEdges := map[int64]bool{10: true}
-
- // buildChart computes maskCoast=false for exempt classes; mirror that here.
- maskCoast := !isCoastlineMaskExempt("LNDARE") // = false
- g, err := constructPolygonGeometry(feat, spatialRecords, coalneEdges, maskCoast)
- if err != nil {
- t.Fatal(err)
- }
-
- if len(g.BoundaryLines) != 4 {
- t.Fatalf("LNDARE (exempt): expected 4 drawable boundary edges, got %d: %v", len(g.BoundaryLines), g.BoundaryLines)
- }
- var found bool
- for _, part := range g.BoundaryLines {
- var has00, has10 bool
- for _, p := range part {
- if p[0] == 0 && p[1] == 0 {
- has00 = true
- }
- if p[0] == 1 && p[1] == 0 {
- has10 = true
- }
- }
- if has00 && has10 {
- found = true
- }
- }
- if !found {
- t.Errorf("LNDARE (exempt): shared coastline edge (0,0)->(1,0) should still be drawn; BoundaryLines=%v", g.BoundaryLines)
- }
-}
diff --git a/internal/s57/parser/conformance.go b/internal/s57/parser/conformance.go
deleted file mode 100644
index 85d070e..0000000
--- a/internal/s57/parser/conformance.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package parser
-
-// conformance.go — non-fatal S-57 / ISO-8211 spec-conformance checks.
-//
-// A chart *reader* has two obligations that pull in opposite directions: it must
-// interpret conformant data per the spec, and it must still render the real-world
-// cells it is handed (which are not always conformant — S-58 exists precisely
-// because producers deviate). So these checks DETECT and REPORT deviations (the
-// S-58 spirit) without rejecting the cell: parsing continues and the chart still
-// renders. The collected warnings are attached to the Chart (see Chart.Warnings);
-// callers that want strict behaviour set ParseOptions.ValidateConformance, which
-// promotes any warning to a parse error.
-//
-// Spec citations are to S-57 Edition 3.1 Part 3 (31Main.pdf) and its Annex A
-// (ISO/IEC 8211), unless noted.
-
-import (
- "fmt"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-// ConformanceWarning is one detected spec deviation. It is non-fatal.
-type ConformanceWarning struct {
- Clause string // governing spec clause, e.g. "8.4.2.1" or "ISO8211 A.2.2"
- Code string // short stable identifier, e.g. "RVER_SEQUENCE"
- Message string // human-readable detail (from the first occurrence)
- Count int // number of times this code was hit during the parse
-}
-
-func (w ConformanceWarning) String() string {
- if w.Count > 1 {
- return fmt.Sprintf("[%s §%s] %s (×%d)", w.Code, w.Clause, w.Message, w.Count)
- }
- return fmt.Sprintf("[%s §%s] %s", w.Code, w.Clause, w.Message)
-}
-
-// conformance accumulates warnings, deduplicated by Code so a deviation that
-// recurs across thousands of records (e.g. a per-DR leader value) reports once
-// with a count instead of flooding the list.
-type conformance struct {
- seen map[string]int // Code -> index into list
- list []ConformanceWarning
-}
-
-// add records a deviation. Safe on a nil receiver (checks are no-ops when the
-// collector was not created).
-func (c *conformance) add(clause, code, msg string) {
- if c == nil {
- return
- }
- if c.seen == nil {
- c.seen = make(map[string]int)
- }
- if i, ok := c.seen[code]; ok {
- c.list[i].Count++
- return
- }
- c.seen[code] = len(c.list)
- c.list = append(c.list, ConformanceWarning{Clause: clause, Code: code, Message: msg, Count: 1})
-}
-
-func (c *conformance) addf(clause, code, format string, args ...any) {
- c.add(clause, code, fmt.Sprintf(format, args...))
-}
-
-// warnings returns the accumulated deviations (nil-safe).
-func (c *conformance) warnings() []ConformanceWarning {
- if c == nil {
- return nil
- }
- return c.list
-}
-
-// asError aggregates all warnings into a single error for strict mode, or nil if
-// none were collected.
-func (c *conformance) asError() error {
- if c == nil || len(c.list) == 0 {
- return nil
- }
- parts := make([]string, len(c.list))
- for i, w := range c.list {
- parts[i] = w.String()
- }
- return fmt.Errorf("S-57 conformance: %d deviation(s): %s", len(c.list), strings.Join(parts, "; "))
-}
-
-// isNullPtrField reports whether v is the S-57 "null" value {255} used for the
-// ORNT/USAG/MASK/TOPI subfields when not applicable.
-func isNullPtrField(v int) bool { return v == 255 }
-
-// validateLeaders checks the ISO/IEC 8211 leader fixed values mandated for S-57
-// (Part 3 Annex A.2.2, Tables A.1 / A.3). The DDR (LeaderIdentifier 'L') and the
-// data records ('D') carry different fixed values; deviations are reported but do
-// not stop parsing (the leader was already structurally usable to get here).
-func validateLeaders(f *iso8211.ISO8211File, c *conformance) {
- if c == nil || f == nil {
- return
- }
- if f.DDR != nil && f.DDR.Leader != nil {
- l := f.DDR.Leader
- // DDR fixed values (Table A.1).
- if l.InterchangeLevel != '3' {
- c.addf("ISO8211 A.2.2", "DDR_INTERCHANGE_LEVEL", "DDR interchange level must be '3', got %q", string(l.InterchangeLevel))
- }
- if l.VersionNumber != '1' {
- c.addf("ISO8211 A.2.2", "DDR_VERSION", "DDR version number must be '1', got %q", string(l.VersionNumber))
- }
- if l.ApplicationIndicator != ' ' {
- c.addf("ISO8211 A.2.2", "DDR_APP_INDICATOR", "DDR application indicator must be SPACE, got %q", string(l.ApplicationIndicator))
- }
- if l.ExtendedCharSet != [3]byte{' ', '!', ' '} {
- c.addf("ISO8211 A.2.2", "DDR_EXT_CHARSET", "DDR extended character set must be ' ! ', got %q", string(l.ExtendedCharSet[:]))
- }
- validateCommonLeader(l, c, "DDR")
- }
- // Data-record leaders (Table A.3): interchange level, version and application
- // indicator are SPACE; extended char set is three SPACEs.
- for _, r := range f.Records {
- if r == nil || r.Leader == nil {
- continue
- }
- l := r.Leader
- if l.InterchangeLevel != ' ' {
- c.addf("ISO8211 A.2.2", "DR_INTERCHANGE_LEVEL", "DR interchange level must be SPACE, got %q", string(l.InterchangeLevel))
- }
- if l.VersionNumber != ' ' {
- c.addf("ISO8211 A.2.2", "DR_VERSION", "DR version number must be SPACE, got %q", string(l.VersionNumber))
- }
- if l.ExtendedCharSet != [3]byte{' ', ' ', ' '} {
- c.addf("ISO8211 A.2.2", "DR_EXT_CHARSET", "DR extended character set must be 3 SPACEs, got %q", string(l.ExtendedCharSet[:]))
- }
- validateCommonLeader(l, c, "DR")
- }
-}
-
-// validateCommonLeader checks leader fields fixed across both DDR and DR.
-func validateCommonLeader(l *iso8211.Leader, c *conformance, kind string) {
- if l.Reserved != '0' {
- c.addf("ISO8211 A.2.2", kind+"_RESERVED", "%s leader reserved byte must be '0', got %q", kind, string(l.Reserved))
- }
- if l.SizeOfFieldTag != 4 {
- c.addf("ISO8211 A.2.2", kind+"_TAG_SIZE", "%s size of field tag must be 4 for S-57, got %d", kind, l.SizeOfFieldTag)
- }
-}
-
-// validateFeatureConformance checks a feature record's FSPT pointer subfields
-// against the geometric-primitive rules in Part 3 §4.7 (and winding.md). The
-// values are interpreted regardless; this only flags out-of-domain values.
-func validateFeatureConformance(fr *featureRecord, c *conformance) {
- if c == nil || fr == nil {
- return
- }
- for _, ref := range fr.SpatialRefs {
- // Domain checks for ORNT/USAG/MASK (Part 3 §4.7.3, table 7.31 domains).
- if ref.Orientation != 1 && ref.Orientation != 2 && !isNullPtrField(ref.Orientation) {
- c.addf("4.7.3", "FSPT_ORNT_DOMAIN", "FSPT ORNT must be 1, 2 or 255, got %d", ref.Orientation)
- }
- if ref.Usage != 1 && ref.Usage != 2 && ref.Usage != 3 && !isNullPtrField(ref.Usage) {
- c.addf("4.7.3", "FSPT_USAG_DOMAIN", "FSPT USAG must be 1, 2, 3 or 255, got %d", ref.Usage)
- }
- if ref.Mask != 1 && ref.Mask != 2 && !isNullPtrField(ref.Mask) {
- c.addf("4.7.3", "FSPT_MASK_DOMAIN", "FSPT MASK must be 1, 2 or 255, got %d", ref.Mask)
- }
- // Point features: ORNT/USAG/MASK must be null {255} (§4.7.1, winding.md §4.7.1).
- if fr.GeomPrim == 1 {
- if !isNullPtrField(ref.Orientation) || !isNullPtrField(ref.Usage) || !isNullPtrField(ref.Mask) {
- c.add("4.7.1", "FSPT_POINT_NOT_NULL", "point feature FSPT ORNT/USAG/MASK must all be null {255}")
- }
- }
- }
-}
-
-// validateSpatialConformance checks a vector record's VRPT pointer subfields
-// against the domains in Part 3 §7.7.1.4 (table 7.31).
-func validateSpatialConformance(sr *spatialRecord, c *conformance) {
- if c == nil || sr == nil {
- return
- }
- for _, ptr := range sr.VectorPointers {
- if ptr.Orientation != 1 && ptr.Orientation != 2 && !isNullPtrField(ptr.Orientation) {
- c.addf("7.7.1.4", "VRPT_ORNT_DOMAIN", "VRPT ORNT must be 1, 2 or 255, got %d", ptr.Orientation)
- }
- if ptr.Usage != 1 && ptr.Usage != 2 && ptr.Usage != 3 && !isNullPtrField(ptr.Usage) {
- c.addf("7.7.1.4", "VRPT_USAG_DOMAIN", "VRPT USAG must be 1, 2, 3 or 255, got %d", ptr.Usage)
- }
- if ptr.Topology < 1 && !isNullPtrField(ptr.Topology) || ptr.Topology > 4 && !isNullPtrField(ptr.Topology) {
- c.addf("7.7.1.4", "VRPT_TOPI_DOMAIN", "VRPT TOPI must be 1..4 or 255, got %d", ptr.Topology)
- }
- if ptr.Mask != 1 && ptr.Mask != 2 && !isNullPtrField(ptr.Mask) {
- c.addf("7.7.1.4", "VRPT_MASK_DOMAIN", "VRPT MASK must be 1, 2 or 255, got %d", ptr.Mask)
- }
- }
-}
diff --git a/internal/s57/parser/conformance_test.go b/internal/s57/parser/conformance_test.go
deleted file mode 100644
index 3439638..0000000
--- a/internal/s57/parser/conformance_test.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package parser
-
-import (
- "strings"
- "testing"
-)
-
-// TestConformanceCollectorDedup verifies that repeated deviations of the same
-// Code collapse to one entry with an occurrence count, while distinct codes are
-// kept separately and in first-seen order.
-func TestConformanceCollectorDedup(t *testing.T) {
- c := &conformance{}
- c.add("4.7.1", "A", "first")
- c.add("4.7.1", "A", "second") // same code → bumps count, keeps first message
- c.addf("7.7.1.4", "B", "val=%d", 9)
-
- ws := c.warnings()
- if len(ws) != 2 {
- t.Fatalf("want 2 unique warnings, got %d: %+v", len(ws), ws)
- }
- if ws[0].Code != "A" || ws[0].Count != 2 || ws[0].Message != "first" {
- t.Errorf("dedup wrong: %+v", ws[0])
- }
- if ws[1].Code != "B" || ws[1].Message != "val=9" {
- t.Errorf("addf wrong: %+v", ws[1])
- }
-}
-
-// TestConformanceNilSafe confirms the checks are no-ops on a nil collector, so
-// callers that don't track conformance pay nothing and never panic.
-func TestConformanceNilSafe(t *testing.T) {
- var c *conformance
- c.add("x", "y", "z") // must not panic
- if c.warnings() != nil {
- t.Error("nil collector should yield nil warnings")
- }
- if c.asError() != nil {
- t.Error("nil collector should yield nil error")
- }
-}
-
-// TestConformanceAsError aggregates warnings into a single strict-mode error.
-func TestConformanceAsError(t *testing.T) {
- c := &conformance{}
- if c.asError() != nil {
- t.Fatal("empty collector should produce no error")
- }
- c.add("8.4.2.1", "RVER_SEQUENCE_FEATURE", "gap")
- err := c.asError()
- if err == nil || !strings.Contains(err.Error(), "RVER_SEQUENCE_FEATURE") {
- t.Fatalf("strict error should mention the code, got %v", err)
- }
-}
-
-// TestValidatePointFeaturePointerRule checks the §4.7.1 rule that a point
-// feature's FSPT ORNT/USAG/MASK must all be null {255}.
-func TestValidatePointFeaturePointerRule(t *testing.T) {
- c := &conformance{}
- // Conformant point feature: all pointer subfields null.
- validateFeatureConformance(&featureRecord{
- GeomPrim: 1,
- SpatialRefs: []spatialRef{{RCNM: 110, Orientation: 255, Usage: 255, Mask: 255}},
- }, c)
- if len(c.warnings()) != 0 {
- t.Fatalf("conformant point feature should not warn: %+v", c.warnings())
- }
-
- // Non-conformant: a point feature carrying USAG=1 (exterior) is wrong.
- c2 := &conformance{}
- validateFeatureConformance(&featureRecord{
- GeomPrim: 1,
- SpatialRefs: []spatialRef{{RCNM: 110, Orientation: 255, Usage: 1, Mask: 255}},
- }, c2)
- if !hasCode(c2, "FSPT_POINT_NOT_NULL") {
- t.Errorf("expected FSPT_POINT_NOT_NULL, got %+v", c2.warnings())
- }
-}
-
-// TestValidateDomainRanges checks out-of-domain ORNT/USAG/MASK/TOPI values.
-func TestValidateDomainRanges(t *testing.T) {
- c := &conformance{}
- validateFeatureConformance(&featureRecord{
- GeomPrim: 3,
- SpatialRefs: []spatialRef{{RCNM: 130, Orientation: 9, Usage: 7, Mask: 4}},
- }, c)
- for _, code := range []string{"FSPT_ORNT_DOMAIN", "FSPT_USAG_DOMAIN", "FSPT_MASK_DOMAIN"} {
- if !hasCode(c, code) {
- t.Errorf("expected %s, got %+v", code, c.warnings())
- }
- }
-
- cs := &conformance{}
- validateSpatialConformance(&spatialRecord{
- VectorPointers: []vectorPointer{{TargetRCNM: 120, Orientation: 9, Usage: 7, Topology: 8, Mask: 4}},
- }, cs)
- for _, code := range []string{"VRPT_ORNT_DOMAIN", "VRPT_USAG_DOMAIN", "VRPT_TOPI_DOMAIN", "VRPT_MASK_DOMAIN"} {
- if !hasCode(cs, code) {
- t.Errorf("expected %s, got %+v", code, cs.warnings())
- }
- }
-}
-
-func hasCode(c *conformance, code string) bool {
- for _, w := range c.warnings() {
- if w.Code == code {
- return true
- }
- }
- return false
-}
-
-// TestRealCellConformance parses a real NOAA cell and exercises both the
-// warn-and-render default and strict mode. A genuine NOAA ENC is expected to be
-// largely conformant; this guards against the checks spuriously flagging valid
-// data (which would flood every chart). It logs whatever is flagged for insight.
-func TestRealCellConformance(t *testing.T) {
- const cell = "../../../testdata/US4MD81M.000"
- p := NewParser()
-
- chart, err := p.ParseWithOptions(cell, DefaultParseOptions())
- if err != nil {
- t.Fatalf("default parse must succeed (warn-and-render): %v", err)
- }
- ws := chart.Warnings()
- t.Logf("%s: %d conformance warning(s)", cell, len(ws))
- for _, w := range ws {
- t.Logf(" %s", w.String())
- }
- // A conformant NOAA base cell should not trip the leader checks at all.
- for _, w := range ws {
- if strings.HasPrefix(w.Code, "DDR_") || strings.HasPrefix(w.Code, "DR_") {
- t.Errorf("unexpected ISO-8211 leader deviation on a real NOAA cell: %s", w.String())
- }
- }
-
- // Strict mode: errors iff the cell had any deviation; otherwise parses clean.
- strict := DefaultParseOptions()
- strict.ValidateConformance = true
- _, serr := p.ParseWithOptions(cell, strict)
- if len(ws) == 0 && serr != nil {
- t.Errorf("strict mode should succeed on a clean cell, got %v", serr)
- }
- if len(ws) > 0 && serr == nil {
- t.Errorf("strict mode should fail when warnings exist (%d)", len(ws))
- }
-}
diff --git a/internal/s57/parser/dataset.go b/internal/s57/parser/dataset.go
deleted file mode 100644
index 57fc289..0000000
--- a/internal/s57/parser/dataset.go
+++ /dev/null
@@ -1,251 +0,0 @@
-package parser
-
-import (
- "encoding/binary"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-// datasetMetadata contains dataset identification and metadata from DSID record.
-// This is private - access metadata through Chart methods.
-//
-// Reference: S-57 Part 3 §7.3.1.1 (31Main.pdf p64, table 7.4):
-// Data Set Identification field structure with all subfields.
-type datasetMetadata struct {
- rcnm int // Record name (10 = dataset)
- rcid int64 // Record identification number
- expp int // Exchange purpose (1=New, 2=Revision)
- intu int // Intended usage
- dsnm string // Data set name - chart identifier (e.g., "GB5X01NE")
- edtn string // Edition number (e.g., "2")
- updn string // Update number (e.g., "0" for base cell)
- uadt string // Update application date (YYYYMMDD format)
- isdt string // Issue date (YYYYMMDD format)
- sted string // Edition number of S-57 (e.g., "03.1")
- prsp int // Product specification (1=ENC, 2=ODD)
- psdn string // Product specification description
- pred string // Product specification edition number
- prof int // Application profile (1=EN new, 2=ER revision, 3=DD)
- agen int // Producing agency code
- comt string // Comment field
-}
-
-// DatasetName returns the dataset name (chart identifier).
-// Example: "US5MA22M", "GB5X01NE"
-func (m *datasetMetadata) DatasetName() string {
- return m.dsnm
-}
-
-// Edition returns the edition number as a string.
-func (m *datasetMetadata) Edition() string {
- return m.edtn
-}
-
-// UpdateNumber returns the update number as a string.
-func (m *datasetMetadata) UpdateNumber() string {
- return m.updn
-}
-
-// UpdateDate returns the update application date (YYYYMMDD format).
-func (m *datasetMetadata) UpdateDate() string {
- return m.uadt
-}
-
-// IssueDate returns the issue date (YYYYMMDD format).
-func (m *datasetMetadata) IssueDate() string {
- return m.isdt
-}
-
-// S57Edition returns the S-57 standard edition (e.g., "03.1").
-func (m *datasetMetadata) S57Edition() string {
- return m.sted
-}
-
-// ProducingAgency returns the agency code.
-func (m *datasetMetadata) ProducingAgency() int {
- return m.agen
-}
-
-// Comment returns the comment field.
-func (m *datasetMetadata) Comment() string {
- return m.comt
-}
-
-// ExchangePurpose returns a human-readable exchange purpose string.
-func (m *datasetMetadata) ExchangePurpose() string {
- switch m.expp {
- case 1:
- return "New"
- case 2:
- return "Revision"
- default:
- return "Unknown"
- }
-}
-
-// ProductSpecification returns a human-readable product specification string.
-func (m *datasetMetadata) ProductSpecification() string {
- switch m.prsp {
- case 1:
- return "ENC"
- case 2:
- return "ODD"
- default:
- return "Unknown"
- }
-}
-
-// ApplicationProfile returns a human-readable application profile string.
-func (m *datasetMetadata) ApplicationProfile() string {
- switch m.prof {
- case 1:
- return "EN (ENC New)"
- case 2:
- return "ER (ENC Revision)"
- case 3:
- return "DD (Data Dictionary)"
- default:
- return "Unknown"
- }
-}
-
-// datasetParams holds dataset-level parameters from DSPM record
-// S-57 §7.3.2 (31Main.pdf p66): Data Set Parameter Record
-type datasetParams struct {
- COMF int32 // Coordinate multiplication factor (typically 10^7)
- SOMF int32 // Sounding (3D) multiplication factor (typically 10)
- HDAT int // Horizontal geodetic datum
- VDAT int // Vertical datum
- SDAT int // Sounding datum
- CSCL int32 // Compilation scale
- DUNI int // Depth units: 1=meters, 2=fathoms+feet, 3=feet, 4=fathoms+fractions
- COUN int // Coordinate units: 1=lat/lon, 2=projected
-
- // comfDefaulted/somfDefaulted record that the raw DSPM multiplier was missing
- // or non-positive and a standard default was substituted (reported as a
- // conformance warning, not an error).
- comfDefaulted bool
- somfDefaulted bool
-}
-
-// defaultDatasetParams returns default parameters when DSPM is not found
-func defaultDatasetParams() datasetParams {
- return datasetParams{
- COMF: 10000000, // 10^7 - standard for lat/lon in units of 0.00000001 degrees
- SOMF: 10, // Standard for depth in decimeters
- }
-}
-
-// extractDatasetParams extracts DSPM record parameters
-// S-57 §7.3.2.1 (31Main.pdf p66): DSPM field structure
-func extractDatasetParams(isoFile *iso8211.ISO8211File) datasetParams {
- params := defaultDatasetParams()
-
- // Look for DSPM record (Data Set Parameters)
- for _, record := range isoFile.Records {
- if dspmData, ok := record.Fields["DSPM"]; ok {
- params = parseDSPM(dspmData)
- break // Use first DSPM found
- }
- }
-
- return params
-}
-
-// parseDSPM parses the DSPM field per S-57 §7.3.2.1 (31Main.pdf p66)
-// Binary format:
-//
-// RCNM (1 byte) - Record name, value 20 = dataset parameters
-// RCID (4 bytes) - Record ID (uint32 LE)
-// HDAT (1 byte) - Horizontal datum
-// VDAT (1 byte) - Vertical datum
-// SDAT (1 byte) - Sounding datum
-// CSCL (4 bytes) - Compilation scale (uint32 LE)
-// DUNI (1 byte) - Depth units
-// HUNI (1 byte) - Height units
-// PUNI (1 byte) - Position units
-// COUN (1 byte) - Coordinate units
-// COMF (4 bytes) - Coordinate multiplication factor (int32 LE)
-// SOMF (4 bytes) - Sounding multiplication factor (int32 LE)
-// COMT (variable) - Comment
-func parseDSPM(data []byte) datasetParams {
- params := defaultDatasetParams()
-
- // Minimum size check: RCNM(1) + RCID(4) + HDAT(1) + VDAT(1) + SDAT(1) + CSCL(4)
- // + DUNI(1) + HUNI(1) + PUNI(1) + COUN(1) + COMF(4) + SOMF(4) = 24 bytes
- if len(data) < 24 {
- return params
- }
-
- // Check RCNM (should be 20 for DSPM)
- rcnm := data[0]
- if rcnm != 20 {
- return params
- }
-
- // Extract fields at fixed offsets
- offset := 1 // Skip RCNM
-
- // RCID (4 bytes) - not used currently
- offset += 4
-
- // HDAT (1 byte)
- params.HDAT = int(data[offset])
- offset++
-
- // VDAT (1 byte)
- params.VDAT = int(data[offset])
- offset++
-
- // SDAT (1 byte)
- params.SDAT = int(data[offset])
- offset++
-
- // CSCL (4 bytes)
- params.CSCL = int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
- offset += 4
-
- // DUNI (1 byte) - Depth units
- params.DUNI = int(data[offset])
- offset++
-
- // Skip HUNI, PUNI (2 bytes total)
- offset += 2
-
- // COUN (1 byte) - Coordinate units
- params.COUN = int(data[offset])
- offset++
-
- // COMF (4 bytes) - int32 signed
- if offset+4 <= len(data) {
- params.COMF = int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
- offset += 4
- }
-
- // SOMF (4 bytes) - int32 signed
- if offset+4 <= len(data) {
- params.SOMF = int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
- }
-
- // Validation: If COMF is 0 or negative, use default (and flag the deviation —
- // §7.3.2.1 requires a positive multiplier). Flagging lets the parser emit a
- // conformance warning while still rendering with the standard factor.
- if params.COMF <= 0 {
- params.COMF = 10000000
- params.comfDefaulted = true
- }
- if params.SOMF <= 0 {
- params.SOMF = 10
- params.somfDefaulted = true
- }
-
- return params
-}
-
-// convertCoordinate converts an integer coordinate to float64 using COMF
-func convertCoordinate(value int32, comf int32) float64 {
- if comf <= 0 {
- comf = 10000000 // Default if invalid
- }
- return float64(value) / float64(comf)
-}
diff --git a/internal/s57/parser/errors.go b/internal/s57/parser/errors.go
deleted file mode 100644
index a11aac4..0000000
--- a/internal/s57/parser/errors.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package parser
-
-import (
- "fmt"
-)
-
-// ErrInvalidCoordinate indicates coordinate out of valid bounds
-type ErrInvalidCoordinate struct {
- Lat, Lon float64
-}
-
-func (e *ErrInvalidCoordinate) Error() string {
- return fmt.Sprintf("invalid coordinate: lat=%f lon=%f (lat must be ±90, lon must be ±180)",
- e.Lat, e.Lon)
-}
-
-// ErrUnknownObjectClass indicates unsupported S-57 object class
-type ErrUnknownObjectClass struct {
- Code int
-}
-
-func (e *ErrUnknownObjectClass) Error() string {
- return fmt.Sprintf("unknown S-57 object class: %d", e.Code)
-}
-
-// ErrInvalidGeometry indicates geometry violates S-57 rules
-type ErrInvalidGeometry struct {
- Type GeometryType
- Reason string
-}
-
-func (e *ErrInvalidGeometry) Error() string {
- if e.Type != 0 {
- return fmt.Sprintf("invalid geometry (%v): %s", e.Type, e.Reason)
- }
- return fmt.Sprintf("invalid geometry: %s", e.Reason)
-}
-
-// ErrMissingSpatialRecord indicates FSPT pointer references non-existent spatial record
-type ErrMissingSpatialRecord struct {
- FeatureID int64
- SpatialID int64
-}
-
-func (e *ErrMissingSpatialRecord) Error() string {
- return fmt.Sprintf("feature %d references missing spatial record %d",
- e.FeatureID, e.SpatialID)
-}
-
-// ErrInvalidSpatialRecord indicates spatial record is not of expected type
-type ErrInvalidSpatialRecord struct {
- SpatialID int64
- Reason string
-}
-
-func (e *ErrInvalidSpatialRecord) Error() string {
- return fmt.Sprintf("invalid spatial record %d: %s", e.SpatialID, e.Reason)
-}
diff --git a/internal/s57/parser/feature.go b/internal/s57/parser/feature.go
deleted file mode 100644
index 163cfb4..0000000
--- a/internal/s57/parser/feature.go
+++ /dev/null
@@ -1,207 +0,0 @@
-package parser
-
-import (
- "encoding/binary"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-// Feature represents a navigational object extracted from S-57 chart data
-// S-57 §2.1 (31Main.pdf p23): Feature objects contain geometric and attribute information
-type Feature struct {
- // ID is the unique feature identifier from the FRID record
- ID int64
- // ObjectClass is the S-57 object class code (e.g., "DEPCNT", "DEPARE", "BOYCAR")
- ObjectClass string
- // Geometry is the spatial representation of the feature
- Geometry Geometry
- // Attributes contains feature attributes as key-value pairs
- // Common attributes: DRVAL1 (depth), COLOUR (color), OBJNAM (name)
- Attributes map[string]interface{}
-}
-
-// spatialRef represents a feature-to-spatial pointer with orientation
-// S-57 §7.6.8 (31Main.pdf p77): FSPT field contains a NAME (RCNM + RCID) + ORNT
-// + USAG + MASK per pointer.
-type spatialRef struct {
- RCNM int // Target spatial record type (110=isolated node, 120=connected node, 130=edge)
- RCID int64 // Spatial record ID (only unique WITHIN an RCNM)
- Orientation int // 1=Forward, 2=Reverse, 255=Null
- Usage int // 1=Exterior, 2=Interior, 3=Exterior truncated
- Mask int // 1=Mask, 2=Show, 255=Null
-}
-
-// featureRecord represents a parsed S-57 feature record
-// S-57 §7.6 (31Main.pdf p74): Feature records contain feature identification and attributes
-type featureRecord struct {
- ID int64 // Feature ID from FOID (for backward compatibility, use FIDN)
- AGEN uint16 // Producing agency from FOID
- FIDN uint32 // Feature identification number from FOID
- FIDS uint16 // Feature identification subdivision from FOID
- ObjectClass int // S-57 object class code (DEPARE=42, etc.)
- GeomPrim int // Geometric primitive from FRID (1=Point, 2=Line, 3=Area, 255=N/A)
- Group int // Group code from FRID
- RecordVersion int // RVER - record version number
- UpdateInstr int // RUIN - update instruction
- Attributes map[string]interface{} // Feature attributes
- SpatialRefs []spatialRef // References to spatial records (from FSPT) with orientation
-}
-
-// parseFeatureRecord extracts feature data from an ISO 8211 record
-// Returns nil if record is not a feature record
-// S-57 §7.6.1 (31Main.pdf p74): Feature records identified by FRID field
-func parseFeatureRecord(record *iso8211.DataRecord) *featureRecord {
- // Check if this is a feature record (has FRID field)
- fridData, hasFRID := record.Fields["FRID"]
- if !hasFRID || len(fridData) < 12 {
- return nil // Need 12 bytes: RCNM(1) + RCID(4) + PRIM(1) + GRUP(1) + OBJL(2) + RVER(2) + RUIN(1)
- }
-
- // Parse FRID (Feature Record Identifier) per S-57 §7.6.1 (31Main.pdf p74)
- // Binary structure (12 bytes total):
- // Byte 0: RCNM (1 byte) - Record name, value 100 = feature record
- // Bytes 1-4: RCID (4 bytes) - Record identification number (uint32 LE)
- // Byte 5: PRIM (1 byte) - Object geometric primitive (1=Point, 2=Line, 3=Area, 255=N/A)
- // Byte 6: GRUP (1 byte) - Group code (1-254, 255=no group)
- // Bytes 7-8: OBJL (2 bytes) - Object label/code (uint16 LE)
- // Bytes 9-10: RVER (2 bytes) - Record version (uint16 LE)
- // Byte 11: RUIN (1 byte) - Record update instruction
-
- rcnm := fridData[0]
- if rcnm != 100 {
- return nil // Not a feature record
- }
-
- featureRec := &featureRecord{
- Attributes: make(map[string]interface{}),
- SpatialRefs: make([]spatialRef, 0),
- }
-
- // Extract RCID (not used currently, but available)
- // featureRec.RecordID = int64(binary.LittleEndian.Uint32(fridData[1:5]))
-
- // Extract PRIM (Object Geometric Primitive) from FRID byte [5]
- featureRec.GeomPrim = int(fridData[5])
-
- // Extract GRUP (Group) from FRID byte [6]
- featureRec.Group = int(fridData[6])
-
- // Extract OBJL (Object Label/Class) from FRID bytes [7:9]
- featureRec.ObjectClass = int(binary.LittleEndian.Uint16(fridData[7:9]))
-
- // Extract RVER (Record Version) from FRID bytes [9:11]
- featureRec.RecordVersion = int(binary.LittleEndian.Uint16(fridData[9:11]))
-
- // Extract RUIN (Record Update Instruction) from FRID byte [11]
- featureRec.UpdateInstr = int(fridData[11])
-
- // Parse FOID (Feature Object Identifier) for feature ID
- // S-57 §7.6.2 (31Main.pdf p75): FOID structure is AGEN (2 bytes) + FIDN (4 bytes) + FIDS (2 bytes)
- if foidData, ok := record.Fields["FOID"]; ok && len(foidData) >= 8 {
- // AGEN (2 bytes at offset 0) - Producing agency code
- featureRec.AGEN = binary.LittleEndian.Uint16(foidData[0:2])
-
- // FIDN (4 bytes at offset 2) - Feature identification number
- featureRec.FIDN = binary.LittleEndian.Uint32(foidData[2:6])
-
- // FIDS (2 bytes at offset 6) - Feature identification subdivision
- featureRec.FIDS = binary.LittleEndian.Uint16(foidData[6:8])
-
- // Set ID for backward compatibility (use FIDN)
- featureRec.ID = int64(featureRec.FIDN)
- }
-
- // Parse ATTF (Feature Record Attribute) for attributes
- if attfData, ok := record.Fields["ATTF"]; ok {
- featureRec.Attributes = parseAttributes(attfData)
- }
-
- // Parse NATF (Feature Record National Attribute) — same [ATTL(2) + ATVL]
- // repeating structure as ATTF (S-57 §7.6.4, 31Main.pdf p76), but carries the
- // national-language attributes (NOBJNM=301, NINFOM=300, NTXTDS=304, …) which
- // share the attribute catalogue's code space. Without this, a feature's
- // national object name / national text was silently dropped. Merge into the
- // same Attributes map; ATTF is parsed first so it wins on any code overlap.
- if natfData, ok := record.Fields["NATF"]; ok {
- for code, val := range parseAttributes(natfData) {
- if _, exists := featureRec.Attributes[code]; !exists {
- featureRec.Attributes[code] = val
- }
- }
- }
-
- // Parse FSPT (Feature to Spatial Pointer) for spatial references
- if fsptData, ok := record.Fields["FSPT"]; ok {
- featureRec.SpatialRefs = parseSpatialPointers(fsptData)
- }
-
- return featureRec
-}
-
-// parseAttributes extracts attributes from ATTF field
-// S-57 Appendix B.1: ATTF contains repeated attribute structures
-func parseAttributes(data []byte) map[string]interface{} {
- attributes := make(map[string]interface{})
-
- // ATTF structure: repeated [ATTL(2 bytes), ATVL(variable)]
- // This is a simplified parser - real implementation needs subfield parsing
- offset := 0
- for offset+2 <= len(data) {
- // Extract attribute code (2 bytes)
- attrCode := binary.LittleEndian.Uint16(data[offset : offset+2])
- offset += 2
-
- // Find attribute value (terminated by unit separator 0x1F or end)
- valueEnd := offset
- for valueEnd < len(data) && data[valueEnd] != 0x1F {
- valueEnd++
- }
-
- if valueEnd > offset {
- // Convert attribute code to name using attribute catalogue
- attrName := AttributeCodeToString(int(attrCode))
- attributes[attrName] = string(data[offset:valueEnd])
- }
-
- offset = valueEnd + 1 // Skip unit separator
- }
-
- return attributes
-}
-
-// parseSpatialPointers extracts spatial record references from FSPT field
-// S-57 §7.6.8 (31Main.pdf p77): FSPT contains pointers to VRID records - 8 bytes per pointer
-func parseSpatialPointers(data []byte) []spatialRef {
- refs := make([]spatialRef, 0)
-
- // FSPT is repeating group per S-57 §7.6.8 (31Main.pdf p77), each entry is 8 bytes:
- // NAME: B(40) - 5 bytes (RCNM=1, RCID=4)
- // Offset 0: NAME_RCNM (1 byte) - Target record type
- // Offset 1-4: NAME_RCID (4 bytes) - Target record ID (uint32 LE)
- // Offset 5: ORNT (1 byte) - Orientation (1=Forward, 2=Reverse, 255=Null)
- // Offset 6: USAG (1 byte) - Usage indicator (1=Exterior, 2=Interior, 3=Exterior truncated)
- // Offset 7: MASK (1 byte) - Masking indicator (1=Mask, 2=Show, 255=Null)
-
- // Binary mode: fixed 8-byte stride (not ASCII with separators)
- for i := 0; i+7 < len(data); i += 8 {
- // Extract NAME = RCNM (target record type) + RCID. RCID alone is NOT unique
- // across record types, so the RCNM must be kept to resolve the right record.
- rcnm := int(data[i])
- rcid := int64(binary.LittleEndian.Uint32(data[i+1 : i+5]))
-
- // Extract ORNT, USAG, MASK per S-57 §4.7.3.2 (31Main.pdf p51)
- orientation := int(data[i+5])
- usage := int(data[i+6])
- mask := int(data[i+7])
-
- refs = append(refs, spatialRef{
- RCNM: rcnm,
- RCID: rcid,
- Orientation: orientation,
- Usage: usage,
- Mask: mask,
- })
- }
-
- return refs
-}
diff --git a/internal/s57/parser/feature_test.go b/internal/s57/parser/feature_test.go
deleted file mode 100644
index 49ad9f3..0000000
--- a/internal/s57/parser/feature_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package parser
-
-import (
- "testing"
-)
-
-// TestFeatureCreation tests basic feature creation
-func TestFeatureCreation(t *testing.T) {
- feature := Feature{
- ID: 12345,
- ObjectClass: "DEPCNT",
- Geometry: Geometry{
- Type: GeometryTypeLineString,
- Coordinates: [][]float64{
- {-71.05, 42.35},
- {-71.04, 42.36},
- },
- },
- Attributes: map[string]interface{}{
- "DRVAL1": 10.0,
- },
- }
-
- if feature.ID != 12345 {
- t.Errorf("Expected ID=12345, got %d", feature.ID)
- }
-
- if feature.ObjectClass != "DEPCNT" {
- t.Errorf("Expected ObjectClass=DEPCNT, got %s", feature.ObjectClass)
- }
-
- if feature.Geometry.Type != GeometryTypeLineString {
- t.Errorf("Expected GeometryTypeLineString, got %v", feature.Geometry.Type)
- }
-
- if len(feature.Attributes) != 1 {
- t.Errorf("Expected 1 attribute, got %d", len(feature.Attributes))
- }
-}
-
-// TestChart tests chart creation and metadata access
-func TestChart(t *testing.T) {
- features := []Feature{
- {ID: 1, ObjectClass: "DEPCNT"},
- {ID: 2, ObjectClass: "DEPARE"},
- }
-
- metadata := &datasetMetadata{
- dsnm: "US5MA22M",
- edtn: "2",
- updn: "0",
- }
-
- chart := Chart{
- Features: features,
- metadata: metadata,
- }
-
- // Test metadata access via public methods
- if chart.DatasetName() != "US5MA22M" {
- t.Errorf("Expected DSNM=US5MA22M, got %s", chart.DatasetName())
- }
-
- if chart.Edition() != "2" {
- t.Errorf("Expected Edition=2, got %s", chart.Edition())
- }
-
- if chart.UpdateNumber() != "0" {
- t.Errorf("Expected UpdateNumber=0, got %s", chart.UpdateNumber())
- }
-
- if len(chart.Features) != 2 {
- t.Errorf("Expected 2 features, got %d", len(chart.Features))
- }
-}
diff --git a/internal/s57/parser/geometry.go b/internal/s57/parser/geometry.go
deleted file mode 100644
index d8c6f3e..0000000
--- a/internal/s57/parser/geometry.go
+++ /dev/null
@@ -1,704 +0,0 @@
-package parser
-
-import "sync"
-
-var (
- coordSlicePool = sync.Pool{
- New: func() interface{} {
- slice := make([][]float64, 0, 64)
- return &slice
- },
- }
-
- visitedMapPool = sync.Pool{
- New: func() interface{} {
- return make(map[int64]bool, 16)
- },
- }
-
- spatialRefSlicePool = sync.Pool{
- New: func() interface{} {
- slice := make([]spatialRef, 0, 16)
- return &slice
- },
- }
-
- coord2DPool = sync.Pool{
- New: func() interface{} {
- return &[2]float64{}
- },
- }
-)
-
-// GeometryType represents the type of geometry for a feature
-type GeometryType int
-
-const (
- // GeometryTypePoint represents a single point location
- GeometryTypePoint GeometryType = iota
- // GeometryTypeLineString represents a line composed of connected points
- GeometryTypeLineString
- // GeometryTypePolygon represents a closed polygon area
- GeometryTypePolygon
-)
-
-// String returns the string representation of the geometry type
-func (g GeometryType) String() string {
- switch g {
- case GeometryTypePoint:
- return "Point"
- case GeometryTypeLineString:
- return "LineString"
- case GeometryTypePolygon:
- return "Polygon"
- default:
- return "Unknown"
- }
-}
-
-// Ring represents a polygon ring (exterior or interior) with usage indicator
-// S-57 §2.2.8: USAG subfield indicates ring type
-type Ring struct {
- // Usage indicates the ring type:
- // 1 = Exterior boundary
- // 2 = Interior boundary (hole)
- // 3 = Exterior boundary truncated at data limit
- Usage int
- // Coordinates is an array of [longitude, latitude] pairs forming the ring
- Coordinates [][]float64
-}
-
-// Geometry represents the spatial representation of a feature
-// S-57 §7.3 (31Main.pdf p64): Spatial record structure
-type Geometry struct {
- // Type is the geometry type (Point, LineString, or Polygon)
- Type GeometryType
- // Coordinates is an array of [longitude, latitude] pairs
- // Per GeoJSON convention: [lon, lat]
- // DEPRECATED for polygons: Use Rings instead to preserve ring structure
- Coordinates [][]float64
- // Rings contains polygon ring data with usage indicators
- // Only populated for Polygon geometry type
- // First ring with Usage=1 is exterior, Usage=2 are holes, Usage=3 are truncated exterior
- Rings []Ring
-
- // BoundaryLines holds the polylines of a polygon's DRAWABLE boundary edges:
- // edges that are NOT masked (FSPT MASK={1}) and NOT cell-boundary/data-limit
- // edges (USAG={3}). Per S-52 PresLib §8.6.2 those edges "must not be drawn",
- // while the area fill must still include them (§8.6.3) — so the fill uses
- // Rings (complete) and the border stroke uses BoundaryLines (edges dropped).
- // One polyline per drawable edge; empty/nil ⇒ fall back to stroking Rings.
- BoundaryLines [][][]float64
-
- // Lines holds the DRAWABLE polylines of a LINE feature: the same edge chain as
- // Coordinates but with masked (FSPT MASK={1}) and data-limit/cell-boundary
- // (USAG={3}) edges removed (S-52 PresLib §8.6.2 — those edges must not be
- // drawn). Because dropping a mid-line edge splits the line, this is MULTI-PART:
- // each element is one contiguous drawn polyline of [lon,lat] points. The flat
- // Coordinates field still carries the FULL concatenation (all edges) for
- // backward compatibility. Empty/nil ⇒ no masking applied (or topology didn't
- // resolve) → fall back to stroking Coordinates.
- Lines [][][]float64
-
- // Quapos is the feature's effective QUAPOS (quality of position): the value
- // held by the majority of its drawn edges (S-57 QUAPOS is a spatial-level
- // attribute on edges, not a feature attribute). 0 ⇒ none/surveyed. A
- // low-accuracy value (not 1/10/11) drives the dashed depth contour (DEPCNT03).
- Quapos int
-}
-
-// constructGeometry builds a Geometry from feature and spatial records.
-// S-57 §2.1 (31Main.pdf p23): Features reference spatial records to build geometry.
-//
-// coalneEdges and maskCoast drive DERIVED coastline-coincident boundary masking
-// (see ParseOptions.MaskCoastlineCoincidentBoundaries). They are consulted only for
-// polygon features; point and line constructors ignore them. When maskCoast is true,
-// any boundary edge whose RCID is in coalneEdges is dropped from the polygon's drawn
-// BoundaryLines (the fill Rings / flat Coordinates are unaffected).
-func constructGeometry(featureRec *featureRecord, spatialRecords map[spatialKey]*spatialRecord, coalneEdges map[int64]bool, maskCoast bool) (Geometry, error) {
- // PRIM=255 means N/A (no geometry) - these are meta-features like C_AGGR, M_COVR, etc.
- // Return empty point geometry for these
- if featureRec.GeomPrim == 255 {
- return Geometry{
- Type: GeometryTypePoint,
- Coordinates: [][]float64{},
- }, nil
- }
-
- // If no spatial references, cannot construct geometry
- if len(featureRec.SpatialRefs) == 0 {
- return Geometry{}, &ErrMissingSpatialRecord{
- FeatureID: featureRec.ID,
- SpatialID: 0,
- }
- }
-
- // Determine geometry type from PRIM field (IHO S-57 §7.6.1, 31Main.pdf p74)
- // PRIM: 1=Point, 2=Line, 3=Area, 255=N/A
- geomType := geomTypeFromPrim(featureRec.GeomPrim)
-
- // For polygon features (PRIM=3), use VRPT topology resolver
- if geomType == GeometryTypePolygon {
- return constructPolygonGeometry(featureRec, spatialRecords, coalneEdges, maskCoast)
- }
-
- // For Point features (PRIM=1), use only the FIRST spatial ref
- // S-57 §7.6 (31Main.pdf p74): Point features reference a single isolated node
- if geomType == GeometryTypePoint {
- return constructPointGeometry(featureRec, spatialRecords)
- }
-
- // For LineString features (PRIM=2), collect coordinates from all spatial refs
- // S-57 §7.6 (31Main.pdf p74): Line features may reference edges (RCNM=130) which require topology resolution
- return constructLineStringGeometry(featureRec, spatialRecords)
-}
-
-// constructLineStringGeometry builds linestring geometry from spatial references
-// S-57 §7.6 (31Main.pdf p74): Line features reference edges (RCNM=130) or connected nodes
-func constructLineStringGeometry(featureRec *featureRecord, spatialRecords map[spatialKey]*spatialRecord) (Geometry, error) {
- allCoordsPtr := coordSlicePool.Get().(*[][]float64)
- allCoords := (*allCoordsPtr)[:0]
- defer coordSlicePool.Put(allCoordsPtr)
- resolver := newPolygonBuilder(spatialRecords)
-
- // Drawable multi-part line geometry (S-52 PresLib §8.6.2): masked / data-limit
- // edges are excluded from what is drawn, but still go into the flat allCoords
- // (backward compat). lineParts accumulates contiguous drawn polylines: a new
- // part is started whenever the chain is broken — either by a skipped (masked)
- // edge, or by a drawable edge whose first point does not continue the previous
- // part's last point. sawMask records whether ANY edge carried masking info, so
- // that an unmasked line leaves Lines nil (callers then stroke allCoords).
- var lineParts [][][]float64
- var curPart [][]float64
- chainBroken := true // force a new part on the first drawable edge
- sawMask := false
- // QUAPOS tally over the feature's DRAWN edges, to derive a feature-level
- // quality of position (it's a spatial-level attribute, one per edge).
- var quaposTotal, quaposLow, quaposLowVal int
- flushPart := func() {
- if len(curPart) >= 2 {
- lineParts = append(lineParts, curPart)
- }
- curPart = nil
- }
-
- for _, spatialRef := range featureRec.SpatialRefs {
- // Resolve the EXACT record the FSPT pointer names (RCNM + RCID). RCID is
- // unique only WITHIN an RCNM, so probing by RCID across record types can
- // grab an unrelated record that reused the id. This bites hardest after an
- // update deletes an edge: e.g. a COALNE references edge 130/78, an update
- // (.011) deletes edge 130/78, but isolated node 110/78 still exists — the
- // old RCID-only search then resolved the dangling edge-ref to that node and
- // spliced its far-off coordinate into the coastline (visible as a long line
- // slashing across the chart). Trust the pointer's RCNM; if that exact record
- // is gone, drop the ref (the line is simply shorter) rather than guess.
- var spatial *spatialRecord
- if spatialRef.RCNM != 0 {
- if sp, ok := spatialRecords[spatialKey{RCNM: spatialRef.RCNM, RCID: spatialRef.RCID}]; ok {
- spatial = sp
- }
- } else {
- // Unknown RCNM in the pointer: fall back to searching by RCID.
- for _, rcnm := range []int{int(spatialTypeEdge), int(spatialTypeConnectedNode), int(spatialTypeIsolatedNode), int(spatialTypeFace)} {
- key := spatialKey{RCNM: rcnm, RCID: spatialRef.RCID}
- if sp, ok := spatialRecords[key]; ok {
- spatial = sp
- break
- }
- }
- }
-
- if spatial == nil {
- // Missing spatial record (or deleted by an update) - skip gracefully
- continue
- }
-
- // If this is an edge (RCNM=130), use full edge resolution including nodes
- if spatial.RecordType == spatialTypeEdge {
- edge, err := resolver.loadEdge(spatial.ID)
- if err != nil {
- continue // Skip edges that can't be loaded
- }
- // Get full edge coordinates with nodes (use orientation from FSPT)
- edgeCoords := resolver.getFullEdgeCoordinates(edge, spatialRef.Orientation)
- for _, coord := range edgeCoords {
- allCoords = append(allCoords, []float64{coord[0], coord[1]})
- }
-
- // Any edge carrying explicit MASK/USAG info means the producer encoded
- // masking for this feature; expose the drawable Lines so the renderer
- // honours §8.6.2. (Mask==0 / Usage==0 on every edge ⇒ no info ⇒ leave
- // Lines nil and fall back to the flat Coordinates, unchanged behaviour.)
- if spatialRef.Mask != 0 || spatialRef.Usage != 0 {
- sawMask = true
- }
-
- // Drawable-part accounting (S-52 §8.6.2). A masked / data-limit edge is
- // dropped from the drawn geometry: end the current part and mark the chain
- // broken so the next drawable edge starts a fresh part.
- if spatialRef.Mask == 1 || spatialRef.Usage == 3 {
- flushPart()
- chainBroken = true
- continue
- }
- if len(edgeCoords) < 2 {
- // Degenerate edge: nothing to draw, but it still interrupts continuity.
- flushPart()
- chainBroken = true
- continue
- }
- // Drawn edge: tally its QUAPOS for the feature-level aggregate.
- quaposTotal++
- if isLowAccuracyQuapos(spatial.Quapos) {
- quaposLow++
- quaposLowVal = spatial.Quapos
- }
- first := []float64{edgeCoords[0][0], edgeCoords[0][1]}
- // Start a new part if the chain was broken (masked gap) or this edge does
- // not continue the previous part's last point.
- if chainBroken || len(curPart) == 0 ||
- curPart[len(curPart)-1][0] != first[0] || curPart[len(curPart)-1][1] != first[1] {
- flushPart()
- curPart = make([][]float64, 0, len(edgeCoords))
- curPart = append(curPart, first)
- for _, coord := range edgeCoords[1:] {
- curPart = append(curPart, []float64{coord[0], coord[1]})
- }
- } else {
- // Continues the current part: skip the duplicated shared node.
- for _, coord := range edgeCoords[1:] {
- curPart = append(curPart, []float64{coord[0], coord[1]})
- }
- }
- chainBroken = false
- } else if len(spatial.Coordinates) > 0 {
- // Direct coordinates from node
- for _, coord := range spatial.Coordinates {
- allCoords = append(allCoords, []float64{coord[0], coord[1]})
- }
- } else if len(spatial.VectorPointers) > 0 {
- // Follow VRPT pointers
- coordsFromPointers := resolveVectorPointers(spatial, spatialRecords)
- allCoords = append(allCoords, coordsFromPointers...)
- }
- }
-
- if len(allCoords) < 2 {
- // Not enough coordinates for a valid line
- // Return empty geometry (feature will be skipped by caller)
- return Geometry{
- Type: GeometryTypeLineString,
- Coordinates: [][]float64{},
- }, nil
- }
-
- flushPart()
-
- result := make([][]float64, len(allCoords))
- copy(result, allCoords)
- geom := Geometry{
- Type: GeometryTypeLineString,
- Coordinates: result,
- }
- // Only expose drawable parts when masking actually applied. Without masking,
- // leaving Lines nil keeps existing renderers stroking the flat Coordinates
- // exactly as before (no behaviour change for the common, unmasked case).
- if sawMask {
- geom.Lines = lineParts
- }
- // Feature is low-accuracy when most of its drawn edges are: dash the contour.
- if quaposTotal > 0 && quaposLow*2 > quaposTotal {
- geom.Quapos = quaposLowVal
- }
- return geom, nil
-}
-
-// isLowAccuracyQuapos reports whether a QUAPOS value means "low accuracy" — i.e.
-// drawn dashed (S-52 DEPCNT03 / DEPARE03): present and not surveyed (1), precisely
-// known (10), or calculated (11). 0 means the attribute was absent.
-func isLowAccuracyQuapos(q int) bool {
- return q != 0 && q != 1 && q != 10 && q != 11
-}
-
-// constructPointGeometry builds point geometry from spatial references
-// S-57 §7.6 (31Main.pdf p74): Point features can reference:
-// - Single isolated node (RCNM=110) for simple point features
-// - Multiple isolated nodes for multipoint features (e.g., SOUNDG with many soundings)
-func constructPointGeometry(featureRec *featureRecord, spatialRecords map[spatialKey]*spatialRecord) (Geometry, error) {
- // Collect coordinates from ALL spatial references
- // For multipoint features like SOUNDG, there can be hundreds of refs
- allCoordsPtr := coordSlicePool.Get().(*[][]float64)
- allCoords := (*allCoordsPtr)[:0]
- defer coordSlicePool.Put(allCoordsPtr)
-
- for _, spatialRef := range featureRec.SpatialRefs {
- // Resolve the EXACT record the FSPT pointer names (RCNM + RCID). RCID is
- // unique only within an RCNM, so probing by RCID alone can grab an unrelated
- // record of a different type that happens to share the id — e.g. a point
- // feature pointing at connected node 120/X mis-resolving to isolated node
- // 110/X, which puts the feature at a completely different location.
- var spatial *spatialRecord
- if spatialRef.RCNM != 0 {
- if sp, ok := spatialRecords[spatialKey{RCNM: spatialRef.RCNM, RCID: spatialRef.RCID}]; ok {
- spatial = sp
- }
- }
- // Fallback for records with no/unknown RCNM in the pointer: check isolated
- // node first, then connected node (isolated holds SG3D for multipoint SOUNDG).
- if spatial == nil {
- for _, rcnm := range []int{int(spatialTypeIsolatedNode), int(spatialTypeConnectedNode)} {
- key := spatialKey{RCNM: rcnm, RCID: spatialRef.RCID}
- if sp, ok := spatialRecords[key]; ok {
- spatial = sp
- break
- }
- }
- }
-
- if spatial == nil {
- // Skip missing spatial records (don't fail entire feature)
- continue
- }
-
- // Get coordinates from this spatial record
- if len(spatial.Coordinates) > 0 {
- // Extract ALL coordinates from spatial record
- // Preserve all dimensions (2D or 3D) - don't strip Z coordinates
- allCoords = append(allCoords, spatial.Coordinates...)
- }
- }
-
- if len(allCoords) == 0 {
- // All spatial refs were missing or had no coordinates
- // Return empty geometry (feature will be skipped by caller)
- return Geometry{
- Type: GeometryTypePoint,
- Coordinates: [][]float64{},
- }, nil
- }
-
- result := make([][]float64, len(allCoords))
- copy(result, allCoords)
- return Geometry{
- Type: GeometryTypePoint,
- Coordinates: result,
- }, nil
-}
-
-// collectRefCoords appends, to out, the coordinates of the spatial records named
-// by the feature's FSPT pointers. It honours each pointer's RCNM — RCID is unique
-// only WITHIN an RCNM — so a dangling reference (e.g. to an edge an update has
-// deleted) does NOT scavenge an unrelated node/edge that reused the same RCID.
-// That collision turned a bridge whose edges were all deleted by update .007 into
-// a scattered stray ring (5 points pulled from connected/isolated nodes 120/110
-// that happened to share the deleted edges' RCIDs). With no valid records the
-// caller sees too few coords and drops the feature, which is correct.
-func collectRefCoords(refs []spatialRef, spatialRecords map[spatialKey]*spatialRecord, out [][]float64) [][]float64 {
- for _, ref := range refs {
- if ref.RCNM != 0 {
- if sp, ok := spatialRecords[spatialKey{RCNM: ref.RCNM, RCID: ref.RCID}]; ok {
- for _, coord := range sp.Coordinates {
- out = append(out, []float64{coord[0], coord[1]})
- }
- }
- continue
- }
- // Unknown RCNM in the pointer: fall back to an RCID search across types.
- for key, spatial := range spatialRecords {
- if key.RCID == ref.RCID && len(spatial.Coordinates) > 0 {
- for _, coord := range spatial.Coordinates {
- out = append(out, []float64{coord[0], coord[1]})
- }
- }
- }
- }
- return out
-}
-
-// constructPolygonGeometry builds polygon geometry using VRPT topology resolution
-// S-57 §7.3 (31Main.pdf p64): Area features use VRPT to reference edge topology.
-//
-// coalneEdges/maskCoast drive derived coastline-coincident boundary masking: when
-// maskCoast is true, boundary edges whose RCID is in coalneEdges are excluded from
-// the drawn BoundaryLines (fill Rings / flat Coordinates remain complete).
-func constructPolygonGeometry(featureRec *featureRecord, spatialRecords map[spatialKey]*spatialRecord, coalneEdges map[int64]bool, maskCoast bool) (Geometry, error) {
- // Create polygon builder
- resolver := newPolygonBuilder(spatialRecords)
-
- // Check if feature references face records (spatial primitives with VRPT)
- // Collect edge references WITH orientation from FSPT
- edgeRefsPtr := spatialRefSlicePool.Get().(*[]spatialRef)
- edgeRefs := (*edgeRefsPtr)[:0]
- defer func() {
- *edgeRefsPtr = (*edgeRefsPtr)[:0]
- spatialRefSlicePool.Put(edgeRefsPtr)
- }()
- for _, fsptRef := range featureRec.SpatialRefs {
- // Resolve the EXACT record the FSPT pointer names (RCNM + RCID) — RCID is
- // unique only within an RCNM. Probing by RCID across types can resolve a
- // dangling/deleted reference to an unrelated record that reused the id (see
- // constructLineStringGeometry). Trust the pointer's RCNM; fall back to the
- // RCID search only when the pointer carries no/unknown RCNM.
- var spatial *spatialRecord
- if fsptRef.RCNM != 0 {
- if sp, ok := spatialRecords[spatialKey{RCNM: fsptRef.RCNM, RCID: fsptRef.RCID}]; ok {
- spatial = sp
- }
- } else {
- for _, rcnm := range []int{int(spatialTypeFace), int(spatialTypeEdge), int(spatialTypeConnectedNode), int(spatialTypeIsolatedNode)} {
- key := spatialKey{RCNM: rcnm, RCID: fsptRef.RCID}
- if sp, ok := spatialRecords[key]; ok {
- spatial = sp
- break
- }
- }
- }
-
- if spatial == nil {
- continue
- }
-
- // If this is a face record (RCNM=140), collect edge references from VRPT
- if spatial.RecordType == spatialTypeFace {
- for _, ptr := range spatial.VectorPointers {
- // Edge records have RCNM=130
- if ptr.TargetRCNM == int(spatialTypeEdge) {
- // Face VRPT has orientation - use it
- edgeRefs = append(edgeRefs, spatialRef{
- RCID: ptr.TargetRCID,
- Orientation: ptr.Orientation,
- Usage: ptr.Usage,
- Mask: ptr.Mask,
- })
- }
- }
- } else if spatial.RecordType == spatialTypeEdge {
- // Direct edge reference - use FSPT orientation
- edgeRefs = append(edgeRefs, fsptRef)
- }
- }
-
- // If we have edge references, resolve topology
- if len(edgeRefs) > 0 {
- ringsWithUsage, err := resolver.resolvePolygonWithUsage(edgeRefs)
- if err != nil {
- // VRPT resolution failed - fall back to direct coordinate collection
- // This can happen if topology is incomplete or malformed (e.g., M_COVR meta features)
- // Try to collect coordinates directly from edges
- allCoordsPtr := coordSlicePool.Get().(*[][]float64)
- allCoords := (*allCoordsPtr)[:0]
- defer coordSlicePool.Put(allCoordsPtr)
- for _, edgeRef := range edgeRefs {
- edgeKey := spatialKey{RCNM: int(spatialTypeEdge), RCID: edgeRef.RCID}
- if edge, ok := spatialRecords[edgeKey]; ok && len(edge.Coordinates) > 0 {
- for _, coord := range edge.Coordinates {
- allCoords = append(allCoords, []float64{coord[0], coord[1]})
- }
- }
- }
-
- if len(allCoords) > 0 {
- allCoords = ensurePolygonClosure(allCoords)
- result := make([][]float64, len(allCoords))
- copy(result, allCoords)
- return Geometry{
- Type: GeometryTypePolygon,
- Coordinates: result,
- Rings: []Ring{{
- Usage: 1, // Default to exterior
- Coordinates: result,
- }},
- }, nil
- }
-
- // If we still can't get coordinates from edges, collect directly from the
- // records the pointers name (respecting RCNM — see collectRefCoords).
- allCoords = collectRefCoords(featureRec.SpatialRefs, spatialRecords, allCoords)
-
- if len(allCoords) > 0 {
- allCoords = ensurePolygonClosure(allCoords)
- result := make([][]float64, len(allCoords))
- copy(result, allCoords)
- return Geometry{
- Type: GeometryTypePolygon,
- Coordinates: result,
- Rings: []Ring{{
- Usage: 1,
- Coordinates: result,
- }},
- }, nil
- }
-
- // Last resort: return the error
- return Geometry{}, err
- }
-
- // Build Rings array with usage indicators
- rings := make([]Ring, len(ringsWithUsage))
- allCoords := make([][]float64, 0)
-
- for i, ringData := range ringsWithUsage {
- // Convert [][2]float64 to [][]float64
- coords := make([][]float64, len(ringData.coords))
- for j, point := range ringData.coords {
- coords[j] = []float64{point[0], point[1]}
- allCoords = append(allCoords, coords[j]) // Also add to flat list for backward compat
- }
-
- rings[i] = Ring{
- Usage: ringData.usage,
- Coordinates: coords,
- }
- }
-
- // Check if we have enough coordinates for a valid polygon
- if len(allCoords) < 3 {
- // Degenerate polygon - return empty geometry
- return Geometry{
- Type: GeometryTypePolygon,
- Coordinates: [][]float64{},
- Rings: []Ring{},
- }, nil
- }
-
- return Geometry{
- Type: GeometryTypePolygon,
- Coordinates: allCoords, // Flattened for backward compatibility
- Rings: rings, // Structured rings with usage indicators
- BoundaryLines: resolver.drawableBoundaryLines(edgeRefs, coalneEdges, maskCoast),
- Quapos: resolver.boundaryQuapos(edgeRefs, coalneEdges, maskCoast),
- }, nil
- }
-
- // Fallback: No VRPT topology, collect direct coordinates (respecting RCNM so a
- // reference to a deleted edge doesn't scavenge an unrelated record — see
- // collectRefCoords).
- allCoordsPtr2 := coordSlicePool.Get().(*[][]float64)
- allCoords := (*allCoordsPtr2)[:0]
- defer coordSlicePool.Put(allCoordsPtr2)
- allCoords = collectRefCoords(featureRec.SpatialRefs, spatialRecords, allCoords)
-
- // Check if we have enough coordinates for a valid polygon
- if len(allCoords) < 3 {
- // Degenerate polygon - return empty geometry
- return Geometry{
- Type: GeometryTypePolygon,
- Coordinates: [][]float64{},
- Rings: []Ring{},
- }, nil
- }
-
- // Ensure polygon closure
- allCoords = ensurePolygonClosure(allCoords)
-
- result := make([][]float64, len(allCoords))
- copy(result, allCoords)
- return Geometry{
- Type: GeometryTypePolygon,
- Coordinates: result,
- Rings: []Ring{{
- Usage: 1, // Default to exterior for fallback path
- Coordinates: result,
- }},
- }, nil
-}
-
-// geomTypeFromPrim converts PRIM value to GeometryType
-// Per IHO S-57 §7.6.1 (31Main.pdf p74): PRIM values are 1=Point, 2=Line, 3=Area, 255=N/A
-func geomTypeFromPrim(prim int) GeometryType {
- switch prim {
- case 1: // Point
- return GeometryTypePoint
- case 2: // Line
- return GeometryTypeLineString
- case 3: // Area
- return GeometryTypePolygon
- default: // 255 = N/A or unknown
- return GeometryTypePoint // Default to point if unknown
- }
-}
-
-// ensurePolygonClosure ensures a polygon is closed (first coordinate == last)
-func ensurePolygonClosure(coords [][]float64) [][]float64 {
- if len(coords) < 3 {
- return coords // Not enough points for polygon
- }
-
- // Check if already closed
- first := coords[0]
- last := coords[len(coords)-1]
-
- if len(first) == 2 && len(last) == 2 {
- if first[0] == last[0] && first[1] == last[1] {
- return coords // Already closed
- }
- }
-
- // Add closing point
- closed := make([][]float64, len(coords)+1)
- copy(closed, coords)
- closed[len(coords)] = []float64{first[0], first[1]}
-
- return closed
-}
-
-// resolveVectorPointers recursively resolves VRPT pointers to collect coordinates
-func resolveVectorPointers(spatial *spatialRecord, spatialRecords map[spatialKey]*spatialRecord) [][]float64 {
- visited := visitedMapPool.Get().(map[int64]bool)
- for k := range visited {
- delete(visited, k)
- }
- defer visitedMapPool.Put(visited)
- return resolveVectorPointersRecursive(spatial, spatialRecords, visited)
-}
-
-// resolveVectorPointersRecursive implements recursive VRPT resolution with cycle detection
-func resolveVectorPointersRecursive(spatial *spatialRecord, spatialRecords map[spatialKey]*spatialRecord, visited map[int64]bool) [][]float64 {
- coordsPtr := coordSlicePool.Get().(*[][]float64)
- coords := (*coordsPtr)[:0]
- defer coordSlicePool.Put(coordsPtr)
-
- for _, ptr := range spatial.VectorPointers {
- // Check for circular reference
- if visited[ptr.TargetRCID] {
- continue // Skip to prevent infinite loop
- }
- visited[ptr.TargetRCID] = true
-
- // Lookup using composite key (RCNM, RCID)
- targetKey := spatialKey{RCNM: ptr.TargetRCNM, RCID: ptr.TargetRCID}
- target, ok := spatialRecords[targetKey]
- if !ok {
- continue // Target not found, skip
- }
-
- // Collect coordinates from target
- if len(target.Coordinates) > 0 {
- // Target has direct coordinates - apply orientation inline
- if ptr.Orientation == 2 { // Reverse
- for i := len(target.Coordinates) - 1; i >= 0; i-- {
- coords = append(coords, []float64{target.Coordinates[i][0], target.Coordinates[i][1]})
- }
- } else { // Forward or null
- for _, coord := range target.Coordinates {
- coords = append(coords, []float64{coord[0], coord[1]})
- }
- }
- } else if len(target.VectorPointers) > 0 {
- // Target has no direct coords, recurse
- targetCoords := resolveVectorPointersRecursive(target, spatialRecords, visited)
- // Apply orientation (reverse if needed)
- if ptr.Orientation == 2 { // Reverse
- for i := len(targetCoords) - 1; i >= 0; i-- {
- coords = append(coords, targetCoords[i])
- }
- } else { // Forward or null
- coords = append(coords, targetCoords...)
- }
- }
- }
-
- result := make([][]float64, len(coords))
- copy(result, coords)
- return result
-}
diff --git a/internal/s57/parser/geometry_test.go b/internal/s57/parser/geometry_test.go
deleted file mode 100644
index 3a448ad..0000000
--- a/internal/s57/parser/geometry_test.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package parser
-
-import (
- "testing"
-)
-
-// TestGeometryTypes tests geometry type enumeration
-func TestGeometryTypes(t *testing.T) {
- tests := []struct {
- geomType GeometryType
- expected string
- }{
- {GeometryTypePoint, "Point"},
- {GeometryTypeLineString, "LineString"},
- {GeometryTypePolygon, "Polygon"},
- }
-
- for _, tt := range tests {
- t.Run(tt.expected, func(t *testing.T) {
- if tt.geomType.String() != tt.expected {
- t.Errorf("Expected %s, got %s", tt.expected, tt.geomType.String())
- }
- })
- }
-}
-
-// TestGeometryCreation tests basic geometry creation
-func TestGeometryCreation(t *testing.T) {
- tests := []struct {
- name string
- geomType GeometryType
- coordinates [][]float64
- }{
- {
- name: "point",
- geomType: GeometryTypePoint,
- coordinates: [][]float64{
- {-71.05, 42.35},
- },
- },
- {
- name: "linestring",
- geomType: GeometryTypeLineString,
- coordinates: [][]float64{
- {-71.05, 42.35},
- {-71.04, 42.36},
- },
- },
- {
- name: "polygon",
- geomType: GeometryTypePolygon,
- coordinates: [][]float64{
- {-71.05, 42.35},
- {-71.04, 42.35},
- {-71.04, 42.36},
- {-71.05, 42.36},
- {-71.05, 42.35}, // Closed
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- geom := Geometry{
- Type: tt.geomType,
- Coordinates: tt.coordinates,
- }
-
- if geom.Type != tt.geomType {
- t.Errorf("Expected Type=%v, got %v", tt.geomType, geom.Type)
- }
-
- if len(geom.Coordinates) != len(tt.coordinates) {
- t.Errorf("Expected %d coordinates, got %d", len(tt.coordinates), len(geom.Coordinates))
- }
- })
- }
-}
-
-// TestPointGeometryResolvesByRCNM is a regression test for the FSPT pointer bug:
-// RCID is unique only WITHIN a record type (RCNM), so a point feature pointing at
-// a connected node (120) must not be mis-resolved to an isolated node (110) that
-// happens to share that RCID. Real-world symptom: range rear lights (which point
-// at connected nodes) placed kilometres from their true position.
-func TestPointGeometryResolvesByRCNM(t *testing.T) {
- const rcid int64 = 5
- spatialRecords := map[spatialKey]*spatialRecord{
- {RCNM: int(spatialTypeIsolatedNode), RCID: rcid}: {ID: rcid, RecordType: spatialTypeIsolatedNode, Coordinates: [][]float64{{-76.0, 38.0}}},
- {RCNM: int(spatialTypeConnectedNode), RCID: rcid}: {ID: rcid, RecordType: spatialTypeConnectedNode, Coordinates: [][]float64{{-76.46, 39.22}}},
- }
- feat := &featureRecord{
- GeomPrim: 1, // point
- SpatialRefs: []spatialRef{{RCNM: int(spatialTypeConnectedNode), RCID: rcid}},
- }
- g, err := constructPointGeometry(feat, spatialRecords)
- if err != nil {
- t.Fatal(err)
- }
- if len(g.Coordinates) != 1 || g.Coordinates[0][0] != -76.46 || g.Coordinates[0][1] != 39.22 {
- t.Fatalf("point resolved to wrong spatial record: got %v, want connected node [-76.46, 39.22]", g.Coordinates)
- }
-}
diff --git a/internal/s57/parser/line_masking_test.go b/internal/s57/parser/line_masking_test.go
deleted file mode 100644
index a239316..0000000
--- a/internal/s57/parser/line_masking_test.go
+++ /dev/null
@@ -1,233 +0,0 @@
-package parser
-
-import "testing"
-
-// edgeRecord builds an edge spatial record with the given RCID, SG2D shape points
-// and (optional) start/end connected-node pointers. Mirrors how the existing
-// topology tests construct edge spatialRecords.
-func edgeRecord(rcid int64, points [][]float64, startNode, endNode int64) *spatialRecord {
- rec := &spatialRecord{
- ID: rcid,
- RecordType: spatialTypeEdge,
- Coordinates: points,
- }
- if startNode != 0 {
- rec.VectorPointers = append(rec.VectorPointers, vectorPointer{TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: startNode})
- }
- if endNode != 0 {
- rec.VectorPointers = append(rec.VectorPointers, vectorPointer{TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: endNode})
- }
- return rec
-}
-
-// hasPoint reports whether any drawable line part contains the [lon,lat] point.
-func hasPoint(parts [][][]float64, lon, lat float64) bool {
- for _, part := range parts {
- for _, p := range part {
- if len(p) >= 2 && p[0] == lon && p[1] == lat {
- return true
- }
- }
- }
- return false
-}
-
-// flatHasPoint reports whether the flat Coordinates contain the [lon,lat] point.
-func flatHasPoint(coords [][]float64, lon, lat float64) bool {
- for _, p := range coords {
- if len(p) >= 2 && p[0] == lon && p[1] == lat {
- return true
- }
- }
- return false
-}
-
-// TestLineMaskingSplitsAtMaskedEdge verifies S-52 PresLib §8.6.2: a LINE feature
-// whose MIDDLE edge carries FSPT MASK={1} must NOT have that edge drawn, and the
-// resulting drawable geometry splits into two disjoint parts. The flat Coordinates
-// must still contain every edge (backward compatibility).
-func TestLineMaskingSplitsAtMaskedEdge(t *testing.T) {
- // Three contiguous edges forming a single chain:
- // e1: (0,0)->(1,0) e2 (MASKED): (1,0)->(2,0) e3: (2,0)->(3,0)
- spatialRecords := map[spatialKey]*spatialRecord{
- {RCNM: int(spatialTypeEdge), RCID: 1}: edgeRecord(1, [][]float64{{0, 0}, {1, 0}}, 0, 0),
- {RCNM: int(spatialTypeEdge), RCID: 2}: edgeRecord(2, [][]float64{{1, 0}, {2, 0}}, 0, 0),
- {RCNM: int(spatialTypeEdge), RCID: 3}: edgeRecord(3, [][]float64{{2, 0}, {3, 0}}, 0, 0),
- }
- feat := &featureRecord{
- GeomPrim: 2, // line
- SpatialRefs: []spatialRef{
- {RCNM: int(spatialTypeEdge), RCID: 1, Orientation: 1, Mask: 2},
- {RCNM: int(spatialTypeEdge), RCID: 2, Orientation: 1, Mask: 1}, // masked middle edge
- {RCNM: int(spatialTypeEdge), RCID: 3, Orientation: 1, Mask: 2},
- },
- }
-
- g, err := constructLineStringGeometry(feat, spatialRecords)
- if err != nil {
- t.Fatal(err)
- }
-
- // Masking applied → exactly two drawable parts.
- if len(g.Lines) != 2 {
- t.Fatalf("expected 2 drawable parts, got %d: %v", len(g.Lines), g.Lines)
- }
-
- // The masked edge's interior coordinates (1,0)->(2,0) must NOT appear as a drawn
- // segment. The shared endpoints (1,0) and (2,0) are also the endpoints of the
- // neighbouring drawable edges, so we assert the masked edge is absent by checking
- // the part split is clean: part 0 ends at (1,0), part 1 starts at (2,0), and no
- // part bridges (1,0)->(2,0).
- if got := g.Lines[0]; len(got) != 2 || got[0][0] != 0 || got[1][0] != 1 {
- t.Fatalf("part 0 = %v, want [[0,0],[1,0]]", got)
- }
- if got := g.Lines[1]; len(got) != 2 || got[0][0] != 2 || got[1][0] != 3 {
- t.Fatalf("part 1 = %v, want [[2,0],[3,0]]", got)
- }
- // No single drawable part may contain both (1,0) and (2,0) (that would mean the
- // masked edge was drawn as a bridge).
- for i, part := range g.Lines {
- var has1, has2 bool
- for _, p := range part {
- if p[0] == 1 && p[1] == 0 {
- has1 = true
- }
- if p[0] == 2 && p[1] == 0 {
- has2 = true
- }
- }
- if has1 && has2 {
- t.Fatalf("part %d bridges the masked edge (1,0)->(2,0): %v", i, part)
- }
- }
-
- // Backward compat: the flat Coordinates must still contain ALL edges, including
- // the masked edge's points.
- if !flatHasPoint(g.Coordinates, 1, 0) || !flatHasPoint(g.Coordinates, 2, 0) {
- t.Fatalf("flat Coordinates missing masked edge endpoints: %v", g.Coordinates)
- }
- if !flatHasPoint(g.Coordinates, 0, 0) || !flatHasPoint(g.Coordinates, 3, 0) {
- t.Fatalf("flat Coordinates missing outer endpoints: %v", g.Coordinates)
- }
-}
-
-// TestLineMaskingUnmaskedSinglePart verifies a fully-unmasked line yields a single
-// contiguous part, and that an unmasked line (no masking info at all) leaves Lines
-// nil so existing renderers fall back to the flat Coordinates (no behaviour change).
-func TestLineMaskingUnmaskedSinglePart(t *testing.T) {
- spatialRecords := map[spatialKey]*spatialRecord{
- {RCNM: int(spatialTypeEdge), RCID: 1}: edgeRecord(1, [][]float64{{0, 0}, {1, 0}}, 0, 0),
- {RCNM: int(spatialTypeEdge), RCID: 2}: edgeRecord(2, [][]float64{{1, 0}, {2, 0}}, 0, 0),
- {RCNM: int(spatialTypeEdge), RCID: 3}: edgeRecord(3, [][]float64{{2, 0}, {3, 0}}, 0, 0),
- }
-
- // Case A: edges all explicitly Mask=2 (show) → masking info present, single part.
- featShown := &featureRecord{
- GeomPrim: 2,
- SpatialRefs: []spatialRef{
- {RCNM: int(spatialTypeEdge), RCID: 1, Orientation: 1, Mask: 2},
- {RCNM: int(spatialTypeEdge), RCID: 2, Orientation: 1, Mask: 2},
- {RCNM: int(spatialTypeEdge), RCID: 3, Orientation: 1, Mask: 2},
- },
- }
- g, err := constructLineStringGeometry(featShown, spatialRecords)
- if err != nil {
- t.Fatal(err)
- }
- if len(g.Lines) != 1 {
- t.Fatalf("expected 1 drawable part for fully-shown line, got %d: %v", len(g.Lines), g.Lines)
- }
- want := [][]float64{{0, 0}, {1, 0}, {2, 0}, {3, 0}}
- if len(g.Lines[0]) != len(want) {
- t.Fatalf("single part = %v, want %v", g.Lines[0], want)
- }
- for i, p := range g.Lines[0] {
- if p[0] != want[i][0] || p[1] != want[i][1] {
- t.Fatalf("single part point %d = %v, want %v", i, p, want[i])
- }
- }
-
- // Case B: no MASK/USAG info anywhere (Mask==0, Usage==0 means null/show). The
- // masking path must NOT engage → Lines nil, flat Coordinates intact.
- featNoMask := &featureRecord{
- GeomPrim: 2,
- SpatialRefs: []spatialRef{
- {RCNM: int(spatialTypeEdge), RCID: 1, Orientation: 1},
- {RCNM: int(spatialTypeEdge), RCID: 2, Orientation: 1},
- {RCNM: int(spatialTypeEdge), RCID: 3, Orientation: 1},
- },
- }
- gNo, err := constructLineStringGeometry(featNoMask, spatialRecords)
- if err != nil {
- t.Fatal(err)
- }
- if gNo.Lines != nil {
- t.Fatalf("expected Lines nil for an unmasked line (fall back to Coordinates), got %v", gNo.Lines)
- }
- if len(gNo.Coordinates) == 0 {
- t.Fatalf("expected flat Coordinates populated for unmasked line")
- }
-}
-
-// TestLineMaskingDataLimitEdge verifies USAG={3} (data-limit / truncated edge) is
-// treated like a masked edge: it must not be drawn and breaks the chain.
-func TestLineMaskingDataLimitEdge(t *testing.T) {
- spatialRecords := map[spatialKey]*spatialRecord{
- {RCNM: int(spatialTypeEdge), RCID: 1}: edgeRecord(1, [][]float64{{0, 0}, {1, 0}}, 0, 0),
- {RCNM: int(spatialTypeEdge), RCID: 2}: edgeRecord(2, [][]float64{{1, 0}, {2, 0}}, 0, 0),
- {RCNM: int(spatialTypeEdge), RCID: 3}: edgeRecord(3, [][]float64{{2, 0}, {3, 0}}, 0, 0),
- }
- feat := &featureRecord{
- GeomPrim: 2,
- SpatialRefs: []spatialRef{
- {RCNM: int(spatialTypeEdge), RCID: 1, Orientation: 1, Mask: 2},
- {RCNM: int(spatialTypeEdge), RCID: 2, Orientation: 1, Usage: 3}, // data-limit edge
- {RCNM: int(spatialTypeEdge), RCID: 3, Orientation: 1, Mask: 2},
- },
- }
- g, err := constructLineStringGeometry(feat, spatialRecords)
- if err != nil {
- t.Fatal(err)
- }
- if len(g.Lines) != 2 {
- t.Fatalf("expected 2 drawable parts around data-limit edge, got %d: %v", len(g.Lines), g.Lines)
- }
- // Backward compat: flat Coordinates still carry the data-limit edge.
- if !flatHasPoint(g.Coordinates, 1, 0) || !flatHasPoint(g.Coordinates, 2, 0) {
- t.Fatalf("flat Coordinates missing data-limit edge endpoints: %v", g.Coordinates)
- }
-}
-
-// TestLineMaskingTrailingMaskedEdge verifies a masked edge at the END of the chain
-// yields a single drawable part (no empty trailing part) but keeps all edges in the
-// flat Coordinates.
-func TestLineMaskingTrailingMaskedEdge(t *testing.T) {
- spatialRecords := map[spatialKey]*spatialRecord{
- {RCNM: int(spatialTypeEdge), RCID: 1}: edgeRecord(1, [][]float64{{0, 0}, {1, 0}}, 0, 0),
- {RCNM: int(spatialTypeEdge), RCID: 2}: edgeRecord(2, [][]float64{{1, 0}, {2, 0}}, 0, 0),
- {RCNM: int(spatialTypeEdge), RCID: 3}: edgeRecord(3, [][]float64{{2, 0}, {3, 0}}, 0, 0),
- }
- feat := &featureRecord{
- GeomPrim: 2,
- SpatialRefs: []spatialRef{
- {RCNM: int(spatialTypeEdge), RCID: 1, Orientation: 1, Mask: 2},
- {RCNM: int(spatialTypeEdge), RCID: 2, Orientation: 1, Mask: 2},
- {RCNM: int(spatialTypeEdge), RCID: 3, Orientation: 1, Mask: 1}, // masked tail
- },
- }
- g, err := constructLineStringGeometry(feat, spatialRecords)
- if err != nil {
- t.Fatal(err)
- }
- if len(g.Lines) != 1 {
- t.Fatalf("expected 1 drawable part for trailing-masked line, got %d: %v", len(g.Lines), g.Lines)
- }
- // The masked tail (3,0) must not be in the drawable part...
- if hasPoint(g.Lines, 3, 0) {
- t.Fatalf("masked tail (3,0) appears in drawable parts: %v", g.Lines)
- }
- // ...but must remain in the flat Coordinates.
- if !flatHasPoint(g.Coordinates, 3, 0) {
- t.Fatalf("flat Coordinates missing masked tail (3,0): %v", g.Coordinates)
- }
-}
diff --git a/internal/s57/parser/objectclass.go b/internal/s57/parser/objectclass.go
deleted file mode 100644
index 46d2d66..0000000
--- a/internal/s57/parser/objectclass.go
+++ /dev/null
@@ -1,288 +0,0 @@
-package parser
-
-import (
- _ "embed"
- "encoding/csv"
- "fmt"
- "strconv"
- "strings"
- "sync"
-)
-
-// S-57 Object Class lookup table
-// Source: IHO S-57 Edition 3.1 Appendix A - Object Catalogue (verified against 31ApAch1.pdf)
-var objectClassNames = map[int]string{
- 1: "ADMARE",
- 2: "AIRARE",
- 3: "ACHBRT",
- 4: "ACHARE",
- 5: "BCNCAR",
- 6: "BCNISD",
- 7: "BCNLAT",
- 8: "BCNSAW",
- 9: "BCNSPP",
- 10: "BERTHS",
- 11: "BRIDGE",
- 12: "BUISGL",
- 13: "BUAARE",
- 14: "BOYCAR",
- 15: "BOYINB",
- 16: "BOYISD",
- 17: "BOYLAT",
- 18: "BOYSAW",
- 19: "BOYSPP",
- 20: "CBLARE",
- 21: "CBLOHD",
- 22: "CBLSUB",
- 23: "CANALS",
- 24: "CANBNK",
- 25: "CTSARE",
- 26: "CAUSWY",
- 27: "CTNARE",
- 28: "CHKPNT",
- 29: "CGUSTA",
- 30: "COALNE",
- 31: "CONZNE",
- 32: "COSARE",
- 33: "CTRPNT",
- 34: "CONVYR",
- 35: "CRANES",
- 36: "CURENT",
- 37: "CUSZNE",
- 38: "DAMCON",
- 39: "DAYMAR",
- 40: "DWRTCL",
- 41: "DWRTPT",
- 42: "DEPARE",
- 43: "DEPCNT",
- 44: "DISMAR",
- 45: "DOCARE",
- 46: "DRGARE",
- 47: "DRYDOC",
- 48: "DMPGRD",
- 49: "DYKCON",
- 50: "EXEZNE",
- 51: "FAIRWY",
- 52: "FNCLNE",
- 53: "FERYRT",
- 54: "FSHZNE",
- 55: "FSHFAC",
- 56: "FSHGRD",
- 57: "FLODOC",
- 58: "FOGSIG",
- 59: "FORSTC",
- 60: "FRPARE",
- 61: "GATCON",
- 62: "GRIDRN",
- 63: "HRBARE",
- 64: "HRBFAC",
- 65: "HULKES",
- 66: "ICEARE",
- 67: "ICNARE",
- 68: "ISTZNE",
- 69: "LAKARE",
- 70: "LAKSHR",
- 71: "LNDARE",
- 72: "LNDELV",
- 73: "LNDRGN",
- 74: "LNDMRK",
- 75: "LIGHTS",
- 76: "LITFLT",
- 77: "LITVES",
- 78: "LOCMAG",
- 79: "LOKBSN",
- 80: "LOGPON",
- 81: "MAGVAR",
- 82: "MARCUL",
- 83: "MIPARE",
- 84: "MORFAC",
- 85: "NAVLNE",
- 86: "OBSTRN",
- 87: "OFSPLF",
- 88: "OSPARE",
- 89: "OILBAR",
- 90: "PILPNT",
- 91: "PILBOP",
- 92: "PIPARE",
- 93: "PIPOHD",
- 94: "PIPSOL",
- 95: "PONTON",
- 96: "PRCARE",
- 97: "PRDARE",
- 98: "PYLONS",
- 99: "RADLNE",
- 100: "RADRNG",
- 101: "RADRFL",
- 102: "RADSTA",
- 103: "RTPBCN",
- 104: "RDOCAL",
- 105: "RDOSTA",
- 106: "RAILWY",
- 107: "RAPIDS",
- 108: "RCRTCL",
- 109: "RECTRC",
- 110: "RCTLPT",
- 111: "RSCSTA",
- 112: "RESARE",
- 113: "RETRFL",
- 114: "RIVERS",
- 115: "RIVBNK",
- 116: "ROADWY",
- 117: "RUNWAY",
- 118: "SNDWAV",
- 119: "SEAARE",
- 120: "SPLARE",
- 121: "SBDARE",
- 122: "SLCONS",
- 123: "SISTAT",
- 124: "SISTAW",
- 125: "SILTNK",
- 126: "SLOTOP",
- 127: "SLOGRD",
- 128: "SMCFAC",
- 129: "SOUNDG",
- 130: "SPRING",
- 131: "SQUARE",
- 132: "STSLNE",
- 133: "SUBTLN",
- 134: "SWPARE",
- 135: "TESARE",
- 136: "TS_PRH",
- 137: "TS_PNH",
- 138: "TS_PAD",
- 139: "TS_TIS",
- 140: "T_HMON",
- 141: "T_NHMN",
- 142: "T_TIMS",
- 143: "TIDEWY",
- 144: "TOPMAR",
- 145: "TSELNE",
- 146: "TSSBND",
- 147: "TSSCRS",
- 148: "TSSLPT",
- 149: "TSSRON",
- 150: "TSEZNE",
- 151: "TUNNEL",
- 152: "TWRTPT",
- 153: "UWTROC",
- 154: "UNSARE",
- 155: "VEGATN",
- 156: "WATTUR",
- 157: "WATFAL",
- 158: "WEDKLP",
- 159: "WRECKS",
- 160: "TS_FEB", // Tidal stream - flood/ebb (S-57 App A §1.173; was missing → rendered QUESMRK)
- 161: "ARCSLN", // Archipelagic Sea Lane (area) → S-101 ArchipelagicSeaLaneArea
- 162: "ASLXIS", // Archipelagic Sea Lane Axis (line) → S-101 ArchipelagicSeaLaneAxis
- // NEWOBJ (code 163) is not in the base Ed 3.1 catalogue but is the ENC
- // convention for producer "new objects"; it carries SYMINS and the PresLib
- // NEWOBJ lookup routes SYMINS-bearing features through CS(SYMINS02). Used by
- // the S-64 test data (V-AIS, temporary/preliminary NtoM).
- 163: "NEWOBJ",
- 300: "M_ACCY",
- 301: "M_CSCL",
- 302: "M_COVR",
- 303: "M_HDAT",
- 304: "M_HOPA",
- 305: "M_NPUB",
- 306: "M_NSYS",
- 307: "M_PROD",
- 308: "M_QUAL",
- 309: "M_SDAT",
- 310: "M_SREL",
- 311: "M_UNIT",
- 312: "M_VDAT",
- 400: "C_AGGR",
- 401: "C_ASSO",
- 402: "C_STAC",
-}
-
-// S-57 attribute catalogue CSV from GDAL project
-// Source: https://gdal.org/ - licensed under MIT/X11
-// This file maps S-57 attribute codes to their standard acronyms per IHO S-57 Appendix A Chapter 2
-//
-//go:embed s57attributes.csv
-var s57AttributesCSV string
-
-var (
- attributeNames map[int]string
- attributeNamesOnce sync.Once
-)
-
-// loadAttributeNames loads the S-57 attribute catalogue from embedded CSV
-func loadAttributeNames() {
- attributeNames = make(map[int]string)
-
- reader := csv.NewReader(strings.NewReader(s57AttributesCSV))
- records, err := reader.ReadAll()
- if err != nil {
- // Fall back to empty map on error
- return
- }
-
- // Skip header row
- for _, record := range records[1:] {
- if len(record) < 3 {
- continue
- }
-
- // Parse: Code, Attribute, Acronym, ...
- code, err := strconv.Atoi(strings.Trim(record[0], "\""))
- if err != nil {
- continue
- }
-
- acronym := strings.Trim(record[2], "\"")
- if acronym != "" {
- attributeNames[code] = acronym
- }
- }
-}
-
-// AttributeCodeToString converts S-57 numeric attribute code to string acronym
-// S-57 Appendix A Chapter 2: Attribute Catalogue
-func AttributeCodeToString(code int) string {
- // Lazy load attribute names from CSV
- attributeNamesOnce.Do(loadAttributeNames)
-
- if name, ok := attributeNames[code]; ok {
- return name
- }
- // Unknown attribute - return generic code
- return fmt.Sprintf("ATTR_%d", code)
-}
-
-// ObjectClassToString converts S-57 numeric object class to string code
-// The object class codes are read from the S-57 file's FRID records
-// and mapped using the S-57 Object Catalogue
-// S-57 Appendix A: Object Catalogue
-func ObjectClassToString(code int) (string, error) {
- if code <= 0 {
- return "", &ErrUnknownObjectClass{Code: code}
- }
-
- // Look up in object class table
- if name, ok := objectClassNames[code]; ok {
- return name, nil
- }
-
- // Unknown object class - return numeric code
- return fmt.Sprintf("OBJL_%d", code), nil
-}
-
-// ObjectClassToInt converts string code to numeric object class
-// S-57 object classes are identified by numeric codes in the binary data
-func ObjectClassToInt(code string) (int, error) {
- // TODO: Implement reverse lookup from object catalogue
- return 0, fmt.Errorf("object class lookup not yet implemented: %s", code)
-}
-
-// IsSupported checks if an object class code is valid
-// All object classes defined in S-57 standard are "supported" for parsing
-// The question is whether we have rendering logic for them (deferred to later iterations)
-func IsSupported(code int) bool {
- // Any positive object class code is valid for parsing
- // We parse all object classes generically (geometry + attributes)
- // Rendering/styling logic will filter which ones to display
- return code > 0
-}
diff --git a/internal/s57/parser/parser.go b/internal/s57/parser/parser.go
deleted file mode 100644
index 88cc2b7..0000000
--- a/internal/s57/parser/parser.go
+++ /dev/null
@@ -1,535 +0,0 @@
-package parser
-
-import (
- "encoding/binary"
- "fmt"
- "io/fs"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-// Parser parses S-57 ENC files and extracts features.
-//
-// S-57 defines an "exchange set" as a collection of files for transferring hydrographic data.
-// Each file contains records (metadata, features, spatial data) structured per ISO 8211.
-// This parser reads the ISO 8211 structure and interprets it according to S-57 semantics.
-//
-// References:
-// - S-57 Part 1 (31Main.pdf p1.1): Definition of "exchange set"
-// - S-57 Part 3 §7 (31Main.pdf p94): Complete record and field structure specification
-type Parser interface {
- // Parse reads an S-57 file and returns extracted chart
- // Returns error if file cannot be read or parsed
- Parse(filename string) (*Chart, error)
-
- // ParseWithOptions parses with custom options
- ParseWithOptions(filename string, opts ParseOptions) (*Chart, error)
-
- // SupportedObjectClasses returns list of supported S-57 object classes
- SupportedObjectClasses() []string
-}
-
-// ParseOptions configures parsing behavior
-type ParseOptions struct {
- // SkipUnknownFeatures: if true, skip features with unknown object classes
- // Default: false (return error on unknown types)
- SkipUnknownFeatures bool
-
- // ValidateGeometry: if true, validate all coordinates and geometry rules
- // Default: true
- ValidateGeometry bool
-
- // ObjectClassFilter: if non-empty, only extract these object classes
- // Empty means extract all supported types
- ObjectClassFilter []string
-
- // ApplyUpdates: if true, automatically discover and apply update files (.001, .002, etc.)
- // Default: true
- ApplyUpdates bool
-
- // ValidateConformance: if true, any S-57 / ISO-8211 spec deviation detected
- // during parsing (see conformance.go) is promoted to a parse error instead of
- // a non-fatal warning. Default: false — deviations are collected on the Chart
- // (Chart.Warnings) and the cell still renders.
- ValidateConformance bool
-
- // MaskCoastlineCoincidentBoundaries: if true, DERIVE coastline-coincident edge
- // masking for area features. S-57 Appendix B.1 Annex A §17 scenario 2 says area
- // boundary edges that coincide with the coastline should be masked to avoid
- // clutter, but the masking flag (FSPT MASK=1) is a producer choice that NOAA
- // cells never set. When this option is on, any area feature's drawn boundary
- // edge whose RCID is ALSO referenced by a COALNE feature is dropped from
- // BoundaryLines (the drawn border), while the fill (Rings) and flat Coordinates
- // are left intact. The coast-definer LNDARE is exempt, so the visible coast is
- // still drawn (by COALNE and LNDARE's own boundary). Default: false.
- MaskCoastlineCoincidentBoundaries bool
-
- // Fs is the filesystem to use for reading files
- // If nil, the OS filesystem is used
- Fs fs.FS
-}
-
-// DefaultParseOptions returns parse options with defaults
-func DefaultParseOptions() ParseOptions {
- return ParseOptions{
- SkipUnknownFeatures: false,
- ValidateGeometry: true,
- ObjectClassFilter: nil,
- ApplyUpdates: true,
- }
-}
-
-// defaultParser implements the Parser interface
-type defaultParser struct {
-}
-
-// NewParser creates a new S-57 parser
-func NewParser() Parser {
- return &defaultParser{}
-}
-
-// DefaultParser returns parser with default options
-func DefaultParser() (Parser, error) {
- return NewParser(), nil
-}
-
-// Parse reads an S-57 file and returns extracted chart
-func (p *defaultParser) Parse(filename string) (*Chart, error) {
- return p.ParseWithOptions(filename, DefaultParseOptions())
-}
-
-// ParseWithOptions parses with custom options
-func (p *defaultParser) ParseWithOptions(filename string, opts ParseOptions) (*Chart, error) {
- // Use OS filesystem if none specified
- fsys := opts.Fs
- if fsys == nil {
- fsys = iso8211.OSFS()
- }
-
- // Collector for non-fatal spec-conformance deviations (see conformance.go).
- // In strict mode (ValidateConformance) these are promoted to an error below;
- // otherwise they are attached to the returned Chart.
- conf := &conformance{}
-
- // 1. Parse base file and extract raw records
- baseData, params, metadata, err := parseBaseFile(fsys, filename, opts, conf)
- if err != nil {
- return nil, err
- }
- baseData.warnings = conf
-
- // 2. Discover and apply updates if enabled
- if opts.ApplyUpdates {
- updateFiles, err := findUpdateFiles(fsys, filename)
- if err != nil {
- return nil, fmt.Errorf("failed to discover update files: %w", err)
- }
- if len(updateFiles) > 0 {
- if err := applyUpdates(fsys, baseData, updateFiles, params); err != nil {
- return nil, fmt.Errorf("failed to apply updates: %w", err)
- }
- }
- }
-
- // 3. Build final chart with geometries
- chart, err := buildChart(baseData, metadata, params, opts)
- if err != nil {
- return nil, err
- }
-
- // 4. Surface conformance deviations: error in strict mode, else attach.
- if opts.ValidateConformance {
- if cerr := conf.asError(); cerr != nil {
- return nil, cerr
- }
- }
- chart.warnings = conf.warnings()
- return chart, nil
-}
-
-// parseBaseFile extracts raw feature and spatial records without building geometries.
-// This allows update files to be applied before geometry construction.
-func parseBaseFile(fsys fs.FS, filename string, opts ParseOptions, conf *conformance) (*chartData, datasetParams, *datasetMetadata, error) {
- // Open ISO 8211 file from filesystem using OpenFS
- parser, err := iso8211.OpenFS(fsys, filename)
- if err != nil {
- return nil, datasetParams{}, nil, fmt.Errorf("failed to open file: %w", err)
- }
- defer parser.Close()
-
- // Parse ISO 8211 structure
- isoFile, err := parser.Parse()
- if err != nil {
- return nil, datasetParams{}, nil, fmt.Errorf("failed to parse ISO 8211: %w", err)
- }
-
- // ISO/IEC 8211 leader conformance (Annex A.2.2 fixed values).
- validateLeaders(isoFile, conf)
-
- // Extract dataset parameters (COMF, SOMF, etc.) from DSPM record
- params := extractDatasetParams(isoFile)
- // DSPM coordinate/sounding multipliers (§7.3.2.1): a non-positive COMF/SOMF is
- // invalid; we fall back to the standard 10^7 / 10 to keep rendering, but report.
- if params.comfDefaulted {
- conf.add("7.3.2.1", "DSPM_COMF_INVALID", "DSPM COMF was missing or <= 0; defaulted to 10000000")
- }
- if params.somfDefaulted {
- conf.add("7.3.2.1", "DSPM_SOMF_INVALID", "DSPM SOMF was missing or <= 0; defaulted to 10")
- }
-
- // Extract dataset metadata from DSID record
- metadata := extractDSID(isoFile)
-
- // Extract feature records (without geometry)
- features := []*featureRecord{}
- featuresByID := make(map[featureID]*featureRecord)
- for _, record := range isoFile.Records {
- if featureRec := parseFeatureRecord(record); featureRec != nil {
- validateFeatureConformance(featureRec, conf)
- features = append(features, featureRec)
- // Create composite key from FOID fields
- key := featureID{
- AGEN: featureRec.AGEN,
- FIDN: featureRec.FIDN,
- FIDS: featureRec.FIDS,
- }
- featuresByID[key] = featureRec
- }
- }
-
- // Extract spatial records
- spatialRecords := make(map[spatialKey]*spatialRecord)
- for _, record := range isoFile.Records {
- if spatialRec := parseSpatialRecordWithParams(record, params); spatialRec != nil {
- validateSpatialConformance(spatialRec, conf)
- key := spatialKey{RCNM: int(spatialRec.RecordType), RCID: spatialRec.ID}
- spatialRecords[key] = spatialRec
- }
- }
-
- return &chartData{
- features: features,
- spatialRecords: spatialRecords,
- metadata: metadata,
- featuresByID: featuresByID,
- }, params, metadata, nil
-}
-
-// buildChart constructs final Chart with geometries from merged data.
-// This is called after all updates have been applied to the raw records.
-func buildChart(data *chartData, metadata *datasetMetadata, params datasetParams, opts ParseOptions) (*Chart, error) {
- // Build geometries for all features
- finalFeatures := []Feature{}
-
- // Derived coastline-coincident edge masking (S-57 App. B.1 Annex A §17 scn 2).
- // Build the set of edge RCIDs referenced by ANY coast-definer (COALNE, LNDARE,
- // SLCONS — see coastDefinerClasses) once; other area features then drop boundary
- // edges that share these RCIDs. See ParseOptions.MaskCoastlineCoincidentBoundaries.
- var coastEdges map[int64]bool
- if opts.MaskCoastlineCoincidentBoundaries {
- coastEdges = map[int64]bool{}
- for _, fr := range data.features {
- if objClass, _ := ObjectClassToString(fr.ObjectClass); !coastDefinerClasses[objClass] {
- continue
- }
- for _, ref := range fr.SpatialRefs {
- // Coast-definers reference edges directly (lines) or via a face (areas).
- // Accept direct edge refs (RCNM=130 / unknown) and, for area-typed
- // definers like LNDARE, edges pulled from any referenced face's VRPT.
- if ref.RCNM == 0 || ref.RCNM == int(spatialTypeEdge) {
- coastEdges[ref.RCID] = true
- continue
- }
- if ref.RCNM == int(spatialTypeFace) {
- if face, ok := data.spatialRecords[spatialKey{RCNM: ref.RCNM, RCID: ref.RCID}]; ok {
- for _, ptr := range face.VectorPointers {
- if ptr.TargetRCNM == int(spatialTypeEdge) {
- coastEdges[ptr.TargetRCID] = true
- }
- }
- }
- }
- }
- }
- }
-
- for _, featureRec := range data.features {
- // Convert object class code to string once (needed for filter, masking, and
- // the final Feature). Errors are deferred to the existing handling below.
- objClass, objClassErr := ObjectClassToString(featureRec.ObjectClass)
-
- // Check object class filter
- if len(opts.ObjectClassFilter) > 0 {
- if !contains(opts.ObjectClassFilter, objClass) {
- continue // Filtered out
- }
- }
-
- // Derived coastline masking applies only to area features (GeomPrim=3) that
- // are not coast-definers (LNDARE). Suppression touches only BoundaryLines.
- maskCoast := opts.MaskCoastlineCoincidentBoundaries &&
- featureRec.GeomPrim == 3 && !isCoastlineMaskExempt(objClass)
-
- // Construct geometry from spatial records
- geometry, err := constructGeometry(featureRec, data.spatialRecords, coastEdges, maskCoast)
- if err != nil {
- if opts.SkipUnknownFeatures {
- continue // Skip this feature
- }
- // Add context about which feature failed
- return nil, fmt.Errorf("feature ID=%d, ObjectClass=%s (OBJL=%d), GeomPrim=%d: %w",
- featureRec.ID, objClass, featureRec.ObjectClass, featureRec.GeomPrim, err)
- }
-
- // Apply geometry validation if enabled
- if opts.ValidateGeometry {
- if err := ValidateGeometry(&geometry); err != nil {
- if opts.SkipUnknownFeatures {
- continue
- }
- return nil, fmt.Errorf("feature %d: %w", featureRec.ID, err)
- }
- }
-
- // Surface any object-class decode error (computed once above).
- if objClassErr != nil {
- if opts.SkipUnknownFeatures {
- continue
- }
- return nil, objClassErr
- }
-
- // Create feature
- feature := Feature{
- ID: featureRec.ID,
- ObjectClass: objClass,
- Geometry: geometry,
- Attributes: featureRec.Attributes,
- }
-
- finalFeatures = append(finalFeatures, feature)
- }
-
- return &Chart{
- metadata: metadata,
- params: params,
- Features: finalFeatures,
- spatialRecords: data.spatialRecords, // Keep for potential future updates
- }, nil
-}
-
-// extractDSID extracts and parses the DSID record from the ISO 8211 file.
-//
-// DSID (Data Set Identification) is the first field in every S-57 dataset's general
-// information record. It contains critical metadata like edition number, issue date,
-// update information, and the S-57 version used. This function searches all records
-// for the DSID field and parses it into a structured datasetMetadata object.
-//
-// Reference: S-57 Part 3 §7.3.1.1 (31Main.pdf p64, table 7.4): Complete DSID
-// field structure showing all 16 subfields including their formats and meanings.
-func extractDSID(isoFile *iso8211.ISO8211File) *datasetMetadata {
- // Look for DSID record (Dataset Identification)
- for _, record := range isoFile.Records {
- if dsidData, ok := record.Fields["DSID"]; ok {
- return parseDSID(dsidData)
- }
- }
- return nil
-}
-
-// parseDSID parses DSID field binary data into a datasetMetadata structure.
-//
-// The DSID field uses a mixed format with both fixed-length binary fields and
-// variable-length ASCII fields. Binary fields (RCNM, RCID, EXPP, INTU, etc.) come
-// first at fixed offsets, followed by ASCII fields terminated by 0x1F (unit separator).
-// This two-phase structure allows efficient parsing while supporting variable-length
-// text like dataset names and comments.
-//
-// Parsing strategy:
-// - Phase 1: Read fixed-length binary fields at known offsets
-// - Phase 2: Read variable-length ASCII fields sequentially until 0x1F delimiter
-//
-// Reference: S-57 Part 3 §7.3.1.1 (31Main.pdf p64, table 7.4):
-// Shows complete field structure with format codes:
-// - b11 = 1-byte binary
-// - b12 = 2-byte binary (uint16 LE)
-// - b14 = 4-byte binary (uint32 LE)
-// - A( ) = variable-length ASCII
-// - R(4) = 4-character real number
-func parseDSID(data []byte) *datasetMetadata {
- dsid := &datasetMetadata{}
-
- // Minimum size check: RCNM(1) + RCID(4) + EXPP(1) + INTU(1) = 7 bytes minimum
- if len(data) < 7 {
- return dsid
- }
-
- offset := 0
-
- // RCNM (1 byte) - Record name. Per table 2.2 (31Main.pdf p3.8), value 10 = "DS" (Dataset)
- // This identifies the record type in the S-57 data structure.
- if offset < len(data) {
- dsid.rcnm = int(data[offset])
- offset++
- }
-
- // RCID (4 bytes, uint32 LE) - Record identification number
- // Combined with RCNM, forms unique record key within the file (31Main.pdf p3.7 §2.2)
- if offset+4 <= len(data) {
- dsid.rcid = int64(binary.LittleEndian.Uint32(data[offset : offset+4]))
- offset += 4
- }
-
- // EXPP (1 byte) - Exchange purpose: 1=New dataset, 2=Revision
- // Indicates whether this is original data or an update (table 7.4)
- if offset < len(data) {
- dsid.expp = int(data[offset])
- offset++
- }
-
- // INTU (1 byte) - Intended usage
- // Numeric code indicating data purpose (defined in product specifications)
- if offset < len(data) {
- dsid.intu = int(data[offset])
- offset++
- }
-
- // Variable-length ASCII fields follow, each terminated by 0x1F (Unit Separator).
- // Per ISO 8211, 0x1F marks the boundary between subfields in variable-length data.
- // We read sequentially: scan until 0x1F, extract string, skip delimiter, repeat.
- extractASCII := func() string {
- if offset >= len(data) {
- return ""
- }
- start := offset
- for offset < len(data) && data[offset] != 0x1F {
- offset++
- }
- result := string(data[start:offset])
- if offset < len(data) && data[offset] == 0x1F {
- offset++ // Skip unit separator
- }
- return result
- }
-
- // DSNM - Data set name. Primary identifier for the chart cell (e.g., "US5MA22M").
- dsid.dsnm = extractASCII()
-
- // EDTN - Edition number. String representing the chart edition (e.g., "12").
- dsid.edtn = extractASCII()
-
- // UPDN - Update number. String showing cumulative update count (e.g., "5").
- dsid.updn = extractASCII()
-
- // UADT - Update application date. A(8) fixed-length field. Format: YYYYMMDD.
- // All updates on or before this date must be applied to have current data.
- // This is a FIXED 8-byte ASCII field, NOT 0x1F-terminated.
- if offset+8 <= len(data) {
- dsid.uadt = strings.TrimRight(string(data[offset:offset+8]), "\x00 ")
- offset += 8
- }
-
- // ISDT - Issue date. A(8) fixed-length field. Format: YYYYMMDD.
- // When the dataset was released by producer.
- // This is a FIXED 8-byte ASCII field, NOT 0x1F-terminated.
- if offset+8 <= len(data) {
- dsid.isdt = strings.TrimRight(string(data[offset:offset+8]), "\x00 ")
- offset += 8
- }
-
- // STED - Edition number of S-57 standard. R(4) fixed-length field.
- // Real number as 4-byte ASCII (e.g., "3.1" or "03.1" for Edition 3.1).
- // This is a FIXED 4-byte ASCII field, NOT 0x1F-terminated.
- if offset+4 <= len(data) {
- dsid.sted = strings.TrimRight(string(data[offset:offset+4]), "\x00 ")
- offset += 4
- }
-
- // PRSP (1 byte) - Product specification code
- // 1 = ENC (Electronic Navigational Chart)
- // 2 = ODD (Object Catalogue Data Dictionary)
- // Returned to binary format after the ASCII fields above.
- if offset < len(data) {
- dsid.prsp = int(data[offset])
- offset++
- }
-
- // PSDN - Product specification description. Human-readable name for non-standard specs.
- dsid.psdn = extractASCII()
-
- // PRED - Product specification edition number. Version of the product spec used.
- dsid.pred = extractASCII()
-
- // PROF (1 byte) - Application profile identification
- // 1 = EN (ENC New edition), 2 = ER (ENC Revision), 3 = DD (Data Dictionary)
- // Defines the data exchange profile being used.
- if offset < len(data) {
- dsid.prof = int(data[offset])
- offset++
- }
-
- // AGEN (2 bytes, uint16 LE) - Producing agency code
- // References IHO agency code table (see Appendix A - Object Catalogue).
- // Example: 550 = NOAA (United States).
- if offset+2 <= len(data) {
- dsid.agen = int(binary.LittleEndian.Uint16(data[offset : offset+2]))
- offset += 2
- }
-
- // COMT - Comment. Free-form text, last field, may extend to end of data.
- dsid.comt = extractASCII()
-
- return dsid
-}
-
-// SupportedObjectClasses returns list of supported S-57 object classes
-func (p *defaultParser) SupportedObjectClasses() []string {
- // All object classes are supported - read dynamically from file
- return []string{"All object classes supported - read dynamically from file"}
-}
-
-// coastDefinerClasses are the object classes that DEFINE the visible coast / shore
-// edge. They play two roles in derived coastline-coincident masking:
-// 1. their boundary edge RCIDs form the "coast edge set" (see buildChart), and
-// 2. they are EXEMPT from masking — they keep their own coincident edges so the
-// shore stays drawn.
-//
-// COALNE (coastline) and SLCONS (shoreline construction: piers, wharves, seawalls)
-// are usually lines; LNDARE (land area) is the area whose boundary IS the shore.
-// In NOAA cells the land/water boundary is frequently encoded only as an LNDARE
-// (or SLCONS) edge with no coincident COALNE — so masking against COALNE alone
-// leaves stray boundary "chevrons" along the coast. Including all three catches
-// them. Add classes here to extend the set.
-var coastDefinerClasses = map[string]bool{
- "COALNE": true,
- "LNDARE": true,
- "SLCONS": true,
-}
-
-// boundaryNeverMaskedClasses are area classes whose drawn boundary is a DISTINCT
-// symbolized line (not the plain coast/shore), so it must NOT be coastline-masked even
-// where it happens to share an edge with a coast-definer. A production/storage area
-// (PRDARE) sitting on an LNDARE shares the box edge with the land area, but its dashed
-// boundary is meaningful symbology — masking it (S-52 §17.2 is only about redundant
-// coast lines) would drop the production-area outline entirely.
-var boundaryNeverMaskedClasses = map[string]bool{
- "PRDARE": true,
-}
-
-// isCoastlineMaskExempt reports whether an area object class is exempt from derived
-// coastline-coincident boundary masking — either a coast-definer (keeps the shore) or
-// a class whose boundary is a distinct symbolized line (boundaryNeverMaskedClasses).
-func isCoastlineMaskExempt(objClass string) bool {
- return coastDefinerClasses[objClass] || boundaryNeverMaskedClasses[objClass]
-}
-
-// contains checks if a slice contains a string
-func contains(slice []string, item string) bool {
- for _, s := range slice {
- if s == item {
- return true
- }
- }
- return false
-}
diff --git a/internal/s57/parser/parser_test.go b/internal/s57/parser/parser_test.go
deleted file mode 100644
index b599d18..0000000
--- a/internal/s57/parser/parser_test.go
+++ /dev/null
@@ -1,316 +0,0 @@
-package parser
-
-import (
- "encoding/binary"
- "fmt"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-// BenchmarkParse benchmarks parsing a real S-57 chart
-func BenchmarkParse(b *testing.B) {
- parser, err := DefaultParser()
- if err != nil {
- b.Fatalf("failed to create parser: %v", err)
- }
-
- opts := ParseOptions{
- SkipUnknownFeatures: true,
- ValidateGeometry: true,
- }
-
- // Run the parse operation b.N times
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _, err := parser.ParseWithOptions("../../../testdata/US4MD81M.000", opts)
- if err != nil {
- b.Fatalf("parse failed: %v", err)
- }
- }
-}
-
-// BenchmarkVRPTResolution benchmarks just the VRPT resolution logic
-func BenchmarkVRPTResolution(b *testing.B) {
- // Create test spatial records with VRPT chains
- spatialRecords := map[spatialKey]*spatialRecord{
- {RCNM: int(spatialTypeIsolatedNode), RCID: 100}: {
- ID: 100,
- RecordType: spatialTypeIsolatedNode,
- Coordinates: [][]float64{
- {-71.0, 42.0}, {-71.1, 42.1}, {-71.2, 42.2},
- },
- },
- {RCNM: int(spatialTypeEdge), RCID: 200}: {
- ID: 200,
- RecordType: spatialTypeEdge,
- VectorPointers: []vectorPointer{
- {TargetRCID: 100, Orientation: 1},
- },
- },
- {RCNM: int(spatialTypeEdge), RCID: 300}: {
- ID: 300,
- RecordType: spatialTypeEdge,
- VectorPointers: []vectorPointer{
- {TargetRCID: 200, Orientation: 1},
- },
- },
- }
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _ = resolveVectorPointers(spatialRecords[spatialKey{RCNM: int(spatialTypeEdge), RCID: 300}], spatialRecords)
- }
-}
-
-func TestParseDSID(t *testing.T) {
- // Create a mock DSID field with all fields
- data := make([]byte, 0, 256)
-
- // RCNM (1 byte) = 10
- data = append(data, 10)
-
- // RCID (4 bytes) = 1
- rcidBytes := make([]byte, 4)
- binary.LittleEndian.PutUint32(rcidBytes, 1)
- data = append(data, rcidBytes...)
-
- // EXPP (1 byte) = 1 (New)
- data = append(data, 1)
-
- // INTU (1 byte) = 1
- data = append(data, 1)
-
- // DSNM (variable) = "GB5X01NE"
- data = append(data, []byte("GB5X01NE")...)
- data = append(data, 0x1F) // Unit separator
-
- // EDTN (variable) = "2"
- data = append(data, []byte("2")...)
- data = append(data, 0x1F)
-
- // UPDN (variable) = "0"
- data = append(data, []byte("0")...)
- data = append(data, 0x1F)
-
- // UADT (fixed A(8)) = "20250107"
- data = append(data, []byte("20250107")...)
-
- // ISDT (fixed A(8)) = "20240101"
- data = append(data, []byte("20240101")...)
-
- // STED (fixed R(4)) = "03.1"
- data = append(data, []byte("03.1")...)
-
- // PRSP (1 byte) = 1 (ENC)
- data = append(data, 1)
-
- // PSDN (variable) = "ENC"
- data = append(data, []byte("ENC")...)
- data = append(data, 0x1F)
-
- // PRED (variable) = "2.0"
- data = append(data, []byte("2.0")...)
- data = append(data, 0x1F)
-
- // PROF (1 byte) = 1 (EN - ENC New)
- data = append(data, 1)
-
- // AGEN (2 bytes) = 540 (UK Hydrographic Office)
- agenBytes := make([]byte, 2)
- binary.LittleEndian.PutUint16(agenBytes, 540)
- data = append(data, agenBytes...)
-
- // COMT (variable) = "Test chart"
- data = append(data, []byte("Test chart")...)
- data = append(data, 0x1F)
-
- // Parse the data
- dsid := parseDSID(data)
-
- // Verify all fields via accessor methods
- tests := []struct {
- name string
- got interface{}
- expected interface{}
- }{
- {"RCNM", dsid.rcnm, 10},
- {"RCID", dsid.rcid, int64(1)},
- {"EXPP", dsid.expp, 1},
- {"INTU", dsid.intu, 1},
- {"DSNM", dsid.DatasetName(), "GB5X01NE"},
- {"EDTN", dsid.Edition(), "2"},
- {"UPDN", dsid.UpdateNumber(), "0"},
- {"UADT", dsid.UpdateDate(), "20250107"},
- {"ISDT", dsid.IssueDate(), "20240101"},
- {"STED", dsid.S57Edition(), "03.1"},
- {"PRSP", dsid.prsp, 1},
- {"PSDN", dsid.psdn, "ENC"},
- {"PRED", dsid.pred, "2.0"},
- {"PROF", dsid.prof, 1},
- {"AGEN", dsid.ProducingAgency(), 540},
- {"COMT", dsid.Comment(), "Test chart"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if tt.got != tt.expected {
- t.Errorf("%s: got %v, expected %v", tt.name, tt.got, tt.expected)
- }
- })
- }
-}
-
-func TestParseDSIDMinimal(t *testing.T) {
- // Test with minimal data (just fixed fields)
- data := make([]byte, 0, 64)
-
- // RCNM (1 byte) = 10
- data = append(data, 10)
-
- // RCID (4 bytes) = 1
- rcidBytes := make([]byte, 4)
- binary.LittleEndian.PutUint32(rcidBytes, 1)
- data = append(data, rcidBytes...)
-
- // EXPP (1 byte) = 1 (New)
- data = append(data, 1)
-
- // INTU (1 byte) = 1
- data = append(data, 1)
-
- // DSNM only
- data = append(data, []byte("TEST")...)
- data = append(data, 0x1F)
-
- dsid := parseDSID(data)
-
- if dsid.rcnm != 10 {
- t.Errorf("RCNM: got %d, expected 10", dsid.rcnm)
- }
- if dsid.DatasetName() != "TEST" {
- t.Errorf("DSNM: got %s, expected TEST", dsid.DatasetName())
- }
- // Other fields should be empty/zero
- if dsid.Edition() != "" {
- t.Errorf("EDTN: got %s, expected empty", dsid.Edition())
- }
-}
-
-func TestParseDSIDEmpty(t *testing.T) {
- // Test with empty/truncated data
- data := []byte{}
- dsid := parseDSID(data)
-
- if dsid == nil {
- t.Error("parseDSID returned nil for empty data")
- }
- if dsid.DatasetName() != "" {
- t.Errorf("DSNM should be empty for empty data, got: %s", dsid.DatasetName())
- }
-}
-
-func TestParserPopulatesMetadata(t *testing.T) {
- // Test that parser properly populates metadata in Chart
- parser := NewParser()
-
- // Parse a test file with validation disabled to avoid test data issues
- opts := ParseOptions{
- SkipUnknownFeatures: true,
- ValidateGeometry: false,
- }
- chart, err := parser.ParseWithOptions("../../../testdata/US4MD81M.000", opts)
- if err != nil {
- t.Fatalf("Failed to parse test file: %v", err)
- }
-
- // Verify metadata is populated via accessor methods
- if chart.DatasetName() == "" {
- t.Error("Chart dataset name is empty")
- }
-
- // Log metadata for inspection
- t.Logf("Chart Metadata:")
- t.Logf(" DSNM (Chart Name): %s", chart.DatasetName())
- t.Logf(" EDTN (Edition): %s", chart.Edition())
- t.Logf(" UPDN (Update): %s", chart.UpdateNumber())
- t.Logf(" ISDT (Issue Date): %s", chart.IssueDate())
- t.Logf(" STED (S-57 Ed): %s", chart.S57Edition())
- t.Logf(" EXPP (Purpose): %s", chart.ExchangePurpose())
- t.Logf(" PRSP (Product): %s", chart.ProductSpecification())
- t.Logf(" PROF (Profile): %s", chart.ApplicationProfile())
- t.Logf(" AGEN (Agency): %d", chart.ProducingAgency())
- if chart.Comment() != "" {
- t.Logf(" COMT (Comment): %s", chart.Comment())
- }
-}
-
-// TestDSIDFieldStructure examines the actual DSID field structure from a real file
-func TestDSIDFieldStructure(t *testing.T) {
- parser, err := iso8211.Open("../../../testdata/US4MD81M.000")
- if err != nil {
- t.Fatalf("Failed to open test file: %v", err)
- }
- defer parser.Close()
-
- isoFile, err := parser.Parse()
- if err != nil {
- t.Fatalf("Failed to parse ISO 8211: %v", err)
- }
-
- // Check DDR for DSID field structure
- if isoFile.DDR == nil {
- t.Fatal("DDR is nil")
- }
-
- dsidControl, ok := isoFile.DDR.FieldControls["DSID"]
- if !ok {
- t.Fatal("DSID field control not found in DDR")
- }
-
- t.Logf("DSID Field Control:")
- t.Logf(" DataStructCode: %d", dsidControl.DataStructCode)
- t.Logf(" DataTypeCode: %d", dsidControl.DataTypeCode)
- t.Logf(" FieldName: %s", dsidControl.FieldName)
- t.Logf(" FormatControls: %s", dsidControl.FormatControls)
- t.Logf(" Subfields: %d", len(dsidControl.Subfields))
-
- for i, subfield := range dsidControl.Subfields {
- t.Logf(" [%d] Label=%s, FormatType=%c, Width=%d",
- i, subfield.Label, subfield.FormatType, subfield.Width)
- }
-
- // Get actual DSID data from first record
- for _, record := range isoFile.Records {
- if dsidData, ok := record.Fields["DSID"]; ok {
- t.Logf("\nDSID Raw Data (%d bytes):", len(dsidData))
- // Print first 100 bytes as hex and ASCII
- limit := len(dsidData)
- if limit > 100 {
- limit = 100
- }
- for i := 0; i < limit; i += 16 {
- end := i + 16
- if end > limit {
- end = limit
- }
- // Hex
- hex := ""
- for j := i; j < end; j++ {
- hex += fmt.Sprintf("%02x ", dsidData[j])
- }
- // ASCII
- ascii := ""
- for j := i; j < end; j++ {
- if dsidData[j] >= 32 && dsidData[j] < 127 {
- ascii += string(dsidData[j])
- } else {
- ascii += "."
- }
- }
- t.Logf(" %04x: %-48s %s", i, hex, ascii)
- }
- break
- }
- }
-}
diff --git a/internal/s57/parser/s57attributes.csv b/internal/s57/parser/s57attributes.csv
deleted file mode 100644
index 3b2c04e..0000000
--- a/internal/s57/parser/s57attributes.csv
+++ /dev/null
@@ -1,484 +0,0 @@
-"Code","Attribute","Acronym","Attributetype","Class"
-1,Agency responsible for production,AGENCY,A,F
-2,Beacon shape,BCNSHP,E,F
-3,Building shape,BUISHP,E,F
-4,Buoy shape,BOYSHP,E,F
-5,Buried depth,BURDEP,F,F
-6,Call sign,CALSGN,S,F
-7,Category of airport/airfield,CATAIR,L,F
-8,Category of anchorage,CATACH,L,F
-9,Category of bridge,CATBRG,L,F
-10,Category of built-up area,CATBUA,E,F
-11,Category of cable,CATCBL,E,F
-12,Category of canal,CATCAN,E,F
-13,Category of cardinal mark,CATCAM,E,F
-14,Category of checkpoint,CATCHP,E,F
-15,Category of coastline,CATCOA,E,F
-16,Category of control point,CATCTR,E,F
-17,Category of conveyor,CATCON,E,F
-18,Category of coverage,CATCOV,E,F
-19,Category of crane,CATCRN,E,F
-20,Category of dam,CATDAM,E,F
-21,Category of distance mark,CATDIS,E,F
-22,Category of dock,CATDOC,E,F
-23,Category of dumping ground,CATDPG,L,F
-24,Category of fence/wall,CATFNC,E,F
-25,Category of ferry,CATFRY,E,F
-26,Category of fishing facility,CATFIF,E,F
-27,Category of fog signal,CATFOG,E,F
-28,Category of fortified structure,CATFOR,E,F
-29,Category of gate,CATGAT,E,F
-30,Category of harbour facility,CATHAF,L,F
-31,Category of hulk,CATHLK,L,F
-32,Category of ice,CATICE,E,F
-33,Category of installation buoy,CATINB,E,F
-34,Category of land region,CATLND,L,F
-35,Category of landmark,CATLMK,L,F
-36,Category of lateral mark,CATLAM,E,F
-37,Category of light,CATLIT,L,F
-38,Category of marine farm/culture,CATMFA,E,F
-39,Category of military practice area,CATMPA,L,F
-40,Category of mooring/warping facility,CATMOR,E,F
-41,Category of navigation line,CATNAV,E,F
-42,Category of obstruction,CATOBS,E,F
-43,Category of offshore platform,CATOFP,L,F
-44,Category of oil barrier,CATOLB,E,F
-45,Category of pile,CATPLE,E,F
-46,Category of pilot boarding place,CATPIL,E,F
-47,Category of pipeline / pipe,CATPIP,L,F
-48,Category of production area,CATPRA,E,F
-49,Category of pylon,CATPYL,E,F
-50,Category of quality of data,CATQUA,E,F
-51,Category of radar station,CATRAS,E,F
-52,Category of radar transponder beacon,CATRTB,E,F
-53,Category of radio station,CATROS,L,F
-54,Category of recommended track,CATTRK,E,F
-55,Category of rescue station,CATRSC,L,F
-56,Category of restricted area,CATREA,L,F
-57,Category of road,CATROD,E,F
-58,Category of runway,CATRUN,E,F
-59,Category of sea area,CATSEA,E,F
-60,Category of shoreline construction,CATSLC,E,F
-61,"Category of signal station, traffic",CATSIT,L,F
-62,"Category of signal station, warning",CATSIW,L,F
-63,Category of silo/tank,CATSIL,E,F
-64,Category of slope,CATSLO,E,F
-65,Category of small craft facility,CATSCF,L,F
-66,Category of special purpose mark,CATSPM,L,F
-67,Category of Traffic Separation Scheme,CATTSS,E,F
-68,Category of vegetation,CATVEG,L,F
-69,Category of water turbulence,CATWAT,E,F
-70,Category of weed/kelp,CATWED,E,F
-71,Category of wreck,CATWRK,E,F
-72,Category of zone of confidence data,CATZOC,E,F
-73,Character spacing,$SPACE,E,$
-74,Character specification,$CHARS,A,$
-75,Colour,COLOUR,L,F
-76,Colour pattern,COLPAT,L,F
-77,Communication channel,COMCHA,A,F
-78,Compass size,$CSIZE,F,$
-79,Compilation date,CPDATE,A,F
-80,Compilation scale,CSCALE,I,F
-81,Condition,CONDTN,E,F
-82,"Conspicuous, Radar",CONRAD,E,F
-83,"Conspicuous, visual",CONVIS,E,F
-84,Current velocity,CURVEL,F,F
-85,Date end,DATEND,A,F
-86,Date start,DATSTA,A,F
-87,Depth range value 1,DRVAL1,F,F
-88,Depth range value 2,DRVAL2,F,F
-89,Depth units,DUNITS,E,F
-90,Elevation,ELEVAT,F,F
-91,Estimated range of transmission,ESTRNG,F,F
-92,Exhibition condition of light,EXCLIT,E,F
-93,Exposition of sounding,EXPSOU,E,F
-94,Function,FUNCTN,L,F
-95,Height,HEIGHT,F,F
-96,Height/length units,HUNITS,E,F
-97,Horizontal accuracy,HORACC,F,F
-98,Horizontal clearance,HORCLR,F,F
-99,Horizontal length,HORLEN,F,F
-100,Horizontal width,HORWID,F,F
-101,Ice factor,ICEFAC,F,F
-102,Information,INFORM,S,F
-103,Jurisdiction,JRSDTN,E,F
-104,Justification - horizontal,$JUSTH,E,$
-105,Justification - vertical,$JUSTV,E,$
-106,Lifting capacity,LIFCAP,F,F
-107,Light characteristic,LITCHR,E,F
-108,Light visibility,LITVIS,L,F
-109,Marks navigational - System of,MARSYS,E,F
-110,Multiplicity of lights,MLTYLT,I,F
-111,Nationality,NATION,A,F
-112,Nature of construction,NATCON,L,F
-113,Nature of surface,NATSUR,L,F
-114,Nature of surface - qualifying terms,NATQUA,L,F
-115,Notice to Mariners date,NMDATE,A,F
-116,Object name,OBJNAM,S,F
-117,Orientation,ORIENT,F,F
-118,Periodic date end,PEREND,A,F
-119,Periodic date start,PERSTA,A,F
-120,Pictorial representation,PICREP,S,F
-121,Pilot district,PILDST,S,F
-122,Producing country,PRCTRY,A,F
-123,Product,PRODCT,L,F
-124,Publication reference,PUBREF,S,F
-125,Quality of sounding measurement,QUASOU,L,F
-126,Radar wave length,RADWAL,A,F
-127,Radius,RADIUS,F,F
-128,Recording date,RECDAT,A,F
-129,Recording indication,RECIND,A,F
-130,Reference year for magnetic variation,RYRMGV,A,F
-131,Restriction,RESTRN,L,F
-132,Scale maximum,SCAMAX,I,F
-133,Scale minimum,SCAMIN,I,F
-134,Scale value one,SCVAL1,I,F
-135,Scale value two,SCVAL2,I,F
-136,Sector limit one,SECTR1,F,F
-137,Sector limit two,SECTR2,F,F
-138,Shift parameters,SHIPAM,A,F
-139,Signal frequency,SIGFRQ,I,F
-140,Signal generation,SIGGEN,E,F
-141,Signal group,SIGGRP,A,F
-142,Signal period,SIGPER,F,F
-143,Signal sequence,SIGSEQ,A,F
-144,Sounding accuracy,SOUACC,F,F
-145,Sounding distance - maximum,SDISMX,I,F
-146,Sounding distance - minimum,SDISMN,I,F
-147,Source date,SORDAT,A,F
-148,Source indication,SORIND,A,F
-149,Status,STATUS,L,F
-150,Survey authority,SURATH,S,F
-151,Survey date - end,SUREND,A,F
-152,Survey date - start,SURSTA,A,F
-153,Survey type,SURTYP,L,F
-154,Symbol scaling factor,$SCALE,F,$
-155,Symbolization code,$SCODE,A,$
-156,Technique of sounding measurement,TECSOU,L,F
-157,Text string,$TXSTR,S,$
-158,Textual description,TXTDSC,S,F
-159,Tidal stream - panel values,TS_TSP,A,F
-160,"Tidal stream, current - time series values",TS_TSV,A,F
-161,Tide - accuracy of water level,T_ACWL,E,F
-162,Tide - high and low water values,T_HWLW,A,F
-163,Tide - method of tidal prediction,T_MTOD,E,F
-164,Tide - time and height differences,T_THDF,A,F
-165,"Tide, current - time interval of values",T_TINT,I,F
-166,Tide - time series values,T_TSVL,A,F
-167,Tide - value of harmonic constituents,T_VAHC,A,F
-168,Time end,TIMEND,A,F
-169,Time start,TIMSTA,A,F
-170,Tint,$TINTS,E,$
-171,Topmark/daymark shape,TOPSHP,E,F
-172,Traffic flow,TRAFIC,E,F
-173,Value of annual change in magnetic variation,VALACM,F,F
-174,Value of depth contour,VALDCO,F,F
-175,Value of local magnetic anomaly,VALLMA,F,F
-176,Value of magnetic variation,VALMAG,F,F
-177,Value of maximum range,VALMXR,F,F
-178,Value of nominal range,VALNMR,F,F
-179,Value of sounding,VALSOU,F,F
-180,Vertical accuracy,VERACC,F,F
-181,Vertical clearance,VERCLR,F,F
-182,"Vertical clearance, closed",VERCCL,F,F
-183,"Vertical clearance, open",VERCOP,F,F
-184,"Vertical clearance, safe",VERCSA,F,F
-185,Vertical datum,VERDAT,E,F
-186,Vertical length,VERLEN,F,F
-187,Water level effect,WATLEV,E,F
-188,Category of Tidal stream,CAT_TS,E,F
-189,Positional accuracy units,PUNITS,E,F
-190,Object class definition,CLSDEF,S,F
-191,Object class name,CLSNAM,S,F
-192,Symbol instruction,SYMINS,S,F
-300,Information in national language,NINFOM,S,N
-301,Object name in national language,NOBJNM,S,N
-302,Pilot district in national language,NPLDST,S,N
-303,Text string in national language,$NTXST,S,N
-304,Textual description in national language,NTXTDS,S,N
-400,Horizontal datum,HORDAT,E,S
-401,Positional Accuracy,POSACC,F,S
-402,Quality of position,QUAPOS,E,S
-0,"###Codes in the 17xxx range come from past s57attributes_iw.csv (Inland Waterways)",###,S,F
-17000,Category of Anchorage area,catach,L,F
-17001,Category of distance mark,catdis,E,F
-17002,Category of signal station trafficcatsit,catsit,L,F
-17003,Category of signal station warning,catsiw,L,F
-17004,Restriction,restrn,L,F
-17005,Vertical datum,verdat,E,F
-17006,Category of bridge,catbrg,L,F
-17007,Category of ferry,catfry,L,F
-17008,Category of harbour facilities,cathaf,L,F
-17009,"Marks navigational System of",marsys,E,F
-17050,Additional mark,addmrk,L,F
-17051,Category of bank,catbnk,E,F
-17052,Category of notice mark,catnmk,E,F
-17055,Class of dangerous cargo,clsdng,E,F
-17056,Direction of impact,dirimp,L,F
-17057,Distance from bank,disbk1,F,F
-17058,Distance from bank,disbk2,F,F
-17059,"Distance of impact, upstream",disipu,F,F
-17060,"Distance of impact, downstream",disipd,F,F
-17061,Elevation 1,eleva1,F,F
-17062,Elevation 2,eleva2,F,F
-17063,Function of notice mark,fnctnm,E,F
-17064,Waterway distance,wtwdis,F,F
-17065,Bunker vessel,bunves,E,F
-17066,Category of berth,catbrt,L,F
-17067,Category of bunker,catbun,L,F
-17068,Category of CEMT class,catccl,L,F
-17069,Category of communication,catcom,L,F
-17070,Category of harbour area,cathbr,L,F
-17071,Category of refuse dump,catrfd,L,F
-17072,Category of terminal,cattml,L,F
-17073,Communication,comctn,S,F
-17074,"Horizontal clearance, length",horcll,F,F
-17075,"Horizontal clearance, width",horclw,F,F
-17076,Transshipping goods,trshgd,L,F
-17077,UN Location Code,unlocd,S,F
-17112,Category of waterway mark,catwwm,E,F
-0,"###Codes in the 20xxx and 22xxx range come from past s57attributes_aml.csv (Additional_Military_Layers)",###,S,F
-20484,"Abandonment Date","databa","A","?"
-20485,"Attenuation","attutn","F","?"
-20486,"Beam of Vessel","vesbem","F","?"
-20487,"Bearing","bearng","F","?"
-20488,"Blind Zone","blndzn","A","?"
-20489,"Breaker Type","brktyp","E","?"
-20490,"Density","bulkdn","F","?"
-20491,"Burial Mechanism","brmchm","E","?"
-20492,"Burial Percentage","brpctg","I","?"
-20493,"Burial Period","brperd","I","?"
-20494,"Burial Probability","brprob","E","?"
-20495,"Cardinal Point Orientation","orcard","E","?"
-20496,"Category of administration area","catadm","E","?"
-20497,"Category of airspace restriction","catasr","E","?"
-20498,"Category of bedrock","N/A","N/A","?"
-20499,"Bottom Feature Classification","catbot","E","?"
-20500,"Category of coastguard station","catcgs","E","?"
-20501,"Category of controlled airspace","catcas","E","?"
-20502,"Fishing Activity","catfsh","E","?"
-20503,"Type of Imagery","catimg","L","?"
-20504,"Category of marine management area","catmma","E","?"
-20505,"Category of maritime safety information","catmsi","E","?"
-20506,"Category of military exercise airspace ","catmea","E","?"
-20507,"Category of patrol area","catpat","E","?"
-20508,"Category of reporting/radio calling-in point","catrep","E","?"
-20509,"Category of regulated airspace","N/A","N/A","?"
-20510,"Category of territorial sea baseline","catsbl","E","?"
-20511,"Trafficability","cattrf","E","?"
-20512,"Command System","comsys","S","?"
-20515,"Controlled airspace class designation","caircd","E","?"
-20516,"Controlling authority","authty","S","?"
-20517,"Current Scour Dimensions","scrdim","A","?"
-20518,"Dangerous Marine and Land Life","dgmrlf","L","?"
-20519,"Date Sunk","datsnk","A","?"
-20520,"Debris Field","debfld","A","?"
-20521,"Depth of Activity","depact","F","?"
-20522,"Depth of Layer","deplyr","F","?"
-20523,"Distance from Small Bottom Object","discon","F","?"
-20524,"Diver’s Thrust Test Depth","dttdep","E","?"
-20525,"Diver’s Thrust Test Number","dttnum","I","?"
-20526,"Diving Activity","divact","E","?"
-20527,"Draught of Vessel","vesdgh","F","?"
-20528,"Exit Usability","exitus","E","?"
-20529,"Field Name","fldnam","S","?"
-20530,"First Detection Year","datfir","A","?"
-20531,"First Sensor","senfir","E","?"
-20532,"First Source","sorfir","E","?"
-20533,"Foliar Index","folinx","F","?"
-20534,"Gas Content","gascon","I","?"
-20535,"General Water Depth","gendep","I","?"
-20536,"Gradient","gradnt","E","?"
-20537,"Grain Size","grnsiz","F","?"
-20538,"Inclination","incltn","F","?"
-20539,"Internal Data Record Identification Number","N/A","N/A","?"
-20540,"Last Detection Year","datlst","A","?"
-20541,"Last Sensor","senlst","E","?"
-20542,"Last Source","sorlst","E","?"
-20543,"Lay Platform","layptm","E","?"
-20544,"Lay Reference Number","layrfn","S","?"
-20545,"Lay Time","laytim","A","?"
-20546,"Layer Number","laynum","I","?"
-20547,"Legal Status","legsta","S","?"
-20548,"Length of Vessel","veslen","F","?"
-20549,"Magnetic Anomaly Detector (MAD) Signature","madsig","E","?"
-20550,"Magnetic Intensity","magint","I","?"
-20551,"Mean Shear Strength","msstrg","F","?"
-20552,"Migration Direction","migdir","I","?"
-20553,"Migration Speed","migspd","F","?"
-20554,"Milec Density","milden","E","?"
-20555,"Mine Index Mine Case","mnimnc","E","?"
-20556,"Mine Index Mine Type","mnimnt","L","?"
-20557,"Mine Reference Number","minern","S","?"
-20558,"Mine-Hunting Classification","mhclas","E","?"
-20559,"Minehunting System","mnhsys","S","?"
-20560,"Minesweeping System","mnssys","S","?"
-20561,"Mission Classification","miscls","E","?"
-20562,"Mission Comments","miscom","S","?"
-20563,"Mission Date","misdat","A","?"
-20564,"Mission Name","misnme","S","?"
-20565,"MWDC Reference Number","mwdcrn","S","?"
-20566,"Nature of Geological Layer","natsed","E","?"
-20567,"Navigation System","navsys","S","?"
-20568,"NOMBO Density","nomden","E","?"
-20569,"Not Found","notfnd","S","?"
-20570,"Number of Previous Observations","nmprob","I","?"
-20571,"Operator","oprtor","S","?"
-20572,"Orientation of Best Observation","orbobn","F","?"
-20573,"Origin of Data","orgdat","E","?"
-20574,"Originator","orgntr","S","?"
-20575,"Porosity","porsty","I","?"
-20576,"Quality of Beach Data","quabch","A","?"
-20577,"Re-entered Date","datren","A","?"
-20578,"Re-suspended Date","datres","A","?"
-20579,"Reverberation","revebn","E","?"
-20580,"Safety Zone","N/A","N/A","?"
-20581,"Sample Retained","samret","S","?"
-20582,"Seabed Coverage","sbdcov","I","?"
-20583,"Ships Speed","shpspd","F","?"
-20584,"Sonar Frequency","snrfrq","E","?"
-20585,"Sonar Range Scale","snrrsc","F","?"
-20586,"Sonar Reflectivity","snrflc","E","?"
-20587,"Sonar Signal Strength","sonsig","E","?"
-20588,"Sound Velocity","sndvel","F","?"
-20589,"Sounding Datum","soudat","E","?"
-20590,"Spudded Date","datspd","A","?"
-20592,"Steepest Face Orientation","stfotn","F","?"
-20593,"Strength According to Richter Scale","ricsca","I","?"
-20594,"Strength of Magnetic Anomaly","magany","E","?"
-20595,"Suitability for ACV Use","stbacv","E","?"
-20596,"Surf Height","srfhgt","F","?"
-20597,"Surf Zone","srfzne","I","?"
-20598,"Survey Date and Time","surdat","A","?"
-20599,"Suspension Date","datsus","A","?"
-20600,"Swell Height","swlhgt","F","?"
-20601,"Tidal Range","tdlrng","F","?"
-20602,"Time of Year","timeyr","L","?"
-20603,"Tonnage","tonage","I","?"
-20604,"Towed Body Depth","twdbdp","F","?"
-20605,"Type of military activity","milact","L","?"
-20606,"Type of Tonnage","typton","E","?"
-20607,"Type of Wreck","typewk","E","?"
-20608,"Underwater Reference Mark","unwrfm","E","?"
-20609,"Unique ID from a Navigational Product","N/A","N/A","?"
-20610,"Water Clarity","watclr","F","?"
-20611,"Wavelength","wavlen","F","?"
-20612,"Weight Bearing Capability","wbrcap","I","?"
-20613,"Width (left)","lftwid","F","?"
-20614,"Width (right)","rgtwid","F","?"
-20615,"Contour Type","hypcat","E","?"
-20616,"Sounding Velocity","souvel","E","?"
-20617,"Access Restriction","accres","S","?"
-20618,"Approach","apprch","S","?"
-20619,"Category of Beach","catbch","E","?"
-20620,"Clearance Percentage","clperc","I","?"
-20621,"Communications","commns","L","?"
-20622,"Confidence Level","conlev","F","?"
-20624,"Exit Description","extdes","S","?"
-20625,"Industry","indtry","S","?"
-20626,"Landing Conditions","lndcon","S","?"
-20627,"Leisure Activity","lsract","S","?"
-20628,"Logistics","logtcs","L","?"
-20629,"Manoeuvring","manvrg","S","?"
-20630,"Mine Threat Density","mntden","I","?"
-20631,"Multiple Contacts","mulcon","I","?"
-20632,"Navigational Description","navdes","S","?"
-20633,"Navigational Difficulty","navdif","E","?"
-20634,"Number of Remaining Mines","numrmn","I","?"
-20635,"Pier Contact Details","pierod","S","?"
-20636,"Pier Description","pierdn","S","?"
-20637,"Prairies Density","prsden","I","?"
-20638,"Probability for Remaining Mines","prbrmn","F","?"
-20639,"Remaining Mines Likely, Maximum Number","rmnlmn","I","?"
-20640,"Self Protection (Air)","sfptna","E","?"
-20641,"Self Protection (Near Defence)","sptnnd","E","?"
-20642,"Self Protection (Surface)","sfptns","E","?"
-20643,"Sensor Coverage","sencov","S","?"
-20644,"Simple Initial Threat","sminth","F","?"
-20645,"Target Reference Weight","tgrfwt","E","?"
-20646,"Tidal Type","tdltyp","E","?"
-20647,"Type of Resource Location","typres","E","?"
-20648,"Undetectable Mines Ratio","undmnr","F","?"
-20649,"Undetectable Mines Ratio with Burial","umnrwb","F","?"
-20650,"Undetectable Mines Ratio without Burial","umrwob","F","?"
-20651,"Weapon Coverage","wpncov","S","?"
-20652,"On Sonar","onsonr","E","?"
-20653,"HF Bottom Loss","hfbmls","F","?"
-20654,"LF Bottom Loss","lfbmls","F","?"
-20655,"Detection Probability","dtprob","F","?"
-20656,"Disposal Probability","dsprob","F","?"
-20657,"Classification Probability","clprob","F","?"
-20658,"Characteristic Detection Width (A)","cswidt","I","?"
-20659,"Characteristic Detection Probability (B)","csprob","F","?"
-20660,"Zone Colour","znecol","E","?"
-20661,"Reverberation Frequency","revfqy","F","?"
-20662,"Reverberation Grazing Angle","revgan","F","?"
-20663,"International Defence Organisation (IDO) status","secido","E","?"
-20664,"Protective Marking","secpmk","E","?"
-20665,"Owner Authority","secown","S","?"
-20666,"Caveat ","seccvt","S","?"
-20667,"Species","spcies","S","?"
-20668,"Swept date","swpdat","A","?"
-20669,"Runway length","rwylen","I","?"
-20670,"Active period","actper","S","?"
-20671,"Maximum altitude","maxalt","I","?"
-20672,"Minimum altitude","minalt","I","?"
-20673,"Maximum Flight Level","maxftl","I","?"
-20674,"Minimum Flight Level","minftl","I","?"
-20675,"Bottom Vertical Safety Separation","bverss","I","?"
-20676,"Minimum Safe Depth","mindep","I","?"
-20677,"Interpolated line characteristic","linech","E","?"
-20678,"Identification","identy","S","?"
-20679,"Route Classification","rclass","E","?"
-20680,"Population","popltn","I","?"
-20681,"Surface Threat","surtht","E","?"
-20682,"Heading-Up Bearing","upbear","F","?"
-20683,"Heading-Down Bearing","dnbear","F","?"
-20684,"Ice Concentration","icencn","I","?"
-20685,"Danger height","dgrhgt","I","?"
-20686,"Depth Restriction","depres","S","?"
-20687,"Area Category","arecat","E","?"
-20688,"Existence of Restricted Area","exzres","E","?"
-20689,"Target Strength","tarstg","I","?"
-20690,"Qualification of Radar Coverage","quarad","I","?"
-20691,"Contact Details","condet","S","?"
-20692,"Limit of Anchors and Chains","limanc","F","?"
-20693,"CCM Index","ccmidx","I","?"
-20694,"Military Load Classification","mlclas","E","?"
-20695,"MGS Type","mgstyp","E","?"
-20696,"Ice Attribute Concentration Total","iceact","E","?"
-20697,"Ice Stage of Development","icesod","E","?"
-20698,"Ice Advisory Code","iceadc","S","?"
-20699,"Number of Icebergs in Area","icebnm","I","?"
-20700,"Ice Line Category","icelnc","E","?"
-20701,"Ice Polynya Type","icepty","E","?"
-20702,"Ice Polynya Status","icepst","E","?"
-20703,"Ice Lead Type","icelty","E","?"
-20704,"Ice Lead Status","icelst","E","?"
-20705,"Iceberg Size","icebsz","E","?"
-20706,"Iceberg Shape","icebsh","E","?"
-20707,"Icedrift or Iceberg Direction","icebdr","E","?"
-20708,"Icedrift or Iceberg Speed","icebsp","F","?"
-20709,"Maximum Ice Thickness","icemax","F","?"
-20710,"Minimum Ice Thickness","icemin","F","?"
-20711,"Ice Ridge Development","icerdv","E","?"
-20712,"Land Ice","icelnd","E","?"
-20713,"Sea Direction","seadir","E","?"
-20714,"Traffic density","traden","S","?"
-20715,"Type of shipping","typshp","L","?"
-20716,"Ice Coverage Type","icecvt","E","?"
-20718,"Status of Small Bottom Object","staobj","L","?"
-20719,"ICAO code","icaocd","S","?"
-20720,"textual description","txtdes","S","?"
-20721,"Object Reference Number","objtrn","S","?"
-20722,"Object Shape","objshp","S","?"
-22484,"Category of completeness","catcnf","E","?"
-22485,"Error Ellipse","errell","A","?"
-22486,"Object classes","N/A","N/A","?"
-22487,"Security classification","N/A","N/A","?"
-22488,"Vertical Datum Shift Parameter","vershf","F","?"
-22489,"Absolute Vertical Accuracy","elvacc","F","?"
-22490,"Reflection Coefficient","reflco","F","?"
-22491,"Copyright statement","cpyrit","S","?"
-0,"###40000 comes from past s57attributes_iw.csv (Inland Waterways)",###,S,F
-40000,Update message,updmsg,S,F
diff --git a/internal/s57/parser/spatial.go b/internal/s57/parser/spatial.go
deleted file mode 100644
index 0c5ce44..0000000
--- a/internal/s57/parser/spatial.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package parser
-
-import (
- "encoding/binary"
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-// vectorPointer represents a pointer to another spatial record
-// S-57 §7.7.1.4 (31Main.pdf p79): Vector Record Pointer (VRPT) - 9 bytes per pointer
-type vectorPointer struct {
- TargetRCNM int // Record type (110=node, 120=connected node, 130=edge, 140=face)
- TargetRCID int64 // Record ID of target
- Orientation int // 1=forward, 2=reverse, 255=null
- Usage int // 1=Exterior, 2=Interior, 3=Exterior boundary truncated
- Topology int // Topology indicator (1=begin, 2=end, 3=left, 4=right, 255=null)
- Mask int // Masking indicator (1=mask, 2=show, 255=null)
-}
-
-// spatialRecord represents a parsed S-57 spatial (vector) record
-// S-57 §2.1 (31Main.pdf p23): Spatial records contain coordinate data
-type spatialRecord struct {
- ID int64 // Spatial record ID from VRID
- RecordType spatialType // Node, Edge, etc.
- Coordinates [][]float64 // Variable-length coordinates [lon, lat] or [lon, lat, depth]
- VectorPointers []vectorPointer // VRPT pointers to other spatial records
- RecordVersion int // RVER - record version number
- UpdateInstr int // RUIN - update instruction
- Quapos int // QUAPOS quality of position (S-57 spatial-level ATTV); 0 if absent
-}
-
-// spatialType represents the type of spatial record
-type spatialType int
-
-const (
- // S-57 Appendix B.1: RCNM values for spatial records
- spatialTypeIsolatedNode spatialType = 110 // VI - Isolated Node
- spatialTypeConnectedNode spatialType = 120 // VC - Connected Node
- spatialTypeEdge spatialType = 130 // VE - Edge
- spatialTypeFace spatialType = 140 // VF - Face
-)
-
-// parseSpatialRecordWithParams extracts spatial data from an ISO 8211 record with dataset params
-// Returns nil if record is not a spatial record
-// S-57 §7.7.1.1 (31Main.pdf p78): Spatial records identified by VRID field
-func parseSpatialRecordWithParams(record *iso8211.DataRecord, params datasetParams) *spatialRecord {
- return parseSpatialRecordInternal(record, params.COMF, params.SOMF)
-}
-
-// parseSpatialRecordInternal is the internal implementation
-func parseSpatialRecordInternal(record *iso8211.DataRecord, comf int32, somf int32) *spatialRecord {
- // Check if this is a spatial record (has VRID field)
- vridData, hasVRID := record.Fields["VRID"]
- if !hasVRID || len(vridData) < 8 {
- return nil // Need 8 bytes: RCNM(1) + RCID(4) + RVER(2) + RUIN(1)
- }
-
- // Parse VRID (Vector Record Identifier) per S-57 §7.7.1.1 (31Main.pdf p78)
- // Binary structure (8 bytes total):
- // Byte 0: RCNM (Record Name) - 110=IsolatedNode, 120=ConnectedNode, 130=Edge, 140=Face
- // Bytes 1-4: RCID (Record ID) - uint32 little-endian
- // Bytes 5-6: RVER (Record Version) - uint16 little-endian
- // Byte 7: RUIN (Record Update Instruction)
-
- rcnm := vridData[0]
- spatialRec := &spatialRecord{
- RecordType: spatialType(rcnm),
- Coordinates: make([][]float64, 0),
- VectorPointers: make([]vectorPointer, 0),
- }
-
- // Extract spatial record ID (4 bytes at offset 1)
- spatialRec.ID = int64(binary.LittleEndian.Uint32(vridData[1:5]))
-
- // Extract record version (2 bytes at offset 5)
- spatialRec.RecordVersion = int(binary.LittleEndian.Uint16(vridData[5:7]))
-
- // Extract update instruction (1 byte at offset 7)
- spatialRec.UpdateInstr = int(vridData[7])
-
- // Parse SG2D (2D Coordinate) field for coordinates
- // Coordinates are scaled by COMF parameter
- if sg2dData, ok := record.Fields["SG2D"]; ok {
- spatialRec.Coordinates = parseCoordinates2D(sg2dData, comf)
- }
-
- // Parse SG3D (3D Coordinate) field if present - includes depth (Z)
- // X/Y scaled by COMF, Z/depth scaled by SOMF
- if sg3dData, ok := record.Fields["SG3D"]; ok {
- spatialRec.Coordinates = parseCoordinates3D(sg3dData, comf, somf)
- }
-
- // Parse VRPT (Vector Record Pointer) - S-57 §7.7.1.4 (31Main.pdf p79)
- if vrptData, ok := record.Fields["VRPT"]; ok {
- spatialRec.VectorPointers = parseVectorPointers(vrptData)
- }
-
- // Parse ATTV (spatial-level attributes) — QUAPOS lives here, not on the
- // feature. Same ATTL+ATVL layout as a feature's ATTF, so reuse parseAttributes.
- if attvData, ok := record.Fields["ATTV"]; ok {
- if q, ok := atoiAttr(parseAttributes(attvData)["QUAPOS"]); ok {
- spatialRec.Quapos = q
- }
- }
-
- return spatialRec
-}
-
-// atoiAttr reads an S-57 attribute value (parsed as a string) as an int.
-func atoiAttr(v interface{}) (int, bool) {
- s, ok := v.(string)
- if !ok {
- return 0, false
- }
- n, err := strconv.Atoi(strings.TrimSpace(s))
- if err != nil {
- return 0, false
- }
- return n, true
-}
-
-// parseCoordinates2D extracts 2D coordinates from SG2D field
-// S-57 §7.7.1.6 (31Main.pdf p80): SG2D contains repeated coordinate pairs
-// Coordinates are stored as signed integers (b24 = int32) that need scaling by COMF
-func parseCoordinates2D(data []byte, comf int32) [][]float64 {
- coords := make([][]float64, 0)
-
- // SG2D structure per S-57 §7.7.1.6 (31Main.pdf p80): [YCOO(4 bytes), XCOO(4 bytes)]
- // Each coordinate pair is 8 bytes (2 * int32 signed)
- // First 4 bytes = Y (latitude), Second 4 bytes = X (longitude)
- offset := 0
- for offset+8 <= len(data) {
- // Parse first 4 bytes - Y (latitude) per S-57 spec
- y := int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
- offset += 4
-
- // Parse second 4 bytes - X (longitude) per S-57 spec
- x := int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
- offset += 4
-
- // Convert to float64 and scale by COMF
- // Per IHO S-57 §7.3.2.1 (31Main.pdf p66): Coordinates are scaled by DSPM_COMF (Coordinate Multiplication Factor)
- // COMF is typically 10000000 (10^7) for coordinates in units of 0.00000001 degrees
- lon := convertCoordinate(x, comf)
- lat := convertCoordinate(y, comf)
-
- // Store in GeoJSON order: [longitude, latitude]
- coords = append(coords, []float64{lon, lat})
- }
-
- return coords
-}
-
-// parseCoordinates3D extracts 3D coordinates from SG3D field including depth (Z)
-// S-57 §7.7.1.7 (31Main.pdf p80): SG3D contains repeated 3D coordinate triples (soundings)
-func parseCoordinates3D(data []byte, comf int32, somf int32) [][]float64 {
- coords := make([][]float64, 0)
-
- // SG3D structure per S-57 §7.7.1.7 (31Main.pdf p80): [YCOO(4 bytes), XCOO(4 bytes), VE3D(4 bytes)]
- // Each coordinate triple is 12 bytes (3 * int32 signed)
- // YCOO/XCOO scaled by COMF, VE3D scaled by SOMF
- // First 4 bytes = Y (latitude), Second 4 bytes = X (longitude), Third 4 bytes = Z (depth)
- offset := 0
- for offset+12 <= len(data) {
- // Parse first 4 bytes - Y (latitude) per S-57 spec
- y := int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
- offset += 4
-
- // Parse second 4 bytes - X (longitude) per S-57 spec
- x := int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
- offset += 4
-
- // Parse VE3D (depth/sounding) - 4 bytes signed int32
- // Scale by SOMF (Sounding Multiplication Factor)
- z := int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
- offset += 4
-
- // Convert to float64 and scale: X/Y by COMF, Z by SOMF
- lon := convertCoordinate(x, comf)
- lat := convertCoordinate(y, comf)
- depth := convertCoordinate(z, somf)
-
- // Store in 3D format: [longitude, latitude, depth]
- coords = append(coords, []float64{lon, lat, depth})
- }
-
- return coords
-}
-
-// parseVectorPointers extracts vector record pointers from VRPT field
-// S-57 §7.7.1.4 (31Main.pdf p79): VRPT structure - 9 bytes per pointer
-func parseVectorPointers(data []byte) []vectorPointer {
- pointers := make([]vectorPointer, 0)
-
- // VRPT is repeating group per S-57 §7.7.1.4 (31Main.pdf p79), each entry is 9 bytes:
- // NAME: B(40) - 5 bytes (RCNM=1, RCID=4)
- // Offset 0: NAME_RCNM (1 byte) - Target record type
- // Offset 1-4: NAME_RCID (4 bytes) - Target record ID (uint32 LE)
- // Offset 5: ORNT (1 byte) - Orientation (1=Forward, 2=Reverse, 255=Null)
- // Offset 6: USAG (1 byte) - Usage indicator (1=Exterior, 2=Interior, 3=Exterior truncated)
- // Offset 7: TOPI (1 byte) - Topology indicator (1=Begin, 2=End, 3=Left, 4=Right, 255=Null)
- // Offset 8: MASK (1 byte) - Masking indicator (1=Mask, 2=Show, 255=Null)
- for i := 0; i+8 < len(data); i += 9 {
- ptr := vectorPointer{
- TargetRCNM: int(data[i]),
- TargetRCID: int64(binary.LittleEndian.Uint32(data[i+1 : i+5])),
- Orientation: int(data[i+5]),
- Usage: int(data[i+6]),
- Topology: int(data[i+7]),
- Mask: int(data[i+8]),
- }
- pointers = append(pointers, ptr)
- }
-
- return pointers
-}
diff --git a/internal/s57/parser/spatial_test.go b/internal/s57/parser/spatial_test.go
deleted file mode 100644
index b4ef214..0000000
--- a/internal/s57/parser/spatial_test.go
+++ /dev/null
@@ -1,251 +0,0 @@
-package parser
-
-import (
- "encoding/binary"
- "testing"
-)
-
-// TestParseVectorPointers tests VRPT field parsing
-func TestParseVectorPointers(t *testing.T) {
- tests := []struct {
- name string
- data []byte
- expected []vectorPointer
- }{
- {
- name: "empty data",
- data: []byte{},
- expected: []vectorPointer{},
- },
- {
- name: "single pointer",
- data: func() []byte {
- // Create a single VRPT entry per S-57 §7.7.1.4: 9 bytes
- // RCNM(1) + RCID(4) + ORNT(1) + USAG(1) + TOPI(1) + MASK(1)
- data := make([]byte, 9)
- data[0] = 130 // RCNM = Edge (130)
- binary.LittleEndian.PutUint32(data[1:5], 12345) // RCID = 12345
- data[5] = 1 // ORNT = Forward
- data[6] = 1 // USAG = Exterior
- data[7] = 1 // TOPI = Begin node
- data[8] = 255 // MASK = NULL
- return data
- }(),
- expected: []vectorPointer{
- {
- TargetRCNM: 130,
- TargetRCID: 12345,
- Orientation: 1,
- Usage: 1,
- Topology: 1,
- Mask: 255,
- },
- },
- },
- {
- name: "multiple pointers",
- data: func() []byte {
- // Create two VRPT entries, 9 bytes each = 18 bytes total
- data := make([]byte, 18)
- // First pointer
- data[0] = 110 // RCNM = IsolatedNode (110)
- binary.LittleEndian.PutUint32(data[1:5], 100)
- data[5] = 1 // ORNT = Forward
- data[6] = 1 // USAG = Exterior
- data[7] = 1 // TOPI = Begin
- data[8] = 255 // MASK = NULL
- // Second pointer
- data[9] = 130 // RCNM = Edge (130)
- binary.LittleEndian.PutUint32(data[10:14], 200)
- data[14] = 2 // ORNT = Reverse
- data[15] = 1 // USAG = Exterior
- data[16] = 2 // TOPI = End
- data[17] = 255 // MASK = NULL
- return data
- }(),
- expected: []vectorPointer{
- {
- TargetRCNM: 110,
- TargetRCID: 100,
- Orientation: 1,
- Usage: 1,
- Topology: 1,
- Mask: 255,
- },
- {
- TargetRCNM: 130,
- TargetRCID: 200,
- Orientation: 2,
- Usage: 1,
- Topology: 2,
- Mask: 255,
- },
- },
- },
- {
- name: "incomplete data (truncated)",
- data: []byte{130, 1, 2, 3, 4, 5, 6, 7}, // Only 8 bytes, need 9
- expected: []vectorPointer{},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := parseVectorPointers(tt.data)
-
- if len(result) != len(tt.expected) {
- t.Errorf("parseVectorPointers() got %d pointers, want %d", len(result), len(tt.expected))
- return
- }
-
- for i, ptr := range result {
- exp := tt.expected[i]
- if ptr.TargetRCNM != exp.TargetRCNM {
- t.Errorf("pointer[%d].TargetRCNM = %d, want %d", i, ptr.TargetRCNM, exp.TargetRCNM)
- }
- if ptr.TargetRCID != exp.TargetRCID {
- t.Errorf("pointer[%d].TargetRCID = %d, want %d", i, ptr.TargetRCID, exp.TargetRCID)
- }
- if ptr.Orientation != exp.Orientation {
- t.Errorf("pointer[%d].Orientation = %d, want %d", i, ptr.Orientation, exp.Orientation)
- }
- }
- })
- }
-}
-
-// TestResolveVectorPointers tests VRPT topology resolution
-func TestResolveVectorPointers(t *testing.T) {
- // Create test spatial records
- spatialRecords := map[spatialKey]*spatialRecord{
- // Node with direct coordinates
- {RCNM: int(spatialTypeIsolatedNode), RCID: 100}: {
- ID: 100,
- RecordType: spatialTypeIsolatedNode,
- Coordinates: [][]float64{
- {-71.0, 42.0},
- {-71.1, 42.1},
- },
- VectorPointers: nil,
- },
- // Edge with direct coordinates
- {RCNM: int(spatialTypeEdge), RCID: 200}: {
- ID: 200,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {-70.0, 43.0},
- {-70.1, 43.1},
- },
- VectorPointers: nil,
- },
- // Edge with VRPT pointers to node 100
- {RCNM: int(spatialTypeEdge), RCID: 300}: {
- ID: 300,
- RecordType: spatialTypeEdge,
- Coordinates: nil, // No direct coords
- VectorPointers: []vectorPointer{
- {
- TargetRCNM: 110,
- TargetRCID: 100,
- Orientation: 1, // Forward
- Usage: 1,
- Topology: 1,
- },
- },
- },
- // Edge with reversed pointer to edge 200
- {RCNM: int(spatialTypeEdge), RCID: 400}: {
- ID: 400,
- RecordType: spatialTypeEdge,
- Coordinates: nil,
- VectorPointers: []vectorPointer{
- {
- TargetRCNM: 130,
- TargetRCID: 200,
- Orientation: 2, // Reverse
- Usage: 1,
- Topology: 1,
- },
- },
- },
- }
-
- tests := []struct {
- name string
- spatial *spatialRecord
- expectedCoords int
- checkReverse bool
- }{
- {
- name: "resolve forward pointer",
- spatial: spatialRecords[spatialKey{RCNM: int(spatialTypeEdge), RCID: 300}],
- expectedCoords: 2, // Should get 2 coords from node 100
- checkReverse: false,
- },
- {
- name: "resolve reverse pointer",
- spatial: spatialRecords[spatialKey{RCNM: int(spatialTypeEdge), RCID: 400}],
- expectedCoords: 2, // Should get 2 coords from edge 200, reversed
- checkReverse: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- coords := resolveVectorPointers(tt.spatial, spatialRecords)
-
- if len(coords) != tt.expectedCoords {
- t.Errorf("resolveVectorPointers() got %d coords, want %d", len(coords), tt.expectedCoords)
- }
-
- // For reverse test, verify coordinates are reversed
- if tt.checkReverse && len(coords) > 0 {
- // Edge 200 has coords: {-70.0, 43.0}, {-70.1, 43.1}
- // Reversed should be: {-70.1, 43.1}, {-70.0, 43.0}
- if coords[0][0] != -70.1 || coords[0][1] != 43.1 {
- t.Errorf("First coord not reversed correctly: got [%f, %f], want [-70.1, 43.1]",
- coords[0][0], coords[0][1])
- }
- }
- })
- }
-}
-
-// TestCircularReferenceDetection tests that circular VRPT references don't cause infinite loops
-func TestCircularReferenceDetection(t *testing.T) {
- // Create circular reference: 100 -> 200 -> 100
- spatialRecords := map[spatialKey]*spatialRecord{
- {RCNM: int(spatialTypeEdge), RCID: 100}: {
- ID: 100,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{{-71.0, 42.0}},
- VectorPointers: []vectorPointer{
- {
- TargetRCNM: 130,
- TargetRCID: 200,
- Orientation: 1,
- },
- },
- },
- {RCNM: int(spatialTypeEdge), RCID: 200}: {
- ID: 200,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{{-70.0, 43.0}},
- VectorPointers: []vectorPointer{
- {
- TargetRCNM: 130,
- TargetRCID: 100, // Circular reference!
- Orientation: 1,
- },
- },
- },
- }
-
- // This should not hang or panic
- coords := resolveVectorPointers(spatialRecords[spatialKey{RCNM: int(spatialTypeEdge), RCID: 100}], spatialRecords)
-
- // Should get at least the direct coordinates from 100
- if len(coords) < 1 {
- t.Errorf("Expected at least 1 coordinate, got %d", len(coords))
- }
-}
diff --git a/internal/s57/parser/topology.go b/internal/s57/parser/topology.go
deleted file mode 100644
index cbc5a7a..0000000
--- a/internal/s57/parser/topology.go
+++ /dev/null
@@ -1,427 +0,0 @@
-package parser
-
-// topology.go - VRPT (Vector Record Pointer Table) topology resolution
-// Implements S-57 Edition 3.1 polygon construction from edge references
-
-// spatialKey uniquely identifies a spatial record by (RCNM, RCID) pair
-// S-57 §2.2.2 (31Main.pdf p27): RCID is unique within a record type, not globally
-type spatialKey struct {
- RCNM int // Record name (110=node, 120=connected node, 130=edge, 140=face)
- RCID int64 // Record ID (unique within RCNM type)
-}
-
-// edge represents a spatial edge record with connectivity information
-// S-57 §5.1.3.2 (31Main.pdf p54): Edges connect nodes to form polygon boundaries
-type edge struct {
- ID int64 // Edge record ID (RCID)
- Points [][2]float64 // Coordinate points along the edge [lon, lat]
- StartNodeID int64 // ID of starting node
- EndNodeID int64 // ID of ending node
-}
-
-// polygonBuilder constructs polygon geometries from topological primitives (edges/nodes)
-// Caches edges to avoid redundant lookups during ring construction
-type polygonBuilder struct {
- spatialRecords map[spatialKey]*spatialRecord // Spatial records indexed by (RCNM, RCID)
- edgeCache map[int64]*edge // Cached edges for reuse
-}
-
-// newPolygonBuilder creates a new polygon builder with given spatial records
-func newPolygonBuilder(spatialRecords map[spatialKey]*spatialRecord) *polygonBuilder {
- return &polygonBuilder{
- spatialRecords: spatialRecords,
- edgeCache: make(map[int64]*edge),
- }
-}
-
-// getNode retrieves a node's coordinates from spatial records
-// Tries connected node first, then isolated node
-func (r *polygonBuilder) getNode(nodeID int64) *spatialRecord {
- // Try connected node
- nodeKey := spatialKey{RCNM: int(spatialTypeConnectedNode), RCID: nodeID}
- if node, ok := r.spatialRecords[nodeKey]; ok && len(node.Coordinates) > 0 {
- return node
- }
- // Try isolated node
- nodeKey = spatialKey{RCNM: int(spatialTypeIsolatedNode), RCID: nodeID}
- if node, ok := r.spatialRecords[nodeKey]; ok && len(node.Coordinates) > 0 {
- return node
- }
- return nil
-}
-
-// getFullEdgeCoordinates builds full edge coordinates: start node + SG2D + end node
-// Reverses the entire array if orientation==2 (like marinejet does)
-func (r *polygonBuilder) getFullEdgeCoordinates(edge *edge, orientation int) [][2]float64 {
- coords := make([][2]float64, 0)
-
- // Add start node
- if edge.StartNodeID != 0 {
- if node := r.getNode(edge.StartNodeID); node != nil && len(node.Coordinates) > 0 {
- // Extract 2D coordinate (first 2 values) from variable-length coordinate
- coord := node.Coordinates[0]
- if len(coord) >= 2 {
- coords = append(coords, [2]float64{coord[0], coord[1]})
- }
- }
- }
-
- // Add SG2D intermediate points
- coords = append(coords, edge.Points...)
-
- // Add end node
- if edge.EndNodeID != 0 {
- if node := r.getNode(edge.EndNodeID); node != nil && len(node.Coordinates) > 0 {
- // Extract 2D coordinate (first 2 values) from variable-length coordinate
- coord := node.Coordinates[0]
- if len(coord) >= 2 {
- coords = append(coords, [2]float64{coord[0], coord[1]})
- }
- }
- }
-
- // Reverse if orientation is 2
- if orientation == 2 {
- reversed := make([][2]float64, len(coords))
- for i, coord := range coords {
- reversed[len(coords)-1-i] = coord
- }
- return reversed
- }
-
- return coords
-}
-
-// drawableBoundaryLines builds the polylines of a polygon's DRAWABLE boundary
-// edges. Per S-52 PresLib §8.6.2, edges with the FSPT MASK subfield = {1} or the
-// USAG subfield = {3} (cell-boundary / data-limit edges) must not be drawn — so
-// they are excluded here. They remain in the fill rings (§8.6.3). One polyline
-// per drawable edge (oriented per its FSPT orientation).
-//
-// DERIVED coastline-coincident masking (S-57 App. B.1 Annex A §17 scn 2): when
-// maskCoast is true, an edge whose RCID is in coalneEdges (i.e. the same edge
-// record a COALNE feature is built from) is ALSO suppressed. NOAA producers never
-// set FSPT MASK=1 for these, so we derive the suppression from the shared edge
-// RCID. This affects only the drawn boundary; the fill rings are untouched.
-func (r *polygonBuilder) drawableBoundaryLines(edgeRefs []spatialRef, coalneEdges map[int64]bool, maskCoast bool) [][][]float64 {
- // Non-nil even when every edge is masked, so the renderer can tell "computed,
- // all edges suppressed" (draw nothing) from "not computed" (stroke the rings).
- lines := make([][][]float64, 0, len(edgeRefs))
- for _, er := range edgeRefs {
- if er.Mask == 1 || er.Usage == 3 {
- continue // masked or data-limit edge — must not be drawn
- }
- if maskCoast && coalneEdges[er.RCID] {
- continue // edge coincident with the coastline (derived) — must not be drawn
- }
- edge, err := r.loadEdge(er.RCID)
- if err != nil || edge == nil {
- continue
- }
- coords := r.getFullEdgeCoordinates(edge, er.Orientation)
- if len(coords) < 2 {
- continue
- }
- line := make([][]float64, len(coords))
- for i, c := range coords {
- line[i] = []float64{c[0], c[1]}
- }
- lines = append(lines, line)
- }
- return lines
-}
-
-// boundaryQuapos returns the feature's effective QUAPOS over its DRAWN boundary
-// edges — the same edge selection as drawableBoundaryLines (S-52 §8.6.2). Returns
-// the low-accuracy value when the majority of drawn edges are low accuracy, else
-// 0. (QUAPOS is a spatial-level attribute on the edge records.)
-func (r *polygonBuilder) boundaryQuapos(edgeRefs []spatialRef, coalneEdges map[int64]bool, maskCoast bool) int {
- var total, low, lowVal int
- for _, er := range edgeRefs {
- if er.Mask == 1 || er.Usage == 3 {
- continue
- }
- if maskCoast && coalneEdges[er.RCID] {
- continue
- }
- sp := r.spatialRecords[spatialKey{RCNM: int(spatialTypeEdge), RCID: er.RCID}]
- if sp == nil {
- continue
- }
- total++
- if isLowAccuracyQuapos(sp.Quapos) {
- low++
- lowVal = sp.Quapos
- }
- }
- if total > 0 && low*2 > total {
- return lowVal
- }
- return 0
-}
-
-// loadEdge loads an edge from spatial records, with caching
-// Returns cached edge if already loaded, otherwise loads from spatial record
-func (r *polygonBuilder) loadEdge(edgeID int64) (*edge, error) {
- // Check cache first
- if edge, ok := r.edgeCache[edgeID]; ok {
- return edge, nil
- }
-
- // Load from spatial records using composite key (RCNM=130 for edges)
- edgeKey := spatialKey{RCNM: int(spatialTypeEdge), RCID: edgeID}
- spatial, ok := r.spatialRecords[edgeKey]
- if !ok {
- return nil, &ErrMissingSpatialRecord{
- FeatureID: 0, // Feature ID not known at this level
- SpatialID: edgeID,
- }
- }
-
- // Verify this is an edge record (RCNM = 130)
- if spatial.RecordType != spatialTypeEdge {
- return nil, &ErrInvalidSpatialRecord{
- SpatialID: edgeID,
- Reason: "expected edge record (RCNM=130)",
- }
- }
-
- // Extract node connectivity from vector pointers
- // S-57 §5.1.3.2 (31Main.pdf p54): Edges must reference nodes via VRPT with topology indicators:
- // B{1} = Beginning node (required)
- // E{2} = End node (required)
- // S{3} = Left face (required in full topology)
- // D{4} = Right face (required in full topology)
- // References must appear in sequence: B, E, S, D
- var startNodeID, endNodeID int64
- for _, ptr := range spatial.VectorPointers {
- // Node records have RCNM = 110 (isolated) or 120 (connected)
- if ptr.TargetRCNM == int(spatialTypeIsolatedNode) || ptr.TargetRCNM == int(spatialTypeConnectedNode) {
- if startNodeID == 0 {
- startNodeID = ptr.TargetRCID
- } else if endNodeID == 0 {
- endNodeID = ptr.TargetRCID
- }
- }
- }
-
- // Extract edge geometry per S-57 §5.1.4.4 (31Main.pdf p56):
- // "The geometry of the connected node is not part of the edge"
- // This means edge.Points contains ONLY the SG2D intermediate shape points
- // Nodes are stored separately and referenced via VRPT
-
- // Edge.Points = SG2D coordinates only (may be empty for straight-line edges)
- // Convert variable-length coordinates to fixed 2D coordinates
- points := make([][2]float64, 0, len(spatial.Coordinates))
- for _, coord := range spatial.Coordinates {
- if len(coord) >= 2 {
- points = append(points, [2]float64{coord[0], coord[1]})
- }
- }
-
- // Create edge
- newEdge := &edge{
- ID: edgeID,
- Points: points,
- StartNodeID: startNodeID,
- EndNodeID: endNodeID,
- }
-
- // Cache for reuse
- r.edgeCache[edgeID] = newEdge
-
- return newEdge, nil
-}
-
-// resolvePolygon constructs polygon rings from edge references via VRPT topology
-// IMPORTANT: Despite S-57 §4.7.3 (31Main.pdf p50) saying edges "must be referenced sequentially",
-// real-world ENC files do NOT provide edges in sequential order. We must follow
-// topology graph by matching node connectivity.
-func (r *polygonBuilder) resolvePolygon(edgeRefs []spatialRef) ([][][2]float64, error) {
- rings, err := r.resolvePolygonWithUsage(edgeRefs)
- if err != nil {
- return nil, err
- }
-
- // Extract coordinates only for backward compatibility
- coords := make([][][2]float64, len(rings))
- for i, ring := range rings {
- coords[i] = ring.coords
- }
- return coords, nil
-}
-
-// resolvePolygonWithUsage constructs polygon rings with usage indicators
-func (r *polygonBuilder) resolvePolygonWithUsage(edgeRefs []spatialRef) ([]ringWithUsage, error) {
- if len(edgeRefs) == 0 {
- return nil, &ErrInvalidGeometry{
- Reason: "no edge references provided",
- }
- }
-
- // Pre-load all edges and store with their orientations
- edgeOrientations := make(map[int64]int) // edgeID -> orientation
- for _, edgeRef := range edgeRefs {
- if _, err := r.loadEdge(edgeRef.RCID); err != nil {
- // Skip edges that fail to load
- continue
- }
- edgeOrientations[edgeRef.RCID] = edgeRef.Orientation
- }
-
- // Build rings by following topology graph
- return r.buildRingsWithUsage(edgeRefs, edgeOrientations)
-}
-
-// ringWithUsage holds a ring and its usage indicator
-type ringWithUsage struct {
- coords [][2]float64
- usage int
-}
-
-// buildRingsWithUsage constructs polygon rings from an area's edges.
-// S-57 §2.2.8: USAG indicates ring type (1=Exterior, 2=Interior, 3=Truncated).
-//
-// Edges are assembled into rings by TOPOLOGY — matching shared endpoints — NOT by
-// raw FSPT order. Although §4.7.3 says boundary edges "must be referenced
-// sequentially", real ENCs routinely list them out of order, and an area commonly
-// comprises several disjoint loops (an exterior plus island holes). The old code
-// concatenated edges in FSPT order and only ended a ring on an exact match back to
-// the start coord; when a loop's closure went undetected it appended the next,
-// unrelated edge anyway, drawing a straight connector segment between them — the
-// long diagonal lines that slashed across large coastal DEPAREs. Here each edge's
-// endpoints (the connected-node coordinates, bit-identical across the two edges
-// that meet at a node) are indexed, and rings are grown by following those shared
-// endpoints, reversing an edge when it connects at its far end, and starting a new
-// ring whenever the current one closes or dead-ends.
-func (r *polygonBuilder) buildRingsWithUsage(edgeRefs []spatialRef, orientations map[int64]int) ([]ringWithUsage, error) {
- _ = orientations // FSPT orientation is read per-edge below via getFullEdgeCoordinates
- if len(edgeRefs) == 0 {
- return nil, &ErrInvalidGeometry{Reason: "no edge references provided"}
- }
-
- // Resolve each FSPT edge to its full coordinate polyline (node + SG2D + node),
- // in FSPT orientation, tagged with its ring usage.
- type seg struct {
- coords [][2]float64
- usage int
- used bool
- }
- segs := make([]seg, 0, len(edgeRefs))
- for _, edgeRef := range edgeRefs {
- edge, err := r.loadEdge(edgeRef.RCID)
- if err != nil || edge == nil {
- continue // skip failed / deleted edges
- }
- coords := r.getFullEdgeCoordinates(edge, edgeRef.Orientation)
- if len(coords) < 2 {
- continue
- }
- usage := edgeRef.Usage
- if usage == 0 {
- usage = 1 // default to exterior
- }
- segs = append(segs, seg{coords: coords, usage: usage})
- }
- if len(segs) == 0 {
- return nil, &ErrInvalidGeometry{Reason: "no valid rings could be constructed"}
- }
-
- // Index both endpoints of every segment so a continuation can be found in O(1).
- endpoints := make(map[[2]float64][]int, len(segs)*2)
- for i, s := range segs {
- a, b := s.coords[0], s.coords[len(s.coords)-1]
- endpoints[a] = append(endpoints[a], i)
- if b != a {
- endpoints[b] = append(endpoints[b], i)
- }
- }
- // nextAt returns an unused segment touching p, or -1.
- nextAt := func(p [2]float64) int {
- for _, j := range endpoints[p] {
- if !segs[j].used {
- return j
- }
- }
- return -1
- }
- // appendSeg joins src to ring, dropping src's leading point when it duplicates
- // the ring's tail (the shared connecting node).
- appendSeg := func(ring, src [][2]float64) [][2]float64 {
- if len(ring) > 0 && len(src) > 0 && ring[len(ring)-1] == src[0] {
- src = src[1:]
- }
- return append(ring, src...)
- }
-
- rings := make([]ringWithUsage, 0)
- for i := range segs {
- if segs[i].used {
- continue
- }
- segs[i].used = true
- ringUsage := segs[i].usage
- ring := append([][2]float64(nil), segs[i].coords...)
- start := ring[0]
-
- for {
- tail := ring[len(ring)-1]
- if tail == start {
- break // ring closed
- }
- j := nextAt(tail)
- if j < 0 {
- break // dead end — no edge continues this ring
- }
- next := segs[j].coords
- if next[0] != tail { // connects at its far end → traverse it reversed
- next = reverseCoords(next)
- }
- ring = appendSeg(ring, next)
- segs[j].used = true
- }
-
- if len(ring) >= 2 {
- if ring[len(ring)-1] != ring[0] {
- ring = append(ring, ring[0]) // close geometrically if it dead-ended
- }
- if len(ring) >= 3 {
- rings = append(rings, ringWithUsage{coords: ring, usage: ringUsage})
- }
- }
- }
-
- if len(rings) == 0 {
- return nil, &ErrInvalidGeometry{Reason: "no valid rings could be constructed"}
- }
-
- // GeoJSON-style order: exterior (1), truncated (3), then interior holes (2).
- sortedRings := make([]ringWithUsage, 0, len(rings))
- for _, usage := range []int{1, 3, 2} {
- for _, ring := range rings {
- if ring.usage == usage {
- sortedRings = append(sortedRings, ring)
- }
- }
- }
- return sortedRings, nil
-}
-
-// reverseCoords returns a new slice with the points in reverse order.
-func reverseCoords(c [][2]float64) [][2]float64 {
- out := make([][2]float64, len(c))
- for i, p := range c {
- out[len(c)-1-i] = p
- }
- return out
-}
-
-// isRingClosed checks if a ring is properly closed
-func isRingClosed(ring [][2]float64) bool {
- if len(ring) < 3 {
- return false
- }
- first := ring[0]
- last := ring[len(ring)-1]
- return first[0] == last[0] && first[1] == last[1]
-}
diff --git a/internal/s57/parser/topology_test.go b/internal/s57/parser/topology_test.go
deleted file mode 100644
index f2b7757..0000000
--- a/internal/s57/parser/topology_test.go
+++ /dev/null
@@ -1,312 +0,0 @@
-package parser
-
-import (
- "testing"
-)
-
-// TestVRPTResolver tests the VRPT topology resolver
-func TestVRPTResolver(t *testing.T) {
- tests := []struct {
- name string
- spatialRecords map[spatialKey]*spatialRecord
- edgeRefs []spatialRef
- expectError bool
- expectRings int
- description string
- }{
- {
- name: "Simple triangle from 3 edges",
- spatialRecords: map[spatialKey]*spatialRecord{
- // Edge 1: node 1 -> node 2
- {RCNM: int(spatialTypeEdge), RCID: 1}: {
- ID: 1,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {0.0, 0.0},
- {1.0, 0.0},
- },
- VectorPointers: []vectorPointer{
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 1}, // start node
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 2}, // end node
- },
- },
- // Edge 2: node 2 -> node 3
- {RCNM: int(spatialTypeEdge), RCID: 2}: {
- ID: 2,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {1.0, 0.0},
- {0.5, 1.0},
- },
- VectorPointers: []vectorPointer{
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 2}, // start node
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 3}, // end node
- },
- },
- // Edge 3: node 3 -> node 1
- {RCNM: int(spatialTypeEdge), RCID: 3}: {
- ID: 3,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {0.5, 1.0},
- {0.0, 0.0},
- },
- VectorPointers: []vectorPointer{
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 3}, // start node
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 1}, // end node
- },
- },
- },
- edgeRefs: []spatialRef{
- {RCID: 1, Orientation: 1},
- {RCID: 2, Orientation: 1},
- {RCID: 3, Orientation: 1},
- },
- expectError: false,
- expectRings: 1,
- description: "Should construct a single triangle ring from 3 connected edges",
- },
- {
- name: "Square polygon (single ring)",
- spatialRecords: map[spatialKey]*spatialRecord{
- // Square edges
- {RCNM: int(spatialTypeEdge), RCID: 10}: {
- ID: 10,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {0.0, 0.0},
- {2.0, 0.0},
- },
- VectorPointers: []vectorPointer{
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 10},
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 11},
- },
- },
- {RCNM: int(spatialTypeEdge), RCID: 11}: {
- ID: 11,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {2.0, 0.0},
- {2.0, 2.0},
- },
- VectorPointers: []vectorPointer{
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 11},
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 12},
- },
- },
- {RCNM: int(spatialTypeEdge), RCID: 12}: {
- ID: 12,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {2.0, 2.0},
- {0.0, 2.0},
- },
- VectorPointers: []vectorPointer{
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 12},
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 13},
- },
- },
- {RCNM: int(spatialTypeEdge), RCID: 13}: {
- ID: 13,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {0.0, 2.0},
- {0.0, 0.0},
- },
- VectorPointers: []vectorPointer{
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 13},
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 10},
- },
- },
- },
- edgeRefs: []spatialRef{
- {RCID: 10, Orientation: 1}, {RCID: 11, Orientation: 1},
- {RCID: 12, Orientation: 1}, {RCID: 13, Orientation: 1},
- },
- expectError: false,
- expectRings: 1,
- description: "Should construct a square ring from 4 connected edges (real S-57 pattern)",
- },
- {
- name: "Empty edge references",
- spatialRecords: map[spatialKey]*spatialRecord{},
- edgeRefs: []spatialRef{},
- expectError: true,
- expectRings: 0,
- description: "Should return error for empty edge references",
- },
- {
- name: "Missing edge record - graceful handling",
- spatialRecords: map[spatialKey]*spatialRecord{
- {RCNM: int(spatialTypeEdge), RCID: 1}: {
- ID: 1,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {0.0, 0.0},
- {1.0, 0.0},
- },
- },
- },
- edgeRefs: []spatialRef{
- {RCID: 1, Orientation: 1},
- {RCID: 999, Orientation: 1}, // Edge 999 doesn't exist - should skip gracefully
- },
- expectError: false, // Real S-57 parsers skip missing edges gracefully
- expectRings: 1, // Should still build ring from valid edge
- description: "Should gracefully skip missing edge references (real S-57 behavior)",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- resolver := newPolygonBuilder(tt.spatialRecords)
-
- rings, err := resolver.resolvePolygon(tt.edgeRefs)
-
- if tt.expectError {
- if err == nil {
- t.Errorf("%s: expected error but got none", tt.description)
- }
- return
- }
-
- if err != nil {
- t.Errorf("%s: unexpected error: %v", tt.description, err)
- return
- }
-
- if len(rings) != tt.expectRings {
- t.Errorf("%s: expected %d rings, got %d", tt.description, tt.expectRings, len(rings))
- }
-
- // Validate ring closure
- for i, ring := range rings {
- if !isRingClosed(ring) {
- t.Errorf("%s: ring %d is not closed", tt.description, i)
- }
- }
- })
- }
-}
-
-// TestLoadEdge tests edge loading and caching
-func TestLoadEdge(t *testing.T) {
- spatialRecords := map[spatialKey]*spatialRecord{
- {RCNM: int(spatialTypeEdge), RCID: 100}: {
- ID: 100,
- RecordType: spatialTypeEdge,
- Coordinates: [][]float64{
- {-1.0, 51.0},
- {-0.9, 51.1},
- },
- VectorPointers: []vectorPointer{
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 1},
- {TargetRCNM: int(spatialTypeConnectedNode), TargetRCID: 2},
- },
- },
- {RCNM: int(spatialTypeIsolatedNode), RCID: 200}: {
- ID: 200,
- RecordType: spatialTypeIsolatedNode, // Not an edge
- Coordinates: [][]float64{
- {0.0, 0.0},
- },
- },
- }
-
- resolver := newPolygonBuilder(spatialRecords)
-
- t.Run("Load valid edge", func(t *testing.T) {
- edge, err := resolver.loadEdge(100)
- if err != nil {
- t.Fatalf("Expected no error, got: %v", err)
- }
-
- if edge.ID != 100 {
- t.Errorf("Expected edge ID 100, got %d", edge.ID)
- }
-
- if edge.StartNodeID != 1 || edge.EndNodeID != 2 {
- t.Errorf("Expected nodes 1->2, got %d->%d", edge.StartNodeID, edge.EndNodeID)
- }
-
- if len(edge.Points) != 2 {
- t.Errorf("Expected 2 points, got %d", len(edge.Points))
- }
- })
-
- t.Run("Load edge from cache", func(t *testing.T) {
- // Load twice - second should come from cache
- edge1, _ := resolver.loadEdge(100)
- edge2, _ := resolver.loadEdge(100)
-
- if edge1 != edge2 {
- t.Error("Expected cached edge to be same instance")
- }
- })
-
- t.Run("Load non-existent edge", func(t *testing.T) {
- _, err := resolver.loadEdge(999)
- if err == nil {
- t.Error("Expected error for non-existent edge")
- }
- })
-
- t.Run("Load wrong record type", func(t *testing.T) {
- _, err := resolver.loadEdge(200) // Node, not edge
- if err == nil {
- t.Error("Expected error for wrong record type")
- }
- })
-}
-
-// TestRingClosure tests ring closure validation
-func TestRingClosure(t *testing.T) {
- tests := []struct {
- name string
- ring [][2]float64
- expected bool
- }{
- {
- name: "Closed ring",
- ring: [][2]float64{
- {0.0, 0.0},
- {1.0, 0.0},
- {1.0, 1.0},
- {0.0, 0.0}, // Same as first
- },
- expected: true,
- },
- {
- name: "Open ring",
- ring: [][2]float64{
- {0.0, 0.0},
- {1.0, 0.0},
- {1.0, 1.0},
- {0.5, 0.5}, // Different from first
- },
- expected: false,
- },
- {
- name: "Too few points",
- ring: [][2]float64{
- {0.0, 0.0},
- {1.0, 1.0},
- },
- expected: false,
- },
- {
- name: "Empty ring",
- ring: [][2]float64{},
- expected: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := isRingClosed(tt.ring)
- if result != tt.expected {
- t.Errorf("Expected %v, got %v for ring: %v", tt.expected, result, tt.ring)
- }
- })
- }
-}
diff --git a/internal/s57/parser/updates.go b/internal/s57/parser/updates.go
deleted file mode 100644
index 87f94ab..0000000
--- a/internal/s57/parser/updates.go
+++ /dev/null
@@ -1,412 +0,0 @@
-package parser
-
-import (
- "encoding/binary"
- "fmt"
- "io/fs"
- "path/filepath"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-// UpdateInstruction represents the RUIN (Record Update Instruction) field values
-// S-57 Part 3 §8.4.2.2 (31Main.pdf p85) and §8.4.3.2 (31Main.pdf p88)
-type UpdateInstruction int
-
-const (
- // UpdateInsert indicates a record should be inserted (RUIN = 1)
- UpdateInsert UpdateInstruction = 1
-
- // UpdateDelete indicates a record should be deleted (RUIN = 2)
- UpdateDelete UpdateInstruction = 2
-
- // UpdateModify indicates a record should be modified (RUIN = 3)
- UpdateModify UpdateInstruction = 3
-)
-
-// findUpdateFiles discovers sequential update files for a base cell
-//
-// Given "GB5X01SW.000", looks for "GB5X01SW.001", "GB5X01SW.002", etc.
-// in the same directory. Returns paths in order.
-func findUpdateFiles(fsys fs.FS, baseFilename string) ([]string, error) {
- // Get base filename without extension
- dir := filepath.Dir(baseFilename)
- base := filepath.Base(baseFilename)
-
- // Remove extension (.000)
- baseName := strings.TrimSuffix(base, filepath.Ext(base))
-
- var updates []string
-
- // Look for sequential updates: .001, .002, .003, etc.
- for updateNum := 1; updateNum <= 999; updateNum++ {
- updateFile := filepath.Join(dir, fmt.Sprintf("%s.%03d", baseName, updateNum))
-
- // Check if file exists using the provided filesystem
- if iso8211.Exists(fsys, updateFile) {
- updates = append(updates, updateFile)
- } else {
- // Stop at first missing update (updates must be sequential)
- break
- }
- }
-
- return updates, nil
-}
-
-// applyUpdates applies update files to parsed chart data
-//
-// Updates are applied at the record level before geometry construction.
-// This modifies featureRecords and spatialRecords in place.
-func applyUpdates(fsys fs.FS, baseChart *chartData, updateFiles []string, params datasetParams) error {
- for _, updateFile := range updateFiles {
- if err := applyUpdate(fsys, baseChart, updateFile, params); err != nil {
- return fmt.Errorf("failed to apply update %s: %w", updateFile, err)
- }
- }
- return nil
-}
-
-// featureID uniquely identifies a feature using the composite key from FOID
-// Per S-57 §7.6.2 (31Main.pdf p75), the unique identifier is (AGEN, FIDN, FIDS), not just FIDN
-type featureID struct {
- AGEN uint16 // Producing agency
- FIDN uint32 // Feature identification number
- FIDS uint16 // Feature identification subdivision
-}
-
-// chartData holds the intermediate chart state during update merging
-type chartData struct {
- features []*featureRecord
- spatialRecords map[spatialKey]*spatialRecord
- metadata *datasetMetadata
-
- // Index for fast lookup during updates
- // CRITICAL: Must use composite key (AGEN, FIDN, FIDS) because FIDN alone is not unique
- featuresByID map[featureID]*featureRecord
-
- // warnings collects non-fatal spec-conformance deviations (see conformance.go),
- // shared with the parse phase so update-time checks (e.g. RVER sequencing) land
- // in the same list. May be nil in code paths that don't track conformance.
- warnings *conformance
-}
-
-// applyUpdate applies a single update file to the chart data
-func applyUpdate(fsys fs.FS, chart *chartData, updateFile string, params datasetParams) error {
- // Open update file from filesystem using OpenFS
- parser, err := iso8211.OpenFS(fsys, updateFile)
- if err != nil {
- return fmt.Errorf("failed to open update file: %w", err)
- }
- defer parser.Close()
-
- isoFile, err := parser.Parse()
- if err != nil {
- return fmt.Errorf("failed to parse update file: %w", err)
- }
-
- // Process each record in update file
- for _, record := range isoFile.Records {
- // Feature record (FRID)
- if fridData, ok := record.Fields["FRID"]; ok && len(fridData) >= 12 {
- if err := applyFeatureUpdate(chart, record, fridData); err != nil {
- return err
- }
- continue
- }
-
- // Spatial record (VRID)
- if vridData, ok := record.Fields["VRID"]; ok && len(vridData) >= 8 {
- if err := applySpatialUpdate(chart, record, vridData, params); err != nil {
- return err
- }
- continue
- }
- }
-
- // Check if update contains new DSID metadata and merge it
- if updatedDSID := extractDSID(isoFile); updatedDSID != nil {
- // Merge updated metadata fields
- // Per S-57 spec, update files can modify UPDN (update number) and UADT (update date)
- // EDTN (edition) and DSNM (dataset name) should NOT change in updates
- if updatedDSID.updn != "" {
- chart.metadata.updn = updatedDSID.updn
- }
- if updatedDSID.uadt != "" {
- chart.metadata.uadt = updatedDSID.uadt
- }
- // Update issue date if present
- if updatedDSID.isdt != "" {
- chart.metadata.isdt = updatedDSID.isdt
- }
- }
-
- return nil
-}
-
-// applyFeatureUpdate handles INSERT/DELETE/MODIFY for features
-func applyFeatureUpdate(chart *chartData, record *iso8211.DataRecord, fridData []byte) error {
- ruin := UpdateInstruction(fridData[11])
-
- // Parse feature record
- featureRec := parseFeatureRecord(record)
- if featureRec == nil {
- return fmt.Errorf("failed to parse feature record")
- }
-
- // Create composite key from FOID fields
- key := featureID{
- AGEN: featureRec.AGEN,
- FIDN: featureRec.FIDN,
- FIDS: featureRec.FIDS,
- }
-
- switch ruin {
- case UpdateInsert:
- // Add or replace feature
- // Note: Some ENC producers use INSERT even when the record exists in the base
- // This is treated as an upsert operation
- if existing, exists := chart.featuresByID[key]; exists {
- // Replace existing feature
- *existing = *featureRec
- } else {
- // Add new feature
- chart.features = append(chart.features, featureRec)
- chart.featuresByID[key] = featureRec
- }
-
- case UpdateDelete:
- // Remove existing feature
- existing, exists := chart.featuresByID[key]
- if !exists {
- // Feature doesn't exist - this is a no-op
- // This can happen if the base cell doesn't have the feature being deleted
- return nil
- }
-
- // Remove from index
- delete(chart.featuresByID, key)
-
- // Remove from slice
- for i, f := range chart.features {
- if f == existing {
- chart.features = append(chart.features[:i], chart.features[i+1:]...)
- break
- }
- }
-
- case UpdateModify:
- // Update existing feature
- existing, exists := chart.featuresByID[key]
- if !exists {
- return fmt.Errorf("MODIFY: feature (AGEN=%d, FIDN=%d, FIDS=%d) not found",
- featureRec.AGEN, featureRec.FIDN, featureRec.FIDS)
- }
-
- // Merge update record into existing feature
- // Per S-57 §8.4.2.2 (31Main.pdf p85): MODIFY only updates fields present in the update record
- // We must selectively update fields rather than wholesale replacement
-
- // RVER sequencing (§8.4.2.1): an update record's version must be exactly one
- // higher than the target it modifies. A gap/mismatch means an update was
- // skipped or applied out of order — reported, not fatal (we still apply it).
- if featureRec.RecordVersion != existing.RecordVersion+1 {
- chart.warnings.addf("8.4.2.1", "RVER_SEQUENCE_FEATURE",
- "feature (AGEN=%d, FIDN=%d) MODIFY version %d is not target version %d + 1",
- featureRec.AGEN, featureRec.FIDN, featureRec.RecordVersion, existing.RecordVersion)
- }
-
- // Always update these core identification fields
- existing.RecordVersion = featureRec.RecordVersion
- existing.UpdateInstr = featureRec.UpdateInstr
-
- // Update attributes if ATTF field present in update record
- // Note: parseFeatureRecord sets Attributes to empty map if no ATTF, so we
- // can't distinguish "no ATTF" from "empty ATTF". For now, always update.
- // TODO: Consider tracking which fields were present in the raw record
- if len(featureRec.Attributes) > 0 {
- existing.Attributes = featureRec.Attributes
- }
-
- // Update spatial refs ONLY if FSPT field present in update record
- // Per S-57 §8.4.2.2 (b): FSPT modification controlled by FSPC field
- // If FSPT field is present, parseFeatureRecord will set SpatialRefs (even if empty)
- // If FSPT field is absent, parseFeatureRecord leaves SpatialRefs as nil
- // Gate on the CONTROL field (FSPC), not the data field (FSPT): a DELETE
- // instruction carries FSPC with NO FSPT (nothing to insert), so gating on FSPT
- // silently dropped every delete — leaving stale edges that scrambled area
- // boundaries. FSPC = insert/delete/modify N pointers at index FSIX (§8.4.2.4).
- if fspc, ok := record.Fields["FSPC"]; ok && len(fspc) >= 5 {
- existing.SpatialRefs = applyControl(existing.SpatialRefs, featureRec.SpatialRefs, fspc)
- } else if _, hasFSPT := record.Fields["FSPT"]; hasFSPT {
- existing.SpatialRefs = featureRec.SpatialRefs // no control field ⇒ full replacement
- }
- // If neither present, preserve existing SpatialRefs
-
- // Keep reference in index
- chart.featuresByID[key] = existing
-
- default:
- return fmt.Errorf("unknown RUIN value for feature: %d", ruin)
- }
-
- return nil
-}
-
-// applySpatialUpdate handles INSERT/DELETE/MODIFY for spatial records
-func applySpatialUpdate(chart *chartData, record *iso8211.DataRecord, vridData []byte, params datasetParams) error {
- ruin := UpdateInstruction(vridData[7])
-
- // Parse spatial record
- spatialRec := parseSpatialRecordWithParams(record, params)
- if spatialRec == nil {
- return fmt.Errorf("failed to parse spatial record")
- }
-
- // Build key from record type and ID
- key := spatialKey{
- RCNM: int(spatialRec.RecordType),
- RCID: spatialRec.ID,
- }
-
- switch ruin {
- case UpdateInsert:
- // Add or replace spatial record
- // Note: Some ENC producers use INSERT even when the record exists in the base
- // This is treated as an upsert operation
- chart.spatialRecords[key] = spatialRec
-
- case UpdateDelete:
- // Remove existing spatial record
- if _, exists := chart.spatialRecords[key]; !exists {
- // Record doesn't exist - this is a no-op
- return nil
- }
- delete(chart.spatialRecords, key)
-
- case UpdateModify:
- // Update existing spatial record
- existing, exists := chart.spatialRecords[key]
- if !exists {
- return fmt.Errorf("MODIFY: spatial record %v not found", key)
- }
-
- // Per S-57 §8.4.3.2 (31Main.pdf p88): MODIFY only updates fields present in the update record
- // We must selectively merge fields rather than wholesale replacement
- // This is critical - update records may omit fields that should be preserved!
-
- // RVER sequencing (§8.4.3.1): mirror the feature-record rule for vectors.
- if spatialRec.RecordVersion != existing.RecordVersion+1 {
- chart.warnings.addf("8.4.3.1", "RVER_SEQUENCE_SPATIAL",
- "spatial record %v MODIFY version %d is not target version %d + 1",
- key, spatialRec.RecordVersion, existing.RecordVersion)
- }
-
- // Always update core identification fields
- existing.RecordVersion = spatialRec.RecordVersion
- existing.UpdateInstr = spatialRec.UpdateInstr
-
- // Update coordinates ONLY if SG2D or SG3D field present in update record.
- // S-57 §8.4.3.2: a coordinate update is NOT a wholesale replacement — the SGCC
- // (Coordinate Control) field says insert/delete/modify CCNC coordinates at
- // index CCIX, with SG2D/SG3D supplying the new ones. Replacing the whole list
- // (the old behavior) collapsed e.g. a 123-point M_COVR coverage ring to the 3
- // points in the update, which then covered nothing — breaking everything that
- // keys off cell coverage (best-available band suppression, no-data, …).
- // Gate on the CONTROL field (SGCC), not the data field (SG2D/SG3D): a coordinate
- // DELETE carries SGCC with NO SG2D, so gating on SG2D dropped deletes. SGCC =
- // insert/delete/modify N coords at index CCIX (§8.4.3.2/3.3). With no SGCC, a
- // SG2D/SG3D update is a full replacement (and for a straight-line edge whose
- // existing coords are empty, replacement == the §8.4.3.3 "append" rule).
- if sgcc, ok := record.Fields["SGCC"]; ok && len(sgcc) >= 5 {
- existing.Coordinates = applyControl(existing.Coordinates, spatialRec.Coordinates, sgcc)
- } else if _, hasSG2D := record.Fields["SG2D"]; hasSG2D {
- existing.Coordinates = spatialRec.Coordinates
- } else if _, hasSG3D := record.Fields["SG3D"]; hasSG3D {
- existing.Coordinates = spatialRec.Coordinates
- }
- // If neither SG2D nor SG3D nor SGCC present, preserve existing coordinates
-
- // Update VRPT (the edge's begin/end node pointers) via the VRPC control
- // field — S-57 §8.4.3.2: a VRPT edit is an indexed insert/delete/modify
- // (VRPC = VPUI instr + VPIX index + NVPT count), NOT a wholesale replace.
- // Gate on the CONTROL field (VRPC), not the data field (VRPT): e.g. an edge
- // whose end-node pointer is MODIFIED ships VRPC{modify,idx=2,count=1} + one
- // new VRPT — replacing the whole list dropped the begin-node pointer, so the
- // edge lost an endpoint (endNode=0), truncating it and tearing a sliver out
- // of the area boundary. Same class as the SGCC/FSPC fix below. With no VRPC
- // present, a bare VRPT is a full replacement.
- if vrpc, ok := record.Fields["VRPC"]; ok && len(vrpc) >= 5 {
- existing.VectorPointers = applyControl(existing.VectorPointers, spatialRec.VectorPointers, vrpc)
- } else if _, hasVRPT := record.Fields["VRPT"]; hasVRPT {
- existing.VectorPointers = spatialRec.VectorPointers
- }
- // If neither VRPC nor VRPT present, preserve existing vector pointers
-
- // Keep existing record in map (already there, but make it explicit)
- chart.spatialRecords[key] = existing
-
- default:
- return fmt.Errorf("unknown RUIN value for spatial: %d", ruin)
- }
-
- return nil
-}
-
-// applyControl applies an S-57 update-control field (SGCC for coordinates §8.4.3.2,
-// FSPC for feature-spatial pointers §8.4.2.2 — identical structure) to an existing
-// list. The control is a repeating group of 5-byte entries: UI (b11: 1=insert,
-// 2=delete, 3=modify), IX (b12, 1-based index), NC (b12, count). Insert/modify
-// consume items from `upd` (the update's new SG2D/SG3D or FSPT, in order); delete
-// consumes none. Instructions are applied in sequence to the evolving list, so a
-// small edit touches only its indexed entries and leaves the rest of the base
-// list intact (e.g. a one-point edit to a 123-point coverage ring keeps the
-// other 122).
-func applyControl[T any](existing, upd []T, ctrl []byte) []T {
- out := append([]T(nil), existing...) // don't mutate the base record's slice
- ui := 0 // cursor into the update's new items
- take := func(n int) []T {
- end := ui + n
- if end > len(upd) {
- end = len(upd)
- }
- o := upd[ui:end]
- ui = end
- return o
- }
- for off := 0; off+5 <= len(ctrl); off += 5 {
- instr := ctrl[off]
- idx := int(binary.LittleEndian.Uint16(ctrl[off+1:off+3])) - 1 // 1-based → 0-based
- nc := int(binary.LittleEndian.Uint16(ctrl[off+3 : off+5]))
- if idx < 0 {
- idx = 0
- }
- switch instr {
- case 1: // insert nc items before idx
- ins := take(nc)
- if idx > len(out) {
- idx = len(out)
- }
- merged := make([]T, 0, len(out)+len(ins))
- merged = append(merged, out[:idx]...)
- merged = append(merged, ins...)
- merged = append(merged, out[idx:]...)
- out = merged
- case 2: // delete nc items starting at idx
- end := idx + nc
- if end > len(out) {
- end = len(out)
- }
- if idx < len(out) && idx < end {
- out = append(out[:idx], out[end:]...)
- }
- case 3: // modify nc items starting at idx (replace with new ones)
- repl := take(nc)
- for k := 0; k < len(repl) && idx+k < len(out); k++ {
- out[idx+k] = repl[k]
- }
- }
- }
- return out
-}
diff --git a/internal/s57/parser/updates_test.go b/internal/s57/parser/updates_test.go
deleted file mode 100644
index ed96e7f..0000000
--- a/internal/s57/parser/updates_test.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package parser
-
-import (
- "path/filepath"
- "testing"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-func TestAutoDiscoverUpdates(t *testing.T) {
- baseFile := "../../../testdata/US4MD81M.000"
-
- updates, err := findUpdateFiles(iso8211.OSFS(), baseFile)
- if err != nil {
- t.Fatal(err)
- }
-
- // Should find .001, .002, .003
- if len(updates) != 3 {
- t.Errorf("Expected 3 updates, got %d", len(updates))
- }
-
- // Verify they're in order
- expectedFiles := []string{
- filepath.Join(filepath.Dir(baseFile), "US4MD81M.001"),
- filepath.Join(filepath.Dir(baseFile), "US4MD81M.002"),
- filepath.Join(filepath.Dir(baseFile), "US4MD81M.003"),
- }
-
- for i, expected := range expectedFiles {
- if updates[i] != expected {
- t.Errorf("Update %d: expected %s, got %s", i, expected, updates[i])
- }
- }
-}
-
-func TestParseWithUpdates(t *testing.T) {
- parser := NewParser()
-
- // Parse with updates enabled (default)
- baseFile := "../../../testdata/US4MD81M.000"
- chart, err := parser.Parse(baseFile)
- if err != nil {
- t.Fatal(err)
- }
-
- // Verify update number reflects latest update
- if chart.UpdateNumber() != "3" {
- t.Errorf("Expected update number 3, got %s", chart.UpdateNumber())
- }
-
- // Verify we have features (basic sanity check)
- if len(chart.Features) == 0 {
- t.Error("Expected features in chart after applying updates")
- }
-
- t.Logf("Chart parsed successfully with %d features, update number: %s", len(chart.Features), chart.UpdateNumber())
-}
-
-func TestParseWithoutUpdates(t *testing.T) {
- parser := NewParser()
-
- baseFile := "../../../testdata/US4MD81M.000"
-
- opts := ParseOptions{
- ApplyUpdates: false, // Disable update merging
- SkipUnknownFeatures: false,
- ValidateGeometry: true,
- ObjectClassFilter: nil,
- }
-
- chart, err := parser.ParseWithOptions(baseFile, opts)
- if err != nil {
- t.Fatal(err)
- }
-
- // Should have base cell data only
- if chart.UpdateNumber() != "0" {
- t.Errorf("Expected update number 0, got %s", chart.UpdateNumber())
- }
-
- t.Logf("Chart parsed successfully without updates, %d features, update number: %s", len(chart.Features), chart.UpdateNumber())
-}
-
-func TestUpdateInstructionConstants(t *testing.T) {
- // Verify the RUIN constants match S-57 spec
- if UpdateInsert != 1 {
- t.Errorf("UpdateInsert should be 1, got %d", UpdateInsert)
- }
- if UpdateDelete != 2 {
- t.Errorf("UpdateDelete should be 2, got %d", UpdateDelete)
- }
- if UpdateModify != 3 {
- t.Errorf("UpdateModify should be 3, got %d", UpdateModify)
- }
-}
diff --git a/internal/s57/parser/validation.go b/internal/s57/parser/validation.go
deleted file mode 100644
index 0ea18f7..0000000
--- a/internal/s57/parser/validation.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package parser
-
-import (
- "fmt"
-)
-
-// ValidateCoordinate validates a single coordinate pair
-// S-57 coordinates must be within valid geographic bounds
-func ValidateCoordinate(lat, lon float64) error {
- if lat < -90.0 || lat > 90.0 {
- return &ErrInvalidCoordinate{Lat: lat, Lon: lon}
- }
- if lon < -180.0 || lon > 180.0 {
- return &ErrInvalidCoordinate{Lat: lat, Lon: lon}
- }
- return nil
-}
-
-// ValidateGeometry validates geometry per S-57 spatial rules
-// S-57 §7.3: Geometry validation rules
-func ValidateGeometry(geometry *Geometry) error {
- if geometry == nil {
- return &ErrInvalidGeometry{Reason: "geometry is nil"}
- }
-
- // Allow empty coordinates for meta-features (PRIM=255) like C_AGGR, M_COVR
- // These features have no spatial representation
- if len(geometry.Coordinates) == 0 {
- return nil // Empty geometry is valid for meta-features
- }
-
- // DEBUG: Log geometry details
- // fmt.Fprintf(os.Stderr, "Validating %s with %d coords\n", geometry.Type, len(geometry.Coordinates))
-
- // Validate coordinate count based on geometry type
- switch geometry.Type {
- case GeometryTypePoint:
- // Point geometry can have 1 coordinate (simple point) or many (multipoint)
- // Multipoint features like SOUNDG can have hundreds of coordinates
- // Allow empty points - they will be skipped during rendering
-
- case GeometryTypeLineString:
- // Allow degenerate lines - they will be skipped during rendering
-
- case GeometryTypePolygon:
- // Allow degenerate polygons - they will be skipped during rendering
- // Validation of polygon closure is done during geometry construction
- }
-
- // Validate each coordinate (2D or 3D)
- // S-57 soundings (SOUNDG) use 3D coordinates [lon, lat, depth]
- for i, coord := range geometry.Coordinates {
- if len(coord) < 2 || len(coord) > 3 {
- return &ErrInvalidGeometry{
- Type: geometry.Type,
- Reason: fmt.Sprintf("coordinate %d must have 2 or 3 values [lon, lat] or [lon, lat, depth], got %d", i, len(coord)),
- }
- }
- lon, lat := coord[0], coord[1]
- if err := ValidateCoordinate(lat, lon); err != nil {
- return &ErrInvalidGeometry{
- Type: geometry.Type,
- Reason: fmt.Sprintf("coordinate %d invalid: %v", i, err),
- }
- }
- // Note: depth (Z) coordinate is not validated - can be any value including negative
- }
-
- return nil
-}
-
-// ValidateFeature validates a feature per S-57 rules
-func ValidateFeature(feature *Feature) error {
- if feature == nil {
- return fmt.Errorf("feature is nil")
- }
-
- if feature.ObjectClass == "" {
- return fmt.Errorf("feature has empty object class")
- }
-
- if err := ValidateGeometry(&feature.Geometry); err != nil {
- return fmt.Errorf("feature %d: %w", feature.ID, err)
- }
-
- return nil
-}
diff --git a/internal/s57/parser/validation_test.go b/internal/s57/parser/validation_test.go
deleted file mode 100644
index e309ddc..0000000
--- a/internal/s57/parser/validation_test.go
+++ /dev/null
@@ -1,248 +0,0 @@
-package parser
-
-import (
- "fmt"
- "strings"
- "testing"
-)
-
-// TestValidateCoordinate tests coordinate validation
-func TestValidateCoordinate(t *testing.T) {
- tests := []struct {
- name string
- lat float64
- lon float64
- wantErr bool
- }{
- {"valid", 42.35, -71.05, false},
- {"lat max boundary", 90.0, 0.0, false},
- {"lat min boundary", -90.0, 0.0, false},
- {"lon max boundary", 0.0, 180.0, false},
- {"lon min boundary", 0.0, -180.0, false},
- {"lat too high", 90.1, 0.0, true},
- {"lat too low", -90.1, 0.0, true},
- {"lon too high", 0.0, 180.1, true},
- {"lon too low", 0.0, -180.1, true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := ValidateCoordinate(tt.lat, tt.lon)
- if (err != nil) != tt.wantErr {
- t.Errorf("ValidateCoordinate() error = %v, wantErr %v", err, tt.wantErr)
- }
- })
- }
-}
-
-// TestValidateGeometry tests geometry validation
-func TestValidateGeometry(t *testing.T) {
- tests := []struct {
- name string
- geometry *Geometry
- wantErr bool
- }{
- {
- name: "valid point",
- geometry: &Geometry{
- Type: GeometryTypePoint,
- Coordinates: [][]float64{{-71.0, 42.0}},
- },
- wantErr: false,
- },
- {
- name: "valid linestring",
- geometry: &Geometry{
- Type: GeometryTypeLineString,
- Coordinates: [][]float64{
- {-71.0, 42.0},
- {-70.0, 43.0},
- },
- },
- wantErr: false,
- },
- {
- name: "valid polygon",
- geometry: &Geometry{
- Type: GeometryTypePolygon,
- Coordinates: [][]float64{
- {-71.0, 42.0},
- {-70.0, 42.0},
- {-70.0, 43.0},
- {-71.0, 42.0}, // Closed
- },
- },
- wantErr: false,
- },
- {
- name: "multipoint (SOUNDG) with multiple coordinates",
- geometry: &Geometry{
- Type: GeometryTypePoint,
- Coordinates: [][]float64{
- {-71.0, 42.0},
- {-70.0, 43.0},
- },
- },
- wantErr: false, // S-57 allows multipoint features like SOUNDG
- },
- {
- name: "degenerate linestring with one coordinate",
- geometry: &Geometry{
- Type: GeometryTypeLineString,
- Coordinates: [][]float64{{-71.0, 42.0}},
- },
- wantErr: false, // Degenerate geometries allowed, skipped during rendering
- },
- {
- name: "degenerate polygon not closed",
- geometry: &Geometry{
- Type: GeometryTypePolygon,
- Coordinates: [][]float64{
- {-71.0, 42.0},
- {-70.0, 42.0},
- {-70.0, 43.0},
- // Missing closing coordinate
- },
- },
- wantErr: false, // Degenerate geometries allowed, skipped during rendering
- },
- {
- name: "invalid latitude",
- geometry: &Geometry{
- Type: GeometryTypePoint,
- Coordinates: [][]float64{{-71.0, 95.0}}, // lat > 90
- },
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := ValidateGeometry(tt.geometry)
- if (err != nil) != tt.wantErr {
- t.Errorf("ValidateGeometry() error = %v, wantErr %v", err, tt.wantErr)
- }
- })
- }
-}
-
-// TestValidateFeature tests feature validation
-func TestValidateFeature(t *testing.T) {
- tests := []struct {
- name string
- feature *Feature
- wantErr bool
- }{
- {
- name: "valid feature",
- feature: &Feature{
- ID: 1,
- ObjectClass: "DEPCNT",
- Geometry: Geometry{
- Type: GeometryTypeLineString,
- Coordinates: [][]float64{{-71.0, 42.0}, {-70.0, 43.0}},
- },
- Attributes: map[string]interface{}{"DRVAL1": 10.0},
- },
- wantErr: false,
- },
- {
- name: "nil feature",
- feature: nil,
- wantErr: true,
- },
- {
- name: "empty object class",
- feature: &Feature{
- ID: 1,
- ObjectClass: "",
- Geometry: Geometry{
- Type: GeometryTypePoint,
- Coordinates: [][]float64{{-71.0, 42.0}},
- },
- },
- wantErr: true,
- },
- {
- name: "invalid geometry",
- feature: &Feature{
- ID: 1,
- ObjectClass: "DEPCNT",
- Geometry: Geometry{
- Type: GeometryTypePoint,
- Coordinates: [][]float64{{-71.0, 95.0}}, // Invalid lat
- },
- },
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := ValidateFeature(tt.feature)
- if (err != nil) != tt.wantErr {
- t.Errorf("ValidateFeature() error = %v, wantErr %v", err, tt.wantErr)
- }
- })
- }
-}
-
-// TestValidationBreakdown shows exactly which validation checks are failing
-func TestValidationBreakdown(t *testing.T) {
- parser, err := DefaultParser()
- if err != nil {
- t.Fatalf("failed to create parser: %v", err)
- }
-
- // Parse without validation
- opts := ParseOptions{
- SkipUnknownFeatures: true,
- ValidateGeometry: false,
- }
- chart, err := parser.ParseWithOptions("../../../testdata/US4MD81M.000", opts)
- if err != nil {
- t.Fatalf("failed to parse: %v", err)
- }
-
- // Now validate each feature manually and categorize failures
- validationErrors := make(map[string]int)
-
- for _, feature := range chart.Features {
- if err := ValidateGeometry(&feature.Geometry); err != nil {
- // Extract the specific error reason
- errMsg := err.Error()
- // Simplify error message to group similar errors
- if strings.Contains(errMsg, "no coordinates") {
- validationErrors["no_coordinates"]++
- } else if strings.Contains(errMsg, "exactly 1 coordinate") {
- validationErrors["point_wrong_count"]++
- } else if strings.Contains(errMsg, "at least 2 coordinates") {
- validationErrors["linestring_too_short"]++
- } else if strings.Contains(errMsg, "at least 3 coordinates") {
- validationErrors["polygon_too_short"]++
- } else if strings.Contains(errMsg, "not closed") {
- validationErrors["polygon_not_closed"]++
- } else if strings.Contains(errMsg, "exactly 2 values") {
- validationErrors["coord_wrong_dimension"]++
- } else if strings.Contains(errMsg, "invalid") {
- validationErrors["coord_out_of_bounds"]++
- } else {
- validationErrors["other: "+errMsg]++
- }
- }
- }
-
- fmt.Printf("\n=== VALIDATION FAILURE BREAKDOWN ===\n")
- fmt.Printf("Total features parsed: %d\n", len(chart.Features))
-
- totalErrors := 0
- for _, count := range validationErrors {
- totalErrors += count
- }
- fmt.Printf("Features failing validation: %d (%.1f%%)\n\n", totalErrors, float64(totalErrors)/float64(len(chart.Features))*100.0)
-
- fmt.Println("Failure reasons:")
- for reason, count := range validationErrors {
- fmt.Printf(" %-30s: %d\n", reason, count)
- }
-}
diff --git a/pkg/geo/geo.go b/pkg/geo/geo.go
deleted file mode 100644
index 35fa93d..0000000
--- a/pkg/geo/geo.go
+++ /dev/null
@@ -1,102 +0,0 @@
-// Package geo holds the shared geographic and screen primitives every other
-// package speaks. Two point types are kept deliberately: LatLon is a WGS84
-// world position (float64), Point is a projected screen position (float32).
-// Collapsing them would silently conflate "where on Earth" with "where on the
-// page" and change precision, so they stay distinct.
-package geo
-
-import "math"
-
-// LatLon is a WGS84 geographic position in degrees.
-type LatLon struct {
- Lat float64
- Lon float64
-}
-
-// Order compares two positions lat-major, then lon. Returns -1, 0, or 1.
-func (a LatLon) Order(b LatLon) int {
- if a.Lat != b.Lat {
- return cmp(a.Lat, b.Lat)
- }
- return cmp(a.Lon, b.Lon)
-}
-
-func cmp(a, b float64) int {
- switch {
- case a < b:
- return -1
- case a > b:
- return 1
- default:
- return 0
- }
-}
-
-// Point is a projected screen position in page/pixel units.
-type Point struct {
- X float32
- Y float32
-}
-
-// BoundingBox is a geographic bounding box in degrees.
-type BoundingBox struct {
- MinLat float64
- MinLon float64
- MaxLat float64
- MaxLon float64
-}
-
-// EmptyBox returns a box that contains nothing; the first ExtendPoint snaps it
-// to that point.
-func EmptyBox() BoundingBox {
- return BoundingBox{
- MinLat: math.Inf(1),
- MinLon: math.Inf(1),
- MaxLat: math.Inf(-1),
- MaxLon: math.Inf(-1),
- }
-}
-
-// ExtendPoint grows the box to include p.
-func (b *BoundingBox) ExtendPoint(p LatLon) {
- if p.Lat < b.MinLat {
- b.MinLat = p.Lat
- }
- if p.Lon < b.MinLon {
- b.MinLon = p.Lon
- }
- if p.Lat > b.MaxLat {
- b.MaxLat = p.Lat
- }
- if p.Lon > b.MaxLon {
- b.MaxLon = p.Lon
- }
-}
-
-// ExtendBox grows the box to include other.
-func (b *BoundingBox) ExtendBox(other BoundingBox) {
- if other.MinLat < b.MinLat {
- b.MinLat = other.MinLat
- }
- if other.MinLon < b.MinLon {
- b.MinLon = other.MinLon
- }
- if other.MaxLat > b.MaxLat {
- b.MaxLat = other.MaxLat
- }
- if other.MaxLon > b.MaxLon {
- b.MaxLon = other.MaxLon
- }
-}
-
-// Contains reports whether p lies within the box (inclusive).
-func (b BoundingBox) Contains(p LatLon) bool {
- return p.Lat >= b.MinLat && p.Lat <= b.MaxLat &&
- p.Lon >= b.MinLon && p.Lon <= b.MaxLon
-}
-
-// Intersects reports whether the two boxes overlap.
-func (b BoundingBox) Intersects(other BoundingBox) bool {
- return b.MinLat <= other.MaxLat && b.MaxLat >= other.MinLat &&
- b.MinLon <= other.MaxLon && b.MaxLon >= other.MinLon
-}
diff --git a/pkg/geo/geo_test.go b/pkg/geo/geo_test.go
deleted file mode 100644
index 86577c7..0000000
--- a/pkg/geo/geo_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package geo
-
-import "testing"
-
-func TestBoundingBoxExtendsAndIntersects(t *testing.T) {
- b := EmptyBox()
- b.ExtendPoint(LatLon{Lat: 42.0, Lon: -71.0})
- b.ExtendPoint(LatLon{Lat: 42.5, Lon: -70.5})
- if b.MinLat != 42.0 {
- t.Fatalf("MinLat = %v, want 42.0", b.MinLat)
- }
- if b.MaxLon != -70.5 {
- t.Fatalf("MaxLon = %v, want -70.5", b.MaxLon)
- }
- if !b.Contains(LatLon{Lat: 42.25, Lon: -70.75}) {
- t.Fatal("expected box to contain interior point")
- }
- if b.Contains(LatLon{Lat: 41.9, Lon: -71.5}) {
- t.Fatal("expected box not to contain exterior point")
- }
- other := BoundingBox{MinLat: 42.4, MinLon: -70.7, MaxLat: 43.0, MaxLon: -70.0}
- if !b.Intersects(other) {
- t.Fatal("expected boxes to intersect")
- }
-}
-
-func TestLatLonOrder(t *testing.T) {
- a := LatLon{Lat: 1.0, Lon: 2.0}
- b := LatLon{Lat: 1.0, Lon: 3.0}
- if got := a.Order(b); got != -1 {
- t.Fatalf("a.Order(b) = %d, want -1", got)
- }
- if got := a.Order(a); got != 0 {
- t.Fatalf("a.Order(a) = %d, want 0", got)
- }
-}
diff --git a/pkg/iso8211/ddr.go b/pkg/iso8211/ddr.go
deleted file mode 100644
index 131c2bf..0000000
--- a/pkg/iso8211/ddr.go
+++ /dev/null
@@ -1,223 +0,0 @@
-package iso8211
-
-import (
- "bytes"
- "fmt"
-)
-
-// parseFieldControls extracts field control information from DDR field area
-// IHO S-57 Part 3 Annex A.2.4.1: DDR field area structure (Tables A.5, A.6, A.7)
-func (p *Parser) parseFieldControls(directory []*DirectoryEntry, fieldArea []byte) (map[string]*FieldControl, error) {
- fieldControls := make(map[string]*FieldControl)
-
- // Find the field control field (tag "0000")
- var controlEntry *DirectoryEntry
- for _, entry := range directory {
- if entry.Tag == "0000" {
- controlEntry = entry
- break
- }
- }
-
- if controlEntry == nil {
- // Field control field is optional in some DDRs
- // Create basic field controls from directory
- for _, entry := range directory {
- if entry.Tag == "0000" || entry.Tag == "0001" {
- continue // Skip control fields
- }
- fieldControls[entry.Tag] = &FieldControl{
- Tag: entry.Tag,
- DataStructCode: 0, // Default: elementary
- DataTypeCode: 0, // Default: character
- FieldName: entry.Tag,
- Subfields: nil,
- FormatControls: "",
- }
- }
- return fieldControls, nil
- }
-
- // Find the field definitions field (tag "0001")
- var defEntry *DirectoryEntry
- for _, entry := range directory {
- if entry.Tag == "0001" {
- defEntry = entry
- break
- }
- }
-
- if defEntry == nil {
- return nil, fmt.Errorf("DDR missing field definitions (tag 0001)")
- }
-
- // Extract field definitions data
- if defEntry.Position < 0 || defEntry.Position >= len(fieldArea) {
- return nil, fmt.Errorf("field definitions position %d out of bounds", defEntry.Position)
- }
- end := defEntry.Position + defEntry.Length
- if end > len(fieldArea) {
- return nil, fmt.Errorf("field definitions end %d exceeds field area size %d", end, len(fieldArea))
- }
-
- fieldDefData := fieldArea[defEntry.Position:end]
- if len(fieldDefData) > 0 && fieldDefData[len(fieldDefData)-1] == fieldTerminator {
- fieldDefData = fieldDefData[:len(fieldDefData)-1]
- }
-
- // Parse field definitions
- // Format: tag!fieldName!arrayDescriptor!formatControls
- // Fields separated by unit terminator (0x1F)
- definitions := bytes.Split(fieldDefData, []byte{unitTerminator})
-
- for _, def := range definitions {
- if len(def) == 0 {
- continue
- }
-
- // Parse definition parts separated by '!'
- parts := bytes.Split(def, []byte{'!'})
- if len(parts) < 1 {
- continue
- }
-
- tag := string(parts[0])
- if tag == "0000" {
- continue // Skip field control field (it contains metadata, not data field definitions)
- }
-
- fc := &FieldControl{
- Tag: tag,
- DataStructCode: 0, // Will be set if present
- DataTypeCode: 0, // Will be set if present
- FieldName: tag,
- Subfields: make([]*SubfieldDef, 0),
- FormatControls: "",
- }
-
- // Parse field name if present
- if len(parts) > 1 && len(parts[1]) > 0 {
- fc.FieldName = string(parts[1])
- }
-
- // Parse array descriptor if present
- if len(parts) > 2 && len(parts[2]) > 0 {
- arrayDesc := parts[2]
- if len(arrayDesc) >= 2 {
- fc.DataStructCode = arrayDesc[0] - '0' // ASCII digit to int
- fc.DataTypeCode = arrayDesc[1] - '0' // ASCII digit to int
- }
- }
-
- // Parse format controls if present
- if len(parts) > 3 {
- fc.FormatControls = string(parts[3])
- // Parse subfields from format controls
- fc.Subfields = parseSubfields(fc.FormatControls)
- }
-
- fieldControls[tag] = fc
- }
-
- // Add field controls for any tags in directory not in definitions
- for _, entry := range directory {
- if entry.Tag == "0000" {
- continue // Skip field control field
- }
- if _, exists := fieldControls[entry.Tag]; !exists {
- fieldControls[entry.Tag] = &FieldControl{
- Tag: entry.Tag,
- DataStructCode: 0,
- DataTypeCode: 0,
- FieldName: entry.Tag,
- Subfields: make([]*SubfieldDef, 0),
- FormatControls: "",
- }
- }
- }
-
- return fieldControls, nil
-}
-
-// parseSubfields parses subfield definitions from format controls
-// ISO 8211 format controls: (formatType[(width)][,formatType[(width)]]...)
-// Format types: A=ASCII, I=integer, R=real, B=binary, S=bit string, C=complex
-//
-// Note: Real ENC files typically have empty format controls, so this function
-// returns a default subfield in most cases. The full parsing logic is implemented
-// for completeness and future file formats that may use it.
-func parseSubfields(formatControls string) []*SubfieldDef {
- // For empty or whitespace-only format controls, return default
- if len(formatControls) == 0 || len(bytes.TrimSpace([]byte(formatControls))) == 0 {
- return []*SubfieldDef{{
- Label: "DATA",
- FormatType: 'A', // ASCII default
- Width: 0, // Variable width
- }}
- }
-
- // Parse non-empty format controls
- subfields := make([]*SubfieldDef, 0)
- controls := formatControls
-
- // Remove leading/trailing parentheses if present
- if controls[0] == '(' {
- controls = controls[1:]
- }
- if len(controls) > 0 && controls[len(controls)-1] == ')' {
- controls = controls[:len(controls)-1]
- }
-
- // Parse format specifications
- i := 0
- subfieldNum := 0
- for i < len(controls) {
- if i >= len(controls) {
- break
- }
-
- // Parse format type (single character)
- formatType := controls[i]
- i++
-
- // Parse optional width in parentheses
- width := 0
- if i < len(controls) && controls[i] == '(' {
- i++ // skip '('
- widthStr := ""
- for i < len(controls) && controls[i] != ')' {
- widthStr += string(controls[i])
- i++
- }
- if i < len(controls) {
- i++ // skip ')'
- }
- if widthStr != "" {
- fmt.Sscanf(widthStr, "%d", &width)
- }
- }
-
- subfields = append(subfields, &SubfieldDef{
- Label: fmt.Sprintf("SUB%d", subfieldNum),
- FormatType: formatType,
- Width: width,
- })
- subfieldNum++
-
- // Skip comma separator if present
- if i < len(controls) && controls[i] == ',' {
- i++
- }
- }
-
- // If parsing failed, return default
- if len(subfields) == 0 {
- return []*SubfieldDef{{
- Label: "DATA",
- FormatType: 'A',
- Width: 0,
- }}
- }
-
- return subfields
-}
diff --git a/pkg/iso8211/ddr_test.go b/pkg/iso8211/ddr_test.go
deleted file mode 100644
index 78ace2c..0000000
--- a/pkg/iso8211/ddr_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package iso8211
-
-import (
- "testing"
-)
-
-// TestDDRFieldControls tests DDR field control extraction
-func TestDDRFieldControls(t *testing.T) {
- // Given: ISO 8211 file
- filepath := "../../testdata/US4MD81M.000"
-
- parser, err := Open(filepath)
- if err != nil {
- t.Fatalf("Failed to open file: %v", err)
- }
- defer parser.Close()
-
- // When: Parsing file
- file, err := parser.Parse()
- if err != nil {
- t.Fatalf("Parse failed: %v", err)
- }
-
- // Then: DDR contains field controls
- if file.DDR == nil {
- t.Fatal("Expected non-nil DDR")
- }
- if len(file.DDR.FieldControls) == 0 {
- t.Error("Expected non-empty field controls")
- }
-
- // And: Field control field (tag "0001") exists
- fc, exists := file.DDR.FieldControls["0001"]
- if !exists {
- t.Error("Expected field control with tag '0001'")
- }
- if fc == nil {
- t.Error("Expected non-nil field control for tag '0001'")
- }
-
- // And: Common field controls exist (field control field and data fields)
- expectedTags := []string{"0001", "DSID", "DSSI", "DSPM", "VRID"}
- for _, tag := range expectedTags {
- if _, exists := file.DDR.FieldControls[tag]; !exists {
- t.Errorf("Expected field control for tag %s", tag)
- }
- }
-
- // And: Field controls have proper structure
- for tag, fc := range file.DDR.FieldControls {
- if fc.Tag != tag {
- t.Errorf("Field control tag mismatch: expected %s, got %s", tag, fc.Tag)
- }
- if fc.FieldName == "" {
- t.Errorf("Field control %s has empty field name", tag)
- }
- }
-}
diff --git a/pkg/iso8211/directory.go b/pkg/iso8211/directory.go
deleted file mode 100644
index 24a051c..0000000
--- a/pkg/iso8211/directory.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package iso8211
-
-import (
- "fmt"
- "io"
-)
-
-const (
- // ISO 8211 field terminator (0x1E = ASCII RS - Record Separator)
- fieldTerminator = 0x1E
- // ISO 8211 unit terminator (0x1F = ASCII US - Unit Separator)
- unitTerminator = 0x1F
-)
-
-// parseDirectory parses the directory section of a record
-// IHO S-57 Part 3 Annex A.2.3: Directory structure and entry format
-// Directory entries are variable-length, terminated by field terminator (0x1E)
-func (p *Parser) parseDirectory(leader *Leader) ([]*DirectoryEntry, error) {
- directory := make([]*DirectoryEntry, 0)
-
- // Calculate entry size from leader
- entrySize := leader.SizeOfFieldTag + leader.SizeOfFieldLength + leader.SizeOfFieldPosition
-
- // Calculate directory size (from after leader to field area start, minus 1 for terminator)
- directoryStart := 24 // Leader is always 24 bytes
- directorySize := leader.FieldAreaStart - directoryStart - 1 // -1 for field terminator
-
- // Read entire directory area
- dirBuf := make([]byte, directorySize+1) // +1 to include terminator
- n, err := io.ReadFull(p.getReader(), dirBuf)
- if err != nil {
- return nil, err
- }
- if n != directorySize+1 {
- return nil, fmt.Errorf("expected %d bytes for directory, got %d", directorySize+1, n)
- }
- p.offset += int64(n)
-
- // Verify field terminator
- if dirBuf[directorySize] != fieldTerminator {
- return nil, fmt.Errorf("expected field terminator (0x1E) at end of directory, got 0x%02X", dirBuf[directorySize])
- }
-
- // Parse directory entries
- numEntries := directorySize / entrySize
- for i := 0; i < numEntries; i++ {
- offset := i * entrySize
- entryBuf := dirBuf[offset : offset+entrySize]
-
- // Parse tag
- tag := string(entryBuf[0:leader.SizeOfFieldTag])
-
- // Parse length
- lengthBuf := entryBuf[leader.SizeOfFieldTag : leader.SizeOfFieldTag+leader.SizeOfFieldLength]
- length, err := parseASCIIInt(lengthBuf)
- if err != nil {
- return nil, fmt.Errorf("failed to parse field length for tag %s: %w", tag, err)
- }
-
- // Parse position
- positionBuf := entryBuf[leader.SizeOfFieldTag+leader.SizeOfFieldLength : entrySize]
- position, err := parseASCIIInt(positionBuf)
- if err != nil {
- return nil, fmt.Errorf("failed to parse field position for tag %s: %w", tag, err)
- }
-
- entry := &DirectoryEntry{
- Tag: tag,
- Length: length,
- Position: position,
- }
-
- directory = append(directory, entry)
- }
-
- return directory, nil
-}
-
-// extractFields extracts field data from the field area using directory entries
-func (p *Parser) extractFields(directory []*DirectoryEntry, fieldArea []byte) (map[string][]byte, error) {
- fields := make(map[string][]byte)
-
- for _, entry := range directory {
- // Validate position and length
- if entry.Position < 0 || entry.Position >= len(fieldArea) {
- return nil, fmt.Errorf("field %s: position %d out of bounds (field area size: %d)", entry.Tag, entry.Position, len(fieldArea))
- }
- end := entry.Position + entry.Length
- if end > len(fieldArea) {
- return nil, fmt.Errorf("field %s: end position %d exceeds field area size %d", entry.Tag, end, len(fieldArea))
- }
-
- // Extract field data (including terminator)
- fieldData := fieldArea[entry.Position:end]
-
- // Verify and remove field terminator if present
- if len(fieldData) > 0 && fieldData[len(fieldData)-1] == fieldTerminator {
- fieldData = fieldData[:len(fieldData)-1]
- }
-
- fields[entry.Tag] = fieldData
- }
-
- return fields, nil
-}
diff --git a/pkg/iso8211/directory_test.go b/pkg/iso8211/directory_test.go
deleted file mode 100644
index f478318..0000000
--- a/pkg/iso8211/directory_test.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package iso8211
-
-import (
- "testing"
-)
-
-// TestDirectoryEntries tests directory entry parsing
-func TestDirectoryEntries(t *testing.T) {
- // Given: ISO 8211 file
- filepath := "../../testdata/US4MD81M.000"
-
- parser, err := Open(filepath)
- if err != nil {
- t.Fatalf("Failed to open file: %v", err)
- }
- defer parser.Close()
-
- file, err := parser.Parse()
- if err != nil {
- t.Fatalf("Parse failed: %v", err)
- }
-
- // When: Examining DDR directory entries
- // Then: All entries have valid fields
- if len(file.DDR.Directory) == 0 {
- t.Error("Expected non-empty DDR directory")
- }
- for i, entry := range file.DDR.Directory {
- if entry.Tag == "" {
- t.Errorf("DDR directory entry %d: tag must not be empty", i)
- }
- if entry.Length <= 0 {
- t.Errorf("DDR directory entry %d: length must be positive, got %d", i, entry.Length)
- }
- if entry.Position < 0 {
- t.Errorf("DDR directory entry %d: position must be non-negative, got %d", i, entry.Position)
- }
- }
-
- // And: Data record directory entries are valid
- for i, record := range file.Records {
- if len(record.Directory) == 0 {
- t.Errorf("Record %d has no directory entries", i)
- }
- for j, entry := range record.Directory {
- if entry.Tag == "" {
- t.Errorf("Record %d directory entry %d: tag must not be empty", i, j)
- }
- if entry.Length <= 0 {
- t.Errorf("Record %d directory entry %d: length must be positive, got %d", i, j, entry.Length)
- }
- if entry.Position < 0 {
- t.Errorf("Record %d directory entry %d: position must be non-negative, got %d", i, j, entry.Position)
- }
- }
- }
-
- // And: Data record fields match directory tags
- for i, record := range file.Records {
- for _, dirEntry := range record.Directory {
- if _, exists := record.Fields[dirEntry.Tag]; !exists {
- t.Errorf("Record %d: directory entry %s not found in fields", i, dirEntry.Tag)
- }
- }
-
- // And: All fields have corresponding directory entries
- for tag := range record.Fields {
- found := false
- for _, dirEntry := range record.Directory {
- if dirEntry.Tag == tag {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("Record %d: field %s has no directory entry", i, tag)
- }
- }
- }
-}
diff --git a/pkg/iso8211/doc.go b/pkg/iso8211/doc.go
deleted file mode 100644
index 620ee7b..0000000
--- a/pkg/iso8211/doc.go
+++ /dev/null
@@ -1,53 +0,0 @@
-// Package iso8211 provides a parser for ISO/IEC 8211 binary files.
-//
-// ISO 8211 is an international standard for file structure and record format
-// for information interchange. It is widely used in geospatial data formats
-// and other applications requiring structured data exchange.
-//
-// # Overview
-//
-// The ISO 8211 format consists of:
-// - A Data Descriptive Record (DDR) that defines the structure of the data
-// - Zero or more Data Records (DRs) containing the actual data
-//
-// Each record has a 24-byte leader, a directory of field tags, and a field area
-// containing the actual data.
-//
-// # Basic Usage
-//
-// To parse an ISO 8211 file:
-//
-// reader, err := iso8211.NewReader("path/to/file.dat")
-// if err != nil {
-// log.Fatal(err)
-// }
-// defer reader.Close()
-//
-// file, err := reader.Parse()
-// if err != nil {
-// log.Fatal(err)
-// }
-//
-// // Access the DDR
-// fmt.Printf("DDR has %d field controls\n", len(file.DDR.FieldControls))
-//
-// // Iterate through data records
-// for i, record := range file.Records {
-// fmt.Printf("Record %d has %d fields\n", i, len(record.Fields))
-// }
-//
-// # Standard References
-//
-// This implementation follows ISO/IEC 8211:1994 - Specification for a data
-// descriptive file for information interchange.
-//
-// Official specification:
-// - ISO/IEC 8211:1994: https://www.iso.org/standard/20694.html
-//
-// For S-57/S-52 implementation details, see:
-// - IHO S-57 Part 3 Annex A: https://iho.int/uploads/user/pubs/standards/s-57/31Main.pdf
-// (ISO 8211 summary and examples for hydrographic data - see Annex A starting at page 3.A.1)
-//
-// ISO 8211 is used in various geospatial and data exchange standards including
-// S-57 Electronic Navigational Charts (ENC).
-package iso8211
diff --git a/pkg/iso8211/errors.go b/pkg/iso8211/errors.go
deleted file mode 100644
index 09e87d3..0000000
--- a/pkg/iso8211/errors.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package iso8211
-
-import "fmt"
-
-// Error definitions for ISO 8211 parser
-//
-// All errors include context and byte offsets where applicable for debugging.
-// For S-57/S-52 implementation details, see IHO S-57 Part 3:
-// https://iho.int/uploads/user/pubs/standards/s-57/31Main.pdf
-
-// ParseError represents a parsing error with context
-type ParseError struct {
- Offset int64 // Byte offset in file where error occurred
- Context string // What was being parsed (e.g., "leader", "directory", "field area")
- Err error // Underlying error
-}
-
-func (e *ParseError) Error() string {
- if e.Offset >= 0 {
- return fmt.Sprintf("parse error at offset %d (%s): %v", e.Offset, e.Context, e.Err)
- }
- return fmt.Sprintf("parse error (%s): %v", e.Context, e.Err)
-}
-
-func (e *ParseError) Unwrap() error {
- return e.Err
-}
-
-// NewParseError creates a new parse error with context
-func NewParseError(offset int64, context string, err error) *ParseError {
- return &ParseError{
- Offset: offset,
- Context: context,
- Err: err,
- }
-}
-
-// ValidationError represents a validation error for invalid data
-type ValidationError struct {
- Field string // Field name that failed validation
- Value string // Invalid value
- Message string // Error message
-}
-
-func (e *ValidationError) Error() string {
- return fmt.Sprintf("validation error: field %s has invalid value %q: %s", e.Field, e.Value, e.Message)
-}
-
-// NewValidationError creates a new validation error
-func NewValidationError(field, value, message string) *ValidationError {
- return &ValidationError{
- Field: field,
- Value: value,
- Message: message,
- }
-}
diff --git a/pkg/iso8211/errors_test.go b/pkg/iso8211/errors_test.go
deleted file mode 100644
index 572692c..0000000
--- a/pkg/iso8211/errors_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package iso8211
-
-import (
- "errors"
- "strings"
- "testing"
-)
-
-// TestParseError tests ParseError creation and methods
-func TestParseError(t *testing.T) {
- baseErr := errors.New("test error")
-
- // Test with offset
- err := NewParseError(1234, "leader", baseErr)
- errStr := err.Error()
-
- if !strings.Contains(errStr, "1234") {
- t.Errorf("Error message should contain offset 1234, got: %s", errStr)
- }
- if !strings.Contains(errStr, "leader") {
- t.Errorf("Error message should contain context 'leader', got: %s", errStr)
- }
- if !strings.Contains(errStr, "test error") {
- t.Errorf("Error message should contain base error, got: %s", errStr)
- }
-
- // Test Unwrap
- if unwrapped := errors.Unwrap(err); unwrapped != baseErr {
- t.Errorf("Expected unwrapped error to be baseErr, got: %v", unwrapped)
- }
-
- // Test without offset
- err2 := NewParseError(-1, "directory", baseErr)
- errStr2 := err2.Error()
-
- if strings.Contains(errStr2, "offset -1") {
- t.Errorf("Error message should not contain negative offset, got: %s", errStr2)
- }
- if !strings.Contains(errStr2, "directory") {
- t.Errorf("Error message should contain context 'directory', got: %s", errStr2)
- }
-}
-
-// TestValidationError tests ValidationError creation and methods
-func TestValidationError(t *testing.T) {
- err := NewValidationError("LeaderIdentifier", "X", "must be L or D")
- errStr := err.Error()
-
- if !strings.Contains(errStr, "LeaderIdentifier") {
- t.Errorf("Error message should contain field name, got: %s", errStr)
- }
- if !strings.Contains(errStr, "X") {
- t.Errorf("Error message should contain value, got: %s", errStr)
- }
- if !strings.Contains(errStr, "must be L or D") {
- t.Errorf("Error message should contain message, got: %s", errStr)
- }
-}
diff --git a/pkg/iso8211/fs.go b/pkg/iso8211/fs.go
deleted file mode 100644
index 934fc2f..0000000
--- a/pkg/iso8211/fs.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package iso8211
-
-import (
- "bytes"
- "io/fs"
- "os"
- "path"
- "time"
-)
-
-// The parser reads files (a base cell + its sibling .001/.002… updates) through
-// the stdlib io/fs.FS interface. Two implementations cover every caller:
-//
-// - OSFS(): the OS filesystem. Unlike os.DirFS / fstest.MapFS it does NOT go
-// through fs.ValidPath, so it accepts the absolute/relative OS paths the CLI
-// and server already pass.
-// - MemFS: a map-backed in-memory FS for parsing raw bytes (the wasm baker
-// and tests), with no filesystem at all.
-//
-// io/fs is chosen over afero specifically so the parser compiles for wasm/tinygo
-// — afero's OsFs pulls in os.Chmod/os.Chown/syscall.EBADFD, which tinygo's wasm
-// os package doesn't implement.
-
-// OSFS returns an fs.FS backed by the OS filesystem (any path os.Open accepts).
-func OSFS() fs.FS { return osFS{} }
-
-type osFS struct{}
-
-func (osFS) Open(name string) (fs.File, error) { return os.Open(name) }
-
-// Exists reports whether name can be opened in fsys. Used to probe for the
-// sequential update files (io/fs has no Exists).
-func Exists(fsys fs.FS, name string) bool {
- f, err := fsys.Open(name)
- if err != nil {
- return false
- }
- _ = f.Close()
- return true
-}
-
-// MemFS is a minimal read-only in-memory fs.FS keyed by exact name (no
-// fs.ValidPath restriction, so synthetic absolute keys like "/US5MD1MC.000"
-// work). Suitable for parsing a handful of cell buffers.
-type MemFS map[string][]byte
-
-func (m MemFS) Open(name string) (fs.File, error) {
- data, ok := m[name]
- if !ok {
- return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
- }
- return &memFile{name: name, r: bytes.NewReader(data), size: int64(len(data))}, nil
-}
-
-type memFile struct {
- name string
- r *bytes.Reader
- size int64
-}
-
-func (f *memFile) Read(p []byte) (int, error) { return f.r.Read(p) }
-func (f *memFile) Close() error { return nil }
-func (f *memFile) Stat() (fs.FileInfo, error) { return memInfo{f.name, f.size}, nil }
-
-type memInfo struct {
- name string
- size int64
-}
-
-func (i memInfo) Name() string { return path.Base(i.name) }
-func (i memInfo) Size() int64 { return i.size }
-func (i memInfo) Mode() fs.FileMode { return 0o444 }
-func (i memInfo) ModTime() time.Time { return time.Time{} }
-func (i memInfo) IsDir() bool { return false }
-func (i memInfo) Sys() any { return nil }
diff --git a/pkg/iso8211/leader.go b/pkg/iso8211/leader.go
deleted file mode 100644
index e36aac4..0000000
--- a/pkg/iso8211/leader.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package iso8211
-
-import (
- "fmt"
- "io"
- "strconv"
-)
-
-// parseLeader parses a 24-byte ISO 8211 leader structure
-// IHO S-57 Part 3 Annex A.2.2.1 (DDR leader - Table A.1) and A.2.2.2 (DR leader - Table A.3)
-func (p *Parser) parseLeader() (*Leader, error) {
- // ISO 8211 leader is exactly 24 bytes
- buf := make([]byte, 24)
- startOffset := p.offset
-
- n, err := io.ReadFull(p.getReader(), buf)
- if err != nil {
- if err == io.EOF && n == 0 {
- return nil, io.EOF
- }
- return nil, err
- }
- if n != 24 {
- return nil, fmt.Errorf("leader must be 24 bytes, got %d", n)
- }
- p.offset += 24
-
- // Trailing padding after the last record — a leader of only spaces and/or
- // null bytes — is end-of-records, not a malformed record. Signal EOF so the
- // data-record loop stops cleanly (some S-57 producers pad the file end).
- blank := true
- for _, b := range buf {
- if b != 0x00 && b != 0x20 {
- blank = false
- break
- }
- }
- if blank {
- return nil, io.EOF
- }
-
- leader := &Leader{}
-
- // Parse record length (positions 0-4, 5 ASCII digits)
- recordLength, err := parseASCIIInt(buf[0:5])
- if err != nil {
- return nil, NewParseError(startOffset, "record length", err)
- }
- leader.RecordLength = recordLength
-
- // A record length of 0 is legal ISO 8211: the length is NOT stored in the
- // leader and must be computed from the directory (some S-57 producers, e.g.
- // USACE Inland ENCs, encode all their data records this way). Only treat a
- // zero length as end-of-records (trailing padding) when the leader is NOT a
- // real record header — a genuine record still carries its 'D'/'L' identifier,
- // and its true length is recovered after the directory is read (see
- // parseDataRecord / parseDDR). Pure space/zero padding is already caught by
- // the blank-leader check above.
- if recordLength == 0 && buf[6] != 'D' && buf[6] != 'L' {
- return nil, io.EOF
- }
-
- // Parse interchange level (position 5, 1 byte)
- leader.InterchangeLevel = buf[5]
-
- // Parse leader identifier (position 6, 1 byte)
- // 'L' for DDR, 'D' for DR
- leader.LeaderIdentifier = buf[6]
-
- // Parse inline code extension (position 7, 1 byte)
- leader.InlineCodeExtension = buf[7]
-
- // Parse version number (position 8, 1 byte)
- leader.VersionNumber = buf[8]
-
- // Parse application indicator (position 9, 1 byte)
- leader.ApplicationIndicator = buf[9]
-
- // Parse field control length (positions 10-11, 2 ASCII digits)
- fieldControlLength, err := parseASCIIInt(buf[10:12])
- if err != nil {
- return nil, NewParseError(startOffset+10, "field control length", err)
- }
- leader.FieldControlLength = fieldControlLength
-
- // Parse field area start (positions 12-16, 5 ASCII digits)
- fieldAreaStart, err := parseASCIIInt(buf[12:17])
- if err != nil {
- return nil, NewParseError(startOffset+12, "field area start", err)
- }
- leader.FieldAreaStart = fieldAreaStart
-
- // Parse extended character set (positions 17-19, 3 bytes)
- copy(leader.ExtendedCharSet[:], buf[17:20])
-
- // Parse size of field length (position 20, 1 ASCII digit)
- sizeOfFieldLength, err := parseASCIIInt(buf[20:21])
- if err != nil {
- return nil, NewParseError(startOffset+20, "size of field length", err)
- }
- leader.SizeOfFieldLength = sizeOfFieldLength
-
- // Parse size of field position (position 21, 1 ASCII digit)
- sizeOfFieldPosition, err := parseASCIIInt(buf[21:22])
- if err != nil {
- return nil, NewParseError(startOffset+21, "size of field position", err)
- }
- leader.SizeOfFieldPosition = sizeOfFieldPosition
-
- // Parse reserved (position 22, 1 byte)
- leader.Reserved = buf[22]
-
- // Parse size of field tag (position 23, 1 ASCII digit)
- sizeOfFieldTag, err := parseASCIIInt(buf[23:24])
- if err != nil {
- return nil, NewParseError(startOffset+23, "size of field tag", err)
- }
- leader.SizeOfFieldTag = sizeOfFieldTag
-
- // Validate leader
- if err := validateLeader(leader); err != nil {
- return nil, NewParseError(startOffset, "leader validation", err)
- }
-
- return leader, nil
-}
-
-// parseASCIIInt parses ASCII digits to an integer
-// ISO 8211 uses ASCII digits for numeric fields (spaces represent zeros)
-// Per IHO S-57 Part 3 Annex A Table A.1/A.3: fields use ASCII spaces, not null bytes
-func parseASCIIInt(buf []byte) (int, error) {
- // Check for null bytes - indicates corrupted or non-ISO 8211 file
- for _, b := range buf {
- if b == 0 {
- return 0, fmt.Errorf("invalid ASCII integer %q: contains null bytes (file corrupted or not ISO 8211 format)", buf)
- }
- }
-
- // Convert to string
- s := string(buf)
-
- // Check if all spaces - treat as zero per spec
- allSpaces := true
- for _, c := range s {
- if c != ' ' {
- allSpaces = false
- break
- }
- }
- if allSpaces {
- return 0, nil
- }
-
- // Parse the integer
- val, err := strconv.Atoi(s)
- if err != nil {
- return 0, fmt.Errorf("invalid ASCII integer %q: %w", s, err)
- }
- return val, nil
-}
-
-// validateLeader validates the leader structure
-func validateLeader(leader *Leader) error {
- // Record length must be at least the 24-byte leader — except 0, the legal
- // ISO 8211 "length not stored, compute it from the directory" convention,
- // which parseDataRecord/parseDDR resolve after reading the directory.
- if leader.RecordLength != 0 && leader.RecordLength < 24 {
- return fmt.Errorf("record length must be >= 24, got %d", leader.RecordLength)
- }
-
- // Leader identifier must be 'L' (DDR) or 'D' (DR)
- if leader.LeaderIdentifier != 'L' && leader.LeaderIdentifier != 'D' {
- return fmt.Errorf("leader identifier must be 'L' or 'D', got %c", leader.LeaderIdentifier)
- }
-
- // Field area start must be at least after leader (24 bytes)
- if leader.FieldAreaStart < 24 {
- return fmt.Errorf("field area start must be >= 24, got %d", leader.FieldAreaStart)
- }
-
- // Size fields must be reasonable (typically 1-9)
- if leader.SizeOfFieldLength < 1 || leader.SizeOfFieldLength > 9 {
- return fmt.Errorf("size of field length must be 1-9, got %d", leader.SizeOfFieldLength)
- }
- if leader.SizeOfFieldPosition < 1 || leader.SizeOfFieldPosition > 9 {
- return fmt.Errorf("size of field position must be 1-9, got %d", leader.SizeOfFieldPosition)
- }
- if leader.SizeOfFieldTag < 1 || leader.SizeOfFieldTag > 9 {
- return fmt.Errorf("size of field tag must be 1-9, got %d", leader.SizeOfFieldTag)
- }
-
- return nil
-}
diff --git a/pkg/iso8211/leader_test.go b/pkg/iso8211/leader_test.go
deleted file mode 100644
index 35c3b23..0000000
--- a/pkg/iso8211/leader_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package iso8211
-
-import (
- "testing"
-)
-
-// TestLeaderStructure tests leader parsing for both DDR and DR
-func TestLeaderStructure(t *testing.T) {
- // Given: ISO 8211 file
- filepath := "../../testdata/US4MD81M.000"
-
- parser, err := Open(filepath)
- if err != nil {
- t.Fatalf("Failed to open file: %v", err)
- }
- defer parser.Close()
-
- // When: Parsing file
- file, err := parser.Parse()
- if err != nil {
- t.Fatalf("Parse failed: %v", err)
- }
-
- // Then: DDR leader has correct structure
- if file.DDR == nil || file.DDR.Leader == nil {
- t.Fatal("Expected non-nil DDR and leader")
- }
- if file.DDR.Leader.LeaderIdentifier != 'L' {
- t.Errorf("Expected DDR leader identifier 'L', got %c", file.DDR.Leader.LeaderIdentifier)
- }
- if file.DDR.Leader.RecordLength <= 24 {
- t.Errorf("Expected DDR record length > 24, got %d", file.DDR.Leader.RecordLength)
- }
- if file.DDR.Leader.FieldAreaStart <= 24 {
- t.Errorf("Expected field area start > 24, got %d", file.DDR.Leader.FieldAreaStart)
- }
-
- // And: All data record leaders have correct structure
- for i, record := range file.Records {
- if record.Leader == nil {
- t.Errorf("Record %d has nil leader", i)
- continue
- }
- if record.Leader.LeaderIdentifier != 'D' {
- t.Errorf("Record %d: expected leader identifier 'D', got %c", i, record.Leader.LeaderIdentifier)
- }
- if record.Leader.RecordLength <= 24 {
- t.Errorf("Record %d: expected record length > 24, got %d", i, record.Leader.RecordLength)
- }
- }
-}
-
-// TestLeaderValidation tests leader validation edge cases
-func TestLeaderValidation(t *testing.T) {
- tests := []struct {
- name string
- filepath string
- expectError bool
- }{
- {
- name: "Valid leader",
- filepath: "../../testdata/US4MD81M.000",
- expectError: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- parser, err := Open(tt.filepath)
- if err != nil {
- if !tt.expectError {
- t.Fatalf("Unexpected error opening file: %v", err)
- }
- return
- }
- defer parser.Close()
-
- _, err = parser.Parse()
- if tt.expectError && err == nil {
- t.Error("Expected error but got none")
- }
- if !tt.expectError && err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- })
- }
-}
-
-// TestCloseNilFile tests closing a parser with nil file
-func TestCloseNilFile(t *testing.T) {
- parser, err := Open("../../testdata/US4MD81M.000")
- if err != nil {
- t.Fatalf("Failed to open file: %v", err)
- }
-
- // Close without parsing
- err = parser.Close()
- if err != nil {
- t.Errorf("Close should not error: %v", err)
- }
-
- // Close again should be safe
- err = parser.Close()
- if err != nil {
- t.Errorf("Second close should not error: %v", err)
- }
-}
diff --git a/pkg/iso8211/parser.go b/pkg/iso8211/parser.go
deleted file mode 100644
index 79de0cd..0000000
--- a/pkg/iso8211/parser.go
+++ /dev/null
@@ -1,216 +0,0 @@
-package iso8211
-
-import (
- "io"
- "io/fs"
-)
-
-// Parser provides methods to parse ISO 8211 binary files
-//
-// Based on ISO/IEC 8211:1994 standard.
-// For S-57/S-52 implementation details, see IHO S-57 Part 3:
-// https://iho.int/uploads/user/pubs/standards/s-57/31Main.pdf
-type Parser struct {
- reader io.Reader // Underlying data reader
- closer io.Closer // Optional closer (for files)
- offset int64 // Current read offset
-}
-
-// NewParser creates a new ISO 8211 parser from an io.Reader
-// Works with any reader: bytes, HTTP responses, etc.
-// Use Open() or OpenFS() for filesystem-based parsing
-func NewParser(r io.Reader) *Parser {
- return &Parser{
- reader: r,
- closer: nil,
- offset: 0,
- }
-}
-
-// Open opens an ISO 8211 file from the OS filesystem and returns a Parser
-// The file will be closed when Parser.Close() is called
-// Returns error if file cannot be opened
-func Open(filepath string) (*Parser, error) {
- return OpenFS(OSFS(), filepath)
-}
-
-// OpenFS opens an ISO 8211 file from a custom io/fs.FS and returns a Parser.
-// This allows using custom filesystem implementations (e.g. in-memory; see MemFS).
-// The file will be closed when Parser.Close() is called.
-// Returns error if file cannot be opened.
-func OpenFS(fsys fs.FS, filepath string) (*Parser, error) {
- file, err := fsys.Open(filepath)
- if err != nil {
- return nil, err
- }
-
- return &Parser{
- reader: file,
- closer: file,
- offset: 0,
- }, nil
-}
-
-// Close closes the underlying reader if it implements io.Closer
-// Can be called multiple times safely (idempotent)
-// Safe to call even if reader doesn't need closing
-func (p *Parser) Close() error {
- if p.closer != nil {
- err := p.closer.Close()
- p.closer = nil // Prevent double close
- return err
- }
- return nil
-}
-
-// getReader returns the underlying io.Reader
-func (p *Parser) getReader() io.Reader {
- return p.reader
-}
-
-// Parse reads and parses the complete ISO 8211 file (DDR + all data records)
-// Returns complete ISO8211File structure with DDR and all records
-// Returns error if file is malformed or cannot be parsed
-func (p *Parser) Parse() (*ISO8211File, error) {
- result := &ISO8211File{
- Records: make([]*DataRecord, 0),
- }
-
- // Parse DDR first (must be first record in file)
- ddr, err := p.parseDDR()
- if err != nil {
- return nil, err
- }
- result.DDR = ddr
-
- // Parse all data records
- for {
- dr, err := p.parseDataRecord(ddr)
- if err != nil {
- if err == io.EOF {
- break
- }
- return nil, err
- }
- result.Records = append(result.Records, dr)
- }
-
- return result, nil
-}
-
-// parseDDR parses the Data Descriptive Record (first record in file)
-func (p *Parser) parseDDR() (*DataDescriptiveRecord, error) {
- // Parse leader
- leader, err := p.parseLeader()
- if err != nil {
- return nil, NewParseError(p.offset, "DDR leader", err)
- }
-
- // Validate this is a DDR
- if leader.LeaderIdentifier != 'L' {
- return nil, NewValidationError("LeaderIdentifier", string(leader.LeaderIdentifier), "expected 'L' for DDR")
- }
-
- // Parse directory
- directory, err := p.parseDirectory(leader)
- if err != nil {
- return nil, NewParseError(p.offset, "DDR directory", err)
- }
-
- // Read field area. A leader record length of 0 means "length not stored —
- // compute it from the directory" (legal ISO 8211); recover the true field-area
- // size from the parsed directory entries and backfill RecordLength so offset
- // tracking and downstream length checks stay correct.
- fieldAreaSize := leader.RecordLength - leader.FieldAreaStart
- if leader.RecordLength == 0 {
- fieldAreaSize = fieldAreaSizeFromDirectory(directory)
- leader.RecordLength = leader.FieldAreaStart + fieldAreaSize
- }
- fieldArea := make([]byte, fieldAreaSize)
- n, err := io.ReadFull(p.getReader(), fieldArea)
- if err != nil {
- return nil, NewParseError(p.offset, "DDR field area", err)
- }
- if n != fieldAreaSize {
- return nil, NewParseError(p.offset, "DDR field area", io.ErrUnexpectedEOF)
- }
- p.offset += int64(n)
-
- // Parse field controls from field area
- fieldControls, err := p.parseFieldControls(directory, fieldArea)
- if err != nil {
- return nil, NewParseError(p.offset, "DDR field controls", err)
- }
-
- return &DataDescriptiveRecord{
- Leader: leader,
- Directory: directory,
- FieldControls: fieldControls,
- FieldArea: fieldArea,
- }, nil
-}
-
-// parseDataRecord parses a single data record
-func (p *Parser) parseDataRecord(ddr *DataDescriptiveRecord) (*DataRecord, error) {
- // Parse leader
- leader, err := p.parseLeader()
- if err != nil {
- return nil, err
- }
-
- // Validate this is a DR
- if leader.LeaderIdentifier != 'D' {
- return nil, NewValidationError("LeaderIdentifier", string(leader.LeaderIdentifier), "expected 'D' for data record")
- }
-
- // Parse directory
- directory, err := p.parseDirectory(leader)
- if err != nil {
- return nil, NewParseError(p.offset, "data record directory", err)
- }
-
- // Read field area. A leader record length of 0 means "length not stored —
- // compute it from the directory" (legal ISO 8211); recover the true field-area
- // size from the parsed directory entries and backfill RecordLength so offset
- // tracking and downstream length checks stay correct.
- fieldAreaSize := leader.RecordLength - leader.FieldAreaStart
- if leader.RecordLength == 0 {
- fieldAreaSize = fieldAreaSizeFromDirectory(directory)
- leader.RecordLength = leader.FieldAreaStart + fieldAreaSize
- }
- fieldArea := make([]byte, fieldAreaSize)
- n, err := io.ReadFull(p.getReader(), fieldArea)
- if err != nil {
- return nil, NewParseError(p.offset, "data record field area", err)
- }
- if n != fieldAreaSize {
- return nil, NewParseError(p.offset, "data record field area", io.ErrUnexpectedEOF)
- }
- p.offset += int64(n)
-
- // Extract fields from field area
- fields, err := p.extractFields(directory, fieldArea)
- if err != nil {
- return nil, NewParseError(p.offset, "data record fields", err)
- }
-
- return &DataRecord{
- Leader: leader,
- Directory: directory,
- Fields: fields,
- }, nil
-}
-
-// fieldAreaSizeFromDirectory computes a record's field-area size from its parsed
-// directory entries, for records whose leader carries a record length of 0
-// (ISO 8211 §"length not specified"). Entries are positioned as an offset from
-// the field-area start, so the size is the furthest entry's end (Position+Length).
-func fieldAreaSizeFromDirectory(dir []*DirectoryEntry) int {
- max := 0
- for _, e := range dir {
- if end := e.Position + e.Length; end > max {
- max = end
- }
- }
- return max
-}
diff --git a/pkg/iso8211/parser_test.go b/pkg/iso8211/parser_test.go
deleted file mode 100644
index dc1826e..0000000
--- a/pkg/iso8211/parser_test.go
+++ /dev/null
@@ -1,222 +0,0 @@
-package iso8211
-
-import (
- "bytes"
- "os"
- "testing"
-)
-
-// TestInvalidFilePath tests that invalid file paths return an error
-func TestInvalidFilePath(t *testing.T) {
- // Given: A non-existent file path
- parser, err := Open("/nonexistent/path/file.000")
-
- // Then: Error returned on creation
- if err == nil {
- t.Error("Expected error for non-existent file path, got nil")
- }
- if parser != nil {
- t.Error("Expected nil parser for non-existent file path")
- }
-}
-
-// TestOpenFS tests opening a file with a custom io/fs.FS
-func TestOpenFS(t *testing.T) {
- // Given: File data in an in-memory filesystem
- filepath := "chart.000"
- data, err := os.ReadFile("../../testdata/US4MD81M.000")
- if err != nil {
- t.Fatalf("Failed to read test file: %v", err)
- }
-
- fsys := MemFS{filepath: data}
-
- // When: Opening with OpenFS
- parser, err := OpenFS(fsys, filepath)
- if err != nil {
- t.Fatalf("Failed to open with OpenFS: %v", err)
- }
- defer parser.Close()
-
- // Then: File parses successfully
- file, err := parser.Parse()
- if err != nil {
- t.Errorf("Parse failed: %v", err)
- }
- if file == nil {
- t.Error("Expected non-nil file")
- return
- }
- if file.DDR == nil {
- t.Error("Expected non-nil DDR")
- }
- if len(file.Records) == 0 {
- t.Error("Expected at least one record")
- }
-}
-
-// TestNewParserFromBytes tests creating a parser from byte slice
-func TestNewParserFromBytes(t *testing.T) {
- // Given: Read an ISO 8211 file into memory
- filepath := "../../testdata/US4MD81M.000"
- data, err := os.ReadFile(filepath)
- if err != nil {
- t.Fatalf("Failed to read test file: %v", err)
- }
-
- // When: Creating parser from bytes
- parser := NewParser(bytes.NewReader(data))
-
- // Then: Parser created successfully
- if parser == nil {
- t.Error("Expected non-nil parser")
- }
- if parser.reader == nil {
- t.Error("Expected non-nil parser.reader")
- }
- if parser.closer != nil {
- t.Error("Expected nil closer for byte parser")
- }
-}
-
-// TestNewReaderFromBytesEmpty tests creating a parser from empty byte slice
-func TestNewReaderFromBytesEmpty(t *testing.T) {
- // Given: Empty byte slice
- data := []byte{}
-
- // When: Creating parser from empty bytes
- parser := NewParser(bytes.NewReader(data))
-
- // Then: Parser created (parsing will fail later)
- if parser == nil {
- t.Error("Expected non-nil parser even for empty data")
- }
-}
-
-// TestParseFromBytes tests parsing ISO 8211 data from byte slice
-func TestParseFromBytes(t *testing.T) {
- // Given: ISO 8211 file data in memory
- filepath := "../../testdata/US4MD81M.000"
- data, err := os.ReadFile(filepath)
- if err != nil {
- t.Fatalf("Failed to read test file: %v", err)
- }
-
- parser := NewParser(bytes.NewReader(data))
- defer parser.Close()
-
- // When: Parsing from bytes
- file, err := parser.Parse()
-
- // Then: File parses successfully
- if err != nil {
- t.Errorf("Parse from bytes failed: %v", err)
- }
- if file == nil {
- t.Error("Expected non-nil file")
- return
- }
- if file.DDR == nil {
- t.Error("Expected non-nil DDR")
- }
- if len(file.Records) == 0 {
- t.Error("Expected at least one record")
- }
-}
-
-// TestParseFromBytesMatchesFile tests that byte and file parsing produce identical results
-func TestParseFromBytesMatchesFile(t *testing.T) {
- // Given: Same ISO 8211 data from file and bytes
- filepath := "../../testdata/US4MD81M.000"
-
- // Parse from file
- fileParser, err := Open(filepath)
- if err != nil {
- t.Fatalf("Failed to open file: %v", err)
- }
- defer fileParser.Close()
-
- fileResult, err := fileParser.Parse()
- if err != nil {
- t.Fatalf("Failed to parse from file: %v", err)
- }
-
- // Parse from bytes
- data, err := os.ReadFile(filepath)
- if err != nil {
- t.Fatalf("Failed to read test file: %v", err)
- }
-
- byteParser := NewParser(bytes.NewReader(data))
- defer byteParser.Close()
-
- byteResult, err := byteParser.Parse()
- if err != nil {
- t.Fatalf("Failed to parse from bytes: %v", err)
- }
-
- // Then: Results should match
- if len(fileResult.Records) != len(byteResult.Records) {
- t.Errorf("Record count mismatch: file=%d, bytes=%d",
- len(fileResult.Records), len(byteResult.Records))
- }
-
- // Compare DDR leaders
- if fileResult.DDR.Leader.RecordLength != byteResult.DDR.Leader.RecordLength {
- t.Error("DDR RecordLength mismatch between file and byte parsing")
- }
- if fileResult.DDR.Leader.LeaderIdentifier != byteResult.DDR.Leader.LeaderIdentifier {
- t.Error("DDR LeaderIdentifier mismatch between file and byte parsing")
- }
-}
-
-// TestByteParserClose tests that closing byte parser doesn't error
-func TestByteParserClose(t *testing.T) {
- // Given: Byte parser
- data := []byte{1, 2, 3}
- parser := NewParser(bytes.NewReader(data))
-
- // When: Closing byte parser
- err := parser.Close()
-
- // Then: No error (idempotent no-op for byte parsers)
- if err != nil {
- t.Errorf("Close should not error for byte parser: %v", err)
- }
-
- // And: Multiple closes are safe
- err = parser.Close()
- if err != nil {
- t.Errorf("Second close should not error: %v", err)
- }
-}
-
-// TestParseISO8211File tests parsing an ISO 8211 file
-func TestParseISO8211File(t *testing.T) {
- // Given: ISO 8211 file
- filepath := "../../testdata/US4MD81M.000"
-
- parser, err := Open(filepath)
- if err != nil {
- t.Fatalf("Failed to open file: %v", err)
- }
- defer parser.Close()
-
- // When: Parsing file
- file, err := parser.Parse()
-
- // Then: File parses successfully
- if err != nil {
- t.Errorf("Parse failed: %v", err)
- }
- if file == nil {
- t.Error("Expected non-nil file")
- return
- }
- if file.DDR == nil {
- t.Error("Expected non-nil DDR")
- }
- if len(file.Records) == 0 {
- t.Error("Expected at least one record")
- }
-}
diff --git a/pkg/iso8211/types.go b/pkg/iso8211/types.go
deleted file mode 100644
index 857e05d..0000000
--- a/pkg/iso8211/types.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package iso8211
-
-// ISO 8211 Type Definitions
-//
-// Based on ISO/IEC 8211:1994 standard for binary file formats.
-// For S-57/S-52 implementation details, see IHO S-57 Part 3:
-// https://iho.int/uploads/user/pubs/standards/s-57/31Main.pdf
-
-// ISO8211File represents a complete ISO 8211 file containing
-// a Data Descriptive Record (DDR) and zero or more Data Records (DR)
-type ISO8211File struct {
- DDR *DataDescriptiveRecord // File structure definition
- Records []*DataRecord // Data records following DDR structure
-}
-
-// DataDescriptiveRecord (DDR) defines the structure and metadata
-// for all subsequent data records in the file
-// IHO S-57 Part 3 Annex A.2: Complete DDR structure (leader A.2.2.1, directory A.2.3, field area A.2.4.1)
-type DataDescriptiveRecord struct {
- Leader *Leader // Record metadata (24 bytes)
- Directory []*DirectoryEntry // Field directory
- FieldControls map[string]*FieldControl // Field structure definitions (keyed by tag)
- FieldArea []byte // Raw field data area
-}
-
-// DataRecord (DR) contains actual chart data organized according to DDR structure
-// IHO S-57 Part 3 Annex A.2: Complete DR structure (leader A.2.2.2, directory A.2.3, field area A.2.4.2)
-type DataRecord struct {
- Leader *Leader // Record metadata (24 bytes)
- Directory []*DirectoryEntry // Field directory for this record
- Fields map[string][]byte // Field data keyed by tag (raw bytes)
-}
-
-// Leader contains record metadata in a 24-byte fixed header
-// IHO S-57 Part 3 Annex A.2.2: DDR leader (Table A.1), DR leader (Table A.3), Entry map (Tables A.2, A.4)
-//
-// The leader is always exactly 24 bytes and describes how to parse the rest of the record.
-// Positions 0-19 describe the record, positions 20-23 (the "entry map") describe the directory format.
-type Leader struct {
- // Record description (positions 0-19)
- RecordLength int // RP 0-4 (5 bytes): Total record length in bytes
- InterchangeLevel byte // RP 5 (1 byte): Interchange level - "3" for S-57
- LeaderIdentifier byte // RP 6 (1 byte): 'L' for DDR, 'D' for DR
- InlineCodeExtension byte // RP 7 (1 byte): Code extension indicator - "E" for extended ASCII in S-57
- VersionNumber byte // RP 8 (1 byte): Format version - "1" for ISO 8211:1994
- ApplicationIndicator byte // RP 9 (1 byte): Application indicator - SPACE for S-57
- FieldControlLength int // RP 10-11 (2 bytes): Length of field control field - "09" for S-57
- FieldAreaStart int // RP 12-16 (5 bytes): Base address of field area (bytes in leader + directory)
- ExtendedCharSet [3]byte // RP 17-19 (3 bytes): Extended character set - " ! " (SPACE,!,SPACE) for S-57
-
- // Entry map - describes directory entry format (positions 20-23)
- // These values tell us how to parse each directory entry
- SizeOfFieldLength int // RP 20 (1 byte): Digits in field length field (variable 1-9, encoder-defined)
- SizeOfFieldPosition int // RP 21 (1 byte): Digits in field position field (variable 1-9, encoder-defined)
- Reserved byte // RP 22 (1 byte): Reserved - "0" in S-57
- SizeOfFieldTag int // RP 23 (1 byte): Characters in field tag - "4" for S-57
-}
-
-// DirectoryEntry maps field tags to their location and size within the field area
-// IHO S-57 Part 3 Annex A.2.3: Directory - tag, length, and position of each field
-type DirectoryEntry struct {
- Tag string // Field tag identifier (e.g., "0001", "DSID")
- Length int // Field length in bytes
- Position int // Field position offset from field area start
-}
-
-// FieldControl defines the internal structure of a field from the DDR
-// IHO S-57 Part 3 Annex A.2.4.1: Field control field (Table A.5), Data descriptive field (Table A.6), Field controls (Table A.7)
-type FieldControl struct {
- Tag string // Field tag this control applies to
- DataStructCode byte // 0=elementary, 1=vector, 2=array
- DataTypeCode byte // 0=char, 1=implicit, 5=binary
- FieldName string // Human-readable field name
- Subfields []*SubfieldDef // Subfield definitions
- FormatControls string // Format string
-}
-
-// SubfieldDef defines a subfield within a field
-type SubfieldDef struct {
- Label string // Subfield label/name
- FormatType byte // Data type (A=ASCII, I=integer, B=binary, etc.)
- Width int // Field width (0 = variable)
-}
diff --git a/pkg/s100/catalog/catalog.go b/pkg/s100/catalog/catalog.go
deleted file mode 100644
index 521c323..0000000
--- a/pkg/s100/catalog/catalog.go
+++ /dev/null
@@ -1,251 +0,0 @@
-// Package catalog loads the static drawing assets of the IHO S-101 Portrayal
-// Catalogue — line styles, area fills, and the colour profile — into Go structs.
-// Symbols (SVG) are handled separately by the rasterizer. These are the definitions that
-// DrawCommand references (LineInstruction/AreaFillReference/colour tokens) are
-// resolved against when lowering S-101 portrayal output to engine primitives.
-package catalog
-
-import (
- "bytes"
- "encoding/xml"
- "fmt"
- "io"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
-)
-
-// Dash is one on-segment of a line-style pattern, positioned along the interval.
-type Dash struct{ Start, Length float64 }
-
-// PlacedSymbol is a symbol repeated along a line style at a given interval position.
-type PlacedSymbol struct {
- Reference string
- Position float64
-}
-
-// LineStyle is one S-101 LineStyles/*.xml definition (S100LineStyle/5.2). Most
-// are single-component; a compositeLineStyle (e.g. a double line) carries its
-// parallel components in Components, each with its own Offset/pen/pattern.
-type LineStyle struct {
- ID string
- Offset float64
- IntervalLength float64
- PenWidth float64
- PenColor string // colour token, e.g. CHMGD
- Dashes []Dash
- Symbols []PlacedSymbol
- Components []LineStyle // non-empty only for composite line styles
-}
-
-// Vec is a 2-D vector in symbol-space mm (used for area-fill tiling basis).
-type Vec struct{ X, Y float64 }
-
-// AreaFill is one S-101 AreaFills/*.xml symbolFill definition (S100AreaFill/5.2):
-// a symbol tiled across the area on the lattice spanned by V1 and V2.
-type AreaFill struct {
- ID string
- CRS string // e.g. GlobalGeometry
- SymbolRef string
- V1, V2 Vec
-}
-
-// --- XML shapes ---
-
-type xmlLineStyle struct {
- Offset float64 `xml:"offset,attr"`
- IntervalLength float64 `xml:"intervalLength"`
- Pen struct {
- Width float64 `xml:"width,attr"`
- Color string `xml:"color"`
- } `xml:"pen"`
- Dashes []struct {
- Start float64 `xml:"start"`
- Length float64 `xml:"length"`
- } `xml:"dash"`
- Symbols []struct {
- Reference string `xml:"reference,attr"`
- Position float64 `xml:"position"`
- } `xml:"symbol"`
- // Inner elements, present only for a compositeLineStyle root.
- Components []xmlLineStyle `xml:"lineStyle"`
-}
-
-type xmlAreaFill struct {
- CRS string `xml:"areaCRS"`
- Symbol struct {
- Reference string `xml:"reference,attr"`
- } `xml:"symbol"`
- V1 struct {
- X float64 `xml:"x"`
- Y float64 `xml:"y"`
- } `xml:"v1"`
- V2 struct {
- X float64 `xml:"x"`
- Y float64 `xml:"y"`
- } `xml:"v2"`
-}
-
-// LoadLineStyle parses one LineStyles/*.xml (simple or composite) by path; the
-// ID is the file stem.
-func LoadLineStyle(path string) (*LineStyle, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- return parseLineStyle(stem(path), data)
-}
-
-func parseLineStyle(id string, data []byte) (*LineStyle, error) {
- var x xmlLineStyle
- if err := decodeXML(data, &x); err != nil {
- return nil, fmt.Errorf("%s: %w", id, err)
- }
- ls := lineStyleFromXML(x)
- ls.ID = id
- return &ls, nil
-}
-
-func lineStyleFromXML(x xmlLineStyle) LineStyle {
- ls := LineStyle{
- Offset: x.Offset,
- IntervalLength: x.IntervalLength,
- PenWidth: x.Pen.Width,
- PenColor: x.Pen.Color,
- }
- for _, d := range x.Dashes {
- ls.Dashes = append(ls.Dashes, Dash{Start: d.Start, Length: d.Length})
- }
- for _, s := range x.Symbols {
- ls.Symbols = append(ls.Symbols, PlacedSymbol{Reference: s.Reference, Position: s.Position})
- }
- for _, c := range x.Components {
- ls.Components = append(ls.Components, lineStyleFromXML(c))
- }
- return ls
-}
-
-// LoadAreaFill parses one AreaFills/*.xml symbolFill by path; the ID is the
-// file stem.
-func LoadAreaFill(path string) (*AreaFill, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- return parseAreaFill(stem(path), data)
-}
-
-func parseAreaFill(id string, data []byte) (*AreaFill, error) {
- var x xmlAreaFill
- if err := decodeXML(data, &x); err != nil {
- return nil, fmt.Errorf("%s: %w", id, err)
- }
- return &AreaFill{
- ID: id,
- CRS: x.CRS,
- SymbolRef: x.Symbol.Reference,
- V1: Vec{X: x.V1.X, Y: x.V1.Y},
- V2: Vec{X: x.V2.X, Y: x.V2.Y},
- }, nil
-}
-
-// LoadLineStyles loads every *.xml in a directory (path), keyed by ID.
-func LoadLineStyles(dir string) (map[string]*LineStyle, error) {
- return loadLineStylesFS(os.DirFS(dir), ".")
-}
-
-// LoadAreaFills loads every *.xml in a directory (path), keyed by ID.
-func LoadAreaFills(dir string) (map[string]*AreaFill, error) {
- return loadAreaFillsFS(os.DirFS(dir), ".")
-}
-
-func loadLineStylesFS(fsys fs.FS, sub string) (map[string]*LineStyle, error) {
- out := map[string]*LineStyle{}
- err := eachXMLFS(fsys, sub, func(name string, data []byte) error {
- ls, err := parseLineStyle(name, data)
- if err != nil {
- return err
- }
- out[ls.ID] = ls
- return nil
- })
- return out, err
-}
-
-func loadAreaFillsFS(fsys fs.FS, sub string) (map[string]*AreaFill, error) {
- out := map[string]*AreaFill{}
- err := eachXMLFS(fsys, sub, func(name string, data []byte) error {
- af, err := parseAreaFill(name, data)
- if err != nil {
- return err
- }
- out[af.ID] = af
- return nil
- })
- return out, err
-}
-
-// --- helpers ---
-
-func decodeXML(data []byte, v any) error {
- dec := xml.NewDecoder(bytes.NewReader(data))
- dec.CharsetReader = charsetReader // some catalogue files declare ISO-8859-1
- return dec.Decode(v)
-}
-
-// charsetReader converts the few non-UTF-8 encodings the catalogue uses.
-// ISO-8859-1 (latin1) maps each byte directly to a code point; everything else
-// is assumed UTF-8 and passed through.
-func charsetReader(label string, input io.Reader) (io.Reader, error) {
- switch strings.ToLower(strings.TrimSpace(label)) {
- case "iso-8859-1", "iso8859-1", "latin1":
- b, err := io.ReadAll(input)
- if err != nil {
- return nil, err
- }
- out := make([]byte, 0, len(b))
- for _, c := range b {
- if c < 0x80 {
- out = append(out, c)
- } else {
- out = append(out, 0xC0|c>>6, 0x80|c&0x3F)
- }
- }
- return bytes.NewReader(out), nil
- default:
- return input, nil
- }
-}
-
-// eachXMLFS calls fn(stem, data) for every *.xml in fsys under sub (use "." for
-// the root). Works for both embed.FS and os.DirFS.
-func eachXMLFS(fsys fs.FS, sub string, fn func(stem string, data []byte) error) error {
- entries, err := fs.ReadDir(fsys, sub)
- if err != nil {
- return err
- }
- for _, e := range entries {
- if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".xml") {
- continue
- }
- p := e.Name()
- if sub != "." {
- p = sub + "/" + e.Name()
- }
- data, err := fs.ReadFile(fsys, p)
- if err != nil {
- return err
- }
- stem := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
- if err := fn(stem, data); err != nil {
- return err
- }
- }
- return nil
-}
-
-func stem(path string) string {
- base := filepath.Base(path)
- return strings.TrimSuffix(base, filepath.Ext(base))
-}
diff --git a/pkg/s100/catalog/catalog_test.go b/pkg/s100/catalog/catalog_test.go
deleted file mode 100644
index d676213..0000000
--- a/pkg/s100/catalog/catalog_test.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package catalog
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-// catalogDir resolves the vendored S-101 PortrayalCatalog, or skips the test.
-// Override with S101_CATALOG=/path/to/PortrayalCatalog.
-func catalogDir(t *testing.T) string {
- t.Helper()
- dir := os.Getenv("S101_CATALOG")
- if dir == "" {
- dir = "/home/jcollins/Projects/s101-portrayal-catalogue/PortrayalCatalog"
- }
- if _, err := os.Stat(filepath.Join(dir, "LineStyles")); err != nil {
- t.Skipf("S-101 catalogue not present (%s); set S101_CATALOG to run", dir)
- }
- return dir
-}
-
-func TestLoadLineStyleACHARE51(t *testing.T) {
- dir := catalogDir(t)
- ls, err := LoadLineStyle(filepath.Join(dir, "LineStyles", "ACHARE51.xml"))
- if err != nil {
- t.Fatal(err)
- }
- if ls.ID != "ACHARE51" {
- t.Errorf("ID = %q", ls.ID)
- }
- if ls.IntervalLength != 32.3 || ls.PenWidth != 0.32 || ls.PenColor != "CHMGD" {
- t.Errorf("pen/interval wrong: %+v", ls)
- }
- if len(ls.Dashes) != 3 || ls.Dashes[0] != (Dash{Start: 2, Length: 6}) {
- t.Errorf("dashes wrong: %+v", ls.Dashes)
- }
- if len(ls.Symbols) != 4 || ls.Symbols[0].Reference != "EMAREMG1" || ls.Symbols[0].Position != 5 {
- t.Errorf("symbols wrong: %+v", ls.Symbols)
- }
-}
-
-func TestLoadAreaFillDIAMOND1(t *testing.T) {
- dir := catalogDir(t)
- af, err := LoadAreaFill(filepath.Join(dir, "AreaFills", "DIAMOND1.xml"))
- if err != nil {
- t.Fatal(err)
- }
- if af.ID != "DIAMOND1" || af.SymbolRef != "DIAMOND1P" || af.CRS != "GlobalGeometry" {
- t.Errorf("areafill wrong: %+v", af)
- }
- if af.V1 != (Vec{X: 22.5, Y: 0}) || af.V2 != (Vec{X: 0, Y: 43.13}) {
- t.Errorf("basis vectors wrong: %+v %+v", af.V1, af.V2)
- }
-}
-
-func TestLoadCompositeLineStyle(t *testing.T) {
- dir := catalogDir(t)
- ls, err := LoadLineStyle(filepath.Join(dir, "LineStyles", "SCLBDY51.xml"))
- if err != nil {
- t.Fatal(err)
- }
- if len(ls.Components) != 3 {
- t.Fatalf("want 3 components, got %d", len(ls.Components))
- }
- c0 := ls.Components[0]
- if c0.Offset != -1.0 || c0.PenWidth != 0.96 || c0.PenColor != "CHGRF" || len(c0.Dashes) != 1 {
- t.Errorf("component 0 wrong: %+v", c0)
- }
-}
-
-func TestLoadColorProfileAndCatalog(t *testing.T) {
- dir := catalogDir(t)
- cat, err := Load(dir)
- if err != nil {
- t.Fatal(err)
- }
- if len(cat.LineStyles) == 0 || len(cat.AreaFills) == 0 {
- t.Fatalf("catalog empty: %d lines, %d fills", len(cat.LineStyles), len(cat.AreaFills))
- }
- // NODTA day sRGB is 147,174,187 (see cmd/s101-color-diff).
- if got := cat.Colors.Day["NODTA"]; got != (RGB{147, 174, 187}) {
- t.Errorf("NODTA day = %+v, want {147 174 187}", got)
- }
- if got := cat.Colors.For("night")["CHBLK"]; got == (RGB{}) && len(cat.Colors.Night) == 0 {
- t.Errorf("night palette empty")
- }
- if len(cat.Colors.Day) != len(cat.Colors.Night) || len(cat.Colors.Day) == 0 {
- t.Errorf("palette sizes off: day=%d night=%d", len(cat.Colors.Day), len(cat.Colors.Night))
- }
-}
-
-// TestLoadAllParse loads the whole catalogue and reports any definitions that
-// came out empty — those are gaps (e.g. a non-symbolFill area-fill variant) to
-// triage, matching the project's surface-the-gaps approach.
-func TestLoadAllParse(t *testing.T) {
- dir := catalogDir(t)
-
- lines, err := LoadLineStyles(filepath.Join(dir, "LineStyles"))
- if err != nil {
- t.Fatal(err)
- }
- fills, err := LoadAreaFills(filepath.Join(dir, "AreaFills"))
- if err != nil {
- t.Fatal(err)
- }
- t.Logf("loaded %d line styles, %d area fills", len(lines), len(fills))
-
- for id, ls := range lines {
- if ls.IntervalLength == 0 && len(ls.Dashes) == 0 && len(ls.Symbols) == 0 && ls.PenColor == "" && len(ls.Components) == 0 {
- t.Errorf("line style %s parsed empty", id)
- }
- }
- for id, af := range fills {
- if af.SymbolRef == "" {
- t.Logf("area fill %s has no symbol reference (non-symbolFill variant?) — triage", id)
- }
- }
-}
diff --git a/pkg/s100/catalog/colors.go b/pkg/s100/catalog/colors.go
deleted file mode 100644
index ab7f932..0000000
--- a/pkg/s100/catalog/colors.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package catalog
-
-import (
- "fmt"
- "io/fs"
- "os"
-)
-
-// RGB is an 8-bit sRGB colour.
-type RGB struct{ R, G, B uint8 }
-
-// Palette maps a colour token (e.g. CHBLK) to its sRGB value for one viewing
-// condition.
-type Palette map[string]RGB
-
-// ColorProfile is the S-101 colorProfile.xml: the same tokens resolved for each
-// viewing condition. (Verified byte-identical to the S-52 DAI colours by
-// cmd/s101-color-diff.)
-type ColorProfile struct {
- Day, Dusk, Night Palette
-}
-
-// For returns the palette for a scheme name ("day"/"dusk"/"night"); unknown
-// names fall back to Day.
-func (c *ColorProfile) For(scheme string) Palette {
- switch scheme {
- case "dusk":
- return c.Dusk
- case "night":
- return c.Night
- default:
- return c.Day
- }
-}
-
-type xmlColorProfile struct {
- Palettes []struct {
- Name string `xml:"name,attr"`
- Items []struct {
- Token string `xml:"token,attr"`
- SRGB struct {
- R int `xml:"red"`
- G int `xml:"green"`
- B int `xml:"blue"`
- } `xml:"srgb"`
- } `xml:"item"`
- } `xml:"palette"`
-}
-
-// LoadColorProfile parses ColorProfiles/colorProfile.xml by path.
-func LoadColorProfile(path string) (*ColorProfile, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- return parseColorProfile(data)
-}
-
-func parseColorProfile(data []byte) (*ColorProfile, error) {
- var x xmlColorProfile
- if err := decodeXML(data, &x); err != nil {
- return nil, err
- }
- cp := &ColorProfile{Day: Palette{}, Dusk: Palette{}, Night: Palette{}}
- for _, p := range x.Palettes {
- var pal Palette
- switch p.Name {
- case "Day":
- pal = cp.Day
- case "Dusk":
- pal = cp.Dusk
- case "Night":
- pal = cp.Night
- default:
- continue
- }
- for _, it := range p.Items {
- pal[it.Token] = RGB{R: clampByte(it.SRGB.R), G: clampByte(it.SRGB.G), B: clampByte(it.SRGB.B)}
- }
- }
- return cp, nil
-}
-
-// Catalog is the loaded static drawing assets of the S-101 Portrayal Catalogue
-// (symbols are rasterized separately). DrawCommand references resolve against it.
-type Catalog struct {
- LineStyles map[string]*LineStyle
- AreaFills map[string]*AreaFill
- Colors *ColorProfile
-}
-
-// Load reads LineStyles/, AreaFills/, and ColorProfiles/colorProfile.xml from a
-// PortrayalCatalog directory (path).
-func Load(portrayalCatalogDir string) (*Catalog, error) {
- return LoadFS(os.DirFS(portrayalCatalogDir))
-}
-
-// LoadFS reads the catalogue from an fs.FS rooted at a PortrayalCatalog (e.g.
-// an embed.FS sub-tree). Expects LineStyles/, AreaFills/, and
-// ColorProfiles/colorProfile.xml under the root.
-func LoadFS(fsys fs.FS) (*Catalog, error) {
- c := &Catalog{}
- var err error
- if c.LineStyles, err = loadLineStylesFS(fsys, "LineStyles"); err != nil {
- return nil, fmt.Errorf("line styles: %w", err)
- }
- if c.AreaFills, err = loadAreaFillsFS(fsys, "AreaFills"); err != nil {
- return nil, fmt.Errorf("area fills: %w", err)
- }
- data, err := fs.ReadFile(fsys, "ColorProfiles/colorProfile.xml")
- if err != nil {
- return nil, fmt.Errorf("colour profile: %w", err)
- }
- if c.Colors, err = parseColorProfile(data); err != nil {
- return nil, fmt.Errorf("colour profile: %w", err)
- }
- return c, nil
-}
-
-func clampByte(v int) uint8 {
- if v < 0 {
- return 0
- }
- if v > 255 {
- return 255
- }
- return uint8(v)
-}
diff --git a/pkg/s100/fc/fc.go b/pkg/s100/fc/fc.go
deleted file mode 100644
index 8d3f0c8..0000000
--- a/pkg/s100/fc/fc.go
+++ /dev/null
@@ -1,244 +0,0 @@
-// Package fc parses the IHO S-101 Feature Catalogue (S100FC/5.2) into a type
-// registry, and — crucially for the S-57→S-101 bridge — exposes the alias
-// mapping the catalogue ships: every feature type and attribute carries its
-// S-57 6-char acronym in , so the name half of the bridge is derived
-// directly from this one file rather than a separate conversion table.
-// Enumerated values keep S-57's integer codes, so attribute values pass through
-// unchanged.
-//
-// The registry also backs the Lua portrayal engine's HostGet*TypeInfo /
-// HostGet*TypeCodes introspection callbacks (the host contract).
-package fc
-
-import (
- "encoding/xml"
- "fmt"
- "os"
- "strings"
-)
-
-// ListedValue is one enumeration member (S-57-compatible integer code + label).
-type ListedValue struct {
- Code int
- Label string
-}
-
-// SimpleAttribute is an S-101 simple attribute type.
-type SimpleAttribute struct {
- Code string // camelCase S-101 code, e.g. beaconShape
- Aliases []string
- Name string
- ValueType string // boolean | enumeration | integer | real | text | date | ...
- ListedValues []ListedValue
-}
-
-// AttributeBinding is a feature type's use of an attribute, with multiplicity.
-type AttributeBinding struct {
- AttributeRef string
- Lower int
- Upper int // -1 == infinite
- PermittedValues []int
-}
-
-// FeatureType is an S-101 feature type.
-type FeatureType struct {
- Code string // camelCase S-101 code, e.g. Anchorage
- Aliases []string
- Name string
- Abstract bool
- Primitives []string // point | curve | surface
- Bindings []AttributeBinding
-}
-
-// ComplexAttribute is an S-101 complex attribute type (a group of sub-attributes,
-// e.g. featureName = {language, name}).
-type ComplexAttribute struct {
- Code string
- Aliases []string
- Bindings []AttributeBinding // sub-attribute bindings
-}
-
-// InformationType is an S-101 information type (e.g. SpatialQuality), bound to a
-// feature/spatial via an information association.
-type InformationType struct {
- Code string
- Aliases []string
- Bindings []AttributeBinding
-}
-
-// Catalogue is the parsed feature catalogue plus reverse (alias→code) indexes.
-type Catalogue struct {
- FeatureTypes map[string]*FeatureType
- SimpleAttrs map[string]*SimpleAttribute
- ComplexAttrs map[string]*ComplexAttribute
- InformationTypes map[string]*InformationType
-
- featureByAlias map[string]string // S-57 OBJL acronym → feature code
- attrByAlias map[string]string // S-57 attribute acronym → attribute code
-}
-
-// FeatureCodeForS57 maps an S-57 object-class acronym (e.g. ACHARE) to its
-// S-101 feature code (e.g. Anchorage).
-func (c *Catalogue) FeatureCodeForS57(objl string) (string, bool) {
- code, ok := c.featureByAlias[strings.ToUpper(objl)]
- return code, ok
-}
-
-// AttrCodeForS57 maps an S-57 attribute acronym (e.g. DRVAL1) to its S-101
-// attribute code (e.g. depthRangeMinimumValue).
-func (c *Catalogue) AttrCodeForS57(acronym string) (string, bool) {
- code, ok := c.attrByAlias[strings.ToUpper(acronym)]
- return code, ok
-}
-
-// --- XML shapes ---
-
-type xmlCatalogue struct {
- SimpleAttributes struct {
- Items []xmlSimpleAttr `xml:"S100_FC_SimpleAttribute"`
- } `xml:"S100_FC_SimpleAttributes"`
- FeatureTypes struct {
- Items []xmlFeatureType `xml:"S100_FC_FeatureType"`
- } `xml:"S100_FC_FeatureTypes"`
- ComplexAttributes struct {
- Items []struct {
- Code string `xml:"code"`
- Aliases []string `xml:"alias"`
- Bindings []xmlBinding `xml:"subAttributeBinding"`
- } `xml:"S100_FC_ComplexAttribute"`
- } `xml:"S100_FC_ComplexAttributes"`
- InformationTypes struct {
- Items []struct {
- Code string `xml:"code"`
- Aliases []string `xml:"alias"`
- Bindings []xmlBinding `xml:"attributeBinding"`
- } `xml:"S100_FC_InformationType"`
- } `xml:"S100_FC_InformationTypes"`
-}
-
-// xmlBinding is an attribute/sub-attribute binding (multiplicity + ref).
-type xmlBinding struct {
- Multiplicity struct {
- Lower int `xml:"lower"`
- Upper struct {
- Infinite string `xml:"infinite,attr"`
- Value int `xml:",chardata"`
- } `xml:"upper"`
- } `xml:"multiplicity"`
- PermittedValues struct {
- Values []int `xml:"value"`
- } `xml:"permittedValues"`
- Attribute struct {
- Ref string `xml:"ref,attr"`
- } `xml:"attribute"`
-}
-
-type xmlSimpleAttr struct {
- Name string `xml:"name"`
- Code string `xml:"code"`
- Aliases []string `xml:"alias"`
- ValueType string `xml:"valueType"`
- ListedValues struct {
- Items []struct {
- Label string `xml:"label"`
- Code int `xml:"code"`
- } `xml:"listedValue"`
- } `xml:"listedValues"`
-}
-
-type xmlFeatureType struct {
- Abstract string `xml:"isAbstract,attr"`
- Name string `xml:"name"`
- Code string `xml:"code"`
- Aliases []string `xml:"alias"`
- Primitives []string `xml:"permittedPrimitives"`
- Bindings []xmlBinding `xml:"attributeBinding"`
-}
-
-// bindings converts parsed xml bindings into AttributeBindings.
-func bindings(xs []xmlBinding) []AttributeBinding {
- var out []AttributeBinding
- for _, b := range xs {
- upper := b.Multiplicity.Upper.Value
- if b.Multiplicity.Upper.Infinite == "true" {
- upper = -1
- }
- out = append(out, AttributeBinding{
- AttributeRef: b.Attribute.Ref,
- Lower: b.Multiplicity.Lower,
- Upper: upper,
- PermittedValues: b.PermittedValues.Values,
- })
- }
- return out
-}
-
-// Load parses a FeatureCatalogue.xml file (by path) into a Catalogue.
-func Load(path string) (*Catalogue, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- return LoadBytes(data)
-}
-
-// LoadBytes parses FeatureCatalogue.xml bytes (e.g. from an embed.FS) into a
-// Catalogue.
-func LoadBytes(data []byte) (*Catalogue, error) {
- var x xmlCatalogue
- if err := xml.Unmarshal(data, &x); err != nil {
- return nil, fmt.Errorf("parse feature catalogue: %w", err)
- }
-
- c := &Catalogue{
- FeatureTypes: map[string]*FeatureType{},
- SimpleAttrs: map[string]*SimpleAttribute{},
- ComplexAttrs: map[string]*ComplexAttribute{},
- InformationTypes: map[string]*InformationType{},
- featureByAlias: map[string]string{},
- attrByAlias: map[string]string{},
- }
-
- for _, sa := range x.SimpleAttributes.Items {
- attr := &SimpleAttribute{
- Code: sa.Code,
- Aliases: sa.Aliases,
- Name: sa.Name,
- ValueType: sa.ValueType,
- }
- for _, lv := range sa.ListedValues.Items {
- attr.ListedValues = append(attr.ListedValues, ListedValue{Code: lv.Code, Label: lv.Label})
- }
- c.SimpleAttrs[sa.Code] = attr
- for _, a := range sa.Aliases {
- c.attrByAlias[strings.ToUpper(a)] = sa.Code
- }
- }
-
- for _, ft := range x.FeatureTypes.Items {
- f := &FeatureType{
- Code: ft.Code,
- Aliases: ft.Aliases,
- Name: ft.Name,
- Abstract: ft.Abstract == "true",
- Primitives: ft.Primitives,
- }
- f.Bindings = bindings(ft.Bindings)
- c.FeatureTypes[ft.Code] = f
- for _, a := range ft.Aliases {
- c.featureByAlias[strings.ToUpper(a)] = ft.Code
- }
- }
-
- for _, ca := range x.ComplexAttributes.Items {
- c.ComplexAttrs[ca.Code] = &ComplexAttribute{Code: ca.Code, Aliases: ca.Aliases, Bindings: bindings(ca.Bindings)}
- for _, a := range ca.Aliases {
- c.attrByAlias[strings.ToUpper(a)] = ca.Code // complex attrs also bridge by alias
- }
- }
- for _, it := range x.InformationTypes.Items {
- c.InformationTypes[it.Code] = &InformationType{Code: it.Code, Aliases: it.Aliases, Bindings: bindings(it.Bindings)}
- }
-
- return c, nil
-}
diff --git a/pkg/s100/fc/fc_test.go b/pkg/s100/fc/fc_test.go
deleted file mode 100644
index 86ef778..0000000
--- a/pkg/s100/fc/fc_test.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package fc
-
-import (
- "os"
- "testing"
-)
-
-// fcPath resolves the vendored S-101 Feature Catalogue, or skips.
-// Override with S101_FC=/path/to/FeatureCatalogue.xml.
-func fcPath(t *testing.T) string {
- t.Helper()
- p := os.Getenv("S101_FC")
- if p == "" {
- p = "/home/jcollins/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml"
- }
- if _, err := os.Stat(p); err != nil {
- t.Skipf("S-101 feature catalogue not present (%s); set S101_FC to run", p)
- }
- return p
-}
-
-func TestLoadAndBridgeMaps(t *testing.T) {
- c, err := Load(fcPath(t))
- if err != nil {
- t.Fatal(err)
- }
- if len(c.FeatureTypes) < 100 || len(c.SimpleAttrs) < 100 {
- t.Fatalf("registry looks short: %d feature types, %d attrs", len(c.FeatureTypes), len(c.SimpleAttrs))
- }
- t.Logf("loaded %d feature types, %d simple attributes", len(c.FeatureTypes), len(c.SimpleAttrs))
-
- // S-57 object class → S-101 feature code (alias-derived bridge).
- if code, ok := c.FeatureCodeForS57("M_ACCY"); !ok || code != "QualityOfNonBathymetricData" {
- t.Errorf("M_ACCY → %q (ok=%v), want QualityOfNonBathymetricData", code, ok)
- }
- // S-57 attribute acronym → S-101 attribute code.
- if code, ok := c.AttrCodeForS57("BCNSHP"); !ok || code != "beaconShape" {
- t.Errorf("BCNSHP → %q (ok=%v), want beaconShape", code, ok)
- }
-
- // Enumerated attribute keeps S-57 integer codes.
- bs := c.SimpleAttrs["beaconShape"]
- if bs == nil || bs.ValueType != "enumeration" || len(bs.ListedValues) == 0 {
- t.Fatalf("beaconShape attr wrong: %+v", bs)
- }
- if bs.ListedValues[0].Code != 1 || bs.ListedValues[0].Label == "" {
- t.Errorf("first listed value = %+v, want code 1 with a label", bs.ListedValues[0])
- }
-}
-
-// TestCoverageVsDAIObjectClasses reports how many of our DAI S-57 object
-// classes the feature catalogue maps — the S-57→S-101 bridge coverage.
-func TestCoverageVsDAIObjectClasses(t *testing.T) {
- c, err := Load(fcPath(t))
- if err != nil {
- t.Fatal(err)
- }
- // A representative slice of the 175 DAI object classes (see coverage matrix).
- sample := []string{"ACHARE", "DEPARE", "LIGHTS", "WRECKS", "SOUNDG", "COALNE", "BOYLAT", "OBSTRN", "RESARE", "M_QUAL"}
- mapped := 0
- for _, o := range sample {
- if _, ok := c.FeatureCodeForS57(o); ok {
- mapped++
- } else {
- t.Logf("no S-101 feature for S-57 %s (bridge gap → placeholder)", o)
- }
- }
- t.Logf("bridge coverage on sample: %d/%d", mapped, len(sample))
- if mapped == 0 {
- t.Fatal("no object classes mapped — alias parsing broken")
- }
-}
diff --git a/pkg/s100/instructions/augray_crs_test.go b/pkg/s100/instructions/augray_crs_test.go
deleted file mode 100644
index 22225e8..0000000
--- a/pkg/s100/instructions/augray_crs_test.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package instructions
-
-import "testing"
-
-// TestAugmentedRayLengthCRS: the AugmentedRay length's CRS decides its unit.
-// LocalCRS ⇒ display millimetres (the 25 mm short sector leg); GeographicCRS ⇒
-// a fixed ground distance in metres (a sectorLineLength / full-VALNMR leg).
-// Conflating them rendered geographic legs at metres-as-mm — ~10× too long.
-func TestAugmentedRayLengthCRS(t *testing.T) {
- cases := []struct {
- in string
- wantMM, wantGndM float64
- }{
- {"AugmentedRay:GeographicCRS,123.4,GeographicCRS,185.2;LineInstruction:_simple_", 0, 185.2},
- {"AugmentedRay:GeographicCRS,123.4,LocalCRS,25;LineInstruction:_simple_", 25, 0},
- }
- for _, c := range cases {
- cmds, _ := Reduce(ParseStream(c.in))
- var got *AugmentedGeom
- for i := range cmds {
- if cmds[i].Augmented != nil {
- got = cmds[i].Augmented
- }
- }
- if got == nil {
- t.Fatalf("%s: no augmented geom", c.in)
- }
- if got.LengthMM != c.wantMM || got.LengthGroundM != c.wantGndM {
- t.Errorf("%s: LengthMM=%v LengthGroundM=%v, want %v / %v", c.in, got.LengthMM, got.LengthGroundM, c.wantMM, c.wantGndM)
- }
- }
-}
diff --git a/pkg/s100/instructions/instructions.go b/pkg/s100/instructions/instructions.go
deleted file mode 100644
index 49932ec..0000000
--- a/pkg/s100/instructions/instructions.go
+++ /dev/null
@@ -1,378 +0,0 @@
-// Package instructions parses the flat drawing-instruction stream emitted by
-// S-101 portrayal rules into structured draw commands that downstream code
-// lowers onto the engine's primitive layer.
-//
-// The stream is a ';'-separated list of "Keyword:arg,arg,..." tokens (or bare
-// keywords like NullInstruction). Following the S-100 Part 9 portrayal model,
-// tokens are either MODIFIERS that set state (ViewingGroup, DrawingPriority,
-// DisplayPlane, LocalOffset, Rotation, LinePlacement, Dash, and an inline
-// LineStyle:_simple_ definition) or DRAWS that consume the current state
-// (PointInstruction, LineInstruction, ColorFill, AreaFillReference, Text,
-// NullInstruction). Reduce folds a parsed stream into one DrawCommand per draw,
-// each carrying a snapshot of the state in effect.
-package instructions
-
-import (
- "strconv"
- "strings"
-)
-
-// Instruction is one parsed token from a drawing-instruction stream.
-type Instruction struct {
- Kind string // token before the first ':' (or the whole bare token)
- Args []string // ','-separated payload after the first ':'
- Raw string
-}
-
-// ParseStream tokenizes a ';'-separated S-101 instruction stream. Empty tokens
-// are skipped; surrounding whitespace is trimmed.
-func ParseStream(stream string) []Instruction {
- var out []Instruction
- for tok := range strings.SplitSeq(stream, ";") {
- tok = strings.TrimSpace(tok)
- if tok == "" {
- continue
- }
- kind, rest, hasArgs := strings.Cut(tok, ":")
- ins := Instruction{Kind: strings.TrimSpace(kind), Raw: tok}
- if hasArgs {
- ins.Args = strings.Split(rest, ",")
- }
- out = append(out, ins)
- }
- return out
-}
-
-// DrawOp classifies a resolved drawing command.
-type DrawOp string
-
-const (
- OpPoint DrawOp = "Point" // place a point symbol
- OpLine DrawOp = "Line" // draw a line style along the geometry
- OpColorFill DrawOp = "ColorFill" // solid area fill with a colour token
- OpAreaFill DrawOp = "AreaFill" // tiled area fill referencing a fill/pattern
- OpText DrawOp = "Text" // text label
- OpNull DrawOp = "Null" // explicit no-op (suppress)
- // OpAugmentedLine strokes a screen-space figure the rule CONSTRUCTED via an
- // AugmentedRay / ArcByRadius instruction (a light-sector leg or arc/ring),
- // rather than the feature's own geometry. The mm sizes are screen-fixed, so the
- // figure is carried (see DrawCommand.Augmented) for per-zoom tessellation.
- OpAugmentedLine DrawOp = "AugmentedLine"
- OpOther DrawOp = "Other" // recognized draw we don't emit yet (gap)
-)
-
-// AugGeomKind is the kind of constructed figure element a DrawCommand.Augmented
-// carries.
-type AugGeomKind uint8
-
-const (
- AugRay AugGeomKind = iota // a straight leg from the anchor (AugmentedRay)
- AugArc // a circular arc/ring centred on the anchor (ArcByRadius)
-)
-
-// AugmentedGeom is one screen-space figure element the rule constructed and a
-// LineInstruction then strokes — a light-sector leg (ray) or its arc/ring. All
-// sizes are display millimetres (the rule emits them in LocalCRS), so the baker
-// tessellates per-zoom; they cannot bake as static geographic geometry.
-type AugmentedGeom struct {
- Kind AugGeomKind
- // Ray ("AugmentedRay:,,,"): a leg from the anchor
- // at BearingDeg (true-north; the rule has already applied the from-seaward
- // +180 reversal) of length LengthMM.
- BearingDeg float64
- LengthMM float64
- // LengthGroundM is the leg length when the rule gave it in GeographicCRS — a
- // fixed GROUND distance in metres (a sectorLineLength or full-VALNMR leg),
- // rendered zoom-dependently. Mutually exclusive with LengthMM (display mm).
- LengthGroundM float64
- // Arc ("ArcByRadius:,,,,"): centred on the
- // anchor, RadiusMM, from StartDeg sweeping SweepDeg degrees clockwise. A full
- // 360° sweep is an all-round ring.
- RadiusMM float64
- StartDeg float64
- SweepDeg float64
-}
-
-// SimpleLine is an inline "LineStyle:_simple_,,," definition,
-// referenced by a subsequent "LineInstruction:_simple_".
-type SimpleLine struct {
- DashLength float64 // 0 for a solid line
- Width float64
- Color string
-}
-
-// DrawCommand is a draw instruction resolved against the portrayal state that
-// was in effect when it was emitted.
-type DrawCommand struct {
- Op DrawOp
- Reference string // symbol id / line-style id / colour token / fill id / text
- ViewingGroup int
- Priority int
- DisplayPlane string // "UnderRadar" | "OverRadar" (empty if unset)
- Offset [2]float64
- // Anchor / HasAnchor carry an explicit draw location from an AugmentedPoint
- // (GeographicCRS lon,lat) — used for SOUNDG, whose one feature emits a symbol
- // per sounding at its own point. When HasAnchor is false the draw attaches to
- // the feature geometry (the usual case).
- Anchor [2]float64 // {lon, lat}
- HasAnchor bool
- Rotation float64
- HasRotation bool
- // RotationTrueNorth is set when the rotation is in GeographicCRS (referenced
- // to true north, so the symbol turns WITH the chart — e.g. a directional
- // light's orientation); false means PortrayalCRS (screen-referenced, e.g. the
- // 135° light flare), which stays upright to the screen.
- RotationTrueNorth bool
-
- LinePlacement string // raw, e.g. "Relative,0.5"
- SimpleLine *SimpleLine // set when Op==OpLine/OpAugmentedLine and Reference=="_simple_"
-
- // Augmented is set when Op==OpAugmentedLine: the constructed ray/arc this draw
- // strokes (with the SimpleLine style). The baker tessellates it per-zoom.
- Augmented *AugmentedGeom
-
- // Text style (set on OpText): the resolved text is in Reference.
- FontColor string // colour token (e.g. CHBLK)
- FontSizePx float64 // 0 ⇒ default
- TextAlignH string // "Center" | "Left" | "Right"
- TextAlignV string // "Top" | "Bottom" | "Center"
- TextVOffset float64
-
- // Date dependency (S-101 §ProcessFixedAndPeriodicDates): a Date:/TimeValid:
- // modifier pair the rule emits for a feature with a fixed (DATSTA/DATEND) or
- // periodic (PERSTA/PEREND) date range. DateStart/DateEnd are S-57 date strings
- // — full "YYYYMMDD" for a fixed range, or an S-57 partial "--MMDD" recurring
- // each year for a periodic one (either may be empty for a semi-open interval).
- // TimeValid is the interval kind ("closedInterval" | "geSemiInterval" |
- // "leSemiInterval"). Empty when the feature carries no date dependency. Carried
- // so a date-aware consumer can show/hide the feature against the current date.
- DateStart string
- DateEnd string
- TimeValid string
-
- Raw string // the originating draw token, for debugging
-}
-
-// Reduce folds a parsed stream into one DrawCommand per draw instruction,
-// snapshotting modifier state. Modifier tokens it does not recognize are
-// returned in unsupported (deduped) so callers can surface gaps; recognized
-// draws it doesn't yet lower become OpOther commands.
-func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) {
- var (
- viewingGroup int
- priority int
- displayPlane string
- offset [2]float64
- anchor [2]float64
- hasAnchor bool
- rotation float64
- hasRotation bool
- rotTrueNorth bool
- linePlace string
- simple *SimpleLine
- fontColor string
- fontSize float64
- textAlignH string
- textAlignV string
- textVOffset float64
- dateStart string
- dateEnd string
- timeValid string
- curAug *AugmentedGeom // current constructed figure (ray/arc), if any
- seenUnsup = map[string]bool{}
- )
- noteUnsupported := func(kind string) {
- if !seenUnsup[kind] {
- seenUnsup[kind] = true
- unsupported = append(unsupported, kind)
- }
- }
- emit := func(op DrawOp, ref, raw string) {
- c := DrawCommand{
- Op: op, Reference: ref, ViewingGroup: viewingGroup, Priority: priority,
- DisplayPlane: displayPlane, Offset: offset, Anchor: anchor, HasAnchor: hasAnchor,
- Rotation: rotation, HasRotation: hasRotation, RotationTrueNorth: rotTrueNorth,
- LinePlacement: linePlace, Raw: raw,
- }
- if op == OpLine && ref == "_simple_" {
- c.SimpleLine = simple
- }
- if op == OpText {
- c.FontColor, c.FontSizePx = fontColor, fontSize
- c.TextAlignH, c.TextAlignV, c.TextVOffset = textAlignH, textAlignV, textVOffset
- }
- if op == OpAugmentedLine {
- c.SimpleLine, c.Augmented = simple, curAug
- }
- c.DateStart, c.DateEnd, c.TimeValid = dateStart, dateEnd, timeValid
- cmds = append(cmds, c)
- }
-
- for _, in := range ins {
- switch in.Kind {
- // --- modifiers (set state) ---
- case "ViewingGroup":
- viewingGroup = atoi(arg(in, 0))
- case "DrawingPriority":
- priority = atoi(arg(in, 0))
- case "DisplayPlane":
- displayPlane = arg(in, 0)
- case "LocalOffset":
- offset = [2]float64{atof(arg(in, 0)), atof(arg(in, 1))}
- case "AugmentedPoint":
- // "AugmentedPoint:,," places subsequent point draws at the
- // geographic point (x=lon, y=lat) — SOUNDG emits one per sounding.
- anchor, hasAnchor = [2]float64{atof(arg(in, 1)), atof(arg(in, 2))}, true
- case "ClearGeometry":
- // End of an augmented-geometry run: drop the explicit anchor/offset and
- // the constructed figure so later draws re-attach to the feature geometry.
- hasAnchor, anchor, offset = false, [2]float64{}, [2]float64{}
- curAug = nil
- // --- geometry construction (screen-space figures the rule builds) ---
- case "AugmentedRay":
- // "AugmentedRay:,,," — a leg from the
- // anchor. The rule emits the bearing already from-seaward-reversed. The
- // LENGTH's CRS (arg 2) decides its unit: LocalCRS ⇒ display mm (the 25 mm
- // short sector leg); GeographicCRS ⇒ ground metres (a sectorLineLength /
- // full-VALNMR leg — a fixed ground distance, NOT mm). Conflating the two
- // rendered geographic legs at metres-as-mm, ~10× too long ("shooting out").
- ar := &AugmentedGeom{Kind: AugRay, BearingDeg: atof(arg(in, 1))}
- if arg(in, 2) == "GeographicCRS" {
- ar.LengthGroundM = atof(arg(in, 3))
- } else {
- ar.LengthMM = atof(arg(in, 3))
- }
- curAug = ar
- case "ArcByRadius":
- // "ArcByRadius:,,,," — an arc/ring
- // centred on the anchor (the cx,cy offset is 0 for sector figures).
- curAug = &AugmentedGeom{Kind: AugArc, RadiusMM: atof(arg(in, 2)), StartDeg: atof(arg(in, 3)), SweepDeg: atof(arg(in, 4))}
- case "AugmentedPath":
- // Declares the CRS sequence stitching the preceding ray/arc into one path;
- // each constructed element is already carried by curAug, so this is a no-op.
- case "Rotation":
- // S-101 form: "Rotation:," where CRS is GeographicCRS
- // (true-north, rotates with the chart) or PortrayalCRS (screen). A
- // bare "Rotation:" (no CRS) is tolerated as screen-referenced.
- crs, ang := arg(in, 0), arg(in, 1)
- if ang == "" {
- crs, ang = "", crs
- }
- rotation, hasRotation = atof(ang), true
- rotTrueNorth = crs == "GeographicCRS"
- case "LinePlacement":
- linePlace = strings.Join(in.Args, ",")
- case "Dash":
- // recorded into the next _simple_ line style via LineStyle below;
- // standalone Dash only meaningfully precedes a LineStyle.
- case "LineStyle":
- simple = parseSimpleLine(in.Args)
- // --- text-style modifiers (apply to the next TextInstruction) ---
- case "FontColor":
- fontColor = arg(in, 0)
- case "FontSize":
- fontSize = atof(arg(in, 0))
- case "TextAlignHorizontal":
- textAlignH = arg(in, 0)
- case "TextAlignVertical":
- textAlignV = arg(in, 0)
- case "TextVerticalOffset":
- textVOffset = atof(arg(in, 0))
- // --- date-dependency modifiers (annotate subsequent draws) ---
- case "Date":
- // "Date:," (either side may be empty for a semi-open
- // interval, e.g. "Date:,--1201"). A bare "Date:" sets the start.
- dateStart, dateEnd = arg(in, 0), arg(in, 1)
- case "TimeValid":
- timeValid = arg(in, 0)
- // modifiers we intentionally ignore when emitting primitives
- case "ScaleMinimum", "ScaleMaximum", "Time", "DateTime",
- "AlertReference", "Warning", "Error", "Hover", "SpatialReference",
- // area-placement / scale-factor modifiers: meaningful only for area-fill
- // placement, not for the point / fill / line / text / augmented draws we
- // emit. They ride along on the AddDateDependentSymbol geometry-reset
- // preamble, so ignore them rather than report them as gaps.
- "AreaPlacement", "AreaCRS", "ScaleFactor":
- // no-op for primitive emission
-
- // --- draws (consume state) ---
- case "PointInstruction":
- emit(OpPoint, arg(in, 0), in.Raw)
- case "LineInstruction", "LineInstructionUnsuppressed":
- // When a figure (ray/arc) is current, the line strokes THAT (screen-space
- // sector geometry); otherwise it strokes the feature's own geometry.
- if curAug != nil {
- emit(OpAugmentedLine, arg(in, 0), in.Raw)
- } else {
- emit(OpLine, arg(in, 0), in.Raw)
- }
- case "ColorFill":
- emit(OpColorFill, arg(in, 0), in.Raw)
- case "AreaFillReference":
- emit(OpAreaFill, arg(in, 0), in.Raw)
- case "TextInstruction":
- // Text is DEF-encoded (separators escaped) so it survives the ;/:/,
- // tokenizing; decode it back to the display string.
- emit(OpText, decodeDEF(strings.Join(in.Args, ",")), in.Raw)
- case "NullInstruction":
- emit(OpNull, "", in.Raw)
-
- // recognized-but-not-yet-emitted draws → gap
- case "CoverageFill":
- emit(OpOther, in.Kind, in.Raw)
-
- default:
- // Font*/TextAlign* etc. are text-state modifiers; everything else
- // unknown is surfaced for triage.
- if !strings.HasPrefix(in.Kind, "Font") && !strings.HasPrefix(in.Kind, "Text") {
- noteUnsupported(in.Kind)
- }
- }
- }
- return cmds, unsupported
-}
-
-// decodeDEF reverses the framework's EncodeDEFString escaping (& ; : , →
-// &a &s &c &m). The escape char (&a→&) is decoded last.
-func decodeDEF(s string) string {
- if !strings.Contains(s, "&") {
- return s
- }
- s = strings.ReplaceAll(s, "&s", ";")
- s = strings.ReplaceAll(s, "&c", ":")
- s = strings.ReplaceAll(s, "&m", ",")
- s = strings.ReplaceAll(s, "&a", "&")
- return s
-}
-
-func parseSimpleLine(args []string) *SimpleLine {
- // LineStyle:_simple_,,,
- sl := &SimpleLine{}
- if len(args) > 1 {
- sl.DashLength = atof(args[1])
- }
- if len(args) > 2 {
- sl.Width = atof(args[2])
- }
- if len(args) > 3 {
- sl.Color = args[3]
- }
- return sl
-}
-
-func arg(in Instruction, i int) string {
- if i < len(in.Args) {
- return strings.TrimSpace(in.Args[i])
- }
- return ""
-}
-
-func atoi(s string) int {
- n, _ := strconv.Atoi(strings.TrimSpace(s))
- return n
-}
-
-func atof(s string) float64 {
- f, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
- return f
-}
diff --git a/pkg/s100/instructions/instructions_test.go b/pkg/s100/instructions/instructions_test.go
deleted file mode 100644
index 826fd90..0000000
--- a/pkg/s100/instructions/instructions_test.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package instructions
-
-import "testing"
-
-// Streams below are the actual output of the real Rapids rule (captured from the
-// S-101 Lua engine), plus a synthetic point example exercising modifiers.
-
-func TestRapidsCurve(t *testing.T) {
- stream := "ViewingGroup:32050;DrawingPriority:9;DisplayPlane:UnderRadar;LineStyle:_simple_,,0.96,CHGRD;LineInstruction:_simple_"
- cmds, unsup := Reduce(ParseStream(stream))
- if len(unsup) != 0 {
- t.Fatalf("unexpected unsupported: %v", unsup)
- }
- if len(cmds) != 1 {
- t.Fatalf("want 1 draw, got %d: %+v", len(cmds), cmds)
- }
- c := cmds[0]
- if c.Op != OpLine || c.Reference != "_simple_" {
- t.Fatalf("want OpLine _simple_, got %s %s", c.Op, c.Reference)
- }
- if c.ViewingGroup != 32050 || c.Priority != 9 || c.DisplayPlane != "UnderRadar" {
- t.Errorf("state not folded: vg=%d prio=%d plane=%s", c.ViewingGroup, c.Priority, c.DisplayPlane)
- }
- if c.SimpleLine == nil || c.SimpleLine.Width != 0.96 || c.SimpleLine.Color != "CHGRD" || c.SimpleLine.DashLength != 0 {
- t.Errorf("simple line not captured: %+v", c.SimpleLine)
- }
-}
-
-func TestRapidsSurface(t *testing.T) {
- stream := "ViewingGroup:32050;DrawingPriority:9;DisplayPlane:UnderRadar;ColorFill:CHGRD"
- cmds, _ := Reduce(ParseStream(stream))
- if len(cmds) != 1 || cmds[0].Op != OpColorFill || cmds[0].Reference != "CHGRD" {
- t.Fatalf("want OpColorFill CHGRD, got %+v", cmds)
- }
-}
-
-func TestRapidsPointNull(t *testing.T) {
- stream := "ViewingGroup:32050;DrawingPriority:9;DisplayPlane:UnderRadar;NullInstruction"
- cmds, _ := Reduce(ParseStream(stream))
- if len(cmds) != 1 || cmds[0].Op != OpNull {
- t.Fatalf("want one OpNull, got %+v", cmds)
- }
-}
-
-func TestPointSymbolWithModifiers(t *testing.T) {
- stream := "ViewingGroup:25010;DrawingPriority:14;LocalOffset:1.5,-2;Rotation:45;PointInstruction:BCNCAR01"
- cmds, unsup := Reduce(ParseStream(stream))
- if len(unsup) != 0 {
- t.Fatalf("unexpected unsupported: %v", unsup)
- }
- if len(cmds) != 1 {
- t.Fatalf("want 1, got %d", len(cmds))
- }
- c := cmds[0]
- if c.Op != OpPoint || c.Reference != "BCNCAR01" {
- t.Fatalf("want OpPoint BCNCAR01, got %s %s", c.Op, c.Reference)
- }
- if c.Offset != [2]float64{1.5, -2} || !c.HasRotation || c.Rotation != 45 || c.ViewingGroup != 25010 || c.Priority != 14 {
- t.Errorf("modifiers not folded: %+v", c)
- }
-}
-
-// TestRotationCRS: the real catalogue emits "Rotation:,". The angle
-// must come from arg 1 (arg 0 is the CRS), and GeographicCRS marks a true-north
-// rotation. Regression: reading arg 0 as the angle made every light flare 0°.
-func TestRotationCRS(t *testing.T) {
- // PortrayalCRS = screen-referenced (the 135° light flare).
- cmds, _ := Reduce(ParseStream("Rotation:PortrayalCRS,135;PointInstruction:LIGHTS11"))
- if cmds[0].Rotation != 135 || cmds[0].RotationTrueNorth {
- t.Errorf("PortrayalCRS,135 → %v (trueN=%v), want 135 screen", cmds[0].Rotation, cmds[0].RotationTrueNorth)
- }
- // GeographicCRS = true-north (a directional light's orientation).
- cmds, _ = Reduce(ParseStream("Rotation:GeographicCRS,200;PointInstruction:LIGHTS82"))
- if cmds[0].Rotation != 200 || !cmds[0].RotationTrueNorth {
- t.Errorf("GeographicCRS,200 → %v (trueN=%v), want 200 true-north", cmds[0].Rotation, cmds[0].RotationTrueNorth)
- }
- // Bare "Rotation:" (no CRS) tolerated as screen-referenced.
- cmds, _ = Reduce(ParseStream("Rotation:45;PointInstruction:BCNCAR01"))
- if cmds[0].Rotation != 45 || cmds[0].RotationTrueNorth {
- t.Errorf("bare 45 → %v (trueN=%v), want 45 screen", cmds[0].Rotation, cmds[0].RotationTrueNorth)
- }
-}
-
-func TestStateCarriesAcrossMultipleDraws(t *testing.T) {
- // One viewing group, two draws: a fill then a boundary line. Both should
- // inherit the viewing group; the line picks up its own _simple_ style.
- stream := "ViewingGroup:33010;AreaFillReference:DRGARE01;LineStyle:_simple_,,0.32,CHGRF;LineInstruction:_simple_"
- cmds, _ := Reduce(ParseStream(stream))
- if len(cmds) != 2 {
- t.Fatalf("want 2 draws, got %d: %+v", len(cmds), cmds)
- }
- if cmds[0].Op != OpAreaFill || cmds[0].Reference != "DRGARE01" || cmds[0].ViewingGroup != 33010 {
- t.Errorf("fill wrong: %+v", cmds[0])
- }
- if cmds[1].Op != OpLine || cmds[1].ViewingGroup != 33010 || cmds[1].SimpleLine.Color != "CHGRF" {
- t.Errorf("line wrong: %+v", cmds[1])
- }
- // The fill must NOT carry the later-defined simple line.
- if cmds[0].SimpleLine != nil {
- t.Errorf("fill leaked a simple line: %+v", cmds[0].SimpleLine)
- }
-}
-
-func TestUnsupportedSurfaced(t *testing.T) {
- _, unsup := Reduce(ParseStream("ViewingGroup:1;Foo:bar;PointInstruction:X"))
- if len(unsup) != 1 || unsup[0] != "Foo" {
- t.Fatalf("want [Foo], got %v", unsup)
- }
-}
-
-// TestSectorAugmentedGeometry: a real LightSectored stream (captured from the
-// S-101 Lua engine) constructs two dashed CHBLK legs and a black-backed coloured
-// arc via AugmentedRay/ArcByRadius. Each LineInstruction must stroke the current
-// figure as an OpAugmentedLine carrying the ray/arc params + the simple-line
-// style — never collapse to an OpLine or land in unsupported.
-func TestSectorAugmentedGeometry(t *testing.T) {
- stream := "ViewingGroup:27070;DrawingPriority:24;DisplayPlane:UnderRadar;Hover:true;" +
- "AugmentedRay:GeographicCRS,83,LocalCRS,25;Dash:0,3.6;LineStyle:_simple_,5.4,0.32,CHBLK;LineInstruction:_simple_;" +
- "AugmentedRay:GeographicCRS,247,LocalCRS,25;LineInstruction:_simple_;" +
- "ArcByRadius:0,0,20,83,164;AugmentedPath:LocalCRS,GeographicCRS,LocalCRS;" +
- "LineStyle:_simple_,,1.28,CHBLK;LineInstruction:_simple_;" +
- "LineStyle:_simple_,,0.64,LITYW;LineInstruction:_simple_;ClearGeometry"
- cmds, unsup := Reduce(ParseStream(stream))
- if len(unsup) != 0 {
- t.Fatalf("unexpected unsupported: %v", unsup)
- }
- var aug []DrawCommand
- for _, c := range cmds {
- if c.Op == OpAugmentedLine {
- aug = append(aug, c)
- } else if c.Op == OpLine {
- t.Errorf("augmented stroke collapsed to OpLine: %+v", c)
- }
- }
- if len(aug) != 4 {
- t.Fatalf("want 4 augmented strokes (2 legs + 2 arc), got %d", len(aug))
- }
- // Leg 1: ray at 83°, length 25mm, dashed CHBLK.
- if aug[0].Augmented == nil || aug[0].Augmented.Kind != AugRay ||
- aug[0].Augmented.BearingDeg != 83 || aug[0].Augmented.LengthMM != 25 {
- t.Errorf("leg1 ray = %+v", aug[0].Augmented)
- }
- if aug[0].SimpleLine == nil || aug[0].SimpleLine.Color != "CHBLK" || aug[0].SimpleLine.DashLength == 0 {
- t.Errorf("leg1 style = %+v", aug[0].SimpleLine)
- }
- // Leg 2: ray at 247°, inherits the same dashed CHBLK style.
- if aug[1].Augmented == nil || aug[1].Augmented.BearingDeg != 247 {
- t.Errorf("leg2 ray = %+v", aug[1].Augmented)
- }
- // Arc backing: radius 20mm, start 83°, sweep 164°, CHBLK 1.28mm solid.
- if aug[2].Augmented == nil || aug[2].Augmented.Kind != AugArc ||
- aug[2].Augmented.RadiusMM != 20 || aug[2].Augmented.StartDeg != 83 || aug[2].Augmented.SweepDeg != 164 {
- t.Errorf("arc backing = %+v", aug[2].Augmented)
- }
- if aug[2].SimpleLine == nil || aug[2].SimpleLine.Color != "CHBLK" || aug[2].SimpleLine.Width != 1.28 {
- t.Errorf("arc backing style = %+v", aug[2].SimpleLine)
- }
- // Arc colour: white light portrayed yellow (LITYW), 0.64mm.
- if aug[3].SimpleLine == nil || aug[3].SimpleLine.Color != "LITYW" || aug[3].SimpleLine.Width != 0.64 {
- t.Errorf("arc colour style = %+v", aug[3].SimpleLine)
- }
-}
diff --git a/pkg/s100/symbols/symbols.go b/pkg/s100/symbols/symbols.go
deleted file mode 100644
index cce85db..0000000
--- a/pkg/s100/symbols/symbols.go
+++ /dev/null
@@ -1,227 +0,0 @@
-// Package symbols flattens IHO S-101 symbol SVGs (resolving their CSS colour
-// classes against a palette stylesheet and stripping the .layout debug boxes)
-// and rasterizes them in pure Go. It is the single source of truth for
-// SVG→raster, used by the sprite-atlas builder.
-//
-// Three oksvg/rasterx defects are worked around here: oksvg ignores a non-zero
-// viewBox origin (we normalize to "0 0 W H" and wrap the content in a
-// translate); it applies stroke-width in device px without scaling by the draw
-// transform (we pre-multiply stroke-width by the px/mm scale); and it ignores
-// the fill-rule attribute while rasterx's ScannerGV cannot do even-odd fills at
-// all (we force even-odd winding and rasterize through scanFT, which honours
-// it) so the catalogue's even-odd danger symbols don't fill their holes solid.
-package symbols
-
-import (
- "bytes"
- "encoding/xml"
- "fmt"
- "image"
- "image/draw"
- "io"
- "math"
- "regexp"
- "strconv"
- "strings"
-
- "github.com/srwiley/oksvg"
- "github.com/srwiley/rasterx"
- "github.com/srwiley/scanFT"
-)
-
-var cssRuleRE = regexp.MustCompile(`\.([A-Za-z0-9_]+)\s*\{([^}]*)\}`)
-
-// LoadCSS parses an S-100 *SvgStyle.css into class name -> declaration string
-// (e.g. "fCHYLW" -> "fill:#E1E139").
-func LoadCSS(data []byte) map[string]string {
- out := map[string]string{}
- for _, m := range cssRuleRE.FindAllStringSubmatch(string(data), -1) {
- decl := strings.TrimSpace(m[2])
- decl = strings.TrimSuffix(decl, ";")
- out[m[1]] = decl
- }
- return out
-}
-
-// Rendered is one rasterized symbol: a straight-alpha image plus the pixel
-// location of the SVG pivot (the (0,0) origin), used as the sprite anchor.
-type Rendered struct {
- Image *image.NRGBA
- PivotX, PivotY float64 // px, +x right +y down
- Missing []string // class tokens with no CSS rule (gaps)
-}
-
-// Render flattens and rasterizes an S-101 symbol SVG at pxPerMM device pixels
-// per millimetre.
-func Render(svg []byte, css map[string]string, pxPerMM float64) (*Rendered, error) {
- flat, vb, missing, err := flatten(svg, css, pxPerMM)
- if err != nil {
- return nil, err
- }
- icon, err := oksvg.ReadIconStream(bytes.NewReader(flat))
- if err != nil {
- return nil, err
- }
- // oksvg ignores the SVG fill-rule attribute and always fills with the
- // nonzero winding rule. Every S-101 symbol is authored with
- // fill-rule="evenodd": the danger hatch of ISODGR01/DANGER0x and similar
- // glyphs is a single compound path whose inner subpath is a hole, and
- // nonzero winding fills that hole solid (an ISODGR01 with no star in it).
- // Force even-odd winding on every path so the holes render. For simple,
- // non-self-intersecting paths even-odd and nonzero are identical, so this
- // is safe for the rest of the set.
- for i := range icon.SVGPaths {
- icon.SVGPaths[i].UseNonZeroWinding = false
- }
- w := int(math.Ceil(vb[2] * pxPerMM))
- h := int(math.Ceil(vb[3] * pxPerMM))
- if w < 1 || h < 1 {
- return nil, fmt.Errorf("degenerate viewBox %gx%g", vb[2], vb[3])
- }
- rgba := image.NewRGBA(image.Rect(0, 0, w, h))
- icon.SetTarget(0, 0, float64(w), float64(h))
- // scanFT (freetype-backed) honours the even-odd winding set above;
- // rasterx's own ScannerGV silently ignores it (it is nonzero-only), which
- // fills the danger-symbol holes solid.
- scanner := scanFT.NewScannerFT(w, h, scanFT.NewRGBAPainter(rgba))
- icon.Draw(rasterx.NewDasher(w, h, scanner), 1.0)
-
- // rasterx writes alpha-premultiplied RGBA; draw.Src into NRGBA converts to
- // the straight-alpha layout the sprite atlas expects.
- nrgba := image.NewNRGBA(rgba.Bounds())
- draw.Draw(nrgba, nrgba.Bounds(), rgba, image.Point{}, draw.Src)
-
- return &Rendered{
- Image: nrgba,
- PivotX: -vb[0] * pxPerMM, // origin lands at (-minX,-minY) after normalize
- PivotY: -vb[1] * pxPerMM,
- Missing: missing,
- }, nil
-}
-
-// flatten resolves CSS classes to inline style, strips .layout elements, and
-// normalizes the viewBox origin to (0,0). It returns the rewritten SVG, the
-// original viewBox [minX,minY,w,h], and any class tokens with no CSS rule.
-func flatten(src []byte, css map[string]string, strokeScale float64) (out []byte, vb [4]float64, missing []string, err error) {
- dec := xml.NewDecoder(bytes.NewReader(src))
- var buf bytes.Buffer
- var stack []string
- seenMissing := map[string]bool{}
-
- for {
- tok, e := dec.Token()
- if e == io.EOF {
- break
- }
- if e != nil {
- return nil, vb, missing, e
- }
- if ee, ok := tok.(xml.EndElement); ok {
- if len(stack) > 0 && stack[len(stack)-1] == ee.Name.Local {
- if ee.Name.Local == "svg" {
- buf.WriteString("")
- } else {
- fmt.Fprintf(&buf, "%s>", ee.Name.Local)
- }
- stack = stack[:len(stack)-1]
- }
- continue
- }
- se, ok := tok.(xml.StartElement)
- if !ok {
- continue
- }
- name := se.Name.Local
- classes := attrVal(se, "class")
- if hasClass(classes, "layout") || attrVal(se, "display") == "none" ||
- name == "metadata" || name == "title" || name == "desc" {
- _ = dec.Skip()
- continue
- }
- style := resolveStyle(classes, css, seenMissing, &missing)
- switch name {
- case "svg":
- vb = parseViewBox(attrVal(se, "viewBox"))
- fmt.Fprintf(&buf, ``, vb[2], vb[3])
- fmt.Fprintf(&buf, ``, -vb[0], -vb[1])
- stack = append(stack, name)
- case "g":
- buf.WriteString("")
- stack = append(stack, name)
- case "path", "rect", "circle", "line", "polygon", "polyline", "ellipse":
- fmt.Fprintf(&buf, "<%s", name)
- writeAttrs(&buf, se, style, strokeScale, "class")
- buf.WriteString("/>")
- _ = dec.Skip()
- }
- }
- return buf.Bytes(), vb, missing, nil
-}
-
-func resolveStyle(classes string, css map[string]string, seen map[string]bool, missing *[]string) string {
- if classes == "" {
- return ""
- }
- var decls []string
- for c := range strings.FieldsSeq(classes) {
- if decl, ok := css[c]; ok {
- if decl != "" {
- decls = append(decls, decl)
- }
- } else if c != "f0" && !seen[c] {
- seen[c] = true
- *missing = append(*missing, c)
- }
- }
- return strings.Join(decls, ";")
-}
-
-func writeAttrs(out *bytes.Buffer, se xml.StartElement, style string, strokeScale float64, skip ...string) {
- skipped := map[string]bool{}
- for _, s := range skip {
- skipped[s] = true
- }
- for _, a := range se.Attr {
- if a.Name.Local == "" || skipped[a.Name.Local] || a.Name.Space == "xmlns" || a.Name.Local == "xmlns" {
- continue
- }
- if a.Name.Local == "stroke-width" && strokeScale != 1 {
- if v, err := strconv.ParseFloat(a.Value, 64); err == nil {
- fmt.Fprintf(out, ` stroke-width=%q`, strconv.FormatFloat(v*strokeScale, 'g', -1, 64))
- continue
- }
- }
- fmt.Fprintf(out, ` %s=%q`, a.Name.Local, a.Value)
- }
- if style != "" {
- fmt.Fprintf(out, ` style=%q`, style)
- }
-}
-
-func parseViewBox(s string) (vb [4]float64) {
- fields := strings.FieldsFunc(s, func(r rune) bool { return r == ' ' || r == ',' || r == '\t' })
- for i := 0; i < 4 && i < len(fields); i++ {
- vb[i], _ = strconv.ParseFloat(fields[i], 64)
- }
- return vb
-}
-
-func attrVal(se xml.StartElement, local string) string {
- for _, a := range se.Attr {
- if a.Name.Local == local {
- return a.Value
- }
- }
- return ""
-}
-
-func hasClass(classes, want string) bool {
- for c := range strings.FieldsSeq(classes) {
- if c == want {
- return true
- }
- }
- return false
-}
diff --git a/pkg/s100/symbols/symbols_test.go b/pkg/s100/symbols/symbols_test.go
deleted file mode 100644
index 860c7e1..0000000
--- a/pkg/s100/symbols/symbols_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package symbols
-
-import "testing"
-
-// TestRenderEvenOddHole guards the fill-rule="evenodd" handling. Every S-101
-// symbol is authored with even-odd winding; the ISODGR01/DANGER0x danger glyphs
-// carve their inner shape out of the outer body with a single compound path
-// whose inner subpath is a hole. oksvg ignores fill-rule and fills nonzero,
-// which would fill that hole solid (the "ISODGR01 with no star in it" bug).
-func TestRenderEvenOddHole(t *testing.T) {
- // Outer 8x8 square with a concentric 4x4 inner subpath, both wound the same
- // way. Under nonzero winding the inner square fills solid; under even-odd it
- // is a hole. fill-rule="evenodd" sits on the root exactly as the
- // catalogue authors it.
- svg := []byte(`
-
-
- `)
- css := map[string]string{"fX": "fill:#C045D1"}
-
- const pxPerMM = 10
- r, err := Render(svg, css, pxPerMM)
- if err != nil {
- t.Fatalf("Render: %v", err)
- }
-
- // Pivot is the SVG origin (0,0); the hole is centred there.
- cx, cy := int(r.PivotX), int(r.PivotY)
- if _, _, _, a := r.Image.At(cx, cy).RGBA(); a != 0 {
- t.Errorf("centre pixel (%d,%d) alpha=%d, want 0 (even-odd hole filled solid — fill-rule ignored)", cx, cy, a>>8)
- }
-
- // A pixel in the ring between the two squares must be painted.
- rx, ry := int(r.PivotX+3*pxPerMM), cy // svg (3,0)
- if _, _, _, a := r.Image.At(rx, ry).RGBA(); a == 0 {
- t.Errorf("ring pixel (%d,%d) is transparent, want painted", rx, ry)
- }
-}
diff --git a/pkg/s57/attributes.go b/pkg/s57/attributes.go
deleted file mode 100644
index f97d474..0000000
--- a/pkg/s57/attributes.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package s57
-
-// AttributeSchema defines essential and conditional attributes for an S-57 object class.
-// This is based on the S-57 Object Catalogue and common cartographic practice.
-type AttributeSchema struct {
- // ObjectClass is the S-57 object class code (e.g., "DEPARE", "LIGHTS")
- ObjectClass string
-
- // Essential attributes that should always be encoded/displayed
- Essential []string
-
- // Conditional attributes that should be encoded if present
- Conditional map[string]bool
-
- // ArrayAttributes that contain multiple values (need special encoding)
- ArrayAttributes []string
-}
-
-// GetAttributeSchema returns the attribute schema for a given S-57 object class.
-// Returns nil if no schema is defined for the object class.
-//
-// These schemas define which attributes are cartographically significant for
-// each object class, based on S-57 Object Catalogue and IHO S-52 presentation rules.
-func GetAttributeSchema(objectClass string) *AttributeSchema {
- schema, exists := attributeSchemas[objectClass]
- if !exists {
- return nil
- }
- return &schema
-}
-
-// GetEssentialAttributes returns the list of essential attributes for an object class.
-// If no schema exists, returns a default set of common navigation attributes.
-func GetEssentialAttributes(objectClass string) []string {
- schema := GetAttributeSchema(objectClass)
- if schema == nil {
- // Default essential attributes for unknown object classes
- return []string{"OBJL", "SCAMIN"}
- }
- return schema.Essential
-}
-
-// IsArrayAttribute checks if an attribute is known to contain array values.
-func IsArrayAttribute(objectClass string, attributeName string) bool {
- schema := GetAttributeSchema(objectClass)
- if schema == nil {
- return false
- }
-
- for _, attr := range schema.ArrayAttributes {
- if attr == attributeName {
- return true
- }
- }
- return false
-}
-
-// attributeSchemas maps S-57 object classes to their attribute schemas.
-// Based on IHO S-57 Object Catalogue Edition 3.1 and S-52 presentation rules.
-var attributeSchemas = map[string]AttributeSchema{
- "DEPARE": {
- ObjectClass: "DEPARE",
- Essential: []string{"DRVAL1", "DRVAL2"},
- Conditional: map[string]bool{"QUASOU": true, "TECSOU": true},
- ArrayAttributes: []string{},
- },
- "DRGARE": {
- ObjectClass: "DRGARE",
- Essential: []string{"DRVAL1", "DRVAL2"},
- Conditional: map[string]bool{},
- ArrayAttributes: []string{},
- },
- "SOUNDG": {
- ObjectClass: "SOUNDG",
- Essential: []string{"OBJL"},
- Conditional: map[string]bool{"QUASOU": true, "TECSOU": true, "STATUS": true},
- ArrayAttributes: []string{"DEPTHS"},
- },
- "DEPCNT": {
- ObjectClass: "DEPCNT",
- Essential: []string{"VALDCO"},
- Conditional: map[string]bool{},
- ArrayAttributes: []string{},
- },
- "BOYLAT": {
- ObjectClass: "BOYLAT",
- Essential: []string{"BOYSHP", "COLOUR", "COLPAT"},
- Conditional: map[string]bool{"CATLIT": true, "MARSYS": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "BOYSAW": {
- ObjectClass: "BOYSAW",
- Essential: []string{"BOYSHP", "COLOUR"},
- Conditional: map[string]bool{"COLPAT": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "BOYCAR": {
- ObjectClass: "BOYCAR",
- Essential: []string{"BOYSHP", "COLOUR", "COLPAT"},
- Conditional: map[string]bool{"CATCAM": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "BOYISD": {
- ObjectClass: "BOYISD",
- Essential: []string{"BOYSHP", "COLOUR"},
- Conditional: map[string]bool{"COLPAT": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "BOYSPP": {
- ObjectClass: "BOYSPP",
- Essential: []string{"BOYSHP", "COLOUR"},
- Conditional: map[string]bool{"COLPAT": true, "CATSPM": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "BCNLAT": {
- ObjectClass: "BCNLAT",
- Essential: []string{"BCNSHP", "COLOUR"},
- Conditional: map[string]bool{"COLPAT": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "BCNCAR": {
- ObjectClass: "BCNCAR",
- Essential: []string{"BCNSHP", "COLOUR", "COLPAT"},
- Conditional: map[string]bool{"CATCAM": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "BCNSAW": {
- ObjectClass: "BCNSAW",
- Essential: []string{"BCNSHP", "COLOUR"},
- Conditional: map[string]bool{"COLPAT": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "BCNISD": {
- ObjectClass: "BCNISD",
- Essential: []string{"BCNSHP", "COLOUR"},
- Conditional: map[string]bool{"COLPAT": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "BCNSPP": {
- ObjectClass: "BCNSPP",
- Essential: []string{"BCNSHP", "COLOUR"},
- Conditional: map[string]bool{"COLPAT": true, "CATSPM": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "LIGHTS": {
- ObjectClass: "LIGHTS",
- Essential: []string{"CATLIT", "COLOUR"},
- Conditional: map[string]bool{"LITCHR": true, "HEIGHT": true, "VALNMR": true, "SIGPER": true},
- ArrayAttributes: []string{"COLOUR"},
- },
- "LNDARE": {
- ObjectClass: "LNDARE",
- Essential: []string{"OBJL"},
- Conditional: map[string]bool{"CATLND": true},
- ArrayAttributes: []string{},
- },
- "OBSTRN": {
- ObjectClass: "OBSTRN",
- Essential: []string{"CATOBS", "VALSOU"},
- Conditional: map[string]bool{"WATLEV": true, "QUASOU": true},
- ArrayAttributes: []string{},
- },
- "UWTROC": {
- ObjectClass: "UWTROC",
- Essential: []string{"VALSOU", "WATLEV"},
- Conditional: map[string]bool{"QUASOU": true},
- ArrayAttributes: []string{},
- },
- "WRECKS": {
- ObjectClass: "WRECKS",
- Essential: []string{"CATWRK", "VALSOU"},
- Conditional: map[string]bool{"WATLEV": true, "QUASOU": true},
- ArrayAttributes: []string{},
- },
- "RESARE": {
- ObjectClass: "RESARE",
- Essential: []string{"CATREA"},
- Conditional: map[string]bool{"RESTRN": true},
- ArrayAttributes: []string{"RESTRN"},
- },
- "ACHARE": {
- ObjectClass: "ACHARE",
- Essential: []string{"CATACH"},
- Conditional: map[string]bool{},
- ArrayAttributes: []string{},
- },
- "CBLARE": {
- ObjectClass: "CBLARE",
- Essential: []string{"CATCBL"},
- Conditional: map[string]bool{"RESTRN": true},
- ArrayAttributes: []string{},
- },
- "PIPARE": {
- ObjectClass: "PIPARE",
- Essential: []string{"CATPIP"},
- Conditional: map[string]bool{"PRODCT": true},
- ArrayAttributes: []string{"PRODCT"},
- },
- "TOPMAR": {
- ObjectClass: "TOPMAR",
- Essential: []string{"TOPSHP"},
- Conditional: map[string]bool{"COLOUR": true},
- ArrayAttributes: []string{"COLOUR"},
- },
-}
diff --git a/pkg/s57/attributes_test.go b/pkg/s57/attributes_test.go
deleted file mode 100644
index 80e13ec..0000000
--- a/pkg/s57/attributes_test.go
+++ /dev/null
@@ -1,152 +0,0 @@
-package s57
-
-import (
- "testing"
-)
-
-func TestGetAttributeSchema(t *testing.T) {
- tests := []struct {
- name string
- objectClass string
- wantNil bool
- checkAttrs []string
- }{
- {
- name: "DEPARE - depth area",
- objectClass: "DEPARE",
- wantNil: false,
- checkAttrs: []string{"DRVAL1", "DRVAL2"},
- },
- {
- name: "LIGHTS - navigation lights",
- objectClass: "LIGHTS",
- wantNil: false,
- checkAttrs: []string{"CATLIT", "COLOUR"},
- },
- {
- name: "Unknown object class",
- objectClass: "UNKNOWN",
- wantNil: true,
- checkAttrs: []string{},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- schema := GetAttributeSchema(tt.objectClass)
-
- if tt.wantNil {
- if schema != nil {
- t.Errorf("Expected nil schema for %s", tt.objectClass)
- }
- return
- }
-
- if schema == nil {
- t.Fatalf("Expected schema for %s, got nil", tt.objectClass)
- }
-
- // Check essential attributes are present
- essentialMap := make(map[string]bool)
- for _, attr := range schema.Essential {
- essentialMap[attr] = true
- }
-
- for _, checkAttr := range tt.checkAttrs {
- if !essentialMap[checkAttr] {
- t.Errorf("Expected %s in essential attributes, not found", checkAttr)
- }
- }
-
- t.Logf("%s schema: %d essential, %d conditional, %d array",
- tt.objectClass,
- len(schema.Essential),
- len(schema.Conditional),
- len(schema.ArrayAttributes))
- })
- }
-}
-
-func TestGetEssentialAttributes(t *testing.T) {
- tests := []struct {
- name string
- objectClass string
- wantContains []string
- }{
- {
- name: "DEPARE",
- objectClass: "DEPARE",
- wantContains: []string{"DRVAL1", "DRVAL2"},
- },
- {
- name: "Unknown - returns defaults",
- objectClass: "UNKNOWN",
- wantContains: []string{"OBJL"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- attrs := GetEssentialAttributes(tt.objectClass)
-
- if len(attrs) == 0 {
- t.Error("Expected non-empty attribute list")
- }
-
- attrMap := make(map[string]bool)
- for _, attr := range attrs {
- attrMap[attr] = true
- }
-
- for _, want := range tt.wantContains {
- if !attrMap[want] {
- t.Errorf("Expected %s in essential attributes", want)
- }
- }
- })
- }
-}
-
-func TestIsArrayAttribute(t *testing.T) {
- tests := []struct {
- name string
- objectClass string
- attributeName string
- want bool
- }{
- {
- name: "SOUNDG DEPTHS - is array",
- objectClass: "SOUNDG",
- attributeName: "DEPTHS",
- want: true,
- },
- {
- name: "BOYLAT COLOUR - is array",
- objectClass: "BOYLAT",
- attributeName: "COLOUR",
- want: true,
- },
- {
- name: "DEPARE DRVAL1 - not array",
- objectClass: "DEPARE",
- attributeName: "DRVAL1",
- want: false,
- },
- {
- name: "Unknown object class",
- objectClass: "UNKNOWN",
- attributeName: "ANYTHING",
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := IsArrayAttribute(tt.objectClass, tt.attributeName)
- if got != tt.want {
- t.Errorf("IsArrayAttribute(%s, %s) = %v, want %v",
- tt.objectClass, tt.attributeName, got, tt.want)
- }
- })
- }
-}
diff --git a/pkg/s57/catalog.go b/pkg/s57/catalog.go
deleted file mode 100644
index a2f9b9e..0000000
--- a/pkg/s57/catalog.go
+++ /dev/null
@@ -1,210 +0,0 @@
-package s57
-
-import (
- "bytes"
- "io/fs"
- "path"
- "strconv"
- "strings"
-
- "github.com/beetlebugorg/chartplotter/pkg/iso8211"
-)
-
-// Catalog is the parsed contents of an S-57 exchange-set catalogue file
-// (CATALOG.031): the directory of every file in the set, with each cell's
-// long name and geographic coverage. It is the cheap way to learn a set's
-// inventory and per-cell bounding boxes WITHOUT parsing every .000 cell.
-type Catalog struct {
- Entries []CatalogEntry
-}
-
-// CatalogEntry is one CATD (catalogue directory) record. S-57 Appendix B.1: the
-// CATD field lists RCNM, RCID, FILE, LFIL, VOLM, IMPL, SLAT, WLON, NLAT, ELON,
-// CRCS, COMT. The catalogue is always ASCII (it must be readable without the
-// DDR), so subfields are unit-terminator (0x1f) delimited, except the fixed-width
-// RCNM(2)+RCID and IMPL(3) prefixes which run straight into the following subfield.
-type CatalogEntry struct {
- File string // path as recorded (e.g. "US5MD1MC\\US5MD1MC.000")
- LongName string // LFIL — the human chart title (e.g. "Annapolis Harbor")
- Impl string // "BIN" (a cell), "ASC", or "TXT" (auxiliary text)
- CRC string // CRCS — the file's CRC (hex), if present
- Comment string // COMT
- HasBBox bool // true when SLAT/WLON/NLAT/ELON were all present
- West float64 // WLON
- South float64 // SLAT
- East float64 // ELON
- North float64 // NLAT
-}
-
-// Base returns the file's basename with the path separators normalised
-// (NOAA records "US5MD1MC\\US5MD1MC.000" with a backslash).
-func (e CatalogEntry) Base() string {
- f := strings.ReplaceAll(e.File, "\\", "/")
- return path.Base(f)
-}
-
-// IsCell reports whether this entry is a base ENC cell (a BIN .000 file) — the
-// rows worth baking, as opposed to updates, text descriptions, or the catalogue
-// itself.
-func (e CatalogEntry) IsCell() bool {
- return e.Impl == "BIN" && strings.HasSuffix(strings.ToUpper(e.Base()), ".000")
-}
-
-// CellStem returns the cell name without extension (e.g. "US5MD1MC") for a cell
-// entry, or "" if this entry is not a .000 cell.
-func (e CatalogEntry) CellStem() string {
- if !e.IsCell() {
- return ""
- }
- b := e.Base()
- return b[:len(b)-len(path.Ext(b))]
-}
-
-// Cells returns just the base-cell entries (BIN .000), the inventory to bake.
-func (c *Catalog) Cells() []CatalogEntry {
- out := make([]CatalogEntry, 0, len(c.Entries))
- for _, e := range c.Entries {
- if e.IsCell() {
- out = append(out, e)
- }
- }
- return out
-}
-
-// Bounds returns the union bounding box [west, south, east, north] of every
-// cell entry that carries coverage, and false if none did.
-func (c *Catalog) Bounds() (bb [4]float64, ok bool) {
- first := true
- for _, e := range c.Entries {
- if !e.HasBBox {
- continue
- }
- if first {
- bb = [4]float64{e.West, e.South, e.East, e.North}
- first = false
- ok = true
- continue
- }
- bb[0] = min(bb[0], e.West)
- bb[1] = min(bb[1], e.South)
- bb[2] = max(bb[2], e.East)
- bb[3] = max(bb[3], e.North)
- }
- return bb, ok
-}
-
-// ParseCatalog parses a CATALOG.031 exchange-set catalogue from raw bytes.
-func ParseCatalog(data []byte) (*Catalog, error) {
- return parseCatalogISO(iso8211.MemFS{"/CATALOG.031": data}, "/CATALOG.031")
-}
-
-// ParseCatalogFS parses a CATALOG.031 from a filesystem (e.g. an unzipped
-// ENC_ROOT or os.DirFS), matching ParseFS for cells.
-func ParseCatalogFS(fsys fs.FS, filename string) (*Catalog, error) {
- return parseCatalogISO(fsys, filename)
-}
-
-func parseCatalogISO(fsys fs.FS, filename string) (*Catalog, error) {
- p, err := iso8211.OpenFS(fsys, filename)
- if err != nil {
- return nil, err
- }
- defer p.Close()
- f, err := p.Parse()
- if err != nil {
- return nil, err
- }
- cat := &Catalog{}
- for _, rec := range f.Records {
- raw, ok := rec.Fields["CATD"]
- if !ok {
- continue
- }
- if e, ok := decodeCATD(raw); ok {
- cat.Entries = append(cat.Entries, e)
- }
- }
- return cat, nil
-}
-
-// decodeCATD splits one CATD field into a CatalogEntry. Layout (NOAA/IHO ASCII
-// catalogue, verified against a real NOAA CATALOG.031):
-//
-// [0] RCNM(2) + RCID(digits) + FILE [1] LFIL [2] VOLM
-// [3] IMPL(3) + SLAT [4] WLON [5] NLAT [6] ELON [7] CRCS [8] COMT
-//
-// The bbox subfields are blank for non-cell files (TXT/ASC), so HasBBox gates
-// on all four parsing.
-func decodeCATD(raw []byte) (CatalogEntry, bool) {
- // Trim the trailing field terminator (0x1e) the record carries.
- raw = bytes.TrimRight(raw, "\x1e")
- parts := strings.Split(string(raw), "\x1f")
- if len(parts) < 4 {
- return CatalogEntry{}, false
- }
- var e CatalogEntry
-
- // [0]: RCNM (2 chars) + RCID (run of digits) + FILE (remainder).
- head := parts[0]
- if len(head) < 2 {
- return CatalogEntry{}, false
- }
- rest := head[2:] // drop RCNM ("CD")
- i := 0
- for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' {
- i++ // consume RCID digits
- }
- e.File = rest[i:]
- if e.File == "" {
- return CatalogEntry{}, false
- }
-
- e.LongName = parts[1]
- // parts[2] is VOLM — not retained.
-
- // [3]: IMPL (3 chars: BIN/ASC/TXT) + SLAT (remainder, may be empty).
- if len(parts) > 3 {
- impl := parts[3]
- if len(impl) >= 3 {
- e.Impl = impl[:3]
- slat := impl[3:]
- wlon := field(parts, 4)
- nlat := field(parts, 5)
- elon := field(parts, 6)
- if s, okS := parseFloat(slat); okS {
- if w, okW := parseFloat(wlon); okW {
- if n, okN := parseFloat(nlat); okN {
- if ea, okE := parseFloat(elon); okE {
- e.South, e.West, e.North, e.East = s, w, n, ea
- e.HasBBox = true
- }
- }
- }
- }
- } else {
- e.Impl = impl
- }
- }
- e.CRC = field(parts, 7)
- e.Comment = field(parts, 8)
- return e, true
-}
-
-func field(parts []string, i int) string {
- if i < len(parts) {
- return parts[i]
- }
- return ""
-}
-
-func parseFloat(s string) (float64, bool) {
- s = strings.TrimSpace(s)
- if s == "" {
- return 0, false
- }
- v, err := strconv.ParseFloat(s, 64)
- if err != nil {
- return 0, false
- }
- return v, true
-}
diff --git a/pkg/s57/catalog_test.go b/pkg/s57/catalog_test.go
deleted file mode 100644
index 37c9026..0000000
--- a/pkg/s57/catalog_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package s57
-
-import (
- "os"
- "testing"
-)
-
-func TestParseCatalog_NOAA(t *testing.T) {
- data, err := os.ReadFile("testdata/US5MD1MC_CATALOG.031")
- if err != nil {
- t.Fatal(err)
- }
- cat, err := ParseCatalog(data)
- if err != nil {
- t.Fatal(err)
- }
-
- // The fixture is a single-cell NOAA exchange set: the catalogue itself,
- // several .TXT descriptions, and one .000 base cell.
- cells := cat.Cells()
- if len(cells) != 1 {
- t.Fatalf("want 1 base cell, got %d (%d total entries)", len(cells), len(cat.Entries))
- }
- c := cells[0]
- if c.CellStem() != "US5MD1MC" {
- t.Errorf("CellStem = %q, want US5MD1MC", c.CellStem())
- }
- if c.LongName != "Annapolis Harbor" {
- t.Errorf("LongName = %q, want %q", c.LongName, "Annapolis Harbor")
- }
- if c.Impl != "BIN" {
- t.Errorf("Impl = %q, want BIN", c.Impl)
- }
- if !c.HasBBox {
- t.Fatal("cell entry should carry a bbox")
- }
- // From the CATD record: SLAT 38.925, WLON -76.5, NLAT 39.0, ELON -76.425.
- wantBox := []struct {
- name string
- got float64
- want float64
- }{
- {"South", c.South, 38.925000},
- {"West", c.West, -76.500000},
- {"North", c.North, 39.000000},
- {"East", c.East, -76.425000},
- }
- for _, b := range wantBox {
- if d := b.got - b.want; d > 1e-6 || d < -1e-6 {
- t.Errorf("%s = %f, want %f", b.name, b.got, b.want)
- }
- }
- if c.CRC == "" {
- t.Error("expected a CRCS value on the cell entry")
- }
-
- // The auxiliary text descriptions are present but are NOT cells.
- var txt int
- for _, e := range cat.Entries {
- if e.Impl == "TXT" {
- txt++
- if e.HasBBox {
- t.Errorf("TXT entry %s should have no bbox", e.Base())
- }
- }
- }
- if txt == 0 {
- t.Error("expected at least one TXT auxiliary entry")
- }
-}
-
-func TestCatalogBounds(t *testing.T) {
- data, err := os.ReadFile("testdata/US5MD1MC_CATALOG.031")
- if err != nil {
- t.Fatal(err)
- }
- cat, err := ParseCatalog(data)
- if err != nil {
- t.Fatal(err)
- }
- bb, ok := cat.Bounds()
- if !ok {
- t.Fatal("expected a union bbox")
- }
- // Single cell → union equals that cell.
- if bb != [4]float64{-76.5, 38.925, -76.425, 39.0} {
- t.Errorf("Bounds = %v", bb)
- }
-}
diff --git a/pkg/s57/chart.go b/pkg/s57/chart.go
deleted file mode 100644
index 1bc8a4b..0000000
--- a/pkg/s57/chart.go
+++ /dev/null
@@ -1,754 +0,0 @@
-package s57
-
-import (
- "github.com/beetlebugorg/chartplotter/internal/s57/parser"
- "github.com/dhconnelly/rtreego"
-)
-
-// Chart represents a parsed S-57 Electronic Navigational Chart.
-//
-// A chart contains metadata (cell name, edition, dates, etc.) and a collection
-// of navigational features (depth contours, buoys, lights, hazards, etc.).
-//
-// Access metadata via methods like DatasetName(), Edition(), IssueDate().
-// Access features via Features(), FeaturesInBounds(), or FeatureCount().
-//
-// All fields are private to maintain encapsulation.
-type Chart struct {
- features []Feature // All features
- spatialIndex *spatialIndex // Fast spatial queries
- bounds Bounds // Chart coverage area
-
- datasetName string
- edition string
- updateNumber string
- updateDate string
- issueDate string
- s57Edition string
- producingAgency int
- comment string
- exchangePurpose string
- productSpec string
- applicationProfile string
- usageBand UsageBand
-
- // Coordinate system metadata (S-57 §7.3.2, 31Main.pdf p66)
- coordinateUnits CoordinateUnits // COUN field from DSPM record
- depthUnits DepthUnits // DUNI field from DSPM record
- horizontalDatum int // HDAT field from DSPM record
- compilationScale int32 // CSCL field from DSPM record
-
- warnings []ConformanceWarning // Non-fatal S-57/ISO-8211 spec deviations
-}
-
-// ConformanceWarning is one non-fatal S-57 / ISO-8211 spec deviation detected
-// while parsing. See Chart.Warnings.
-type ConformanceWarning struct {
- Clause string // governing spec clause
- Code string // short stable identifier
- Message string // human-readable detail
- Count int // times this code was hit during the parse
-}
-
-// Warnings returns the non-fatal spec-conformance deviations detected while
-// parsing this chart. Empty when the cell is fully conformant. Use
-// ParseOptions.ValidateConformance to turn these into a parse error instead.
-func (c *Chart) Warnings() []ConformanceWarning {
- return c.warnings
-}
-
-// CoordinateUnits indicates how coordinates are encoded in the chart.
-//
-// S-57 §7.3.2.1 (31Main.pdf p66): COUN field in DSPM record defines coordinate units.
-// Reference: S-57 Part 3 Table 3.2
-type CoordinateUnits int
-
-const (
- // CoordinateUnitsLatLon indicates coordinates are in latitude/longitude (WGS-84).
- // This is the most common format for ENC charts.
- // Coordinates are decimal degrees, typically scaled by 10^7.
- CoordinateUnitsLatLon CoordinateUnits = 1
-
- // CoordinateUnitsEastNorth indicates coordinates are in projected Easting/Northing.
- // Less common; requires DSPR record to specify projection parameters.
- CoordinateUnitsEastNorth CoordinateUnits = 2
-
- // CoordinateUnitsUnknown indicates coordinate units are not specified.
- // Treat as lat/lon by default (S-57 default assumption).
- CoordinateUnitsUnknown CoordinateUnits = 0
-)
-
-// String returns a human-readable name for the coordinate units.
-func (c CoordinateUnits) String() string {
- switch c {
- case CoordinateUnitsLatLon:
- return "Latitude/Longitude (WGS-84)"
- case CoordinateUnitsEastNorth:
- return "Easting/Northing (Projected)"
- default:
- return "Unknown"
- }
-}
-
-// DepthUnits indicates the units used for depth measurements in the chart.
-//
-// S-57 §7.3.2.1 (31Main.pdf p66): DUNI field in DSPM record defines depth units.
-type DepthUnits int
-
-const (
- // DepthUnitsMeters indicates depths are in meters.
- // This is the standard S-57 depth unit.
- DepthUnitsMeters DepthUnits = 1
-
- // DepthUnitsFathomsAndFeet indicates depths are in fathoms and feet.
- // Whole fathoms for depths >= 11 fathoms, fathoms and feet for shallower.
- DepthUnitsFathomsAndFeet DepthUnits = 2
-
- // DepthUnitsFeet indicates depths are in feet.
- // Common for US NOAA charts.
- DepthUnitsFeet DepthUnits = 3
-
- // DepthUnitsFathomsAndFractions indicates depths are in fathoms and fractions.
- DepthUnitsFathomsAndFractions DepthUnits = 4
-
- // DepthUnitsUnknown indicates depth units are not specified.
- // Assume meters (S-57 default).
- DepthUnitsUnknown DepthUnits = 0
-)
-
-// String returns a human-readable name for the depth units.
-func (d DepthUnits) String() string {
- switch d {
- case DepthUnitsMeters:
- return "Meters"
- case DepthUnitsFathomsAndFeet:
- return "Fathoms and Feet"
- case DepthUnitsFeet:
- return "Feet"
- case DepthUnitsFathomsAndFractions:
- return "Fathoms and Fractions"
- default:
- return "Unknown (assuming Meters)"
- }
-}
-
-// UsageBand defines the ENC usage band (navigational purpose) of the chart.
-//
-// ENC cells are organized by usage band, which determines the level of detail
-// and appropriate display scale. Applications should load the appropriate band
-// based on the current zoom level.
-//
-// Reference: S-57 Part 3 §7.3.1.1 (31Main.pdf p64, INTU field) and S-52 Section 3.4
-type UsageBand int
-
-const (
- // UsageBandUnknown indicates the band is not specified.
- UsageBandUnknown UsageBand = 0
-
- // UsageBandOverview - For overview navigation (≥ 1:1,500,000).
- // Provides general context and route planning.
- UsageBandOverview UsageBand = 1
-
- // UsageBandGeneral - For general navigation (1:350,000 - 1:1,500,000).
- // Used for open ocean and offshore navigation.
- UsageBandGeneral UsageBand = 2
-
- // UsageBandCoastal - For coastal navigation (1:90,000 - 1:350,000).
- // Used for navigation along coastlines and approaching ports.
- UsageBandCoastal UsageBand = 3
-
- // UsageBandApproach - For approach navigation (1:22,000 - 1:90,000).
- // Used when approaching ports, harbours, and pilot stations.
- UsageBandApproach UsageBand = 4
-
- // UsageBandHarbour - For harbour navigation (1:4,000 - 1:22,000).
- // Used for navigation within harbours and restricted waters.
- UsageBandHarbour UsageBand = 5
-
- // UsageBandBerthing - For berthing (≤ 1:4,000).
- // Used for final approach to berth and detailed harbour navigation.
- UsageBandBerthing UsageBand = 6
-)
-
-// String returns the human-readable name of the usage band.
-func (ub UsageBand) String() string {
- switch ub {
- case UsageBandOverview:
- return "Overview"
- case UsageBandGeneral:
- return "General"
- case UsageBandCoastal:
- return "Coastal"
- case UsageBandApproach:
- return "Approach"
- case UsageBandHarbour:
- return "Harbour"
- case UsageBandBerthing:
- return "Berthing"
- default:
- return "Unknown"
- }
-}
-
-// ScaleRange returns the recommended scale range for this usage band.
-//
-// Returns (minScale, maxScale) where scales are denominators (e.g., 1:90000 returns 90000).
-// For overview and berthing (open-ended ranges), one value may be 0.
-func (ub UsageBand) ScaleRange() (min, max int) {
- switch ub {
- case UsageBandOverview:
- return 1500000, 0 // 1:1,500,000 and smaller (larger denominators)
- case UsageBandGeneral:
- return 350000, 1500000
- case UsageBandCoastal:
- return 90000, 350000
- case UsageBandApproach:
- return 22000, 90000
- case UsageBandHarbour:
- return 4000, 22000
- case UsageBandBerthing:
- return 0, 4000 // 1:4,000 and larger (smaller denominators)
- default:
- return 0, 0
- }
-}
-
-// spatialIndex provides O(log n) spatial queries using R-tree.
-// Dramatically faster than linear O(n) scan for large charts.
-type spatialIndex struct {
- rtree *rtreego.Rtree // R-tree for fast spatial queries
-}
-
-// indexedFeature wraps a feature for R-tree storage.
-type indexedFeature struct {
- feature Feature
- bounds Bounds
-}
-
-// Bounds implements rtreego.Spatial interface.
-func (f *indexedFeature) Bounds() rtreego.Rect {
- point := rtreego.Point{f.bounds.MinLon, f.bounds.MinLat}
-
- // Calculate lengths, ensuring minimum size for point features
- // R-tree requires non-zero dimensions
- lonLength := f.bounds.MaxLon - f.bounds.MinLon
- latLength := f.bounds.MaxLat - f.bounds.MinLat
-
- // For point features (zero-area), use small epsilon (~11 meters at equator)
- const epsilon = 0.0001
- if lonLength < epsilon {
- lonLength = epsilon
- }
- if latLength < epsilon {
- latLength = epsilon
- }
-
- lengths := []float64{lonLength, latLength}
- rect, _ := rtreego.NewRect(point, lengths)
- return rect
-}
-
-// Features returns all features in the chart.
-//
-// Features include depth contours, buoys, lights, hazards, restricted areas,
-// and all other navigational objects defined in the S-57 Object Catalogue.
-//
-// Each feature contains ObjectClass, Attributes, and Geometry needed for
-// S-52 presentation library symbology lookup and rendering.
-func (c *Chart) Features() []Feature {
- return c.features
-}
-
-// FeatureCount returns the number of features in the chart.
-func (c *Chart) FeatureCount() int {
- return len(c.features)
-}
-
-// Bounds returns the geographic coverage area of the chart.
-//
-// This represents the minimum bounding box containing all features.
-func (c *Chart) Bounds() Bounds {
- return c.bounds
-}
-
-// FeaturesInBounds returns all features that intersect the given bounding box.
-//
-// This is the primary method for viewport-based rendering. Only features that
-// could be visible in the viewport are returned.
-//
-// Example:
-//
-// viewport := s57.Bounds{
-// MinLon: -71.5, MaxLon: -71.0,
-// MinLat: 42.0, MaxLat: 42.5,
-// }
-// visibleFeatures := chart.FeaturesInBounds(viewport)
-// for _, feature := range visibleFeatures {
-// render(feature)
-// }
-func (c *Chart) FeaturesInBounds(bounds Bounds) []Feature {
- if c.spatialIndex == nil || c.spatialIndex.rtree == nil {
- // No spatial index, fallback to linear search
- return c.featuresInBoundsLinear(bounds)
- }
-
- // Query R-tree: O(log n) instead of O(n)
- point := rtreego.Point{bounds.MinLon, bounds.MinLat}
- lengths := []float64{
- bounds.MaxLon - bounds.MinLon,
- bounds.MaxLat - bounds.MinLat,
- }
- queryRect, _ := rtreego.NewRect(point, lengths)
-
- // Search R-tree for intersecting features
- spatials := c.spatialIndex.rtree.SearchIntersect(queryRect)
-
- // Extract features from indexed wrappers
- result := make([]Feature, 0, len(spatials))
- for _, spatial := range spatials {
- indexed := spatial.(*indexedFeature)
- result = append(result, indexed.feature)
- }
-
- return result
-}
-
-// featuresInBoundsLinear performs linear search when no spatial index exists.
-func (c *Chart) featuresInBoundsLinear(bounds Bounds) []Feature {
- result := make([]Feature, 0, len(c.features)/10)
- for _, feature := range c.features {
- fb := featureBounds(feature)
- if bounds.Intersects(fb) {
- result = append(result, feature)
- }
- }
- return result
-}
-
-// DatasetName returns the chart's dataset name (cell identifier).
-//
-// Example: "US5MA22M", "GB5X01NE"
-func (c *Chart) DatasetName() string { return c.datasetName }
-
-// Edition returns the chart's edition number.
-func (c *Chart) Edition() string { return c.edition }
-
-// UpdateNumber returns the chart's update number.
-//
-// "0" indicates a base cell, higher numbers indicate applied updates.
-func (c *Chart) UpdateNumber() string { return c.updateNumber }
-
-// UpdateDate returns the update application date in YYYYMMDD format.
-//
-// All updates dated on or before this date must be applied for current data.
-func (c *Chart) UpdateDate() string { return c.updateDate }
-
-// IssueDate returns the chart issue date in YYYYMMDD format.
-//
-// This is when the dataset was released by the producing agency.
-func (c *Chart) IssueDate() string { return c.issueDate }
-
-// S57Edition returns the S-57 standard edition used.
-//
-// Example: "03.1" for S-57 Edition 3.1
-func (c *Chart) S57Edition() string { return c.s57Edition }
-
-// ProducingAgency returns the producing agency code.
-//
-// Example: 550 = NOAA (United States)
-//
-// Full agency list available in IHO S-57 Appendix A.
-func (c *Chart) ProducingAgency() int { return c.producingAgency }
-
-// Comment returns the metadata comment field.
-func (c *Chart) Comment() string { return c.comment }
-
-// ExchangePurpose returns human-readable exchange purpose.
-//
-// Returns "New" for new datasets or "Revision" for updates.
-func (c *Chart) ExchangePurpose() string { return c.exchangePurpose }
-
-// ProductSpecification returns human-readable product specification.
-//
-// Typically "ENC" for Electronic Navigational Charts.
-func (c *Chart) ProductSpecification() string { return c.productSpec }
-
-// ApplicationProfile returns human-readable application profile.
-//
-// Examples: "EN (ENC New)", "ER (ENC Revision)"
-func (c *Chart) ApplicationProfile() string { return c.applicationProfile }
-
-// UsageBand returns the ENC usage band of this chart.
-//
-// This indicates the intended usage and appropriate scale range:
-// - Overview: ≥1:1,500,000 (route planning)
-// - General: 1:350,000-1:1,500,000 (open ocean)
-// - Coastal: 1:90,000-1:350,000 (coastal navigation)
-// - Approach: 1:22,000-1:90,000 (approaching ports)
-// - Harbour: 1:4,000-1:22,000 (harbour navigation)
-// - Berthing: ≤1:4,000 (final approach)
-//
-// Applications should load the appropriate band based on zoom level.
-func (c *Chart) UsageBand() UsageBand { return c.usageBand }
-
-// CoordinateUnits returns the coordinate system used in the chart.
-//
-// Most ENC charts use CoordinateUnitsLatLon (lat/lon in WGS-84).
-// Some charts may use CoordinateUnitsEastNorth for projected coordinates.
-//
-// S-57 §7.3.2.1 (31Main.pdf p66): COUN field in DSPM record.
-func (c *Chart) CoordinateUnits() CoordinateUnits { return c.coordinateUnits }
-
-// HorizontalDatum returns the horizontal geodetic datum code.
-//
-// Common values:
-// - 2: WGS-84 (most common for modern ENCs)
-// - Other values defined in S-57 Part 3 Table 3.1
-//
-// S-57 §7.3.2.1 (31Main.pdf p66): HDAT field in DSPM record.
-func (c *Chart) HorizontalDatum() int { return c.horizontalDatum }
-
-// CompilationScale returns the compilation scale denominator of the chart.
-//
-// For example, a value of 50000 indicates the chart was compiled at 1:50,000 scale.
-// This helps determine appropriate display scales and SCAMIN filtering.
-//
-// S-57 §7.3.2.1 (31Main.pdf p66): CSCL field in DSPM record.
-// Returns 0 if not specified.
-func (c *Chart) CompilationScale() int32 { return c.compilationScale }
-
-// DepthUnits returns the depth units used in this chart (DUNI from DSPM).
-func (c *Chart) DepthUnits() DepthUnits { return c.depthUnits }
-
-// Feature represents a navigational object from an S-57 chart.
-//
-// Features include depth contours, buoys, lights, hazards, restricted areas,
-// and all other objects defined in the S-57 Object Catalogue.
-//
-// Access feature data via methods:
-// - ID() returns the unique identifier
-// - ObjectClass() returns the S-57 object class (e.g., "DEPCNT", "LIGHTS")
-// - Geometry() returns the spatial representation
-// - Attributes() returns all attributes
-// - Attribute(name) returns a specific attribute value
-type Feature struct {
- id int64
- objectClass string
- geometry Geometry
- attributes map[string]interface{}
-}
-
-// NewFeature constructs a Feature. Useful for tests and for synthesizing
-// features outside the parser; the parser builds them directly.
-func NewFeature(id int64, objectClass string, geometry Geometry, attributes map[string]interface{}) Feature {
- return Feature{id: id, objectClass: objectClass, geometry: geometry, attributes: attributes}
-}
-
-// ID returns the unique feature identifier.
-func (f *Feature) ID() int64 {
- return f.id
-}
-
-// ObjectClass returns the S-57 object class code.
-//
-// Common examples:
-// - "DEPCNT": Depth contour
-// - "DEPARE": Depth area
-// - "BOYCAR": Buoy, cardinal
-// - "LIGHTS": Light
-// - "OBSTRN": Obstruction
-// - "RESARE": Restricted area
-func (f *Feature) ObjectClass() string {
- return f.objectClass
-}
-
-// Geometry returns the spatial representation of the feature.
-func (f *Feature) Geometry() Geometry {
- return f.geometry
-}
-
-// Attributes returns all feature attributes as a map.
-//
-// Common attributes:
-// - "DRVAL1": Depth range value 1 (minimum depth)
-// - "DRVAL2": Depth range value 2 (maximum depth)
-// - "COLOUR": Color code
-// - "OBJNAM": Object name
-//
-// Attribute meanings are defined in the S-57 Object Catalogue.
-func (f *Feature) Attributes() map[string]interface{} {
- return f.attributes
-}
-
-// Attribute returns a specific attribute value by name.
-//
-// Returns the value and true if the attribute exists, or nil and false if not found.
-//
-// Example:
-//
-// if depth, ok := feature.Attribute("DRVAL1"); ok {
-// fmt.Printf("Depth: %v meters\n", depth)
-// }
-func (f *Feature) Attribute(name string) (interface{}, bool) {
- val, ok := f.attributes[name]
- return val, ok
-}
-
-// Ring represents a polygon ring (exterior boundary or interior hole).
-//
-// S-57 §2.2.8: USAG subfield indicates ring type per IHO S-57 specification.
-type Ring struct {
- // Usage indicates the ring type:
- // 1 = Exterior boundary (outer ring)
- // 2 = Interior boundary (hole)
- // 3 = Exterior boundary truncated at data limit
- Usage int
-
- // Coordinates contains [longitude, latitude] pairs forming the ring.
- // First and last coordinates are identical (closed ring).
- Coordinates [][]float64
-}
-
-// Geometry represents the spatial representation of a feature.
-//
-// Coordinates follow GeoJSON convention: [longitude, latitude] pairs.
-// All coordinates are in WGS-84 decimal degrees.
-type Geometry struct {
- // Type indicates the geometry type (Point, LineString, or Polygon).
- Type GeometryType
-
- // Coordinates contains [longitude, latitude] pairs.
- //
- // For Point: Single coordinate pair
- // For LineString: Array of coordinate pairs forming a line
- // For Polygon: Array of coordinate pairs forming a closed ring (DEPRECATED - use Rings)
- //
- // Note: Coordinates follow GeoJSON convention [lon, lat], not [lat, lon].
- // DEPRECATED for Polygon geometry: Use Rings field instead to access ring structure.
- Coordinates [][]float64
-
- // Rings contains polygon ring data with usage indicators.
- // Only populated for Polygon geometry type.
- // First ring(s) with Usage=1 or 3 are exterior boundaries.
- // Rings with Usage=2 are interior boundaries (holes).
- Rings []Ring
-
- // BoundaryLines holds the polylines of a polygon's DRAWABLE boundary edges —
- // those NOT masked (FSPT MASK={1}) and NOT data-limit/cell-boundary edges
- // (USAG={3}). Per S-52 PresLib §8.6.2 those edges must not be drawn, though
- // the fill keeps them (§8.6.3). Renderers stroke the border from these (one
- // polyline per drawable edge); empty ⇒ stroke the Rings instead.
- BoundaryLines [][][]float64
-
- // Lines holds the DRAWABLE polylines of a LINE feature with masked (FSPT
- // MASK={1}) and data-limit (USAG={3}) edges removed (S-52 PresLib §8.6.2).
- // Multi-part: dropping a mid-line edge splits the line, so each element is one
- // contiguous drawn polyline of [lon,lat] points. The flat Coordinates field
- // keeps the full concatenation for backward compatibility. Empty/nil ⇒ no
- // masking applied → stroke Coordinates.
- Lines [][][]float64
-
- // Quapos is the feature's effective QUAPOS (quality of position), derived from
- // its edges' spatial-level QUAPOS attribute. 0 ⇒ none/surveyed; a low-accuracy
- // value (not 1/10/11) means the depth contour is drawn dashed (S-52 DEPCNT03).
- Quapos int
-}
-
-// GeometryType represents the type of geometry.
-type GeometryType int
-
-const (
- // GeometryTypePoint represents a single point location.
- GeometryTypePoint GeometryType = iota
-
- // GeometryTypeLineString represents a line composed of connected points.
- GeometryTypeLineString
-
- // GeometryTypePolygon represents a closed polygon area.
- GeometryTypePolygon
-)
-
-// String returns the string representation of the geometry type.
-func (g GeometryType) String() string {
- switch g {
- case GeometryTypePoint:
- return "Point"
- case GeometryTypeLineString:
- return "LineString"
- case GeometryTypePolygon:
- return "Polygon"
- default:
- return "Unknown"
- }
-}
-
-// convertChart converts internal chart to public API chart
-func convertChart(internal *parser.Chart) *Chart {
- features := make([]Feature, len(internal.Features))
- for i, f := range internal.Features {
- attributes := f.Attributes
-
- // Special handling for SOUNDG (Sounding) features:
- // Extract Z coordinates (depths) from geometry and add as DEPTHS attribute
- // SOUNDG features are multipoint with Z values containing depth soundings
- if f.ObjectClass == "SOUNDG" && len(f.Geometry.Coordinates) > 0 {
- depths := make([]float64, 0, len(f.Geometry.Coordinates))
- for _, coord := range f.Geometry.Coordinates {
- // Coordinates are [lon, lat, depth] for 3D points
- if len(coord) >= 3 {
- depths = append(depths, coord[2])
- }
- }
- if len(depths) > 0 {
- // Make a copy of attributes map and add DEPTHS
- attrs := make(map[string]interface{}, len(attributes)+1)
- for k, v := range attributes {
- attrs[k] = v
- }
- attrs["DEPTHS"] = depths
- attributes = attrs
- }
- }
-
- // Convert internal rings to public Ring type
- rings := make([]Ring, len(f.Geometry.Rings))
- for j, internalRing := range f.Geometry.Rings {
- rings[j] = Ring{
- Usage: internalRing.Usage,
- Coordinates: internalRing.Coordinates,
- }
- }
-
- features[i] = Feature{
- id: f.ID,
- objectClass: f.ObjectClass,
- geometry: Geometry{
- Type: GeometryType(f.Geometry.Type),
- Coordinates: f.Geometry.Coordinates,
- Rings: rings,
- BoundaryLines: f.Geometry.BoundaryLines,
- Lines: f.Geometry.Lines,
- Quapos: f.Geometry.Quapos,
- },
- attributes: attributes,
- }
- }
-
- chart := &Chart{
- features: features,
- datasetName: internal.DatasetName(),
- edition: internal.Edition(),
- updateNumber: internal.UpdateNumber(),
- updateDate: internal.UpdateDate(),
- issueDate: internal.IssueDate(),
- s57Edition: internal.S57Edition(),
- producingAgency: internal.ProducingAgency(),
- comment: internal.Comment(),
- exchangePurpose: internal.ExchangePurpose(),
- productSpec: internal.ProductSpecification(),
- applicationProfile: internal.ApplicationProfile(),
- usageBand: UsageBand(internal.IntendedUsage()),
- // Coordinate system metadata from DSPM record
- coordinateUnits: CoordinateUnits(internal.CoordinateUnits()),
- depthUnits: DepthUnits(internal.DepthUnits()),
- horizontalDatum: internal.HorizontalDatum(),
- compilationScale: internal.CompilationScale(),
- }
-
- // Carry over non-fatal conformance warnings.
- for _, w := range internal.Warnings() {
- chart.warnings = append(chart.warnings, ConformanceWarning{
- Clause: w.Clause, Code: w.Code, Message: w.Message, Count: w.Count,
- })
- }
-
- // Build spatial index for fast viewport queries
- chart.buildSpatialIndex()
-
- return chart
-}
-
-// buildSpatialIndex creates an R-tree spatial index for O(log n) bounding box queries.
-// This provides 100× faster viewport queries compared to linear O(n) scan.
-func (c *Chart) buildSpatialIndex() {
- if len(c.features) == 0 {
- return
- }
-
- // Create R-tree (2D, min=25 children, max=50 children)
- // These parameters are optimal for most use cases
- rtree := rtreego.NewTree(2, 25, 50)
-
- // Calculate bounds - prefer M_COVR (Meta Coverage) feature if available
- // M_COVR defines the official coverage area of the chart
- var chartBounds *Bounds
-
- // First pass: look for M_COVR features
- var foundMCOVR bool
- for _, feature := range c.features {
- if feature.ObjectClass() == "M_COVR" {
- foundMCOVR = true
- fb := featureBounds(feature)
- if chartBounds == nil {
- chartBounds = &fb
- } else {
- // Expand with M_COVR bounds
- if fb.MinLon < chartBounds.MinLon {
- chartBounds.MinLon = fb.MinLon
- }
- if fb.MaxLon > chartBounds.MaxLon {
- chartBounds.MaxLon = fb.MaxLon
- }
- if fb.MinLat < chartBounds.MinLat {
- chartBounds.MinLat = fb.MinLat
- }
- if fb.MaxLat > chartBounds.MaxLat {
- chartBounds.MaxLat = fb.MaxLat
- }
- }
- }
- }
-
- // Second pass: insert features into R-tree and calculate fallback bounds if no M_COVR
- for _, feature := range c.features {
- fb := featureBounds(feature)
-
- // Insert feature into R-tree
- indexed := &indexedFeature{
- feature: feature,
- bounds: fb,
- }
- rtree.Insert(indexed)
-
- // Only calculate bounds from features if no M_COVR was found
- if !foundMCOVR {
- if chartBounds == nil {
- // Initialize with first feature bounds
- chartBounds = &fb
- } else {
- // Expand chart bounds to include this feature
- if fb.MinLon < chartBounds.MinLon {
- chartBounds.MinLon = fb.MinLon
- }
- if fb.MaxLon > chartBounds.MaxLon {
- chartBounds.MaxLon = fb.MaxLon
- }
- if fb.MinLat < chartBounds.MinLat {
- chartBounds.MinLat = fb.MinLat
- }
- if fb.MaxLat > chartBounds.MaxLat {
- chartBounds.MaxLat = fb.MaxLat
- }
- }
- }
- }
-
- // Assign R-tree to spatial index
- c.spatialIndex = &spatialIndex{
- rtree: rtree,
- }
-
- if chartBounds != nil {
- c.bounds = *chartBounds
- }
-}
diff --git a/pkg/s57/doc.go b/pkg/s57/doc.go
deleted file mode 100644
index 3fb3b97..0000000
--- a/pkg/s57/doc.go
+++ /dev/null
@@ -1,99 +0,0 @@
-// Package s57 provides a parser for IHO S-57 Electronic Navigational Charts.
-//
-// This package is designed for chart rendering applications. It provides fast spatial
-// queries, feature grouping, and a clean API optimized for viewport-based rendering.
-//
-// # Basic Usage
-//
-// parser := s57.NewParser()
-// chart, err := parser.Parse("US5MA22M.000")
-// if err != nil {
-// log.Fatal(err)
-// }
-//
-// fmt.Printf("Chart: %s covers %+v\n", chart.DatasetName(), chart.Bounds())
-//
-// # Rendering Workflow
-//
-// The typical rendering workflow uses spatial queries to efficiently render only
-// visible features:
-//
-// // 1. Query features in viewport
-// viewport := s57.Bounds{
-// MinLon: -71.5, MaxLon: -71.0,
-// MinLat: 42.0, MaxLat: 42.5,
-// }
-// visibleFeatures := chart.FeaturesInBounds(viewport)
-//
-// // 2. Pass to S-52 presentation library for rendering
-// // S-52 handles all grouping, ordering, and symbology based on its lookup tables
-// s52.Render(visibleFeatures, displaySettings)
-//
-// # Spatial Queries
-//
-// The chart automatically builds a spatial index for fast viewport queries:
-//
-// // Get chart coverage
-// bounds := chart.Bounds()
-//
-// // Query visible features
-// visible := chart.FeaturesInBounds(viewport)
-//
-// // Features are returned as a slice - no allocation overhead for iteration
-//
-// # Feature Access
-//
-// Access all features or query by object class:
-//
-// // Get all features in the chart
-// allFeatures := chart.Features()
-//
-// // Each feature contains everything needed for S-101 symbology lookup:
-// for _, feature := range allFeatures {
-// class := feature.ObjectClass() // "ACHARE", "DEPARE", "LNDARE"
-// attrs := feature.Attributes() // All feature attributes
-// geom := feature.Geometry() // Geometry with type and coordinates
-// // Pass to S-101 for symbology lookup and rendering
-// }
-//
-// # Accessing Feature Data
-//
-// for _, feature := range visibleFeatures {
-// id := feature.ID()
-// class := feature.ObjectClass() // "DEPCNT", "LIGHTS", etc.
-// geom := feature.Geometry()
-//
-// // Access coordinates
-// for _, coord := range geom.Coordinates {
-// lon, lat := coord[0], coord[1]
-// // ... project and render
-// }
-//
-// // Access attributes for styling
-// if depth, ok := feature.Attribute("DRVAL1"); ok {
-// // Apply depth-based color
-// }
-// }
-//
-// # Integration with S-52 Presentation Library
-//
-// This library handles S-57 parsing only. Features are designed to work directly
-// with S-52 presentation libraries for symbology lookup and rendering.
-//
-// // Parse S-57 chart
-// chart, _ := s57Parser.Parse("chart.000")
-//
-// // S-52 uses ObjectClass + Attributes + Geometry for lookup
-// for _, feature := range chart.Features() {
-// // S-52 looks up: ObjectClass + GeometryType + Attributes → Symbology
-// symbology := s52.Lookup(feature.ObjectClass(), feature.GeometryType(), feature.Attributes())
-// render(feature.Geometry(), symbology)
-// }
-//
-// # Performance
-//
-// - Spatial index built automatically during parsing
-// - Viewport queries are O(n) with low constant factor (simple bounding box checks)
-// - No allocations during iteration
-// - Features parsed eagerly (charts fit in memory)
-package s57
diff --git a/pkg/s57/options.go b/pkg/s57/options.go
deleted file mode 100644
index 5fa57f0..0000000
--- a/pkg/s57/options.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package s57
-
-import "io/fs"
-
-// ParseOptions configures parsing behavior.
-type ParseOptions struct {
- SkipUnknownFeatures bool
- ValidateGeometry bool
- ObjectClassFilter []string
-
- // ApplyUpdates controls whether to automatically discover and apply
- // update files (.001, .002, etc.) when parsing a base cell (.000).
- // Default is true - updates are automatically applied.
- //
- // When true, the parser looks for sequential update files in the same
- // directory as the base file and applies them in order.
- //
- // Set to false to parse only the base cell without updates.
- ApplyUpdates bool
-
- // ValidateConformance promotes S-57 / ISO-8211 spec deviations from non-fatal
- // warnings (the default; see Chart.Warnings) to a parse error. Default false.
- ValidateConformance bool
-
- // MaskCoastlineCoincidentBoundaries derives coastline-coincident edge masking
- // for area features (S-57 Appendix B.1 Annex A §17 scenario 2). When true, an
- // area feature's drawn boundary edge whose RCID is also referenced by a COALNE
- // feature is dropped from BoundaryLines (the fill/Coordinates stay intact). The
- // coast-definer LNDARE is exempt. Default is false. The baker enables this.
- MaskCoastlineCoincidentBoundaries bool
-
- // Fs is the filesystem to use for reading files.
- // If nil, the OS filesystem is used (iso8211.OSFS()).
- // This allows custom io/fs.FS implementations for testing or specialized
- // storage (e.g. iso8211.MemFS for parsing raw bytes).
- //
- // Example with an in-memory filesystem:
- // fsys := iso8211.MemFS{"/test.000": data}
- // opts := s57.ParseOptions{Fs: fsys}
- // chart, err := s57.NewParser().ParseWithOptions("/test.000", opts)
- Fs fs.FS
-}
-
-// DefaultParseOptions returns default options.
-func DefaultParseOptions() ParseOptions {
- return ParseOptions{
- SkipUnknownFeatures: false,
- ValidateGeometry: true,
- ObjectClassFilter: nil,
- ApplyUpdates: true, // Auto-apply updates by default
- }
-}
diff --git a/pkg/s57/parser.go b/pkg/s57/parser.go
deleted file mode 100644
index aa4bd0f..0000000
--- a/pkg/s57/parser.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package s57
-
-import (
- "io/fs"
-
- "github.com/beetlebugorg/chartplotter/internal/s57/parser"
-)
-
-// Parser parses S-57 Electronic Navigational Chart files.
-//
-// Create a parser with NewParser and use Parse or ParseWithOptions to read charts.
-type Parser interface {
- // Parse reads an S-57 file and returns the parsed chart.
- //
- // The filename should point to an S-57 base cell (.000) or update file (.001, .002, etc.).
- // Returns an error if the file cannot be read or parsed according to S-57 Edition 3.1.
- Parse(filename string) (*Chart, error)
-
- // ParseWithOptions parses an S-57 file with custom options.
- //
- // Use ParseOptions to control validation, error handling, and feature filtering.
- ParseWithOptions(filename string, opts ParseOptions) (*Chart, error)
-}
-
-// NewParser creates a new S-57 parser with default settings.
-//
-// Example:
-//
-// parser := s57.NewParser()
-// chart, err := parser.Parse("US5MA22M.000")
-func NewParser() Parser {
- return &parserWrapper{
- internal: parser.NewParser(),
- }
-}
-
-// parserWrapper wraps the internal parser and converts types
-type parserWrapper struct {
- internal parser.Parser
-}
-
-func (p *parserWrapper) Parse(filename string) (*Chart, error) {
- internalChart, err := p.internal.Parse(filename)
- if err != nil {
- return nil, err
- }
- return convertChart(internalChart), nil
-}
-
-func (p *parserWrapper) ParseWithOptions(filename string, opts ParseOptions) (*Chart, error) {
- internalOpts := parser.ParseOptions{
- SkipUnknownFeatures: opts.SkipUnknownFeatures,
- ValidateGeometry: opts.ValidateGeometry,
- ObjectClassFilter: opts.ObjectClassFilter,
- ApplyUpdates: opts.ApplyUpdates,
-
- MaskCoastlineCoincidentBoundaries: opts.MaskCoastlineCoincidentBoundaries,
-
- ValidateConformance: opts.ValidateConformance,
-
- Fs: opts.Fs,
- }
- internalChart, err := p.internal.ParseWithOptions(filename, internalOpts)
- if err != nil {
- return nil, err
- }
- return convertChart(internalChart), nil
-}
-
-// Parse reads an S-57 file from the OS filesystem and returns the parsed chart.
-// This is a convenience function equivalent to:
-//
-// parser := s57.NewParser()
-// chart, err := parser.Parse(filename)
-//
-// Example:
-//
-// chart, err := s57.Parse("US5MA22M.000")
-func Parse(filename string) (*Chart, error) {
- p := NewParser()
- return p.Parse(filename)
-}
-
-// ParseFS reads an S-57 file from a custom io/fs.FS and returns the parsed chart.
-// This allows custom filesystem implementations (e.g. iso8211.MemFS for raw
-// bytes) for testing or specialized storage systems.
-//
-// The filesystem is used for both the base file and any update files (.001, .002, etc.)
-// if ApplyUpdates is enabled in the options.
-//
-// Example with an in-memory filesystem:
-//
-// fsys := iso8211.MemFS{"/chart.000": data}
-// chart, err := s57.ParseFS(fsys, "/chart.000")
-//
-// Example with custom options:
-//
-// fsys := iso8211.MemFS{"/chart.000": data}
-// opts := s57.DefaultParseOptions()
-// opts.Fs = fsys
-// opts.ApplyUpdates = false
-// chart, err := s57.ParseWithOptions("/chart.000", opts)
-func ParseFS(fsys fs.FS, filename string) (*Chart, error) {
- opts := DefaultParseOptions()
- opts.Fs = fsys
- p := NewParser()
- return p.ParseWithOptions(filename, opts)
-}
-
-// ParseWithOptions reads an S-57 file with custom options.
-// This is a convenience function equivalent to:
-//
-// parser := s57.NewParser()
-// chart, err := parser.ParseWithOptions(filename, opts)
-//
-// Example:
-//
-// opts := s57.DefaultParseOptions()
-// opts.SkipUnknownFeatures = true
-// chart, err := s57.ParseWithOptions("US5MA22M.000", opts)
-func ParseWithOptions(filename string, opts ParseOptions) (*Chart, error) {
- p := NewParser()
- return p.ParseWithOptions(filename, opts)
-}
diff --git a/pkg/s57/parser_test.go b/pkg/s57/parser_test.go
deleted file mode 100644
index 38331b5..0000000
--- a/pkg/s57/parser_test.go
+++ /dev/null
@@ -1,356 +0,0 @@
-package s57
-
-import (
- "testing"
-)
-
-const testChartPath = "../../testdata/US4MD81M.000"
-
-// TestPublicAPI tests the public parser API
-func TestPublicAPI(t *testing.T) {
- parser := NewParser()
- if parser == nil {
- t.Fatal("NewParser returned nil")
- }
-
- // Test default options
- opts := DefaultParseOptions()
- if opts.ValidateGeometry != true {
- t.Error("Default ValidateGeometry should be true")
- }
- if opts.SkipUnknownFeatures != false {
- t.Error("Default SkipUnknownFeatures should be false")
- }
-}
-
-// TestParseRealChart tests parsing a real NOAA ENC chart
-// S-57 §7.2: Dataset General Information Record (DSID)
-func TestParseRealChart(t *testing.T) {
- parser := NewParser()
- chart, err := parser.Parse(testChartPath)
- if err != nil {
- t.Fatalf("Failed to parse chart: %v", err)
- }
-
- // Verify DSID metadata - S-57 §7.2.1
- if chart.DatasetName() == "" {
- t.Error("Dataset name should not be empty")
- }
- if chart.Edition() == "" {
- t.Error("Edition should not be empty")
- }
- if chart.ProducingAgency() == 0 {
- t.Error("Producing agency should not be zero")
- }
-
- // Verify features were parsed - S-57 §7.3
- if chart.FeatureCount() == 0 {
- t.Error("Chart should contain features")
- }
-
- // Verify spatial bounds calculated
- bounds := chart.Bounds()
- if bounds.MinLon >= bounds.MaxLon {
- t.Errorf("Invalid longitude bounds: %f to %f", bounds.MinLon, bounds.MaxLon)
- }
- if bounds.MinLat >= bounds.MaxLat {
- t.Errorf("Invalid latitude bounds: %f to %f", bounds.MinLat, bounds.MaxLat)
- }
-
- t.Logf("Parsed %s: %d features, bounds [%.6f,%.6f] to [%.6f,%.6f]",
- chart.DatasetName(), chart.FeatureCount(),
- bounds.MinLon, bounds.MinLat, bounds.MaxLon, bounds.MaxLat)
-}
-
-// TestUpdateFileHandling tests automatic update file application
-// S-57 §3.1: Exchange Set Structure
-func TestUpdateFileHandling(t *testing.T) {
- parser := NewParser()
-
- // Parse with updates (default behavior)
- chart, err := parser.Parse(testChartPath)
- if err != nil {
- t.Fatalf("Failed to parse with updates: %v", err)
- }
-
- // Should have applied updates .001, .002, .003
- updateNum := chart.UpdateNumber()
- if updateNum == "0" {
- t.Error("Expected updates to be applied, got update number 0")
- }
-
- t.Logf("Chart update number: %s", updateNum)
-
- // Parse without updates
- opts := ParseOptions{ApplyUpdates: false}
- baseChart, err := parser.ParseWithOptions(testChartPath, opts)
- if err != nil {
- t.Fatalf("Failed to parse base cell: %v", err)
- }
-
- if baseChart.UpdateNumber() != "0" {
- t.Errorf("Base cell should have update number 0, got %s", baseChart.UpdateNumber())
- }
-
- // Chart with updates should have different feature count than base
- // (updates modify the dataset)
- t.Logf("Base features: %d, Updated features: %d",
- baseChart.FeatureCount(), chart.FeatureCount())
-}
-
-// TestFeatureObjects tests S-57 feature objects
-// S-57 §7.3: Feature Object Records
-func TestFeatureObjects(t *testing.T) {
- parser := NewParser()
- chart, err := parser.Parse(testChartPath)
- if err != nil {
- t.Fatalf("Failed to parse chart: %v", err)
- }
-
- // Count feature types
- counts := make(map[string]int)
- for _, f := range chart.Features() {
- counts[f.ObjectClass()]++
- }
-
- // Verify common S-57 object classes exist
- expectedClasses := []string{"DEPCNT", "LIGHTS", "BUAARE"}
- for _, class := range expectedClasses {
- if count, ok := counts[class]; !ok || count == 0 {
- t.Errorf("Expected to find %s features", class)
- }
- }
-
- // Test feature accessor methods
- features := chart.Features()
- if len(features) == 0 {
- t.Fatal("No features to test")
- }
-
- f := features[0]
- if f.ID() == 0 {
- t.Error("Feature ID should not be zero")
- }
- if f.ObjectClass() == "" {
- t.Error("Feature ObjectClass should not be empty")
- }
-
- // Geometry should be valid
- geom := f.Geometry()
- if geom.Type != GeometryTypePoint && geom.Type != GeometryTypeLineString && geom.Type != GeometryTypePolygon {
- t.Errorf("Unexpected geometry type: %s", geom.Type)
- }
-
- t.Logf("Sample feature: ID=%d, Class=%s, Type=%s, Coords=%d",
- f.ID(), f.ObjectClass(), geom.Type, len(geom.Coordinates))
-}
-
-// TestSpatialIndexing tests R-tree spatial index for viewport queries
-// S-57 §7.3.3: Spatial Objects
-func TestSpatialIndexing(t *testing.T) {
- parser := NewParser()
- chart, err := parser.Parse(testChartPath)
- if err != nil {
- t.Fatalf("Failed to parse chart: %v", err)
- }
-
- // Get chart bounds
- chartBounds := chart.Bounds()
-
- // Query a subset viewport (middle 50% of chart)
- lonRange := chartBounds.MaxLon - chartBounds.MinLon
- latRange := chartBounds.MaxLat - chartBounds.MinLat
-
- viewport := Bounds{
- MinLon: chartBounds.MinLon + lonRange*0.25,
- MaxLon: chartBounds.MaxLon - lonRange*0.25,
- MinLat: chartBounds.MinLat + latRange*0.25,
- MaxLat: chartBounds.MaxLat - latRange*0.25,
- }
-
- visible := chart.FeaturesInBounds(viewport)
-
- // Should return subset of features
- totalFeatures := chart.FeatureCount()
- if len(visible) == 0 {
- t.Error("Viewport query should return some features")
- }
- if len(visible) >= totalFeatures {
- t.Error("Viewport query should return fewer features than total")
- }
-
- // Note: R-tree spatial index can return features slightly outside the query bounds
- // due to node overlap and floating point precision. This is expected behavior.
- // We just verify that the query returns a reasonable subset of features.
-
- t.Logf("Viewport query: %d/%d features visible", len(visible), totalFeatures)
-}
-
-// TestGeometryTypes tests S-57 geometry types
-// S-57 §7.3.3: Spatial Primitives (Point, Line, Area)
-func TestGeometryTypes(t *testing.T) {
- parser := NewParser()
- chart, err := parser.Parse(testChartPath)
- if err != nil {
- t.Fatalf("Failed to parse chart: %v", err)
- }
-
- // Count geometry types
- typeCounts := make(map[GeometryType]int)
- for _, f := range chart.Features() {
- geom := f.Geometry()
- typeCounts[geom.Type]++
- }
-
- // S-57 uses Point, Line (LineString), and Area (Polygon)
- if typeCounts[GeometryTypePoint] == 0 {
- t.Error("Expected some point geometries")
- }
- if typeCounts[GeometryTypeLineString] == 0 {
- t.Error("Expected some line geometries")
- }
- if typeCounts[GeometryTypePolygon] == 0 {
- t.Error("Expected some polygon geometries")
- }
-
- t.Logf("Geometry types: Point=%d, Line=%d, Polygon=%d",
- typeCounts[GeometryTypePoint],
- typeCounts[GeometryTypeLineString],
- typeCounts[GeometryTypePolygon])
-}
-
-// TestFeatureAttributes tests S-57 feature attributes
-// S-57 §7.3.1: Feature Attributes
-func TestFeatureAttributes(t *testing.T) {
- parser := NewParser()
- chart, err := parser.Parse(testChartPath)
- if err != nil {
- t.Fatalf("Failed to parse chart: %v", err)
- }
-
- // Find a LIGHTS feature (should have attributes)
- var light Feature
- found := false
- for _, f := range chart.Features() {
- if f.ObjectClass() == "LIGHTS" {
- light = f
- found = true
- break
- }
- }
-
- if !found {
- t.Skip("No LIGHTS feature found in test chart")
- }
-
- // Test attribute access
- attrs := light.Attributes()
- if len(attrs) == 0 {
- t.Error("LIGHTS feature should have attributes")
- }
-
- // Test individual attribute access
- if val, ok := light.Attribute("OBJNAM"); ok {
- t.Logf("Light name: %v", val)
- }
-
- t.Logf("LIGHTS feature has %d attributes", len(attrs))
-}
-
-// TestObjectClassFiltering tests filtering by object class
-// S-57 §7.3: Feature Object Class codes
-func TestObjectClassFiltering(t *testing.T) {
- parser := NewParser()
-
- // Parse only depth contours
- opts := ParseOptions{
- ObjectClassFilter: []string{"DEPCNT"},
- }
-
- chart, err := parser.ParseWithOptions(testChartPath, opts)
- if err != nil {
- t.Fatalf("Failed to parse with filter: %v", err)
- }
-
- // All features should be DEPCNT
- for _, f := range chart.Features() {
- if f.ObjectClass() != "DEPCNT" {
- t.Errorf("Expected only DEPCNT features, got %s", f.ObjectClass())
- }
- }
-
- t.Logf("Filtered to %d DEPCNT features", chart.FeatureCount())
-}
-
-// TestUsageBand tests ENC usage band classification
-// S-57 Appendix B.1: Navigational Purpose
-func TestUsageBand(t *testing.T) {
- tests := []struct {
- band UsageBand
- name string
- minScale int
- maxScale int
- }{
- {UsageBandOverview, "Overview", 1500000, 0},
- {UsageBandGeneral, "General", 350000, 1500000},
- {UsageBandCoastal, "Coastal", 90000, 350000},
- {UsageBandApproach, "Approach", 22000, 90000},
- {UsageBandHarbour, "Harbour", 4000, 22000},
- {UsageBandBerthing, "Berthing", 0, 4000},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if tt.band.String() != tt.name {
- t.Errorf("Expected name %s, got %s", tt.name, tt.band.String())
- }
- min, max := tt.band.ScaleRange()
- if min != tt.minScale || max != tt.maxScale {
- t.Errorf("Expected scale range (%d, %d), got (%d, %d)",
- tt.minScale, tt.maxScale, min, max)
- }
- })
- }
-}
-
-// TestBoundsOperations tests bounding box operations
-func TestBoundsOperations(t *testing.T) {
- b1 := Bounds{MinLon: -71.0, MaxLon: -70.0, MinLat: 42.0, MaxLat: 43.0}
- b2 := Bounds{MinLon: -70.5, MaxLon: -69.5, MinLat: 42.5, MaxLat: 43.5}
- b3 := Bounds{MinLon: -69.0, MaxLon: -68.0, MinLat: 44.0, MaxLat: 45.0}
-
- // Test Intersects
- if !b1.Intersects(b2) {
- t.Error("b1 and b2 should intersect")
- }
- if b1.Intersects(b3) {
- t.Error("b1 and b3 should not intersect")
- }
-
- // Test Contains
- if !b1.Contains(-70.5, 42.5) {
- t.Error("b1 should contain point (-70.5, 42.5)")
- }
- if b1.Contains(-69.0, 44.0) {
- t.Error("b1 should not contain point (-69.0, 44.0)")
- }
-}
-
-// TestGeometryTypeString tests geometry type string conversion
-func TestGeometryTypeString(t *testing.T) {
- tests := []struct {
- gtype GeometryType
- expected string
- }{
- {GeometryTypePoint, "Point"},
- {GeometryTypeLineString, "LineString"},
- {GeometryTypePolygon, "Polygon"},
- }
-
- for _, tt := range tests {
- if tt.gtype.String() != tt.expected {
- t.Errorf("GeometryType %d: expected %s, got %s",
- tt.gtype, tt.expected, tt.gtype.String())
- }
- }
-}
diff --git a/pkg/s57/soundg_test.go b/pkg/s57/soundg_test.go
deleted file mode 100644
index 3862543..0000000
--- a/pkg/s57/soundg_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package s57_test
-
-import (
- "testing"
-
- s57 "github.com/beetlebugorg/chartplotter/pkg/s57"
-)
-
-func TestSOUNDGWith3DCoordinates(t *testing.T) {
- // Parse test chart from test/ directory
- chartPath := "../../testdata/US4MD81M.000"
-
- parser := s57.NewParser()
- chart, err := parser.Parse(chartPath)
- if err != nil {
- t.Fatalf("Failed to parse chart: %v", err)
- }
-
- // Find all SOUNDG features
- features := chart.Features()
- var soundings []s57.Feature
- for _, f := range features {
- if f.ObjectClass() == "SOUNDG" {
- soundings = append(soundings, f)
- }
- }
-
- if len(soundings) == 0 {
- t.Skip("No SOUNDG features found in test chart")
- }
-
- t.Logf("Found %d SOUNDG feature(s)", len(soundings))
-
- // Check first SOUNDG feature to verify 3D coordinates
- soundg := soundings[0]
- geom := soundg.Geometry()
-
- if geom.Type != s57.GeometryTypePoint {
- t.Errorf("Expected SOUNDG geometry type Point, got %v", geom.Type)
- }
-
- if len(geom.Coordinates) == 0 {
- t.Fatal("SOUNDG has no coordinates")
- }
-
- // Verify 3D coordinates (lon, lat, depth)
- foundWith3D := 0
- foundWithout3D := 0
-
- for _, coord := range geom.Coordinates {
- if len(coord) < 2 {
- t.Errorf("Coordinate has less than 2 values: %v", coord)
- continue
- }
-
- if len(coord) >= 3 {
- foundWith3D++
- } else {
- foundWithout3D++
- }
- }
-
- t.Logf("First SOUNDG feature has %d coordinates: %d with depth (3D), %d without depth (2D)",
- len(geom.Coordinates), foundWith3D, foundWithout3D)
-
- if foundWith3D == 0 {
- t.Error("Expected at least some coordinates with Z value (depth)")
- }
-}
diff --git a/pkg/s57/spatial.go b/pkg/s57/spatial.go
deleted file mode 100644
index 7e7251c..0000000
--- a/pkg/s57/spatial.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package s57
-
-// Bounds represents a geographic bounding box in WGS-84 coordinates.
-//
-// Coordinates are in decimal degrees.
-type Bounds struct {
- MinLon float64 // Western edge
- MaxLon float64 // Eastern edge
- MinLat float64 // Southern edge
- MaxLat float64 // Northern edge
-}
-
-// Contains returns true if the point (lon, lat) is within the bounds.
-func (b Bounds) Contains(lon, lat float64) bool {
- return lon >= b.MinLon && lon <= b.MaxLon &&
- lat >= b.MinLat && lat <= b.MaxLat
-}
-
-// Intersects returns true if the given bounds intersects with this bounds.
-func (b Bounds) Intersects(other Bounds) bool {
- return !(other.MaxLon < b.MinLon ||
- other.MinLon > b.MaxLon ||
- other.MaxLat < b.MinLat ||
- other.MinLat > b.MaxLat)
-}
-
-// Expand returns a new Bounds expanded by the given margin in all directions.
-//
-// Margin is in decimal degrees.
-func (b Bounds) Expand(margin float64) Bounds {
- return Bounds{
- MinLon: b.MinLon - margin,
- MaxLon: b.MaxLon + margin,
- MinLat: b.MinLat - margin,
- MaxLat: b.MaxLat + margin,
- }
-}
-
-// Union returns a new Bounds that encompasses both this bounds and the other.
-//
-// The resulting bounds will be the smallest bounding box that contains both inputs.
-func (b Bounds) Union(other Bounds) Bounds {
- result := b
-
- if other.MinLon < result.MinLon {
- result.MinLon = other.MinLon
- }
- if other.MaxLon > result.MaxLon {
- result.MaxLon = other.MaxLon
- }
- if other.MinLat < result.MinLat {
- result.MinLat = other.MinLat
- }
- if other.MaxLat > result.MaxLat {
- result.MaxLat = other.MaxLat
- }
-
- return result
-}
-
-// featureBounds calculates the bounding box for a feature's geometry.
-func featureBounds(f Feature) Bounds {
- if len(f.geometry.Coordinates) == 0 {
- return Bounds{}
- }
-
- // Initialize with first coordinate
- first := f.geometry.Coordinates[0]
- bounds := Bounds{
- MinLon: first[0],
- MaxLon: first[0],
- MinLat: first[1],
- MaxLat: first[1],
- }
-
- // Expand to include all coordinates
- for _, coord := range f.geometry.Coordinates {
- lon, lat := coord[0], coord[1]
- if lon < bounds.MinLon {
- bounds.MinLon = lon
- }
- if lon > bounds.MaxLon {
- bounds.MaxLon = lon
- }
- if lat < bounds.MinLat {
- bounds.MinLat = lat
- }
- if lat > bounds.MaxLat {
- bounds.MaxLat = lat
- }
- }
-
- return bounds
-}
diff --git a/scripts/fetch-demo-cells.sh b/scripts/fetch-demo-cells.sh
index 66d5359..d59de61 100755
--- a/scripts/fetch-demo-cells.sh
+++ b/scripts/fetch-demo-cells.sh
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
-# Download the curated NOAA ENC cells for the read-only Annapolis demo, one per
-# usage band. NOAA publishes no overview (band 1) or berthing (band 6) cell over
-# this spot, so the set is bands 2-5 (general -> harbor); the general band renders
-# the zoomed-out view, so the full zoom range still works on little disk.
+# Download the curated NOAA ENC cells for the read-only Annapolis demo: the
+# general + coastal cells that render the zoomed-out view, plus the approach and
+# harbor cells clustered around Annapolis (the Severn/Magothy, Bay Bridge, Kent
+# Island, and South/West/Rhode river charts) so you can zoom from the whole bay
+# down to the docks across the immediate area. Still a few MB of tiles.
#
# Idempotent: a cell already present in the cache dir is skipped, so CI can cache
# the directory across runs. Driven by `make demo`; override via env:
@@ -13,7 +14,7 @@ set -euo pipefail
BASE="${NOAA_URL_BASE:-https://www.charts.noaa.gov/ENCs}"
OUT="${DEMO_CACHE:-.demo-cache}"
-CELLS="${DEMO_CELLS:-US2EC03M US3EC08M US4MD1DC US5MD1MC}"
+CELLS="${DEMO_CELLS:-US2EC03M US3EC08M US4MD1DC US4MD1EC US5MD1MC US5MD1MD US5MD1ME US5MD1LB US5MD1LC US5MD1NB US5MD1NC}"
mkdir -p "$OUT"
for c in $CELLS; do
diff --git a/scripts/quill-sign.sh b/scripts/quill-sign.sh
deleted file mode 100755
index dd1b8b7..0000000
--- a/scripts/quill-sign.sh
+++ /dev/null
@@ -1,35 +0,0 @@
-#!/usr/bin/env bash
-# GoReleaser post-build hook: sign + notarize macOS binaries with quill (Anchore),
-# straight from the Linux release runner — no Mac needed. quill writes the Apple
-# code signature into the Mach-O in place and submits it to Apple's notary service,
-# so the archives GoReleaser builds afterwards contain the signed binary.
-#
-# It is a no-op for non-darwin targets, and a no-op when the signing credentials
-# are not in the environment — so local `goreleaser release --snapshot`, forks, and
-# PRs still build without secrets.
-#
-# Required env (wired from GitHub secrets on the release workflow):
-# QUILL_SIGN_P12 base64 of the "Developer ID Application" cert (.p12)
-# QUILL_SIGN_PASSWORD the .p12 export password
-# QUILL_NOTARY_KEY base64 of the App Store Connect API key (.p8)
-# QUILL_NOTARY_KEY_ID the API key ID
-# QUILL_NOTARY_ISSUER the App Store Connect issuer ID
-# (quill reads all of these directly from the environment.)
-#
-# Usage, from a .goreleaser.yaml build post-hook:
-# quill-sign.sh "{{ .Path }}" "{{ .Os }}"
-set -euo pipefail
-
-bin="${1:?usage: quill-sign.sh }"
-os="${2:?usage: quill-sign.sh }"
-
-if [ "$os" != "darwin" ]; then
- exit 0 # only macOS binaries are code-signed
-fi
-if [ -z "${QUILL_SIGN_P12:-}" ]; then
- echo "quill-sign: no signing credentials in env — skipping $bin" >&2
- exit 0
-fi
-
-echo "quill-sign: signing + notarizing $bin" >&2
-quill sign-and-notarize "$bin"
diff --git a/scripts/xbuild-tile57.sh b/scripts/xbuild-tile57.sh
new file mode 100755
index 0000000..11f2bca
--- /dev/null
+++ b/scripts/xbuild-tile57.sh
@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+# Cross-compile chartplotter as a CGO binary linking the native libtile57 engine,
+# using Zig as the C toolchain (`zig cc`). This is how the tile57-only build keeps
+# single-command cross-compilation despite requiring CGO.
+#
+# Targets: linux + windows, amd64 & arm64 — all four proven to cross-link cleanly
+# from any host with Zig alone. darwin is deliberately EXCLUDED: with GOOS=darwin,
+# Go's own crypto/x509 links -framework Security / CoreFoundation, which Zig does
+# not bundle (it ships a macOS libc, not Apple's frameworks). macOS release
+# binaries are therefore built natively on a macOS CI runner, not here.
+#
+# For each target: build libtile57 for that arch into the engine's zig-out/lib
+# (the Go binding links it by that fixed path), then cross-compile with zig cc.
+# The host lib is rebuilt at the end so a later native `make build` / `go test`
+# works without a manual step.
+#
+# LIBC selects the linux C ABI:
+# gnu (default) — dynamically links glibc (portable across mainstream distros)
+# musl — links musl STATICALLY (`-extldflags "-static -s"`), yielding a
+# fully self-contained binary (`ldd` → "not a dynamic executable")
+# that drops straight into a FROM scratch Docker image — the
+# "go-dims" pattern. musl is linux-only; windows stays gnu and
+# darwin is skipped, so `LIBC=musl` builds only the linux targets.
+# SKIP_RESTORE=1 skips the final host-lib rebuild — for single-shot container builds
+# where the engine tree is thrown away after the binary is produced.
+set -euo pipefail
+
+REPO="$(cd "$(dirname "$0")/.." && pwd)"
+# Engine checkout: the ./tile57 submodule by default; a relative override
+# (e.g. TILE57=../tile57 from make) is resolved against the repo root.
+TILE57="${TILE57:-$REPO/tile57}"
+case "$TILE57" in /*) ;; *) TILE57="$REPO/$TILE57" ;; esac
+OUT="${OUT:-$REPO/dist}"
+VERSION="${VERSION:-dev}"
+# Engine-commit stamp (see Makefile ENGINE_COMMIT): make passes it in; a direct
+# script run resolves it the same way. The `-e .git` guard keeps git from walking
+# up into THIS repo when the submodule dir is empty.
+ENGINE_COMMIT="${ENGINE_COMMIT:-$( { test -e "$TILE57/.git" && git -C "$TILE57" rev-parse --short=9 HEAD 2>/dev/null; } || echo unknown)}"
+# gnu (default) → dynamic glibc; musl → fully-static, FROM scratch-ready. See header.
+LIBC="${LIBC:-gnu}"
+# 1 → don't rebuild the host lib at the end (single-shot container builds).
+SKIP_RESTORE="${SKIP_RESTORE:-0}"
+
+# Space-separated "GOOS/GOARCH" list; override for a subset, e.g.
+# PLATFORMS="linux/arm64" scripts/xbuild-tile57.sh. musl is linux-only, so its
+# default drops the windows targets (an explicit windows/* is skipped with a note).
+if [ "$LIBC" = musl ]; then
+ PLATFORMS="${PLATFORMS:-linux/amd64 linux/arm64}"
+else
+ PLATFORMS="${PLATFORMS:-linux/amd64 linux/arm64 windows/amd64 windows/arm64}"
+fi
+
+command -v zig >/dev/null 2>&1 || { echo "zig (0.16) not on PATH"; exit 1; }
+[ -f "$TILE57/include/tile57.h" ] || { echo "missing $TILE57 — run 'git submodule update --init --recursive' for the ./tile57 submodule (or set TILE57= to another engine checkout)"; exit 1; }
+
+# GOOS/GOARCH → Zig target triple. The linux ABI follows $LIBC (gnu|musl); windows
+# is always gnu (mingw), darwin builds on a macOS runner.
+zig_triple() {
+ case "$1" in
+ linux/amd64) echo "x86_64-linux-$LIBC" ;;
+ linux/arm64) echo "aarch64-linux-$LIBC" ;;
+ windows/amd64) echo x86_64-windows-gnu ;;
+ windows/arm64) echo aarch64-windows-gnu ;;
+ darwin/*) echo "" ;; # built on a macOS runner, not cross-compiled here
+ *) echo "" ;;
+ esac
+}
+
+mkdir -p "$OUT"
+libdir="$TILE57/zig-out/lib"
+
+build_lib() { # $1 = zig triple ("" → host)
+ # Clear any stale libtile57.a / tile57.lib so a prior arch never lingers (Zig
+ # names the Windows static lib tile57.lib and won't overwrite libtile57.a).
+ rm -f "$libdir/libtile57.a" "$libdir/tile57.lib"
+ if [ -n "$1" ]; then
+ ( cd "$TILE57" && zig build -Dtarget="$1" -Doptimize=ReleaseFast )
+ else
+ ( cd "$TILE57" && zig build -Doptimize=ReleaseFast )
+ fi
+ # Normalize the Windows MSVC-style name to the libtile57.a the binding links.
+ [ -f "$libdir/libtile57.a" ] || cp "$libdir/tile57.lib" "$libdir/libtile57.a"
+}
+
+# musl → static suffix on the artifact name so a musl build never overwrites a gnu
+# one in the same dist/, and add the fully-static external-link flags. `-s -w`
+# strips Go's own tables; `-extldflags "-static -s"` makes zig's lld emit a static
+# ELF and strip the C side too (libtile57 + its deps) — proven to halve the size.
+sfx=""; static_ldflags=""
+if [ "$LIBC" = musl ]; then
+ sfx="_musl"
+ static_ldflags='-linkmode external -extldflags "-static -s"'
+fi
+
+for plat in $PLATFORMS; do
+ goos="${plat%/*}"; goarch="${plat#*/}"; ext=""; [ "$goos" = windows ] && ext=.exe
+ if [ "$LIBC" = musl ] && [ "$goos" != linux ]; then echo "skip $plat (musl is linux-only)"; continue; fi
+ triple="$(zig_triple "$plat")"
+ if [ -z "$triple" ]; then echo "skip $plat (no zig triple — darwin builds on a macOS runner)"; continue; fi
+ echo "→ $plat (zig -target $triple, libc=$LIBC)"
+ build_lib "$triple"
+ CGO_ENABLED=1 GOOS="$goos" GOARCH="$goarch" \
+ CC="zig cc -target $triple" CXX="zig c++ -target $triple" \
+ go build -trimpath \
+ -ldflags "-s -w $static_ldflags -X main.version=$VERSION -X main.engineCommit=$ENGINE_COMMIT" \
+ -o "$OUT/chartplotter_${goos}_${goarch}${sfx}${ext}" ./cmd/chartplotter
+done
+
+if [ "$SKIP_RESTORE" = 1 ]; then
+ echo "→ SKIP_RESTORE=1: leaving the last cross-built libtile57.a in place"
+else
+ echo "→ restoring host libtile57.a"
+ build_lib ""
+fi
+
+echo "→ $OUT"
+ls -1 "$OUT"/chartplotter_* 2>/dev/null || true
\ No newline at end of file
diff --git a/testdata/1R7YM012.000 b/testdata/1R7YM012.000
deleted file mode 100644
index dfc0b61..0000000
Binary files a/testdata/1R7YM012.000 and /dev/null differ
diff --git a/tile57 b/tile57
new file mode 160000
index 0000000..068474e
--- /dev/null
+++ b/tile57
@@ -0,0 +1 @@
+Subproject commit 068474e3daf1926a094d101ea2d57945d1e94063
diff --git a/web/fonts/OFL.txt b/web/fonts/OFL.txt
new file mode 100644
index 0000000..6843f31
--- /dev/null
+++ b/web/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs
index 65d94b3..53abb17 100644
--- a/web/src/chart-canvas/chart-canvas.mjs
+++ b/web/src/chart-canvas/chart-canvas.mjs
@@ -50,7 +50,7 @@
// Baking runs server-side; the client only renders tiles.
import { PMTilesArchive, registerPmtilesProtocol } from "./pmtiles-source.mjs";
import { convertDistance, unitSuffix } from "../lib/units.mjs";
-import { zoomForScale, DEFAULT_PX_PITCH_MM, clampPxPitch } from "../lib/util.mjs"; // shared scale↔zoom (512-tile MapLibre resolution)
+import { zoomForScale, scaleDenomPhysical, DEFAULT_PX_PITCH_MM, clampPxPitch } from "../lib/util.mjs"; // shared scale↔zoom (512-tile MapLibre resolution)
import * as S52 from "./s52-style.mjs";
import { SpriteBuilder } from "./sprite-builder.mjs";
// Chart SOURCE / ARCHIVE management lives in its own stateful collaborator now (the
@@ -145,6 +145,22 @@ export class ChartCanvas extends HTMLElement {
this._coastline = null; // offline GSHHG basemap GeoJSON fallback, if available
this._coastlineArchive = null; // offline GSHHG coastline PMTiles (preferred vector basemap)
this._mariner = {}; // current mariner settings (engine-side)
+ // tile57 engine-style mode (set at boot by _initEngineStyle when the server serves
+ // /api/style.json): render from the engine style + apply mariner toggles as engine-
+ // computed diffs. Off → the JS style builder (Go backend). See buildStyle.
+ this._engineMode = false;
+ this._engineStyle = null; // cached full engine style for the last-applied mariner
+ this._engineSet = null; // set the engine style targets (live "tile57" or a baked pack)
+ this._lastMariner = null; // mariner query the engine style currently reflects (diff `from`)
+ // Server-LESS (widget/demo) engine mode: the same tile57 style engine, but run
+ // CLIENT-side from the vendored WASM (../../vendor/tile57-style-engine) instead of
+ // the server's /api/style.json — its chart layers fanned across the per-band
+ // PMTiles sources (mergeStyles-style). Distinct from server engine mode so the
+ // restyle path regenerates via WASM (no /api/style-diff) and _engineStyleMerged
+ // keeps the client's own per-band chart sources. See _initClientEngineStyle.
+ this._engineClient = false;
+ this._engineWasm = null; // loaded StyleEngine (WASM), reused across restyles
+ this._engineLoad = null; // in-flight load promise (load the engine once)
// DEBUG (?ignoreScamin / ?noscamin): drop the per-SCAMIN display gate so every
// feature shows in-band regardless of its 1:N min-display-scale. Deliberately a
// per-page-load CONSTANT read from the URL — NOT a mariner toggle — so it's baked
@@ -155,6 +171,38 @@ export class ChartCanvas extends HTMLElement {
try { const q = new URLSearchParams(location.search); return q.has("ignoreScamin") || q.has("noscamin"); }
catch (e) { return false; }
})();
+ // Engine mode uses the filter-gated SCAMIN style (one live-filtered layer per
+ // render-type instead of per-value #sm bucket layers — scamin-layers.md). This is
+ // what keeps the engine style at ~35 layers instead of ~1200, so a mariner-toggle
+ // diff is ~34 ops, not ~1200 setFilter calls. ?noScaminGate opts out (A/B). The
+ // client re-injects the current display-scale denominator (curDenom) into the gated
+ // layers on SCAMIN-ladder boundary crossings — see _scaminUpdate.
+ this._scaminGate = (() => {
+ try { return !new URLSearchParams(location.search).has("noScaminGate"); }
+ catch (e) { return true; }
+ })();
+ // SCAMIN merged mode is the DEFAULT: collapse the SCAMIN filter-gate/bucket
+ // layer explosion (~18 kinds × up to 33 SCAMIN values × N packs) down to ~1
+ // layer per kind by asking the engine for its MERGED zoom-expression gate
+ // (empty manifest → style.zig zoom_gate). That clause self-gates on the live
+ // zoom, so the entire client SCAMIN injection path (_scaminUpdate /
+ // _scaminApplySettled / _scaminForceWhenReady + the straggler sweep) is
+ // UNNECESSARY and disabled below — no setFilter, no source reload on zoom,
+ // which is what killed the "tiles crawl in a second after you stop scrolling"
+ // lag. Trade-off: integer-zoom snap (the clause steps at whole zoom levels, not
+ // the exact physical crossing) — imperceptible in practice since tiles are
+ // integer-zoom quantized anyway. Opt OUT to the exact per-value filter-gate
+ // (client-injected physical display denominator, no integer snap) with
+ // ?scaminexact — for side-by-side comparison or exact-declutter needs.
+ this._scaminMerged = (() => {
+ try { return !new URLSearchParams(location.search).has("scaminexact"); }
+ catch (e) { return true; }
+ })();
+ this._engineScaminValues = []; // SCAMIN ladder (from the set tilejson) — the crossing boundaries
+ this._scaminBandLast = -1; // last-applied band index (count of ladder values below curDenom)
+ this._scaminApplyT = 0; // settle timer for the deferred gate apply (see _scaminUpdate)
+ this._scaminLightApplied = false; // a mid-zoom LIGHT apply ran — the settle pass must do a real reload
+ this._scaminLayersCache = null; this._chartLayerIdsCache = null; // cached ids of the gated chart layers (filter carries the scamin clause)
this._layerBase = {}; // chart layer id → intrinsic (pre-category) filter
this._bandsHidden = new Set(); // usage bands turned off via setBandVisible (host-persisted)
this._layerVis = {}; // chart layer id → intended (mariner) visibility, so band on/off restores it
@@ -313,6 +361,11 @@ export class ChartCanvas extends HTMLElement {
registerPmtilesProtocol(maplibregl, "chart-" + slug, () => this._sources.bandArchive(slug));
}
+ // tile57 engine-style probe: if the server serves /api/style.json (a -tags tile57
+ // backend), adopt the engine style as the render style (engine mode) BEFORE the first
+ // buildStyle. A 501/error leaves engine mode off → the JS builder (Go backend).
+ await this._initEngineStyle();
+
// -- map ----------------------------------------------------------------
const [lon, lat] = (this.getAttribute("center") || "-76.4875,38.975")
.split(",").map(Number);
@@ -343,6 +396,12 @@ export class ChartCanvas extends HTMLElement {
// Tile fade-in duration. The default 300ms makes pan feel laggy as new tiles
// fade in slowly; 100ms is still smooth but noticeably snappier.
fadeDuration: 100,
+ // Load tiles DURING a zoom gesture, not only after it settles. MapLibre's
+ // default (true) cancels/defers new-zoom tile requests while zooming and only
+ // fires them at moveend, so the finer tiles crawl in ~1s AFTER you stop
+ // scrolling ("everything appears a second after scrolling stops"). Off = the
+ // tiles stream in as you zoom, so they're ready (or nearly) when you stop.
+ cancelPendingTileRequestsWhileZooming: false,
});
this._map = map;
this.map = map; // public handle
@@ -361,14 +420,22 @@ export class ChartCanvas extends HTMLElement {
};
window.__chartGL = () => this._logGLDiag();
- // Touch gestures: keep pinch-zoom but DROP two-finger rotate (and drag-rotate)
- // so a pinch can't tilt/spin the chart out from under the north-up/course-up/
- // head-up follow modes (setCameraMode/updateFollow). MapLibre's default
- // touchZoomRotate couples zoom+rotate on the same two-finger gesture, which on
- // iOS/iPad constantly fought the orientation lock. If free rotation is wanted
- // later, gate this on the camera mode instead of disabling outright.
+ // Touch/rotate/pitch gestures start OFF so a stray pinch can't spin or tilt the
+ // chart out from under the north-up/course-up/head-up follow modes (MapLibre's
+ // touchZoomRotate couples zoom+rotate on one two-finger gesture, which on
+ // iOS/iPad fought the orientation lock). setCameraMode gates the full set — 3D
+ // bearing + pitch — back ON in "free" mode via _setRotateGestures; boot is
+ // north-up, so they stay off (flat, north-up) here.
if (map.touchZoomRotate && map.touchZoomRotate.disableRotation) map.touchZoomRotate.disableRotation();
if (map.dragRotate && map.dragRotate.disable) map.dragRotate.disable();
+ if (map.touchPitch && map.touchPitch.disable) map.touchPitch.disable();
+ // Desktop 3D: Shift + left-drag (free mode only) — horizontal rotates, vertical
+ // MapLibre's own dragRotate binds ctrl+left / right-button, but neither is
+ // reliable cross-platform: on macOS ctrl+click IS the OS secondary click (so
+ // ctrl+drag becomes a right-click/context-menu, never a rotate) and right-drag
+ // pops the menu; on Wayland ctrl/alt+drag is grabbed by the compositor for
+ // window moves. Shift is free everywhere, so bind our own explicit handler.
+ this._installFreeRotate(map);
// Graphical bar scale, complementing the numeric 1:N readout in the app HUD.
// Follows the mariner unit setting: metric (m/km) or imperial (ft/mi); MapLibre
@@ -385,7 +452,30 @@ export class ChartCanvas extends HTMLElement {
// it lives in this element's shadow root, out of reach of the app's :host([spec]) CSS.
if (document.querySelector("chart-plotter-app[spec], chart-plotter[spec]")) this._scaleEl.style.display = "none";
map.addControl({ onAdd: () => this._scaleEl, onRemove: () => { this._scaleEl = null; } }, "bottom-left");
- map.on("move", () => this._renderScalebar());
+ map.on("move", () => {
+ // `move` fires several times per frame; coalesce the scalebar redraw to one
+ // rAF (it rebuilds DOM, and _renderScalebar skips when the bar is unchanged
+ // — which it usually is, since the bar only steps at zoom boundaries).
+ if (!this._scalebarRaf) this._scalebarRaf = requestAnimationFrame(() => { this._scalebarRaf = 0; this._renderScalebar(); });
+ // While ZOOMING (only — pans wait for settle), a crossed ladder boundary
+ // applies at most every 500ms so a long continuous zoom doesn't render a
+ // stale cutoff all the way to moveend. _scaminUpdate early-returns when
+ // no boundary was crossed, so this is cheap per frame; the settle pass
+ // below still runs the final exact apply.
+ if (map.isZooming() && performance.now() - (this._scaminLastApply || 0) >= 500) this._scaminUpdate();
+ });
+ // SCAMIN filter-gate: apply ladder crossings only once the camera SETTLES.
+ // Each setFilter on a gated layer forces MapLibre to re-parse every loaded
+ // tile of that layer's source in the workers (bucket rebuild + symbol
+ // re-placement + GPU re-upload) — measured at ~68 setFilter calls and 4
+ // full source reloads per crossing on a multi-set install, ~110ms of
+ // main-thread time each, several times per zoom gesture. Firing that
+ // mid-gesture was the "jumpy zoom" + symbols-blink storm; at settle it is
+ // ONE coalesced apply, and MapLibre keeps the old buckets on screen while
+ // the reload happens. movestart cancels a pending apply so a resumed
+ // gesture never reloads underneath itself.
+ map.on("movestart", () => clearTimeout(this._scaminApplyT));
+ map.on("moveend", () => this._scaminApplySettled(120));
// Surface MapLibre's own errors (style/source/tile/WebGL) to the console —
// otherwise a failed texture upload is silent (renders black).
@@ -405,6 +495,7 @@ export class ChartCanvas extends HTMLElement {
map.on("idle", () => this._sources._refreshScaminBuckets());
map.on("load", async () => {
this._renderScalebar(); // initial draw (the move hook only fires on movement)
+ this._scaminForceWhenReady(); // engine gate: initial SCAMIN cutoff
// Images are registered LAZILY via the styleimagemissing handler above —
// only the symbols/patterns actually referenced by visible tiles enter
// MapLibre's icon atlas. Eagerly registering all ~750 (724 symbols + 26
@@ -513,7 +604,10 @@ export class ChartCanvas extends HTMLElement {
const dark = this.token("SCLBR", "#e8820c"), light = this.token("CHGRD", "#dfe3e7");
let bar = "";
for (let i = 0; i < 4; i++) bar += ` `;
- el.innerHTML = `${dist} ${unitSuffix(unit)} ${bar} `;
+ const html = `${dist} ${unitSuffix(unit)} ${bar} `;
+ // The bar only changes at zoom-step boundaries (and on unit/scheme change), so
+ // most move frames produce identical HTML — skip the re-parse when unchanged.
+ if (html !== this._lastScalebarHtml) { el.innerHTML = html; this._lastScalebarHtml = html; }
}
// SAFCON01 (S-52 §13.2.13): the depth-contour value label. Drawn client-side
@@ -613,6 +707,10 @@ export class ChartCanvas extends HTMLElement {
for (const id in this._layerBase) {
if (map.getLayer(id)) map.setFilter(id, this.combineFilters(this._layerBase[id]), { validate: false });
}
+ // The gated layers' base filter carries the SCAMIN clause at curDenom=0 (show-all);
+ // re-inject the live cutoff so a mariner toggle doesn't reveal everything until the
+ // next pan/zoom. Cheap (~1 setFilter per gated layer, ~16), only when the gate is on.
+ if (this._scaminGate) this._scaminForceWhenReady();
}
// Update a chart layer's base filter and re-apply it combined with the live
@@ -630,6 +728,8 @@ export class ChartCanvas extends HTMLElement {
setScheme(name) {
if (!this._colortables[name]) return;
this._active = name;
+ // Engine mode: scheme is a mariner field → the diff emits the colour ops.
+ if (this._engineMode) { this._engineRestyle(); return; }
const map = this._map;
// A chart base id targets every band variant; basemap ids fall back to self.
const setIf = (id, prop, val) => { for (const lid of this._variantIds(id)) if (map.getLayer(lid)) map.setPaintProperty(lid, prop, val); };
@@ -670,6 +770,8 @@ export class ChartCanvas extends HTMLElement {
const v = (typeof mm === "number" && mm > 0) ? mm : undefined;
if (v === this._pxPitch) return;
this._pxPitch = v;
+ // Engine mode: sizeScale (from the pixel pitch) is a style input → engine diff.
+ if (this._engineMode) { this._engineRestyle(); return; }
if (this._map && this._sources) {
// Patterns bake the physical-size correction into their pixelRatio at
// registration; a calibration change alters that ratio but registerPattern
@@ -782,11 +884,87 @@ export class ChartCanvas extends HTMLElement {
// "head-up" — recentre on the target, chart rotated to the target's heading
setCameraMode(mode) {
this._cameraMode = mode || "free";
- if (this._map && this._cameraMode === "north-up") this._map.easeTo({ bearing: 0, duration: 300 });
+ // 3D rotation (bearing + pitch) is allowed ONLY in free mode — Shift+drag on
+ // desktop, two-finger twist/tilt on touch. The follow modes are flat and their
+ // bearing is owned by the vessel fix, so the gestures stay off there (a stray
+ // drag would fight the orientation lock). "Gate on the camera mode", per the
+ // boot note.
+ this._setRotateGestures(this._cameraMode === "free");
+ // Leaving free returns to a flat chart; north-up also snaps bearing to 0
+ // (course-/head-up get their bearing from the fix via updateFollow). One
+ // combined easeTo — two separate ones cancel each other.
+ if (this._map && this._cameraMode !== "free") {
+ const cam = { pitch: 0, duration: 300 };
+ if (this._cameraMode === "north-up") cam.bearing = 0;
+ this._map.easeTo(cam);
+ }
if (this._followFix) this.updateFollow(this._followFix);
return this._cameraMode;
}
+ // Shift + left-drag 3D control (free mode only), installed once at map init:
+ // horizontal drag rotates (bearing), vertical drag tilts (pitch) — the same
+ // model as MapLibre's dragRotate, but on the Shift trigger (see the boot note on
+ // why ctrl/right aren't reliable). Drag up = more oblique. drag-pan is suspended
+ // for the drag so it doesn't also translate the map; touch 3D is handled
+ // separately by MapLibre (touchZoomRotate + touchPitch, enabled in free mode).
+ _installFreeRotate(map) {
+ if (!map || !map.getCanvasContainer) return;
+ const cvs = map.getCanvasContainer();
+ const DEG_PER_PX = 0.4;
+ let active = false, startX = 0, startY = 0, startBearing = 0, startPitch = 0, maxPitch = 60;
+ const onMove = (e) => {
+ if (!active) return;
+ map.setBearing(startBearing + (e.clientX - startX) * DEG_PER_PX);
+ const pitch = startPitch - (e.clientY - startY) * DEG_PER_PX; // drag up → more tilt
+ map.setPitch(Math.max(0, Math.min(maxPitch, pitch)));
+ e.preventDefault();
+ };
+ const onUp = () => {
+ if (!active) return;
+ active = false;
+ if (map.dragPan && map.dragPan.enable) map.dragPan.enable();
+ window.removeEventListener("mousemove", onMove, true);
+ window.removeEventListener("mouseup", onUp, true);
+ };
+ cvs.addEventListener("mousedown", (e) => {
+ if (this._cameraMode !== "free" || e.button !== 0 || !e.shiftKey) return;
+ active = true;
+ startX = e.clientX; startY = e.clientY;
+ startBearing = map.getBearing();
+ startPitch = map.getPitch();
+ maxPitch = map.getMaxPitch ? map.getMaxPitch() : 60;
+ if (map.dragPan && map.dragPan.disable) map.dragPan.disable();
+ e.preventDefault();
+ e.stopPropagation();
+ window.addEventListener("mousemove", onMove, true);
+ window.addEventListener("mouseup", onUp, true);
+ });
+ }
+
+ // Enable/disable the full free-mode 3D gesture set (touch two-finger rotate +
+ // pitch, desktop drag-rotate) as one unit. Guarded: the handlers exist only
+ // after map init and some builds gate them, so every call is optional-chained.
+ _setRotateGestures(on) {
+ const map = this._map;
+ if (!map) return;
+ if (on) {
+ if (map.touchZoomRotate && map.touchZoomRotate.enableRotation) map.touchZoomRotate.enableRotation();
+ if (map.touchPitch && map.touchPitch.enable) map.touchPitch.enable();
+ if (map.dragRotate && map.dragRotate.enable) map.dragRotate.enable();
+ // Shift+drag is MapLibre's box-zoom by default — it would fire on the SAME
+ // gesture as our Shift+drag 3D control, so box-zoom yields to it in free mode
+ // (and comes back in the follow modes, where Shift+drag doesn't rotate).
+ if (map.boxZoom && map.boxZoom.disable) map.boxZoom.disable();
+ } else {
+ if (map.touchZoomRotate && map.touchZoomRotate.disableRotation) map.touchZoomRotate.disableRotation();
+ if (map.touchPitch && map.touchPitch.disable) map.touchPitch.disable();
+ if (map.dragRotate && map.dragRotate.disable) map.dragRotate.disable();
+ if (map.boxZoom && map.boxZoom.enable) map.boxZoom.enable();
+ // (the pitch/bearing reset is a single easeTo in setCameraMode)
+ }
+ }
+
// Push the latest target fix {lng, lat, courseDeg?, headingDeg?} from a tracking
// plugin; the camera recentres (and, in course-/head-up, rotates) per the active
// mode. A no-op in "free" mode, so a plugin can stream fixes regardless of mode.
@@ -873,11 +1051,34 @@ export class ChartCanvas extends HTMLElement {
// /tiles/{set}/…), baked + registered by the Go server. Switches into server mode,
// (re)builds the style, and re-requests tiles. Pass [] to clear. Returns the
// active set names.
- setServerSets(names) { return this._sources.setServerSets(names); }
+ async setServerSets(names) {
+ // Re-evaluate the engine style for the NEW active set(s) first (a tile57-baked pack
+ // renders from the engine style, not the JS builder), so buildStyle picks the right
+ // path when the source manager rebuilds. Reset engine state, re-probe for `names`.
+ this._engineMode = false; this._engineStyle = null; this._engineSet = null; this._scaminLayersCache = null; this._chartLayerIdsCache = null;
+ await this._initEngineStyle(Array.isArray(names) ? names : (names ? [names] : []));
+ const active = await this._sources.setServerSets(names);
+ // The source rebuild re-applies the engine style with the SCAMIN filter-gate at its
+ // baked curDenom=0 (show-all). Re-inject the live display-scale cutoff once that new
+ // style has settled — this is the one setStyle path that enters engine mode, and
+ // unlike _engineRestyle/_engineRebuild it had no post-apply _scaminUpdate. Without
+ // it a fresh boot (or set-switch) shows the fully-ungated, over-cluttered chart —
+ // every SCAMIN-gated sounding/symbol drawn regardless of scale — until the first
+ // `move` fires _scaminUpdate. See scamin-layers.md (client half of the filter-gate).
+ if (this._scaminGate && this._engineMode && this._map) {
+ this._map.once("idle", () => this._scaminForceWhenReady());
+ }
+ return active;
+ }
// Convenience: render a single server set (or none). See setServerSets.
setServerSet(name) { return this._sources.setServerSet(name); }
+ // Deepest zoom the loaded prebaked archives hold a tile at over (lng,lat) —
+ // the widget's per-location data depth for the host's camera cap (null in
+ // server mode / before an archive loads). See ChartSources.dataMaxZoomAt.
+ dataMaxZoomAt(lng, lat) { return this._sources.dataMaxZoomAt(lng, lat); }
+
// The active server tile-set names ([] when not in server mode).
serverSets() { return this._sources.serverSets(); }
@@ -918,6 +1119,15 @@ export class ChartCanvas extends HTMLElement {
// once and immutable. Colour scheme is separate (setScheme).
setMariner(settings) {
this._mariner = { ...this._mariner, ...settings };
+ // The no-data hatch is CLIENT chrome (grafted under the engine chart
+ // layers), so its toggle must apply in BOTH modes — the engine style-diff
+ // below knows nothing about the nodata layer and silently dropped it.
+ if ("showNoData" in settings && this._map) {
+ this._eachLayer("nodata", (id) => this._setVis(id, this._mariner.showNoData === false ? "none" : "visible"));
+ }
+ // tile57 engine mode: the display state lives in the engine-generated style, so a
+ // toggle is an engine-computed diff applied in place (no JS in-place updaters).
+ if (this._engineMode) { this._engineRestyle(); return; }
const keys = Object.keys(settings);
const map = this._map;
if (!map) return;
@@ -969,6 +1179,16 @@ export class ChartCanvas extends HTMLElement {
if (keys.includes("showScaleBoundaries")) {
this._eachLayer("scale-boundaries", (id) => this._setVis(id, this._mariner.showScaleBoundaries === false ? "none" : "visible"));
}
+ // S-52 §10.1.10 overscale pattern: a visibility toggle on the per-band
+ // AP(OVERSC01) layers this (JS-builder) path emits ("overscale@").
+ // Engine mode never reaches here — the toggle rides the style-diff instead.
+ if (keys.includes("showOverscale")) {
+ const vis = this._mariner.showOverscale === false ? "none" : "visible";
+ const style = map.getStyle && map.getStyle();
+ if (style && style.layers) for (const l of style.layers) {
+ if (l.id === "overscale" || l.id.startsWith("overscale@")) this._setVis(l.id, vis);
+ }
+ }
// S-52 individually-selectable "Other" items, each a plain visibility
// toggle on its own layer (all default on): spot soundings, light
// descriptions (LIGHTS06 text), and geographic names / object labels.
@@ -989,7 +1209,7 @@ export class ChartCanvas extends HTMLElement {
// Display category (multi-select) and boundary symbolization both filter
// every chart layer by a baked per-feature tag (cat / bnd) — re-apply the
// combined feature filter. Instant — no re-bake.
- if (keys.some((k) => k === "displayBase" || k === "displayStandard" || k === "displayOther" || k === "boundaryStyle" || k === "simplifiedPoints" || k === "showFullSectorLines" || k === "showIsolatedDangersShallow" || k === "dataQuality" || k === "showMetaBounds" || k === "dateDependent" || k === "dateView" || k === "highlightDateDependent" || k === "showInformCallouts")) {
+ if (keys.some((k) => k === "displayBase" || k === "displayStandard" || k === "displayOther" || k === "boundaryStyle" || k === "simplifiedPoints" || k === "showFullSectorLines" || k === "showIsolatedDangersShallow" || k === "dataQuality" || k === "showMetaBounds" || k === "dateDependent" || k === "dateView" || k === "highlightDateDependent" || k === "showInformCallouts" || k === "viewingGroupsOff")) {
this.applyFeatureFilters();
}
}
@@ -1163,6 +1383,598 @@ export class ChartCanvas extends HTMLElement {
return { ...(L.layout || {}), visibility: this._bandsHidden.has(band) ? "none" : vis };
}
buildStyle() {
+ // tile57 ENGINE-STYLE MODE: on the native tile57 backend the MapLibre style comes
+ // from the engine (/api/style.json), not the JS builder — ONE style source, no
+ // drift (see _engineStyleMerged + tile57-style-adoption). The client grafts only its
+ // own basemap + no-data + overlays onto the engine's chart layers. Otherwise (the Go
+ // backend) build the style in JS as before.
+ if (this._engineMode && this._engineStyle) return this._engineStyleMerged();
+ return this._buildStyleJS();
+ }
+
+ // ---- tile57 engine-style mode -----------------------------------------
+
+ // Merge the engine's /api/style.json (chart source + layers, sprite, glyphs) with the
+ // client's own basemap + no-data + overlay chrome: reuse the JS builder purely for
+ // that chrome (its chart layers/sources are dropped) and graft it UNDER the engine's
+ // chart layers. The engine's own background is dropped — the chrome supplies one.
+ _engineStyleMerged() {
+ const engine = this._engineStyle;
+ const js = this._buildStyleJS(); // bg + basemap + no-data (+ JS chart layers we drop)
+ const isChart = (s) => typeof s === "string" && (s === "chart" || s.startsWith("chart-"));
+ const chrome = js.layers.filter((l) => !isChart(l.source));
+ let sources;
+ if (this._engineClient) {
+ // Server-LESS widget/demo: the chart tiles come from the JS builder's per-band
+ // PMTiles sources (chart-://, with their live maxzoom/encoding/cache-bust),
+ // so KEEP those and only swap in the engine's chart LAYERS. (Server mode instead
+ // takes the chart sources from the engine style — the else branch below.)
+ sources = { ...js.sources };
+ } else {
+ const chromeSources = {};
+ for (const [k, v] of Object.entries(js.sources)) if (!isChart(k)) chromeSources[k] = v;
+ sources = { ...chromeSources, ...engine.sources };
+ }
+ this._applySourceBounds(sources); // per-pack bounds → skip non-covering packs' tile requests
+ return {
+ version: 8,
+ glyphs: engine.glyphs || js.glyphs,
+ // No `sprite`: the client renders chart symbols via its own SpriteBuilder
+ // (map.addImage from the tile57-custom sprite.json/png fetched separately),
+ // so a MapLibre sprite URL here only makes it fetch a standard @2x atlas that
+ // doesn't exist (404s). icon-image resolves against the addImage'd images.
+ sources,
+ // chrome (bg → basemap → no-data) UNDER the engine chart layers; drop the engine's
+ // own background so there is a single (client, scheme-aware) sea background.
+ layers: [...chrome, ...engine.layers.filter((l) => l.type !== "background")],
+ };
+ }
+
+ // Serialize the current mariner + scheme to the query /api/style.json & /api/style-diff
+ // read (server marinerFromQuery): bools as 1/0, contours as numbers, viewingGroupsOff
+ // as CSV, sizeScale from the calibrated physical scale.
+ _marinerQuery() {
+ const m = this._mariner, p = new URLSearchParams();
+ p.set("scheme", this._active || "day");
+ if (this._engineSet) p.set("set", this._engineSet); // target set (live "tile57" or a baked pack) → tiles URL + scamin
+
+ const numK = (k) => { if (m[k] != null) p.set(k, String(m[k])); };
+ const boolK = (k) => { if (m[k] != null) p.set(k, m[k] ? "1" : "0"); };
+ numK("shallowContour"); numK("safetyContour"); numK("deepContour"); numK("safetyDepth");
+ boolK("fourShadeWater");
+ if (m.depthUnit) p.set("depthUnit", m.depthUnit);
+ boolK("displayBase"); boolK("displayStandard"); boolK("displayOther");
+ boolK("dataQuality"); boolK("showInformCallouts"); boolK("showMetaBounds"); boolK("showIsolatedDangersShallow");
+ boolK("showOverscale");
+ if (m.boundaryStyle) p.set("boundaryStyle", m.boundaryStyle);
+ boolK("simplifiedPoints"); boolK("showFullSectorLines");
+ boolK("textNames"); boolK("showLightDescriptions"); boolK("textOther");
+ boolK("dateDependent"); boolK("highlightDateDependent");
+ if (m.dateView) p.set("dateView", m.dateView);
+ if (this._ignoreScamin) p.set("ignoreScamin", "1");
+ // Merged mode asks the engine for the zoom-expression gate (empty manifest,
+ // filter-gate off) instead of the per-value/filter-gate bucket layers.
+ if (this._scaminMerged) p.set("scaminMerge", "1");
+ else if (this._scaminGate) p.set("scaminFilterGate", "1");
+ p.set("sizeScale", String(this._featureSizeScale()));
+ if (m.viewingGroupsOff && m.viewingGroupsOff.length) p.set("viewingGroupsOff", m.viewingGroupsOff.join(","));
+ return p.toString();
+ }
+
+ // Fetch the full engine style for the current mariner (null on any failure).
+ async _fetchEngineStyle() {
+ try {
+ const r = await fetch(this._assets + "api/style.json?" + this._marinerQuery(), { cache: "no-store" });
+ if (!r.ok) return null;
+ return await r.json();
+ } catch { return null; }
+ }
+
+ // Probe + adopt the engine style at boot: if /api/style.json serves one (a -tags tile57
+ // backend), cache it + record the mariner it reflects. 501/error → Go path (no-op).
+ async _initEngineStyle(setNames) {
+ // libtile57 is the sole engine, so render ALL active packs from the engine style —
+ // the server composes a multi-source style (one chart- source per pack). The
+ // ?set query is a CSV of the active sets; the server 404s only when nothing is
+ // registered, in which case we leave engine mode off (the map stays chrome-only).
+ const sets = setNames || ((this._sources && this._sources.serverSets && this._sources.serverSets()) || []);
+ this._engineSets = sets;
+ this._engineSet = sets.join(","); // ?set CSV → tiles URLs + per-set scamin
+ if (sets.length === 0) {
+ // No server sets → either a server-less widget/demo (drive the style from the
+ // vendored WASM engine, below) or a not-yet-populated install (nothing to do).
+ await this._initClientEngineStyle();
+ return;
+ }
+ const style = await this._fetchEngineStyle();
+ if (style && Array.isArray(style.layers)) {
+ this._engineStyle = style;
+ this._engineMode = true;
+ this._lastMariner = this._marinerQuery();
+ // SCAMIN ladder (the boundary-crossing set) from the set's TileJSON, for the
+ // filter-gate controller (_scaminUpdate). Best-effort — empty → gate shows all.
+ // This ALSO collects each set's bounds (this._setBounds) from the same fetch,
+ // applied to the chart sources in _engineStyleMerged (below).
+ this._engineScaminValues = await this._fetchScaminValues();
+ }
+ }
+
+ // ---- server-LESS (widget/demo) engine mode: WASM style engine ----------
+ //
+ // The widget/demo has no server, so there is no /api/style.json to adopt — but the
+ // chart style should still come from the tile57 engine, not the JS builder (kills the
+ // JS-vs-engine drift, tile57-style-adoption). Run the SAME engine client-side from the
+ // vendored WASM: generateStyle(mariner) yields the engine's single-source chart style,
+ // which we fan across the client's per-band PMTiles sources exactly like the server's
+ // mergeStyles. On any failure (no vendored wasm / older deployment) we leave engine mode
+ // off and the JS builder (buildChartLayers) renders as before.
+ async _initClientEngineStyle() {
+ if (this._sources && this._sources.server) return; // server mode owns the engine style
+ try {
+ const eng = await this._loadClientEngine();
+ if (!eng) return;
+ this._engineWasm = eng;
+ this._engineStyle = this._composeClientEngineStyle(eng);
+ this._engineMode = true;
+ this._engineClient = true;
+ // The engine's *_scamin layers self-gate on the web-mercator zoom (a baked-in
+ // zoom expression), so the client SCAMIN filter-gate machinery stays OFF — the
+ // same "merged zoom-gate" the server serves by default. Force it on even under
+ // ?scaminexact: the exact per-value filter-gate needs the server's style, which
+ // a server-less widget can't produce.
+ this._scaminMerged = true;
+ } catch (e) {
+ console.warn("[engine-style] client engine unavailable, using JS builder:", e && e.message);
+ this._engineMode = false; this._engineClient = false; this._engineStyle = null; this._engineWasm = null;
+ }
+ }
+
+ // Load (once) the vendored tile57 WASM style engine. The binding module is resolved
+ // relative to THIS module (../../vendor/…); the .wasm bytes are fetched relative to
+ // `assets` (so a subpath-hosted widget finds them). Cached — reused across restyles.
+ _loadClientEngine() {
+ if (this._engineLoad) return this._engineLoad;
+ this._engineLoad = (async () => {
+ const [{ loadStyleEngine }, wasmResp] = await Promise.all([
+ import("../../vendor/tile57-style-engine/index.js"),
+ fetch(this._assets + "vendor/tile57-style-engine/style-engine.wasm"),
+ ]);
+ if (!wasmResp || !wasmResp.ok) throw new Error("style-engine.wasm HTTP " + (wasmResp && wasmResp.status));
+ const wasmBytes = new Uint8Array(await wasmResp.arrayBuffer());
+ return loadStyleEngine({ wasmBytes });
+ })().catch((e) => { this._engineLoad = null; throw e; });
+ return this._engineLoad;
+ }
+
+ // Build the cached client engine style for the current mariner: generate the engine's
+ // single-source chart style from the WASM, then fan its layers across the per-band
+ // PMTiles sources. Glyphs/sources are intentionally omitted — _engineStyleMerged (the
+ // client branch) supplies the client glyphs URL and the per-band chart sources.
+ _composeClientEngineStyle(eng) {
+ const base = eng.generateStyle(this._marinerToEngine());
+ return { layers: this._fanEngineLayers(base.layers || []) };
+ }
+
+ // Fan the engine's chart layers (which reference the single "chart" source) across the
+ // client's per-band chart- sources, mirroring the JS builder's pmtiles fan-out
+ // (chart-style.mjs). Group-outer / band-inner (coarse→fine) so the global draw order is
+ // by S-52 class then band — finer bands' fills over coarser ones, symbols/text above
+ // every band's fills. Grouping keeps each fill/line tier's plain + *_scamin variants
+ // together per band (else a coarse band's SCAMIN area stacks over a finer band's plain
+ // fill — "coastal docks over harbor water"). This is the server mergeStyles fan-out
+ // adapted to the widget's per-band (rather than per-provider) sources.
+ _fanEngineLayers(engineLayers) {
+ const chart = engineLayers.filter((l) => l.type !== "background"); // client chrome supplies the bg
+ // A layer joins the previous group when it shares the same BASE source-layer
+ // (its *_scamin sibling, or another dash/rot variant of the same tier).
+ const baseSrc = (l) => (l["source-layer"] || "").replace(/_scamin$/, "");
+ const groups = [];
+ for (const l of chart) {
+ const g = groups[groups.length - 1];
+ if (g && baseSrc(g[0]) === baseSrc(l)) g.push(l);
+ else groups.push([l]);
+ }
+ const out = [];
+ for (const group of groups) {
+ for (const band of CHART_BANDS) {
+ // Cap overview/general line, pattern-fill and point-symbol layers at their
+ // band max so their overzoomed copies don't duplicate a finer band's
+ // coast/contour/marks (mirrors buildChartLayers _capsAtBand).
+ const cap = (band.slug === "overview" || band.slug === "general") ? band.max : null;
+ for (const l of group) {
+ const v = { ...l, id: l.id + "--" + band.slug, source: "chart-" + band.slug };
+ if (cap != null && this._engineLayerCaps(l)) v.maxzoom = cap;
+ out.push(v);
+ }
+ }
+ }
+ return out;
+ }
+
+ // Which engine layers get the coarse-band maxzoom cap: LINES, pattern (hatch) FILLS,
+ // and POINT-SYMBOL layers — the marks that visibly duplicate a finer band's
+ // coast/contour/symbols when a coarse band overzooms past its compilation scale.
+ _engineLayerCaps(l) {
+ const sl = l["source-layer"] || "";
+ return l.type === "line"
+ || sl === "point_symbols" || sl === "point_symbols_scamin"
+ || (l.type === "fill" && l.paint && l.paint["fill-pattern"] !== undefined);
+ }
+
+ // Map the client mariner state (camelCase this._mariner + this._active scheme) to the
+ // WASM engine's MarinerSettings struct (snake_case; see the vendored index.d.ts). Only
+ // the fields the tile57/1 engine understands are sent — omitted fields take the engine
+ // default. sizeScale / viewingGroupsOff / overscale / scamin toggles are NOT in the
+ // tile57/1 mariner struct (a tile57/2 concern), so they're intentionally left out.
+ _marinerToEngine() {
+ const m = this._mariner || {};
+ const s = { scheme: this._active || "day" };
+ const num = (k, key) => { if (m[k] != null) s[key] = Number(m[k]); };
+ const bool = (k, key) => { if (m[k] != null) s[key] = !!m[k]; };
+ num("shallowContour", "shallow_contour");
+ num("safetyContour", "safety_contour");
+ num("deepContour", "deep_contour");
+ num("safetyDepth", "safety_depth");
+ bool("fourShadeWater", "four_shade_water");
+ if (m.depthUnit) s.depth_unit = m.depthUnit === "ft" ? "feet" : "meters";
+ bool("displayBase", "display_base");
+ bool("displayStandard", "display_standard");
+ bool("displayOther", "display_other");
+ bool("dataQuality", "data_quality");
+ bool("showInformCallouts", "show_inform_callouts");
+ bool("showMetaBounds", "show_meta_bounds");
+ bool("showIsolatedDangersShallow", "show_isolated_dangers_shallow");
+ if (m.boundaryStyle) s.boundary_style = m.boundaryStyle;
+ bool("simplifiedPoints", "simplified_points");
+ bool("showFullSectorLines", "show_full_sector_lines");
+ bool("textNames", "text_names");
+ bool("showLightDescriptions", "show_light_descriptions");
+ bool("textOther", "text_other");
+ bool("dateDependent", "date_dependent");
+ bool("highlightDateDependent", "highlight_date_dependent");
+ if (m.dateView) s.date_view = m.dateView;
+ return s;
+ }
+
+ // Client engine restyle: regenerate the engine style from the WASM for the current
+ // mariner/scheme and apply it with setStyle(diff:true) — the layer SET is stable
+ // across mariners (only filters/paint/colours change), so the diff is in-place with no
+ // tile reload or flicker. This is the server-less counterpart to _engineRestyleNow
+ // (which POSTs /api/style-diff); the widget has no server, so it regenerates locally.
+ async _engineClientRestyleNow() {
+ if (!this._engineClient || !this._map) return;
+ if (!this._map.isStyleLoaded()) { this._map.once("idle", () => this._engineRestyle()); return; }
+ try {
+ const eng = this._engineWasm || await this._loadClientEngine();
+ if (!eng) return;
+ this._engineWasm = eng;
+ this._engineStyle = this._composeClientEngineStyle(eng);
+ this._map.setStyle(this.buildStyle(), { diff: true });
+ } catch (e) {
+ console.warn("[engine-style] client restyle:", e && e.message);
+ }
+ }
+
+ // Inject each active set's geographic bounds onto its chart- vector source,
+ // so MapLibre only requests tiles from packs whose coverage intersects the view.
+ // Without bounds, every viewport tile position is requested from ALL active packs
+ // — 8 of 9 return empty 204s but still saturate the browser's 6-connection pool,
+ // so tiles at a new zoom crawl in ("everything appears a second after you stop
+ // scrolling"). Applied in the merge so it survives a style rebuild.
+ _applySourceBounds(sources) {
+ if (!sources || !this._setBounds) return;
+ const sets = this._engineSets || [];
+ for (const set of sets) {
+ const b = this._setBounds[set];
+ if (!b) continue;
+ // Multi-pack sources are `chart-`; a single live set is just `chart`.
+ const src = sources["chart-" + set] || (sets.length === 1 ? sources["chart"] : null);
+ if (src && src.type === "vector") src.bounds = b;
+ }
+ }
+
+ // The distinct SCAMIN denominators the active packs carry, ascending — the ~19 ladder
+ // boundaries the display scale crosses (for the filter-gate). UNION across every active
+ // set's TileJSON `scamin` array, so a multi-pack install gates on all their boundaries.
+ async _fetchScaminValues() {
+ const sets = (this._engineSets && this._engineSets.length) ? this._engineSets : [this._engineSet].filter(Boolean);
+ const all = new Set();
+ this._setBounds = {};
+ for (const set of sets) {
+ try {
+ const r = await fetch(this._assets + "tiles/" + set + ".json", { cache: "no-store" });
+ if (!r.ok) continue;
+ const tj = await r.json();
+ if (Array.isArray(tj.scamin)) tj.scamin.forEach((v) => all.add(v));
+ if (Array.isArray(tj.bounds) && tj.bounds.length === 4) this._setBounds[set] = tj.bounds;
+ } catch (e) { /* offline / older server → skip */ }
+ }
+ return [...all].sort((a, b) => a - b);
+ }
+
+ // A mariner/scheme change in engine mode: fetch the engine-computed diff (last-applied
+ // → current mariner) and apply the ops IN PLACE — no setStyle, no tile reload, no
+ // overlay churn, no flicker. A `rebuild` op (layer set changed) falls back to a full
+ // re-fetch. Keeps _engineStyle current so a later full rebuild is correct.
+ // DEBOUNCED trigger. The shell pushes several settings at boot (scheme, mariner,
+ // pxPitch) and a user can flip toggles fast; without coalescing, EACH call would fetch
+ // + apply a full diff — on the old bucket style that was ~1200 setFilter ops per call,
+ // ×N calls = the "thousands of setFilter, page won't render" storm. Coalesce to ONE
+ // diff against the last-applied mariner.
+ _engineRestyle() {
+ if (!this._engineMode || !this._map) return;
+ clearTimeout(this._engineRestyleT);
+ // Server-less widget/demo regenerates from the WASM; server mode POSTs a style-diff.
+ this._engineRestyleT = setTimeout(
+ () => (this._engineClient ? this._engineClientRestyleNow() : this._engineRestyleNow()), 40);
+ }
+
+ async _engineRestyleNow() {
+ if (!this._engineMode || !this._map) return;
+ // Don't mutate the style mid-load (setFilter/setStyle re-enters MapLibre's run loop →
+ // "already running"). Defer once the map settles; the boot style already reflects the
+ // boot mariner, so nothing is lost by waiting.
+ if (!this._map.isStyleLoaded()) { this._map.once("idle", () => this._engineRestyle()); return; }
+ const to = this._marinerQuery();
+ const from = this._lastMariner != null ? this._lastMariner : to;
+ if (from === to) { this._lastMariner = to; return; }
+ try {
+ const r = await fetch(this._assets + "api/style-diff", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ from, to }),
+ });
+ if (!r.ok) throw new Error("style-diff HTTP " + r.status);
+ const ops = await r.json();
+ if (ops.length === 1 && ops[0].op === "rebuild") { await this._engineRebuild(); return; }
+ for (const op of ops) this._applyStyleOp(op);
+ this._applyOpsToCached(ops);
+ this._lastMariner = to;
+ // The diff rewrote gated layers' filters back to their baked placeholders
+ // (scamin: show-all; oscl: hide-the-hatch) — re-inject the live cutoff,
+ // retrying past the mid-load window the diff ops themselves create.
+ this._scaminForceWhenReady();
+ } catch (e) {
+ console.warn("[engine-restyle] full rebuild:", e.message);
+ await this._engineRebuild();
+ }
+ }
+
+ // Force-apply the gate cutoff, retrying past mid-load windows. A style-diff
+ // (mariner/VG toggle) rewrites gated filters with their baked placeholders,
+ // and the setFilter ops the diff just applied leave the style mid-load, so a
+ // bare _scaminUpdate(true) silently no-ops and stale gates linger until the
+ // next crossing — force it once the style settles.
+ _scaminForceWhenReady() {
+ // Merged mode's engine style self-gates on the live zoom (zoom-expression clause),
+ // so there is no client cutoff to inject — every force path is a no-op.
+ if (this._scaminMerged) return;
+ const m = this._map;
+ if (!m) return;
+ if (!m.isStyleLoaded || !m.isStyleLoaded()) { m.once("idle", () => this._scaminForceWhenReady()); return; }
+ this._scaminLayersCache = null; this._chartLayerIdsCache = null;
+ this._scaminUpdate(true);
+ }
+
+ // Schedule the settle-deferred gate apply `delay` ms out. If the style is still
+ // mid-load when the timer fires (tiles streaming right after a zoom gesture),
+ // _scaminUpdate's isStyleLoaded guard would silently drop the crossing — so poll
+ // until the style is ready. movestart clears the timer (gesture resumed).
+ _scaminApplySettled(delay) {
+ clearTimeout(this._scaminApplyT);
+ if (this._scaminMerged) return; // merged mode self-gates on zoom — no settle apply
+ this._scaminApplyT = setTimeout(() => {
+ const m = this._map;
+ if (!this._scaminGate || !this._engineMode || !m) return;
+ if (!m.isStyleLoaded || !m.isStyleLoaded()) { this._scaminApplySettled(250); return; }
+ this._scaminUpdate();
+ }, delay);
+ }
+
+ // Re-inject the current display-scale denominator (curDenom) into every gated chart
+ // layer's SCAMIN clause, but ONLY when curDenom has crossed a SCAMIN-ladder boundary
+ // since the last apply (≤19 boundaries across a full zoom sweep). curDenom is the
+ // physical display-scale denominator (zoom + lat + calibrated pxPitch — the same scale
+ // the HUD readout shows). This is the client half of scamin-layers.md.
+ //
+ // COST: each setFilter here makes MapLibre reload the layer's whole SOURCE (worker
+ // re-parse of every loaded tile + symbol re-placement), so this must only run from
+ // the settle-deferred moveend hook or the force paths (boot/restyle/rebuild) — never
+ // per move frame. With N active sets there are ~17×N gated layers but still one
+ // reload per source; the setFilter loop itself is main-thread (validation skipped).
+ _scaminUpdate(force) {
+ // Merged mode: the engine's zoom-expression gate hides/shows features itself, so
+ // there is nothing to setFilter — early-return kills the whole injection loop
+ // (this also covers the mid-zoom `move` hook, which funnels through here).
+ if (this._scaminMerged) return;
+ // Only in engine mode, and only once the style is loaded (getStyle() is undefined
+ // and setFilter throws before that — the `move`/`load` hooks can fire mid-load).
+ if (!this._scaminGate || !this._engineMode || !this._map) return;
+ if (!this._map.isStyleLoaded || !this._map.isStyleLoaded()) return;
+ // Engine mode gets the ladder from the tile57 set TileJSON; the JS builder gets it
+ // from the chart-source manager (values discovered from the loaded tiles' manifest).
+ const values = this._engineMode ? this._engineScaminValues : ((this._sources && this._sources.scaminValues) || []);
+ const denom = scaleDenomPhysical(this._map.getZoom(), this._map.getCenter().lat, this._pxPitch);
+ let band = 0;
+ for (const v of values) if (v < denom) band++;
+ // Mid-gesture (the `move` hook) crossings get a LIGHT apply: sync the new cutoff
+ // to the worker layers so tiles PARSED FROM HERE ON use it, but skip the source
+ // reload — a full setFilter reloads the whole source (re-parse of every loaded
+ // tile + cache reset + symbol re-placement), and doing that up to ~10× inside one
+ // continuous zoom was ~80-90 wasted chart-tile re-parses per gesture whose
+ // results were stale on arrival anyway. Already-loaded tiles keep their old
+ // cutoff until the settle apply — which stays a real (reloading) apply, so the
+ // settled chart is always SCAMIN-exact.
+ const light = !force && this._map.isZooming();
+ // A light apply leaves loaded tiles stale, so a later settle pass must run even
+ // when the band hasn't moved since (the usual dedup would skip it).
+ if (!force && band === this._scaminBandLast && !(this._scaminLightApplied && !light)) return;
+ this._scaminBandLast = band;
+ this._scaminLastApply = performance.now(); // rate-limits the mid-zoom applies (move hook)
+ const map = this._map;
+ let anyLight = false;
+ for (const id of this._scaminGatedLayers()) {
+ // The gated-layer cache can go stale across style diffs/rebuilds, and
+ // MapLibre's getFilter THROWS on a missing id — unguarded, one stale
+ // entry killed this whole loop mid-iteration (silently, in a timer),
+ // freezing every later layer's cutoff at its last-applied denominator
+ // ("light with SCAMIN 499999 dips out at 255000").
+ let f = null;
+ try { f = map.getLayer(id) ? map.getFilter(id) : null; } catch { /* stale id */ }
+ if (!f) { this._scaminLayersCache = null; this._chartLayerIdsCache = null; continue; } // recollect next apply
+ const nf = JSON.parse(JSON.stringify(f));
+ if (setScaminDenom(nf, denom)) {
+ if (light && this._scaminLightSetFilter(id, nf)) { anyLight = true; continue; }
+ try { map.setFilter(id, nf, { validate: false }); } catch { /* ignore */ }
+ }
+ }
+ // A successful LIGHT apply did not reload anything, so there are no straggler
+ // parses to sweep — and arming the sweep mid-gesture would reintroduce the
+ // very reload churn the light path exists to avoid. The settle apply below
+ // (a real setFilter pass) arms it as usual.
+ if (anyLight) { this._scaminLightApplied = true; return; }
+ if (!light && this._scaminLightApplied) {
+ // Settle after light applies: the loop above went through the real setFilter,
+ // but MapLibre deep-equal-skips a filter identical to the light-applied one
+ // (same denom → no reload → tiles parsed before the light sync stay stale).
+ // Queue the gated sources for one explicit reload to guarantee exactness.
+ this._scaminLightApplied = false;
+ this._scaminQueueGatedReloads();
+ }
+ // Straggler sweep: a tile whose worker parse STARTED before the apply's layer
+ // broadcast but FINISHED after the reload keeps buckets built under the old
+ // filters — permanently (MapLibre re-parses only 'loaded' tiles). Symptom: a
+ // steady blank chart window that appears flakily at BOOT and never heals. That
+ // race is a ONE-TIME boot condition (once the layers are broadcast, later tiles
+ // parse correctly), so sweep the chart sources ONCE on the first idle after the
+ // first apply — NOT on every zoom settle. The old per-apply sweep re-reloaded
+ // (re-fetched + re-parsed) every tile of every source on every band crossing,
+ // which on a multi-pack install was a huge chunk of the "tiles crawl in after
+ // you stop scrolling" lag.
+ if (this._scaminSwept || this._scaminSweepArmed) return;
+ this._scaminSweepArmed = true;
+ map.once("idle", () => {
+ this._scaminSweepArmed = false;
+ this._scaminSwept = true;
+ try {
+ const tms = map.style.tileManagers || map.style.sourceCaches;
+ for (const sid of Object.keys(tms)) {
+ if (sid === "chart" || sid.startsWith("chart-")) map.style._reloadSource(sid);
+ }
+ } catch { /* internal API drifted — the next crossing still converges */ }
+ });
+ }
+
+ // The internals half of the light apply: set the layer's filter + mark it for the
+ // next worker-layer sync WITHOUT marking its source for reload (map.setFilter does
+ // both — see style._updateLayer). Uses the same style internals the chart-source
+ // manager already duck-types (style.tileManagers); returns false → the caller falls
+ // back to the full setFilter, so a MapLibre upgrade can only lose the optimisation.
+ _scaminLightSetFilter(id, nf) {
+ const st = this._map && this._map.style;
+ const ly = st && st._layers && st._layers[id];
+ if (!ly || typeof ly.setFilter !== "function" || !st._updatedLayers) return false;
+ try { ly.setFilter(nf); } catch { return false; }
+ st._updatedLayers[id] = true;
+ st._changed = true; // flushed by style.update() on the next render frame (we're zooming — frames are flowing)
+ return true;
+ }
+
+ // Force one reload of every source that carries gated layers — the settle-side
+ // counterpart of the light apply. Mirrors what style._updateLayer queues for a real
+ // setFilter (reload entry + paused manager, resumed by _reloadSource in the flush).
+ // Best-effort: if the internals moved, the next real crossing still reloads.
+ _scaminQueueGatedReloads() {
+ const m = this._map, st = m && m.style;
+ if (!st || !st._updatedSources || !st.tileManagers) return;
+ for (const id of this._scaminGatedLayers()) {
+ const ly = st._layers && st._layers[id];
+ const src = ly && ly.source;
+ if (!src || st._updatedSources[src] || !st.tileManagers[src]) continue;
+ st._updatedSources[src] = "reload";
+ st.tileManagers[src].pause();
+ st._changed = true;
+ }
+ // Make sure the flush actually runs — after moveend there may be no render queued.
+ if (typeof m._update === "function") m._update(true);
+ else if (typeof m.triggerRepaint === "function") m.triggerRepaint();
+ }
+
+ // Cached ids of the chart layers whose filter carries the SCAMIN clause (the gated
+ // layers). Recomputed after a diff/rebuild (cache nulled) — ~16 layers, cheap to scan.
+ _scaminGatedLayers() {
+ if (this._scaminLayersCache) return this._scaminLayersCache;
+ const style = this._map && this._map.getStyle();
+ if (!style || !style.layers) return []; // style not ready yet — don't cache the empty result
+ const out = [];
+ for (const l of style.layers) {
+ if (l.filter && setScaminDenom(JSON.parse(JSON.stringify(l.filter)), 0, true)) out.push(l.id);
+ }
+ return (this._scaminLayersCache = out);
+ }
+
+ // Cached ids of every layer drawn from a chart source (the tile57/2 schema's 6
+ // merged source-layers: areas, area_patterns, lines, point_symbols, soundings,
+ // text — across the live "chart" source and per-band "chart-" sources).
+ // Selected dynamically by source prefix, so this tracks whatever layers the engine
+ // style ships (SCAMIN twins and complex/sector lines are folded into those 6).
+ // Passing these to queryRenderedFeatures({layers}) lets MapLibre skip the basemap /
+ // OSM / no-data / overlay layers entirely, so the hot pick + inspect-hover paths
+ // query only what they'll keep instead of querying ALL layers and filtering by
+ // source afterward. Cache nulled on every style diff/rebuild (alongside
+ // _scaminLayersCache).
+ chartLayerIds() {
+ if (this._chartLayerIdsCache) return this._chartLayerIdsCache;
+ const style = this._map && this._map.getStyle();
+ if (!style || !style.layers) return []; // style not ready — don't cache the empty result
+ const out = [];
+ for (const l of style.layers) {
+ if (l.source && String(l.source).startsWith("chart") && !l.id.startsWith("scaminprobe")) out.push(l.id);
+ }
+ return (this._chartLayerIdsCache = out);
+ }
+
+ // Apply one engine diff op to the live map (ops only reference engine chart layers;
+ // a stray unknown layer just no-ops in the try/catch).
+ _applyStyleOp(op) {
+ const map = this._map; if (!map) return;
+ try {
+ if (op.op === "setFilter") map.setFilter(op.layer, op.value ?? null);
+ else if (op.op === "setPaintProperty") map.setPaintProperty(op.layer, op.property, op.value ?? null);
+ else if (op.op === "setLayoutProperty") map.setLayoutProperty(op.layer, op.property, op.value ?? null);
+ } catch { /* layer not in the live style — ignore */ }
+ }
+
+ // Keep the cached engine style consistent with applied ops, so a later FULL rebuild
+ // (basemap change, calibration) reflects the current mariner, not the boot one.
+ _applyOpsToCached(ops) {
+ const byId = {};
+ for (const l of this._engineStyle.layers) byId[l.id] = l;
+ for (const op of ops) {
+ const l = byId[op.layer]; if (!l) continue;
+ if (op.op === "setFilter") { if (op.value == null) delete l.filter; else l.filter = op.value; }
+ else if (op.op === "setPaintProperty") { l.paint = l.paint || {}; if (op.value == null) delete l.paint[op.property]; else l.paint[op.property] = op.value; }
+ else if (op.op === "setLayoutProperty") { l.layout = l.layout || {}; if (op.value == null) delete l.layout[op.property]; else l.layout[op.property] = op.value; }
+ }
+ }
+
+ // Full engine-style rebuild: re-fetch for the current mariner + setStyle(diff:true) so
+ // MapLibre applies the delta without flicker; overlays self-heal on style.load.
+ async _engineRebuild() {
+ const style = await this._fetchEngineStyle();
+ if (!style || !this._map) return;
+ this._engineStyle = style;
+ // Don't setStyle mid-load (re-entrancy → "already running"); defer to the next idle.
+ if (!this._map.isStyleLoaded()) { this._map.once("idle", () => { this._map.setStyle(this.buildStyle(), { diff: true }); this._lastMariner = this._marinerQuery(); this._scaminForceWhenReady(); }); return; }
+ this._map.setStyle(this.buildStyle(), { diff: true });
+ this._lastMariner = this._marinerQuery();
+ this._scaminForceWhenReady(); // re-inject the cutoff once the new style settles
+ }
+
+ // The JS S-52 style builder — the Go-backend render path. Assembles the chart layers
+ // (buildChartLayers, from baked per-feature tags) + basemap + no-data. On the tile57
+ // backend this is superseded by the engine style; _engineStyleMerged still calls it to
+ // reuse the client's own basemap/no-data chrome (grafting it onto the engine layers).
+ _buildStyleJS() {
// The CHART band + server-set sources (per-band prebaked sources in BOTH modes,
// plus one source per active server pack) are assembled by the chart-source
// manager, keyed by its cache-bust token. The element then adds the basemap +
@@ -1195,7 +2007,11 @@ export class ChartCanvas extends HTMLElement {
const basemap = this.getAttribute("basemap") || "none";
if (basemap === "osm") {
- sources.osm = { type: "raster", tileSize: 256, maxzoom: 19, tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], attribution: "© OpenStreetMap contributors" };
+ // Fetch OSM raster tiles through the server proxy (same-origin): OSM's tile
+ // policy 403s direct browser requests that can't set an identifying
+ // User-Agent (a forbidden fetch header); the server sets a compliant one.
+ const osmUrl = new URL(this._assets + "osm/{z}/{x}/{y}.png", location.href).href;
+ sources.osm = { type: "raster", tileSize: 256, maxzoom: 19, tiles: [osmUrl], attribution: "© OpenStreetMap contributors" };
layers.push({ id: "osm", type: "raster", source: "osm", paint: this._osmRasterPaint() });
} else if (basemap === "osmvec" && this._osmvecArchive) {
// Hosted OSM vector (Protomaps schema). Styled per source-layer (no kind
@@ -1258,4 +2074,31 @@ export class ChartCanvas extends HTMLElement {
// and refilter that single id directly.
// Custom element names must contain a hyphen (HTML spec) — ``.
+// Find the tile57 filter-gate SCAMIN clause `[">=", ["coalesce",["get","scamin"],1e12], N]`
+// and the overscale shape `[">", ["coalesce",["get","oscl"],0], N]` (the S-52
+// §10.1.10 AP(OVERSC01) hatch + the overscaled/at-scale fill split; the at-scale
+// pass wraps it in ["!", …], which this recursion reaches on its own; ../tile57
+// overscale spec) — anywhere in a MapLibre filter and set each clause's literal N
+// to `denom` (the current display-scale denominator). (The band-handoff `smax`
+// twin is retired: the coverage-clipped composite owns cross-band occlusion
+// geometrically, and the engine style emits no smax clause.)
+// detectOnly=true just reports whether a gated clause is present (no mutation).
+// Returns true if a clause was found. Mutates `node` in place.
+function setScaminDenom(node, denom, detectOnly) {
+ if (!Array.isArray(node)) return false;
+ if (node[0] === ">=" && Array.isArray(node[1]) && node[1][0] === "coalesce"
+ && Array.isArray(node[1][1]) && node[1][1][0] === "get" && node[1][1][1] === "scamin") {
+ if (!detectOnly) node[2] = denom;
+ return true;
+ }
+ if (node[0] === ">" && Array.isArray(node[1]) && node[1][0] === "coalesce"
+ && Array.isArray(node[1][1]) && node[1][1][0] === "get" && node[1][1][1] === "oscl") {
+ if (!detectOnly) node[2] = denom;
+ return true; // overscale gate: hatch + fill-areas#oscl / negated fill-areas split
+ }
+ let found = false;
+ for (const c of node) if (setScaminDenom(c, denom, detectOnly)) found = true;
+ return found;
+}
+
customElements.define("chart-canvas", ChartCanvas);
diff --git a/web/src/chart-canvas/chart-canvas.scaminmerge.test.mjs b/web/src/chart-canvas/chart-canvas.scaminmerge.test.mjs
new file mode 100644
index 0000000..80f46a7
--- /dev/null
+++ b/web/src/chart-canvas/chart-canvas.scaminmerge.test.mjs
@@ -0,0 +1,104 @@
+// Tests the ?scaminmerge A/B toggle on : in merged mode the client
+// asks the engine for its zoom-expression SCAMIN gate (scaminMerge=1, NOT
+// scaminFilterGate) and DISABLES the whole client SCAMIN injection path
+// (_scaminUpdate / _scaminApplySettled / _scaminForceWhenReady early-return, so no
+// setFilter / source reload fires on zoom). Normal mode is unchanged.
+// Run: node --test web/src/chart-canvas/chart-canvas.scaminmerge.test.mjs
+//
+// extends HTMLElement and calls customElements.define at load, so we
+// shim the two custom-element globals before importing — the module is otherwise
+// node-safe (maplibre is a lazy dynamic import). We drive the pure methods via
+// prototype.call(stub) so no DOM/map is constructed.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+globalThis.HTMLElement = globalThis.HTMLElement || class {};
+globalThis.customElements = globalThis.customElements || { define() {} };
+
+const { ChartCanvas } = await import("./chart-canvas.mjs");
+const proto = ChartCanvas.prototype;
+
+// Minimal `this` for _marinerQuery: it reads _mariner (an object of settings, all
+// omitted here so every key is skipped), _active, _engineSet, the three SCAMIN
+// flags, and _featureSizeScale().
+function marinerStub(over) {
+ return Object.assign({
+ _mariner: {},
+ _active: "day",
+ _engineSet: "tile57",
+ _ignoreScamin: false,
+ _scaminMerged: false,
+ _scaminGate: true,
+ _featureSizeScale: () => 1,
+ }, over);
+}
+
+test("_marinerQuery: merged mode sends scaminMerge=1 and NOT scaminFilterGate", () => {
+ const q = new URLSearchParams(proto._marinerQuery.call(marinerStub({ _scaminMerged: true })));
+ assert.equal(q.get("scaminMerge"), "1");
+ assert.equal(q.get("scaminFilterGate"), null);
+ assert.equal(q.get("set"), "tile57");
+});
+
+test("_marinerQuery: normal (non-merged) mode sends scaminFilterGate=1 and NOT scaminMerge", () => {
+ const q = new URLSearchParams(proto._marinerQuery.call(marinerStub({ _scaminMerged: false, _scaminGate: true })));
+ assert.equal(q.get("scaminFilterGate"), "1");
+ assert.equal(q.get("scaminMerge"), null);
+});
+
+test("_scaminForceWhenReady: merged mode is a no-op (never calls _scaminUpdate)", () => {
+ let updates = 0;
+ const stub = {
+ _scaminMerged: true,
+ _map: { isStyleLoaded: () => true, once: () => { throw new Error("must not defer"); } },
+ _scaminUpdate: () => { updates++; },
+ };
+ proto._scaminForceWhenReady.call(stub);
+ assert.equal(updates, 0, "merged mode must not inject a cutoff");
+});
+
+test("_scaminForceWhenReady: normal mode DOES inject the cutoff (calls _scaminUpdate(true))", () => {
+ const args = [];
+ const stub = {
+ _scaminMerged: false,
+ _map: { isStyleLoaded: () => true },
+ _scaminLayersCache: {}, _chartLayerIdsCache: {},
+ _scaminUpdate: (force) => { args.push(force); },
+ };
+ proto._scaminForceWhenReady.call(stub);
+ assert.deepEqual(args, [true], "normal mode re-injects the live cutoff");
+});
+
+test("_scaminUpdate: merged mode early-returns before any setFilter (even with all other guards open)", () => {
+ let setFilters = 0;
+ // All the NON-merged guards are deliberately satisfied, so _scaminMerged is the
+ // ONLY thing that can stop the injection loop.
+ const stub = {
+ _scaminMerged: true,
+ _scaminGate: true,
+ _engineMode: true,
+ _map: {
+ isStyleLoaded: () => true,
+ getZoom: () => 13, getCenter: () => ({ lat: 38.9 }),
+ getLayer: () => ({}), getFilter: () => ["all"],
+ setFilter: () => { setFilters++; },
+ },
+ _pxPitch: undefined,
+ _engineScaminValues: [30000, 12000],
+ _scaminGatedLayers: () => { throw new Error("must not scan gated layers in merged mode"); },
+ };
+ proto._scaminUpdate.call(stub, true);
+ assert.equal(setFilters, 0, "merged mode must issue zero setFilter (the zoom-expression self-gates)");
+});
+
+test("_scaminApplySettled: merged mode schedules no settle apply", async () => {
+ let scheduled = false;
+ const realSetTimeout = globalThis.setTimeout;
+ globalThis.setTimeout = (fn, d) => { scheduled = true; return realSetTimeout(fn, d); };
+ try {
+ proto._scaminApplySettled.call({ _scaminMerged: true, _scaminApplyT: 0 }, 120);
+ assert.equal(scheduled, false, "merged mode must not arm the settle timer");
+ } finally {
+ globalThis.setTimeout = realSetTimeout;
+ }
+});
diff --git a/web/src/chart-canvas/chart-sources.engine-stamp.test.mjs b/web/src/chart-canvas/chart-sources.engine-stamp.test.mjs
new file mode 100644
index 0000000..5033e80
--- /dev/null
+++ b/web/src/chart-canvas/chart-sources.engine-stamp.test.mjs
@@ -0,0 +1,64 @@
+// Tests engineStamp — the attribution-corner ENGINE-COMMIT stamp built from the
+// active server sets' TileJSON `engine` fields: one muted commit when every set
+// agrees; a per-pack "label:commit" list flagged mixed (minority groups marked ✱)
+// when they differ (a partially re-baked cache); null (stamp hidden) when no set
+// reports an engine (pmtiles mode / an older server).
+// Run: node --test web/src/chart-canvas/chart-sources.engine-stamp.test.mjs
+import test from "node:test";
+import assert from "node:assert/strict";
+import { engineStamp } from "./chart-sources.mjs";
+
+test("all sets agree → the single commit, not mixed", () => {
+ const s = engineStamp([
+ { name: "noaa-d5-coastal", engine: "abc123def" },
+ { name: "noaa-d5-harbor", engine: "abc123def" },
+ { name: "noaa-d7-approach", engine: "abc123def" },
+ ]);
+ assert.ok(s);
+ assert.equal(s.text, "abc123def");
+ assert.equal(s.mixed, false);
+ // Tooltip lists every set's full detail.
+ assert.match(s.title, /noaa-d5-coastal: abc123def/);
+ assert.match(s.title, /noaa-d7-approach: abc123def/);
+});
+
+test("differing engines → per-pack groups, majority first, minority marked", () => {
+ const s = engineStamp([
+ { name: "noaa-d5-coastal", engine: "abc123def" },
+ { name: "noaa-d5-harbor", engine: "abc123def" },
+ { name: "noaa-d7-approach", engine: "def456abc" },
+ ]);
+ assert.ok(s);
+ assert.equal(s.mixed, true);
+ // Majority (d5 ×2) leads unmarked; the disagreeing d7 group carries the ✱.
+ assert.equal(s.text, "d5:abc123def d7:def456abc✱");
+ assert.match(s.title, /✱ differs/);
+});
+
+test("band suffixes collapse to one pack label per group", () => {
+ const s = engineStamp([
+ { name: "noaa-d5-coastal", engine: "aaa" },
+ { name: "noaa-d5-harbor", engine: "aaa" },
+ { name: "noaa-d5-berthing", engine: "bbb" }, // one band re-baked by a newer engine
+ ]);
+ assert.equal(s.mixed, true);
+ assert.equal(s.text, "d5:aaa d5:bbb✱");
+});
+
+test("live tile57 set and pre-stamp packs keep their labels/values", () => {
+ const s = engineStamp([
+ { name: "tile57", engine: "abc123def" }, // live set → running binary's commit
+ { name: "ienc-coastal", engine: "pre-stamp" }, // legacy pack without the sidecar
+ ]);
+ assert.equal(s.mixed, true);
+ // Non-noaa names keep their pack name as the label.
+ assert.ok(s.text.includes("tile57:abc123def"));
+ assert.ok(s.text.includes("ienc:pre-stamp✱"));
+});
+
+test("no engine info anywhere → null (stamp hidden)", () => {
+ assert.equal(engineStamp([]), null);
+ assert.equal(engineStamp(null), null);
+ // Older server: metas exist but carry no engine field.
+ assert.equal(engineStamp([{ name: "noaa-d5-coastal" }, { name: "noaa-d5-harbor", engine: "" }]), null);
+});
diff --git a/web/src/chart-canvas/chart-sources.mjs b/web/src/chart-canvas/chart-sources.mjs
index c650815..4d2956e 100644
--- a/web/src/chart-canvas/chart-sources.mjs
+++ b/web/src/chart-canvas/chart-sources.mjs
@@ -64,7 +64,11 @@ export const BAND_DISPLAY_MIN = { overview: 0, general: 0, coastal: 9, approach:
// no-SCAMIN counterparts stay in the original (areas/area_patterns/lines/
// complex_lines) layers — single, always-in-band, NOT bucketed.
export const SCAMIN_BUCKET_LAYERS = new Set(["point_symbols", "soundings", "text", "sector_lines",
- "areas_scamin", "area_patterns_scamin", "lines_scamin", "complex_lines_scamin"]);
+ "areas_scamin", "area_patterns_scamin", "lines_scamin", "complex_lines_scamin",
+ // The native tile57 engine splits SCAMIN point symbols + text into their own
+ // source-layers (vs. the Go baker's in-layer `scamin` property), so bucket those
+ // too — without them, tile57's SCAMIN buoys/beacons/lights/labels never show.
+ "point_symbols_scamin", "text_scamin"]);
// Centre-latitude drift (degrees) that triggers a SCAMIN bucket-minzoom rebuild.
// The cutoff zoom shifts with cos(lat); 2° keeps the error under ~0.05 zoom at
@@ -109,6 +113,47 @@ export function bandOfSet(name) {
return "all";
}
+// engineStamp — the compact ENGINE-COMMIT stamp for the attribution corner: which
+// tile57 engine commit baked the ACTIVE sets' visible tiles. Each set's TileJSON
+// carries `engine` (bake-time truth for packs, stamped when they were baked;
+// "pre-stamp" for packs baked before stamping; the RUNNING binary's commit for
+// live --tile57/dynamic sets). Returns null when no active set reports one
+// (pmtiles mode / an older server) — the stamp hides.
+// • all sets agree → { text: " | | | | | | | | |