diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a4a83c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,58 @@ +# Docker build context trimming. The image build only needs the Go source, the +# embedded web assets (web/embed.go go:embed list), go.mod/go.sum, and +# scripts/xbuild-tile57.sh — everything else just bloats the context. The tile57 +# engine is git-cloned inside the builder, not copied from here. + +# VCS + agent/editor state +.git/ +.github/ +.claude/ +.idea/ +.vscode/ +.DS_Store + +# The tile57 engine submodule: cloned fresh in the builder (shallow + floating), +# never copied from the context — so the local checkout doesn't leak in or pin it. +tile57/ + +# Build output / release staging +bin/ +dist/ +release-assets/ +*.test +*.out +coverage.* + +# Local Go workspace override (must never reach the builder — it would repoint the +# engine replace at a path that doesn't exist in the image). +go.work +go.work.sum + +# Baked tile archives + generated (non-embedded) client assets +*.pmtiles +web/*.pmtiles +web/colortables.json +web/linestyles.json +web/patterns.json +web/patterns.png +web/sprite.json +web/sprite.png +web/chartplotter.wasm +web/charts-index.json +web/charts-user.* + +# Large test/data material + local artifacts (not needed to build the binary) +testdata/ +examples/ +*.cpx +/chartplotter + +# The docs site (Docusaurus) is not part of the binary build +docs/node_modules/ +docs/build/ +docs/.docusaurus/ + +# Docker + compose files themselves +Dockerfile +.dockerignore +compose.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3e0131..5b6eb6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,31 +4,71 @@ on: push: pull_request: +# libtile57 is the sole engine: the build is CGO and statically links the native +# lib from the public beetlebugorg/tile57 repo, built with Zig 0.16. That repo is +# the ./tile57 git submodule (go.mod's replace points at ./tile57/bindings/go, and +# the binding's cgo LDFLAGS at its zig-out/lib); its own submodules fetch the IHO +# S-101 catalogues. `submodules: recursive` checks it out at the committed pin, then +# we float it to the engine's latest main (the pin is just last-known-good). +# NOTE: correct-by-construction but unvalidated until pushed to GitHub. jobs: build-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - name: Checkout chartplotter (+ tile57 engine submodule) + uses: actions/checkout@v7 + with: + path: chartplotter + submodules: recursive + # Keep building against the engine's latest main (floating); the committed + # submodule pin is just last-known-good. Move tile57 to origin/main, then + # re-sync its nested IHO catalogues to that commit's pins. + - name: Float tile57 submodule to latest main + run: | + git -C chartplotter submodule update --remote tile57 + git -C chartplotter submodule update --init --recursive tile57 - uses: actions/setup-go@v6 with: go-version: '1.26' cache: true + cache-dependency-path: chartplotter/go.sum + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Build libtile57 + run: make -C chartplotter tile57-lib - name: Format check - run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1) + run: make -C chartplotter fmt-check - name: Vet - run: go vet ./... + run: make -C chartplotter vet - name: Test - run: go test ./... + run: make -C chartplotter test - name: Build - run: go build ./... + run: make -C chartplotter build govulncheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - name: Checkout chartplotter (+ tile57 engine submodule) + uses: actions/checkout@v7 + with: + path: chartplotter + submodules: recursive + - name: Float tile57 submodule to latest main + run: | + git -C chartplotter submodule update --remote tile57 + git -C chartplotter submodule update --init --recursive tile57 - uses: actions/setup-go@v6 with: go-version: '1.26' cache: true + cache-dependency-path: chartplotter/go.sum + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Build libtile57 + run: make -C chartplotter tile57-lib - name: Vulnerability scan - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + run: cd chartplotter && go run golang.org/x/vuln/cmd/govulncheck@latest ./... diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..bfb6793 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,76 @@ +name: Docker + +# Build + publish the multi-arch container image to GHCR. On a v* tag we push the +# version + `latest`; on main we push `edge` (a rolling pre-release image). The +# image is a FROM scratch wrapper around the fully-static musl binary (the +# "go-dims" pattern) — see Dockerfile. +# +# Multi-arch is done by zig-cc CROSS-COMPILE inside the builder stage, NOT QEMU: +# the builder is pinned to the native BUILDPLATFORM and links each TARGETARCH with +# `zig cc`, so buildx emits linux/amd64 + linux/arm64 from one fast amd64 runner. +# The engine (public github.com/beetlebugorg/tile57, whose submodules hold the IHO +# S-101 catalogues) is git-cloned inside the Dockerfile, so no sibling checkout is +# needed here. Same IHO-embed caveat as the binary releases (accepted — the image +# embeds the catalogue via libtile57). +# +# NOTE: correct-by-construction but UNVALIDATED until a real push runs on GitHub +# (buildx needs GHCR auth + the runner, neither available locally). + +on: + push: + branches: [main] + tags: ["v*"] + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/beetlebugorg/chartplotter + +jobs: + image: + runs-on: ubuntu-latest + steps: + - name: Checkout chartplotter + uses: actions/checkout@v7 + + - name: Set up QEMU + # Only needed so buildx can DECLARE the arm64 platform; the actual build is + # a native cross-compile (the builder stage pins --platform=$BUILDPLATFORM), + # so no guest code is emulated. + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build + push (linux/amd64, linux/arm64) + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('edge-{0}', github.sha) }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9bcbd39..a7c7d52 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,6 +11,7 @@ on: - "web/**" - "cmd/**" - "internal/**" + - "pkg/**" - "scripts/fetch-demo-cells.sh" - "Makefile" - "go.mod" @@ -27,16 +28,38 @@ concurrency: group: pages cancel-in-progress: true +# The demo + Chart 1 tiles are baked by the chartplotter binary, which links the +# native libtile57 engine — so this build needs the ./tile57 engine submodule (its +# own submodules fetch the IHO S-101 catalogues from the IHO's own repos) and the +# Zig toolchain, same as ci.yml. `submodules: recursive` checks it out at the +# committed pin, then it's floated to the engine's latest main (matching go.mod's +# ./tile57/bindings/go replace). +# NOTE: correct-by-construction but unvalidated until pushed to GitHub. jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - name: Checkout chartplotter (+ tile57 engine submodule) + uses: actions/checkout@v7 + with: + path: chartplotter + submodules: recursive + - name: Float tile57 submodule to latest main + run: | + git -C chartplotter submodule update --remote tile57 + git -C chartplotter submodule update --init --recursive tile57 - uses: actions/setup-go@v6 with: go-version: "1.26" cache: true + cache-dependency-path: chartplotter/go.sum + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Build libtile57 + run: make -C chartplotter tile57-lib # Cache the curated NOAA demo-cell downloads (one per band over Annapolis), # keyed on the cell list / fetch script so a change re-downloads. Lets the @@ -45,7 +68,7 @@ jobs: uses: actions/cache@v6 with: path: ${{ runner.temp }}/demo-cells - key: demo-cells-${{ hashFiles('scripts/fetch-demo-cells.sh', 'Makefile') }} + key: demo-cells-${{ hashFiles('chartplotter/scripts/fetch-demo-cells.sh', 'chartplotter/Makefile') }} # Cache the S-52 PresLib "ECDIS Chart 1" source cells (the IHO draft zip is # fetched once and extracted), keyed on the fetch script so a change re-pulls. @@ -53,31 +76,26 @@ jobs: uses: actions/cache@v6 with: path: ${{ runner.temp }}/preslib-cells - key: preslib-cells-${{ hashFiles('scripts/fetch-preslib-cells.sh') }} + key: preslib-cells-${{ hashFiles('chartplotter/scripts/fetch-preslib-cells.sh') }} # Build the read-only widget demo bundle into docs/static/demo so Docusaurus - # copies it into the published site at /chartplotter/demo/. Needs the S-101 - # catalogue (IHO material kept out of the repo): clone it and let `make build` - # (a dep of `make demo`) embed it. The bundle is pure static files — no backend. + # copies it into the published site at /chartplotter/demo/. The S-101 + # catalogue is embedded in libtile57 (fetched via the tile57 submodules + # above), so no catalogue clone or --s101 override is needed. The bundle is + # pure static files — no backend. - name: Build live demo bundle (docs/static/demo) run: | - git clone --depth 1 https://github.com/iho-ohi/S-101_Portrayal-Catalogue.git "$RUNNER_TEMP/s101-pc" - git clone --depth 1 https://github.com/iho-ohi/S-101-Documentation-and-FC.git "$RUNNER_TEMP/s101-fc" - make demo \ - S101_PC="$RUNNER_TEMP/s101-pc/PortrayalCatalog" \ - S101_FC="$RUNNER_TEMP/s101-fc/S-101FC/FeatureCatalogue.xml" \ + make -C chartplotter demo \ DEMO_CACHE="$RUNNER_TEMP/demo-cells" \ DEMO_OUT="docs/static/demo" # Bake the S-52 "ECDIS Chart 1" reference sheet to tiles into docs/static/chart1 # (published at /chartplotter/chart1/). The symbol-compliance docs page embeds - # it live, reusing the demo bundle's frontend assets. Reuses the S-101 catalogue - # cloned above; the source cells come from the IHO PresLib draft (fetched once). + # it live, reusing the demo bundle's frontend assets; the source cells come + # from the IHO PresLib draft (fetched once). - name: Build live Chart 1 tiles (docs/static/chart1) run: | - make demo-chart1 \ - S101_PC="$RUNNER_TEMP/s101-pc/PortrayalCatalog" \ - S101_FC="$RUNNER_TEMP/s101-fc/S-101FC/FeatureCatalogue.xml" \ + make -C chartplotter demo-chart1 \ PRESLIB_CACHE="$RUNNER_TEMP/preslib-cells" \ DEMO_CHART1_OUT="docs/static/chart1" @@ -85,17 +103,17 @@ jobs: with: node-version: "20" cache: npm - cache-dependency-path: docs/package-lock.json + cache-dependency-path: chartplotter/docs/package-lock.json - name: Install - working-directory: docs + working-directory: chartplotter/docs run: npm ci - name: Build - working-directory: docs + working-directory: chartplotter/docs run: npm run build - uses: actions/upload-pages-artifact@v5 with: - path: docs/build + path: chartplotter/docs/build deploy: needs: build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a3b573..89dede8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,31 @@ name: Release +# chartplotter publishes per-platform binaries on every v* tag. The binary is a +# CGO build that statically links the native libtile57 engine and can't be +# cross-compiled by an off-the-shelf builder, so the binaries are cross-built HERE, +# then a plain shell step archives + checksums + releases them: +# +# xbuild (ubuntu) → linux + windows, amd64/arm64 via `make xbuild` (zig cc) +# release (ubuntu) → downloads the artifact set, archives + checksums + release +# +# macOS is intentionally NOT built: with GOOS=darwin, Go's crypto/x509 + runtime/cgo +# link Apple's Security/CoreFoundation frameworks, which Zig doesn't bundle, so darwin +# can't be cross-built with zig cc and would need a paid macOS runner. Mac users build +# from source per the README. +# +# The engine is the public github.com/beetlebugorg/tile57 (its submodules hold the +# IHO S-101 catalogues); it is the ./tile57 git submodule so go.mod's +# ./tile57/bindings/go replace and the binding's cgo LDFLAGS resolve — the same +# layout as ci.yml. `submodules: recursive` checks it out at the committed pin, then +# it's floated to the engine's latest main. main.version + main.engineCommit are +# stamped into every binary via ldflags (preserved across both jobs). +# +# Packaging is plain shell (tar/zip + checksums + gh release) — no GoReleaser, no +# paid license — so nothing here depends on a GoReleaser Pro key. +# +# NOTE: correct-by-construction but UNVALIDATED until a real v* tag is pushed — +# nothing runs on push/PR, and packaging needs the CI-built binaries. + on: push: tags: @@ -9,49 +35,105 @@ permissions: contents: write jobs: - goreleaser: + # ---- linux + windows (amd64/arm64): CGO cross-compile with zig cc ------------- + xbuild: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - name: Checkout chartplotter (+ tile57 engine submodule) + uses: actions/checkout@v7 with: + path: chartplotter fetch-depth: 0 - + # ./tile57 submodule (go.mod replaces the binding at ./tile57/bindings/go); + # its own submodules carry the IHO catalogues. + submodules: recursive + - name: Float tile57 submodule to latest main + run: | + git -C chartplotter submodule update --remote tile57 + git -C chartplotter submodule update --init --recursive tile57 - uses: actions/setup-go@v6 with: go-version: "1.26" cache: true + cache-dependency-path: chartplotter/go.sum + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 - # The S-101 Portrayal + Feature Catalogues are IHO material kept out of this - # repo. Clone them (public, no auth) into a temp dir and sync into the - # gitignored embed dir so GoReleaser builds self-contained (-tags embed_s101) - # binaries. The IHO repos declare no license; see THIRD-PARTY-NOTICES.md. - - name: Fetch IHO S-101 catalogues - run: | - git clone --depth 1 https://github.com/iho-ohi/S-101_Portrayal-Catalogue.git "$RUNNER_TEMP/s101-pc" - git clone --depth 1 https://github.com/iho-ohi/S-101-Documentation-and-FC.git "$RUNNER_TEMP/s101-fc" - make sync-s101 \ - S101_PC="$RUNNER_TEMP/s101-pc/PortrayalCatalog" \ - S101_FC="$RUNNER_TEMP/s101-fc/S-101FC/FeatureCatalogue.xml" - - # quill signs + notarizes the macOS binaries from this Linux runner (no Mac - # needed). The GoReleaser build hook (scripts/quill-sign.sh) invokes it. - - name: Install quill - run: | - go install github.com/anchore/quill/cmd/quill@latest - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + # xbuild-tile57.sh builds libtile57 per target with zig, then links each + # binary with `zig cc`, emitting dist/chartplotter_${goos}_${goarch}${ext} + # (ext=.exe for windows) for linux+windows amd64/arm64, stamping VERSION + + # ENGINE_COMMIT (the Makefile derives ENGINE_COMMIT from the tile57 submodule HEAD). + - name: Cross-build linux + windows binaries + run: make -C chartplotter xbuild TILE57=tile57 VERSION="${{ github.ref_name }}" - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + - name: Upload linux + windows binaries + uses: actions/upload-artifact@v4 with: - distribution: goreleaser - version: "~> v2" - args: release --clean + name: binaries-xbuild + path: chartplotter/dist/chartplotter_* + if-no-files-found: error + + # ---- package + publish: plain shell (no GoReleaser, no paid license) --------- + # The binaries are already built by the xbuild job above; this just archives each + # (tar.gz for unix, zip for windows), writes checksums.txt, and creates the + # GitHub release with all of it attached. (GoReleaser's prebuilt builder would do + # the same packaging but is Pro-only; shell keeps the release path license-free.) + release: + runs-on: ubuntu-latest + needs: [xbuild] + steps: + - name: Checkout chartplotter + uses: actions/checkout@v7 + with: + path: chartplotter + fetch-depth: 0 + + # Land all four prebuilt binaries into dist/ (naming: chartplotter_{os}_{arch} + # [.exe], from xbuild-tile57.sh — linux + windows, amd64/arm64). + - name: Download prebuilt binaries + uses: actions/download-artifact@v4 + with: + name: binaries-xbuild + path: chartplotter/dist + + # Archive each binary alongside README + LICENSE + THIRD-PARTY-NOTICES, then + # one checksums.txt over the archives. tar.gz for unix, zip for windows. + # upload-artifact drops the exec bit, so it's restored before archiving. + - name: Package archives + checksums + working-directory: chartplotter + run: | + set -euo pipefail + tag="${{ github.ref_name }}" + out=release-assets + mkdir -p "$out" + for bin in dist/chartplotter_*; do + base="$(basename "$bin")" # chartplotter_linux_amd64[.exe] + platform="${base#chartplotter_}" # linux_amd64[.exe] + stage="$(mktemp -d)" + cp README.md LICENSE THIRD-PARTY-NOTICES.md "$stage/" + if [ "${base##*.}" = "exe" ]; then + platform="${platform%.exe}" + cp "$bin" "$stage/chartplotter.exe" + ( cd "$stage" && zip -q -r - . ) > "$out/chartplotter_${tag}_${platform}.zip" + else + cp "$bin" "$stage/chartplotter" + chmod +x "$stage/chartplotter" + tar -czf "$out/chartplotter_${tag}_${platform}.tar.gz" -C "$stage" . + fi + rm -rf "$stage" + done + ( cd "$out" && sha256sum chartplotter_* > checksums.txt ) + ls -1 "$out" + + - name: Create GitHub release + working-directory: chartplotter env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # macOS signing/notarization (quill). Set these as repo secrets; if they - # are absent the hook skips signing and the release still builds. - QUILL_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - QUILL_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - QUILL_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - QUILL_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - QUILL_NOTARY_ISSUER: ${{ secrets.MACOS_NOTARY_ISSUER }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + --verify-tag \ + --generate-notes \ + --notes "Linux + Windows binaries (amd64/arm64) below. Each embeds the IHO S-101 catalogue (via the tile57 engine) — see THIRD-PARTY-NOTICES.md. macOS is not shipped as a binary; build from source per the README." \ + release-assets/* diff --git a/.gitignore b/.gitignore index 1699cbd..4e51a1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Build output /bin/ /dist/ +# Release packaging staging (the release workflow's shell packaging step). +/release-assets/ *.test *.out coverage.* @@ -26,6 +28,12 @@ web/chartplotter.wasm # Working design notes / specs (kept local, never committed) /specs/ +# Local Go workspace override (cross-repo engine hacking against an engine checkout +# other than the ./tile57 submodule — see README.md "Developing the engine"); never +# committed. +go.work +go.work.sum + # Editor / OS .DS_Store .idea/ @@ -33,9 +41,6 @@ web/chartplotter.wasm web/vendor/wasm_exec.js web/demo-cell.000 -# S-101 catalogue: build-synced into the binary (-tags embed_s101), never committed -internal/engine/s101catalog/catalog/ - # S-101 client portrayal assets: generated at build time (make emit-assets) + serve time, never committed web/colortables.json web/linestyles.json @@ -44,3 +49,12 @@ web/patterns.png web/sprite.json web/sprite.png /testdata/s64-pages-out/ + +# Local artifacts that must never be committed (restricted IHO test material, +# local builds, plugin bundles, agent workspaces). +/chartplotter +*.cpx +/examples/ +/testdata/*.zip +/testdata/*.pdf +/.claude/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6b7cbd5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "tile57"] + path = tile57 + url = https://github.com/beetlebugorg/tile57 + branch = main diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index c5a5be1..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,95 +0,0 @@ -# GoReleaser configuration for chartplotter. -# Docs: https://goreleaser.com -version: 2 - -project_name: chartplotter - -before: - hooks: - - go mod tidy - -# Two builds per platform, mirroring `make xbuild`: -# - plain: needs `--s101 ` at runtime (no IHO content embedded) -# - _s101: self-contained, embeds the S-101 Portrayal + Feature Catalogues -# (the release workflow runs `make sync-s101` first to populate the embed dir). -# That catalogue is IHO material with no declared license — see -# THIRD-PARTY-NOTICES.md. -# Shared settings live in an anchor so the two only differ by the embed tag. -builds: - - id: chartplotter - main: ./cmd/chartplotter - binary: chartplotter - env: [CGO_ENABLED=0] - flags: [-trimpath] - ldflags: ["-s -w -X main.version={{ .Version }}"] - goos: [linux, darwin, windows] - goarch: [amd64, arm64] - # No 32-bit / niche targets: the tile baker is memory-hungry. - # Sign + notarize the macOS binaries in place (no-op elsewhere / without creds). - hooks: - post: - - cmd: ./scripts/quill-sign.sh "{{ .Path }}" "{{ .Os }}" - output: true - - id: chartplotter-s101 - main: ./cmd/chartplotter - binary: chartplotter - env: [CGO_ENABLED=0] - tags: [embed_s101] - flags: [-trimpath] - ldflags: ["-s -w -X main.version={{ .Version }}"] - goos: [linux, darwin, windows] - goarch: [amd64, arm64] - hooks: - post: - - cmd: ./scripts/quill-sign.sh "{{ .Path }}" "{{ .Os }}" - output: true - -archives: - - id: chartplotter - ids: [chartplotter] - formats: [tar.gz] - format_overrides: - - goos: windows - formats: [zip] - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - files: [README.md, LICENSE, THIRD-PARTY-NOTICES.md] - - id: chartplotter-s101 - ids: [chartplotter-s101] - formats: [tar.gz] - format_overrides: - - goos: windows - formats: [zip] - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}_s101" - files: [README.md, LICENSE, THIRD-PARTY-NOTICES.md] - -checksum: - name_template: "checksums.txt" - -snapshot: - version_template: "{{ incpatch .Version }}-next" - -changelog: - sort: asc - use: github - filters: - exclude: - - "^docs:" - - "^test:" - - "^chore:" - - "Merge pull request" - - "Merge branch" - groups: - - title: Features - regexp: '^.*?feat(\(.+\))??!?:.+$' - order: 0 - - title: Bug fixes - regexp: '^.*?fix(\(.+\))??!?:.+$' - order: 1 - - title: Others - order: 999 - -release: - # Repo is auto-detected from the git remote, so this targets whatever repo the - # tag was pushed to (no owner/name mismatch to maintain). - draft: false - prerelease: auto diff --git a/CLAUDE.md b/CLAUDE.md index 7de8ccb..5f888f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,22 +2,44 @@ A marine chart plotter in Go. It reads NOAA S-57 ENC cells, draws them with the S-101 Portrayal Catalogue, and bakes them into PMTiles vector-tile archives. A -`` web component renders the tiles with MapLibre GL JS. The Go -backend does all baking; the browser only renders pre-baked tiles. S-102 -bathymetric support is planned. +`` web component renders the tiles with MapLibre GL JS. The native +`libtile57` engine (Zig, the `./tile57` git submodule of github.com/beetlebugorg/tile57) +does all tiling, portrayal, style, and asset generation, linked into the Go +binary via CGO; the browser only renders pre-baked tiles. S-102 bathymetric +support is planned. ## Commands -- `make build` — build `bin/chartplotter` (embeds the S-101 catalogue when it is - available locally). +- `make build` — build `bin/chartplotter`. CGO build linking `libtile57` (built on + demand from the `./tile57` git submodule with Zig 0.16); the S-101 catalogue + is embedded in the lib, so there's no separate sync/embed step. `build` fetches + the submodule on demand (`git submodule update --init --recursive`) if it's + missing, so a clone without `--recurse-submodules` still works. Needs git + Zig. + (`make build-tile57` is a back-compat alias.) - `make test` / `make vet` / `make fmt` — run before you commit. -- `make serve` — build and serve `web/` on `:8080`. +- `make serve` — build and serve `web/` on `:8080`. Baked tiles are the only + tile path: import/bake charts through the app (or `bake`), no live tile set. +- `make xbuild` — cross-compile release binaries with `zig cc` (linux + windows, + amd64/arm64). darwin is built natively on a macOS CI runner (Go's own + `runtime/cgo`/`crypto/x509` link Apple frameworks Zig doesn't bundle). ## Conventions -- **Stay CGO-free.** Builds run with `CGO_ENABLED=0` (the cross-compiled release - binaries depend on it). Use pure-Go libraries only — e.g. `modernc.org/sqlite`, - not `mattn/go-sqlite3`. Do not add a dependency that needs cgo. +- **CGO is required.** libtile57 is the sole tile/portrayal engine, linked via CGO, + so `CGO_ENABLED=0` no longer builds. Cross-compilation is preserved with **Zig as + the C toolchain** (`zig cc`), not by staying CGO-free. Pure-Go deps are still + preferred where a native lib isn't the point (e.g. `modernc.org/sqlite`, not + `mattn/go-sqlite3`). The `./tile57` submodule (with its nested IHO catalogue + submodules) + Zig 0.16 are hard build deps. +- **Engine dev override.** The default engine source is the `./tile57` git + submodule — go.mod replaces the binding at `./tile57/bindings/go` and the Makefile + defaults `TILE57 ?= tile57`, so a plain `--recurse-submodules` clone needs no + `go.work`. CI floats the submodule to the engine's latest `main`; the committed + pin is last-known-good (bump it with `git add tile57`). `go.work` is OPTIONAL: use + it only to build against a DIFFERENT engine checkout (e.g. a separate sibling clone + you develop in), overriding BOTH halves — a gitignored `go.work` replacing + `github.com/beetlebugorg/tile57/bindings/go => /bindings/go` plus + `make TILE57= …`. Never commit `go.work`. - Use https://www.openbridge.no/ for design and icons. - Match the style of the code around you. - Never run `git add -A` or `git add .`. The repo holds large untracked files diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8b66b7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,114 @@ +# syntax=docker/dockerfile:1 + +# ============================================================================= +# chartplotter — a tiny multi-arch image built from a FULLY STATIC musl binary +# (the "go-dims" pattern). The Go binary is already self-contained: the web +# assets, the S-101 catalogue, and the native libtile57 engine are all embedded +# / statically linked, so a static musl build dropped into `FROM scratch` yields +# an image that is basically just the ~25 MB binary (+ a CA bundle). +# +# Multi-arch WITHOUT QEMU emulation: the builder runs on the native BUILDPLATFORM +# and cross-links each TARGETARCH with `zig cc` (the same recipe as `make musl`), +# so one fast builder produces both linux/amd64 and linux/arm64 by target swap. +# docker buildx build --platform linux/amd64,linux/arm64 -t chartplotter . +# A plain `docker build` builds only the host arch. +# ============================================================================= + +# ---- builder: Go 1.26 + Zig 0.16, cross-links the static musl binary --------- +# Pinned to BUILDPLATFORM so it stays native (no emulation) even when the target +# is a different arch — Go + zig do the cross-compile. +FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS builder + +# Zig 0.16 is the C toolchain for the CGO cross-link (see scripts/xbuild-tile57.sh). +ARG ZIG_VERSION=0.16.0 +# Predefined by buildx: BUILDARCH = the builder's own arch (fetch the matching +# host Zig); TARGETARCH = the arch we cross-compile FOR. +ARG BUILDARCH +ARG TARGETARCH +# Stamped into the binary (main.version). Pass --build-arg VERSION=v1.2.3 in CI. +ARG VERSION=docker +# Optional: pin the engine to a branch/tag for reproducible builds (default: the +# repo's default branch). The engine ABI lives in include/ + bindings/go. +ARG TILE57_REF= + +# Install Zig 0.16 (host arch) — the C cross-compiler `zig cc` links libtile57 + +# the cgo objects against musl for any target triple from this one host. xz-utils +# is needed to unpack Zig's .tar.xz (not in the golang image by default). +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends xz-utils; \ + rm -rf /var/lib/apt/lists/*; \ + case "$BUILDARCH" in \ + amd64) zarch=x86_64 ;; \ + arm64) zarch=aarch64 ;; \ + *) echo "unsupported build arch: $BUILDARCH" >&2; exit 1 ;; \ + esac; \ + url="https://ziglang.org/download/${ZIG_VERSION}/zig-${zarch}-linux-${ZIG_VERSION}.tar.xz"; \ + curl -fsSL "$url" -o /tmp/zig.tar.xz; \ + mkdir -p /opt/zig; \ + tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1; \ + rm /tmp/zig.tar.xz; \ + ln -s /opt/zig/zig /usr/local/bin/zig; \ + zig version + +# chartplotter source. The engine is the ./tile57 submodule, but the build context +# doesn't carry submodule contents (.dockerignore excludes tile57/), so clone it +# fresh IN-TREE below — a shallow, floating clone of the PUBLIC +# github.com/beetlebugorg/tile57 (its own submodules carry the IHO S-101 +# catalogues). go.mod's `replace … => ./tile57/bindings/go` and the binding's cgo +# LDFLAGS resolve against ./tile57. +COPY . /src/chartplotter +WORKDIR /src/chartplotter +RUN set -eux; \ + rm -rf tile57; \ + if [ -n "$TILE57_REF" ]; then br="--branch $TILE57_REF"; else br=""; fi; \ + git clone --depth 1 $br --recurse-submodules --shallow-submodules \ + https://github.com/beetlebugorg/tile57 tile57; \ + git -C tile57 rev-parse --short=9 HEAD + +# Cross-link the fully-static musl binary for TARGETARCH. LIBC=musl makes zig's +# lld emit a static ELF (`ldd` → "not a dynamic executable"); SKIP_RESTORE=1 skips +# the throwaway host-lib rebuild (the tree is discarded after this stage). The Go +# build cache + module cache are mounted so rebuilds are fast. +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + set -eux; \ + LIBC=musl SKIP_RESTORE=1 \ + PLATFORMS="linux/${TARGETARCH}" \ + OUT=/out \ + VERSION="${VERSION}" \ + TILE57=tile57 \ + ENGINE_COMMIT="$(git -C tile57 rev-parse --short=9 HEAD)" \ + scripts/xbuild-tile57.sh; \ + cp "/out/chartplotter_linux_${TARGETARCH}_musl" /chartplotter; \ + # Smoke-test only on a native build — a cross-built (e.g. arm64-on-amd64) + # binary can't be executed by the amd64 builder (no emulation here). + if [ "$TARGETARCH" = "$BUILDARCH" ]; then /chartplotter version; fi + +# An empty, world-writable /tmp to seed into the scratch image: `serve` uses +# os.MkdirTemp for the regenerated S-101 client assets, and scratch has no /tmp. +RUN mkdir -m 1777 -p /image-tmp + +# ---- final: FROM scratch — just the binary + a CA bundle -------------------- +FROM scratch + +# A static musl binary has NO system trust store, and chartplotter makes outbound +# HTTPS calls (charts.noaa.gov / ienccloud.us chart downloads). VERIFIED: without +# a CA bundle those fetches fail with `x509: certificate signed by unknown +# authority`. Bundle the roots from the builder and point Go's crypto/x509 at them. +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +COPY --from=builder /chartplotter /chartplotter + +# scratch ships no /tmp; `serve` needs one for the regenerated S-101 assets. +COPY --from=builder /image-tmp /tmp + +# Persistent chart state (source ENC + baked tiles) — back it with a named volume. +VOLUME ["/data"] +EXPOSE 8080 + +# `serve` defaults to --host 127.0.0.1, which is unreachable from outside the +# container, so bind 0.0.0.0. Source ENC + baked tiles land under the volume. +ENTRYPOINT ["/chartplotter"] +CMD ["serve", "--host", "0.0.0.0", "--data", "/data", "--cache", "/data/cache"] diff --git a/Makefile b/Makefile index e6352e7..17129fc 100644 --- a/Makefile +++ b/Makefile @@ -19,17 +19,15 @@ DOCS_PORT ?= 3000 # Mirrors server.DefaultCacheDir(): $XDG_CACHE_HOME/chartplotter, else ~/.cache. CACHE ?= $(if $(XDG_CACHE_HOME),$(XDG_CACHE_HOME),$(HOME)/.cache)/chartplotter -# S-101 portrayal for `make serve` (transitional, until the catalogue is embedded). -# The catalogue + feature catalogue are vendored as siblings of the repo (not -# committed — IHO DRAFT licence unconfirmed); override the paths if they live -# elsewhere. Baked tiles carry their portrayal, so S-101 uses its OWN cache dir -# (a subdir of $(CACHE), still wiped by clear-cache) to avoid mixing with any -# S-52 tiles; the SOURCE ENC dir (--data) is portrayal-agnostic and stays shared. +# OPTIONAL external S-101 PortrayalCatalog override (for iterating on symbology +# rules): pass --s101 --s101-fc to serve/bake/emit-assets and both +# the tiles and the emitted client assets use it instead of libtile57's embedded +# catalogue. Defaults to sibling checkouts; override if they live elsewhere. Not +# needed for a normal build — the catalogue lives inside libtile57. S101_PC ?= $(HOME)/Projects/s101-portrayal-catalogue/PortrayalCatalog S101_FC ?= $(HOME)/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml -S101_CACHE ?= $(CACHE)/s101 -.PHONY: build xbuild test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages +.PHONY: build build-tile57 tile57-lib vendor-style-engine xbuild xbuild-tile57 musl test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages # Prebaked prod test set (US Inland ENC bundle + the NOAA world archive). # NB: keep these as bare values with NO inline `#` comments — Make folds any @@ -56,79 +54,118 @@ NOAA_CACHE ?= $(CACHE)/noaa NOAA_JOBS ?= 5 # ^ districts baked CONCURRENTLY (NOAA_JOBS=9 for all at once). Each bake is itself # multi-threaded, so peak load ≈ NOAA_JOBS × cores and RAM scales with it too. -# Each district bakes into one gap-clipped archive PER navigational band -# (noaa-d-.pmtiles) so the frontend reproduces the realtime best- -# available display (coarse bands fill finer gaps, none bleed). The bake writes -# several files, so Make tracks each district by a stamp. -NOAA_BANDS := overview general coastal approach harbor berthing -NOAA_STAMPS := $(foreach d,$(DISTRICTS),noaa-d$(d).stamp) - -S101_EMBED_DIR := internal/engine/s101catalog/catalog -# Our own additions to the catalogue (symbols/rules the upstream S-101 PortrayalCatalog -# lacks, e.g. the NEWOBJ "!" symbol). Committed here and re-applied OVER the upstream -# sync, so they survive a re-sync and live in this repo — not the external catalogue. -S101_CUSTOM := internal/engine/s101catalog/custom-overlay - -# Copy the external S-101 catalogue into the (gitignored) embed dir so a -# `-tags embed_s101` build bakes it into the binary. Files never enter the repo. -sync-s101: ## Sync the external S-101 PortrayalCatalog + our custom overlay into the embed dir - @rm -rf "$(S101_EMBED_DIR)" - @mkdir -p "$(S101_EMBED_DIR)/PortrayalCatalog" - @cp -a "$(S101_PC)/." "$(S101_EMBED_DIR)/PortrayalCatalog/" - @cp -a "$(S101_FC)" "$(S101_EMBED_DIR)/FeatureCatalogue.xml" - @cp -a "$(S101_CUSTOM)/." "$(S101_EMBED_DIR)/PortrayalCatalog/" - @echo "synced S-101 catalogue (+ custom overlay) → $(S101_EMBED_DIR)" - -# Embed the S-101 catalogue when it's available locally (the normal dev/deploy -# case); otherwise build without it (the binary then needs --s101 at runtime). -build: ## Build the self-contained shim (embeds web/ + S-101 catalogue) into bin/ - @if [ -d "$(S101_PC)" ] && [ -f "$(S101_FC)" ]; then \ - $(MAKE) --no-print-directory sync-s101; \ - echo "building with embedded S-101 catalogue (-tags embed_s101)…"; \ - go build -tags embed_s101 -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/chartplotter; \ +# Each district bakes into ONE merged archive (noaa-d.pmtiles): the +# coverage-clipped composite resolves best-available inside it, so the frontend +# loads one source per district (the per-band archives are retired). +NOAA_ARCHIVES := $(foreach d,$(DISTRICTS),noaa-d$(d).pmtiles) + + +# --- native libtile57 engine (the SOLE tile/portrayal/asset engine) ------------- +# TILE57 points at the engine repo, by default the ./tile57 git submodule +# (github.com/beetlebugorg/tile57, whose nested submodules carry the IHO +# catalogues). A fresh clone materializes it automatically — `build` runs +# `git submodule update --init --recursive` when the source is missing (or clone +# with --recurse-submodules). go.mod's replace targets ./tile57/bindings/go to +# match. Override to build against another checkout, e.g. +# `make TILE57=../tile57-experiment build` (pair it with a gitignored go.work so the +# Go binding follows; see README.md "Developing the engine"). Its static lib is +# built on demand with Zig 0.16. +TILE57 ?= tile57 +TILE57_LIB := $(TILE57)/zig-out/lib/libtile57.a + +# Engine-commit stamp: the tile57 checkout's HEAD, linked into the binary beside +# main.version so every bake can record WHICH engine produced its tiles (and the +# client can flag a mixed-engine cache). Resolves for the default ./tile57 submodule +# AND a TILE57=… override; "unknown" when git can't answer (submodule not yet +# initialized, tarball checkout). The `test -e .git` guard matters: git -C into a +# missing dir would walk up and report THIS repo's HEAD instead of failing (for the +# submodule .git is a gitdir FILE, for a plain clone a directory — test -e matches +# both, so either resolves cleanly). +ENGINE_COMMIT ?= $(shell test -e "$(TILE57)/.git" && git -C "$(TILE57)" rev-parse --short=9 HEAD 2>/dev/null || echo unknown) +LDFLAGS += -X main.engineCommit=$(ENGINE_COMMIT) + +# Materialize the engine source if it isn't there yet. For the default ./tile57 +# submodule this fetches it (and its nested IHO catalogues) with one `git submodule +# update --init --recursive`; for a TILE57= override the checkout must already +# exist (we don't guess where an external engine tree should come from). +$(TILE57)/include/tile57.h: + @if [ "$(TILE57)" = "tile57" ] && [ -f .gitmodules ]; then \ + echo "fetching the tile57 engine submodule (git submodule update --init --recursive)…"; \ + git submodule update --init --recursive tile57; \ else \ - echo "S-101 catalogue not found at $(S101_PC); building WITHOUT it (needs --s101 at runtime)"; \ - go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/chartplotter; \ + echo "missing $(TILE57)/include/tile57.h — TILE57=$(TILE57) is not the default submodule; point it at a github.com/beetlebugorg/tile57 checkout"; \ + exit 1; \ fi +# Build the static library on demand (only when absent). Needs Zig 0.16 on PATH. +$(TILE57_LIB): $(TILE57)/include/tile57.h + @command -v zig >/dev/null 2>&1 || { echo "Zig 0.16 not on PATH and $(TILE57_LIB) missing — install Zig or prebuild the lib"; exit 1; } + @echo "building libtile57.a (zig build in $(TILE57))…" + cd "$(TILE57)" && zig build + +tile57-lib: ## Force-rebuild $(TILE57)/zig-out/lib/libtile57.a (the native engine static lib) + @command -v zig >/dev/null 2>&1 || { echo "Zig 0.16 not on PATH"; exit 1; } + cd "$(TILE57)" && zig build + +# Build bin/chartplotter. libtile57 is the sole engine, so this is a CGO build that +# statically links the native lib; the S-101 catalogue lives inside libtile57, so +# there is no separate sync/embed step (web/ is still embedded). Fetches the ./tile57 +# submodule on demand (see the $(TILE57)/include/tile57.h rule) + needs Zig 0.16. +build: $(TILE57_LIB) ## Build bin/chartplotter (CGO + native libtile57; fetches the ./tile57 submodule on demand + needs Zig 0.16) + @# Force the link: go's build-cache action ID does NOT hash external static-lib + @# content, so with an existing up-to-date-looking $(BIN) `go build` silently + @# skips the relink and a fresh libtile57.a never reaches the output. + @rm -f $(BIN) + CGO_ENABLED=1 go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/chartplotter + @echo "→ $(BIN) (native libtile57 engine)" + +# Back-compat alias — libtile57 is now the default engine, so this is just `build`. +build-tile57: build ## Alias for `build` (libtile57 is the sole engine now) + # Quick cross-platform test builds. CGO is off, so this is pure `go build` per # target — fast cold, near-instant on re-runs thanks to the build cache. Stamps # the same version as `build`; strips symbols (-s -w) and paths (-trimpath) like a # release binary. Outputs dist/chartplotter__[.exe] (cleaned by `clean`). -xbuild: ## Cross-compile per platform — both a plain binary (needs --s101) and a self-contained _s101 one (embedded catalogue), into dist/ - @mkdir -p dist - @embed=""; \ - if [ -d "$(S101_PC)" ] && [ -f "$(S101_FC)" ]; then $(MAKE) --no-print-directory sync-s101; embed=1; \ - else echo "no S-101 catalogue ($(S101_PC)) — building only the plain (--s101 at runtime) binaries"; fi; \ - for p in $(PLATFORMS); do \ - os=$${p%/*}; arch=$${p#*/}; ext=; [ "$$os" = windows ] && ext=.exe; \ - echo "building $$os/$$arch (plain, needs --s101)…"; \ - CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "-s -w $(LDFLAGS)" \ - -o "dist/chartplotter_$${os}_$${arch}$$ext" ./cmd/chartplotter || exit 1; \ - if [ -n "$$embed" ]; then \ - echo "building $$os/$$arch (self-contained, embedded catalogue)…"; \ - CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -tags embed_s101 -trimpath -ldflags "-s -w $(LDFLAGS)" \ - -o "dist/chartplotter_$${os}_$${arch}_s101$$ext" ./cmd/chartplotter || exit 1; \ - fi; \ - done - @echo "→ dist/"; ls -1 dist/chartplotter_* - -serve: build ## Serve the web frontend + provisioning API, S-101 portrayal (HOST/PORT/ASSETS/S101_* overridable) - $(BIN) serve --host $(HOST) --port $(PORT) --assets $(ASSETS) \ - --s101 $(S101_PC) --s101-fc $(S101_FC) --cache $(S101_CACHE) +# Cross-compile the CGO+libtile57 binary via the Zig C toolchain (`zig cc`) — how +# the tile57-only build keeps single-command cross-compilation despite needing +# CGO. Covers linux + windows (amd64/arm64), all +# proven to cross-link from any host with Zig alone. darwin is built NATIVELY on a +# macOS CI runner: with GOOS=darwin, Go's crypto/x509 links Apple frameworks +# (Security/CoreFoundation) that Zig doesn't bundle. The S-101 catalogue lives in +# libtile57, so there's no embed step. Fetches the ./tile57 submodule on demand + Zig 0.16. +# Outputs dist/chartplotter__[.exe]. +xbuild xbuild-tile57: $(TILE57)/include/tile57.h ## Cross-compile CGO+libtile57 binaries with zig cc (linux+windows; darwin builds on a Mac runner) + VERSION="$(VERSION)" TILE57="$(TILE57)" ENGINE_COMMIT="$(ENGINE_COMMIT)" scripts/xbuild-tile57.sh + +# Fully-static musl binaries for the tiny FROM scratch Docker image (the "go-dims" +# pattern): CGO+libtile57 linked against musl STATICALLY via zig cc, so `ldd` reports +# "not a dynamic executable" and the binary drops straight into `FROM scratch`. Builds +# linux/amd64 + linux/arm64 into dist/chartplotter_linux__musl (same zig-cc +# cross-build as xbuild — no QEMU; one host cross-links both arches by target swap). +# The Dockerfile calls scripts/xbuild-tile57.sh with LIBC=musl directly; this target +# is the local/CI equivalent. musl-static is strictly more portable than the gnu +# xbuild (no glibc floor), so it's the recommended linux artifact — the gnu xbuild +# path is kept intact for anyone who needs a glibc-dynamic build. +MUSL_PLATFORMS ?= linux/amd64 linux/arm64 +musl: $(TILE57)/include/tile57.h ## Build fully-static musl binaries (linux amd64+arm64) into dist/ for the scratch Docker image + VERSION="$(VERSION)" TILE57="$(TILE57)" ENGINE_COMMIT="$(ENGINE_COMMIT)" \ + LIBC=musl PLATFORMS="$(MUSL_PLATFORMS)" scripts/xbuild-tile57.sh + +serve: build ## Serve the web frontend + provisioning API on :8080 (HOST/PORT/ASSETS overridable) + $(BIN) serve --host $(HOST) --port $(PORT) --assets $(ASSETS) bake-ienc: build $(IENC_PMTILES) ## Bake every IENC cell in $(IENC_SRC) into $(IENC_PMTILES) -# --overzoom: a standalone large-scale set with no overview cells, so it must -# overzoom down to stay visible when zoomed out (mirrors the realtime upload path). +# A standalone large-scale set with no overview cells floats down to the world +# view automatically (the coarsest populated band extends to minzoom 0). $(IENC_PMTILES): $(BIN) - $(BIN) bake "$(IENC_SRC)" -o "$(IENC_PMTILES)" --overzoom --max-zoom $(IENC_MAXZOOM) + $(BIN) bake "$(IENC_SRC)" -o "$(IENC_PMTILES)" --max-zoom $(IENC_MAXZOOM) # Build the binary first (single-threaded), then bake the districts $(NOAA_JOBS) # at a time via a recursive parallel sub-make (the district .pmtiles targets are # independent, so -j fans them out; download + bake of each runs concurrently). bake-noaa: build ## Bake each USCG district ($(DISTRICTS)) into per-band noaa-d-.pmtiles, $(NOAA_JOBS) in parallel - $(MAKE) -j$(NOAA_JOBS) $(NOAA_STAMPS) + $(MAKE) -j$(NOAA_JOBS) $(NOAA_ARCHIVES) # Keep the downloaded district zips — without this Make treats them as # intermediate (made by one pattern rule, consumed by another) and deletes them @@ -141,31 +178,29 @@ $(NOAA_CACHE)/%CGD_ENCs.zip: @echo "downloading $*CGD_ENCs.zip from NOAA…" curl -fSL --retry 3 -o "$@" "$(NOAA_URL_BASE)/$*CGD_ENCs.zip" -# Bake a district bundle into per-band gap-clipped archives (--bands writes -# noaa-d-.pmtiles for each band present). NO --overzoom: a district -# bundle carries its own overview/general cells, so the zoomed-out skeleton is -# already present. $(BIN) is an order-only prereq so rebuilding the binary doesn't -# force a (very slow) re-bake. Stamped because the bake produces several files. -noaa-d%.stamp: $(NOAA_CACHE)/%CGD_ENCs.zip | $(BIN) - $(BIN) bake "$<" -o "noaa-d$*.pmtiles" --bands - @touch "$@" +# Bake a district bundle into ONE merged archive (the coverage-clipped composite +# resolves best-available inside it; each band also bakes FILLUP_DZ zooms past +# its window). $(BIN) is an order-only prereq so rebuilding the binary doesn't +# force a (very slow) re-bake. +noaa-d%.pmtiles: $(NOAA_CACHE)/%CGD_ENCs.zip | $(BIN) + $(BIN) bake "$<" -o "$@" # Serve the per-district NOAA archives + the baked IENC archive TOGETHER, # prebaked, in read-only widget mode on 0.0.0.0:8080. Every .pmtiles lives at the # project root; they're symlinked into web/ (the served asset dir) and listed in a # combined charts-index.json manifest the widget app loads via ?catalog=. Open the # printed URL. -serve-widget: build bake-noaa ## Serve per-district per-band NOAA + IENC prebaked pmtiles together, read-only widget mode, on 0.0.0.0:8080 +serve-widget: build bake-noaa ## Serve per-district NOAA + IENC prebaked pmtiles together, read-only widget mode, on 0.0.0.0:8080 @ln -sf "$(abspath $(IENC_PMTILES))" web/ienc.pmtiles - @for d in $(DISTRICTS); do for s in $(NOAA_BANDS); do \ - f="noaa-d$$d-$$s.pmtiles"; [ -f "$$f" ] && ln -sf "$(abspath .)/$$f" "web/$$f" || true; \ - done; done + @for d in $(DISTRICTS); do \ + f="noaa-d$$d.pmtiles"; [ -f "$$f" ] && ln -sf "$(abspath .)/$$f" "web/$$f" || true; \ + done @{ \ printf '{\n "districts": [\n'; \ - for d in $(DISTRICTS); do for s in $(NOAA_BANDS); do \ - f="noaa-d$$d-$$s.pmtiles"; [ -f "$$f" ] && printf ' { "file": "%s", "band": "%s" },\n' "$$f" "$$s"; \ - done; done; \ - printf ' { "file": "ienc.pmtiles", "band": "all" }\n ]\n}\n'; \ + for d in $(DISTRICTS); do \ + f="noaa-d$$d.pmtiles"; [ -f "$$f" ] && printf ' { "file": "%s" },\n' "$$f"; \ + done; \ + printf ' { "file": "ienc.pmtiles" }\n ]\n}\n'; \ } > web/charts-index.json @echo @echo " Prebaked widget test server — open:" @@ -181,15 +216,28 @@ serve-widget: build bake-noaa ## Serve per-district per-band NOAA + IENC prebake # DEMO_OUT, e.g. DEMO_OUT=docs/static/demo in CI): the per-band .pmtiles + manifest, # the generated S-101 client assets, and the committed static frontend (demo.html # as index.html). Serve it from ANY static host / CDN — no server logic required. -DEMO_CELLS ?= US2EC03M US3EC08M US4MD1DC US5MD1MC +DEMO_CELLS ?= US2EC03M US3EC08M US4MD1DC US4MD1EC US5MD1MC US5MD1MD US5MD1ME US5MD1LB US5MD1LC US5MD1NB US5MD1NC DEMO_CACHE ?= $(CACHE)/demo DEMO_OUT ?= dist/demo DEMO_MAXZOOM ?= 16 -demo: build ## Assemble the read-only Annapolis widget demo bundle into $(DEMO_OUT) +# The client-side WASM style engine (web/vendor/tile57-style-engine). The server-less +# widget/demo runs the SAME tile57 chartstyle engine CLIENT-side (no /api/style.json), +# so this .wasm must track the engine — a stale one silently drops the SCAMIN scale +# gate and every feature shows at every zoom. Rebuilt from $(TILE57) and re-vendored so +# a committed .wasm can never drift; `demo` depends on it so the shipped bundle always +# carries the current engine's style. +vendor-style-engine: ## Rebuild + re-vendor the WASM style engine (web/vendor/tile57-style-engine) from $(TILE57) + @echo "building the WASM style engine (zig build wasm in $(TILE57))…" + cd "$(TILE57)" && zig build wasm + @cp "$(TILE57)/zig-out/bin/style-engine.wasm" web/vendor/tile57-style-engine/style-engine.wasm + @cp "$(TILE57)/bindings/js/index.js" "$(TILE57)/bindings/js/index.d.ts" web/vendor/tile57-style-engine/ + @echo " vendored web/vendor/tile57-style-engine/style-engine.wasm ($$(wc -c < web/vendor/tile57-style-engine/style-engine.wasm) bytes)" + +demo: build vendor-style-engine ## Assemble the read-only Annapolis widget demo bundle into $(DEMO_OUT) DEMO_CACHE="$(DEMO_CACHE)" DEMO_CELLS="$(DEMO_CELLS)" NOAA_URL_BASE="$(NOAA_URL_BASE)" scripts/fetch-demo-cells.sh @mkdir -p "$(DEMO_OUT)" - $(BIN) bake "$(DEMO_CACHE)" -o "$(DEMO_OUT)/demo.pmtiles" --bands --max-zoom $(DEMO_MAXZOOM) --manifest "$(DEMO_OUT)/charts-index.json" + $(BIN) bake "$(DEMO_CACHE)" -o "$(DEMO_OUT)/demo.pmtiles" --max-zoom $(DEMO_MAXZOOM) --manifest "$(DEMO_OUT)/charts-index.json" $(BIN) emit-assets "$(DEMO_OUT)" $(if $(wildcard $(S101_PC)),--s101 "$(S101_PC)") @echo "assembling static frontend → $(DEMO_OUT)" @cp web/demo.html "$(DEMO_OUT)/index.html" @@ -212,7 +260,7 @@ CHART1_MAXZOOM ?= 16 demo-chart1: build ## Bake the S-52 ECDIS Chart 1 sheet to tiles for the docs (into $(DEMO_CHART1_OUT)) PRESLIB_CACHE="$(PRESLIB_CACHE)" scripts/fetch-preslib-cells.sh @mkdir -p "$(DEMO_CHART1_OUT)" - $(BIN) bake "$(PRESLIB_CACHE)/cells" -o "$(DEMO_CHART1_OUT)/chart1.pmtiles" --bands --max-zoom $(CHART1_MAXZOOM) --manifest "$(DEMO_CHART1_OUT)/charts-index.json" + $(BIN) bake "$(PRESLIB_CACHE)/cells" -o "$(DEMO_CHART1_OUT)/chart1.pmtiles" --max-zoom $(CHART1_MAXZOOM) --manifest "$(DEMO_CHART1_OUT)/charts-index.json" @echo " chart1 tiles ready: $(DEMO_CHART1_OUT)/ — served beside the demo bundle as /chart1/" # LOCAL PREVIEW ONLY. The bundle is pure static files — deploy it to ANY @@ -222,8 +270,7 @@ demo-chart1: build ## Bake the S-52 ECDIS Chart 1 sheet to tiles for the docs (i # range-capable static file server (the widget page makes no /api calls). serve-demo: demo ## Preview the static demo bundle locally (range-capable static serve; HOST/PORT overridable) @echo " Read-only widget demo — open: http://$(HOST):$(PORT)/" - $(BIN) serve --host $(HOST) --port $(PORT) --assets "$(DEMO_OUT)" \ - $(if $(wildcard $(S101_PC)),--s101 "$(S101_PC)" --s101-fc "$(S101_FC)" --cache "$(S101_CACHE)") + $(BIN) serve --host $(HOST) --port $(PORT) --assets "$(DEMO_OUT)" docs: ## Run the documentation site dev server (Docusaurus; DOCS_HOST/DOCS_PORT overridable) cd docs && { [ -d node_modules ] || npm install; } && npm start -- --host $(DOCS_HOST) --port $(DOCS_PORT) @@ -251,8 +298,7 @@ s64-pages: ## Render S-64 ENC test pages for spec comparison (one PNG per test s DOCS_SHOTS_PORT ?= 8199 docs-shots: build ## Regenerate docs UI screenshots from the live app into docs/static/img/ui/ @set -e; \ - $(BIN) serve --host 127.0.0.1 --port $(DOCS_SHOTS_PORT) --assets web \ - --s101 $(S101_PC) --s101-fc $(S101_FC) --cache $(S101_CACHE) & \ + $(BIN) serve --host 127.0.0.1 --port $(DOCS_SHOTS_PORT) --assets web & \ srv=$$!; trap "kill $$srv 2>/dev/null || true" EXIT; \ for i in $$(seq 1 50); do \ curl -fsS "http://127.0.0.1:$(DOCS_SHOTS_PORT)/api/health" >/dev/null 2>&1 && break; \ @@ -274,8 +320,8 @@ vet: # Format with the gofmt of the toolchain go.mod pins (Go 1.26), NOT whatever # gofmt happens to be on PATH — gofmt's rules change between Go minor releases, # so a stray 1.25 gofmt reintroduces drift that the 1.26 CI check rejects. Invoke -# gofmt over `.` (not `go fmt ./...`, which skips files behind build tags like -# embed_s101) so the file set matches the CI `gofmt -l .` gate exactly. +# gofmt over `.` (not `go fmt ./...`, which can skip build-tagged files) so the +# file set matches the CI `gofmt -l .` gate exactly. fmt: @"$$(go env GOROOT)/bin/gofmt" -w . diff --git a/README.md b/README.md index 0c06e9e..92de473 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@

chartplotter

- ⚓ A marine chart plotter, in Go.
- Generate offline vector-tile archives from NOAA S-57 ENC cells and render them in the browser. + ⚓ A marine chart plotter.
+ Turn NOAA S-57 ENC cells into offline vector-tile charts and render them in the browser.

CI - Release Go Report Card License

@@ -42,45 +41,65 @@ in a web browser, online or fully offline. It reads **S-57** electronic navigational chart (ENC) cells and draws them with the **S-101 Portrayal Catalogue**, the modern IHO standard for how charts look. It -writes the result to a single **PMTiles** archive of **Mapbox Vector Tiles**. A small -`` web component, built on +writes the result to **PMTiles** archives of vector tiles — **MapLibre Tiles +(MLT)** by default, **Mapbox Vector Tiles (MVT)** on request — plus a matching +MapLibre style. A `` web component, built on [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/), draws the chart. In short: the heavy lifting happens once, up front. chartplotter reads the raw NOAA -charts and renders every feature — its colors, symbols, and lines — into map tiles, -saved as a single file on your machine. After that the browser only *displays* those -tiles — panning, zooming, switching palettes — and never touches the raw charts again. +charts and renders every feature — its colors, symbols, and lines — into map tiles +saved on your machine. After that the browser only *displays* those tiles — +panning, zooming, switching palettes — and never touches the raw charts again. + +## 🧱 Two repositories, one program + +chartplotter is built from two repos that work as a pair: + +- **[`chartplotter`](https://github.com/beetlebugorg/chartplotter)** (this repo, + Go) — the application: the HTTP server and chart library, the `bake`/`serve` + 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 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, "", 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: "", mixed:false } — one muted commit. +// • they DIFFER (a partially re-baked cache — the case the stamp exists for) → +// { text: "d5:abc123 d7:def456✱", mixed:true }: one "label:commit" group per +// distinct engine, majority first, every minority group marked ✱; the caller +// also warn-tints the whole stamp via `mixed`. +// `title` always carries the full per-set detail for the tooltip. +export function engineStamp(metas) { + const seen = []; + for (const m of metas || []) { + if (!m || typeof m.engine !== "string" || !m.engine) continue; + // Pack label: the set minus its band suffix ("noaa-d5-coastal" → "noaa-d5"), + // minus the noaa- provider prefix ("d5") — short enough for the corner. + const name = m.name || ""; + const band = bandOfSet(name); + let pack = name; + if (band !== "all" && pack.endsWith("-" + band)) pack = pack.slice(0, -(band.length + 1)); + seen.push({ set: name, label: pack.replace(/^noaa-/, "") || pack, engine: m.engine }); + } + if (!seen.length) return null; + const title = "Engine commit that baked each active set:\n" + seen.map((e) => `${e.set}: ${e.engine}`).join("\n"); + const groups = new Map(); // engine → { engine, labels:[…], count } + for (const e of seen) { + let g = groups.get(e.engine); + if (!g) groups.set(e.engine, (g = { engine: e.engine, labels: [], count: 0 })); + if (!g.labels.includes(e.label)) g.labels.push(e.label); + g.count++; + } + if (groups.size === 1) return { text: seen[0].engine, mixed: false, title }; + // Mixed bake: majority group first (ties broken by commit for stability); every + // group after the majority carries the ✱ disagreement marker. + const ordered = [...groups.values()].sort((a, b) => b.count - a.count || (a.engine < b.engine ? -1 : 1)); + const text = ordered.map((g, i) => `${g.labels.join(",")}:${g.engine}${i ? "✱" : ""}`).join(" "); + return { text, mixed: true, title: title + "\n✱ differs from the majority engine — a partial re-bake" }; +} + export class ChartSources { constructor({ assets, getMap, rebuild, getPxPitch }) { this.assets = assets; // resolved assets base URL (trailing "/") @@ -116,6 +161,7 @@ export class ChartSources { this.rebuild = rebuild; // () => map.setStyle(buildStyle(), {diff:false,validate:false}) this.getPxPitch = getPxPitch || (() => undefined); // () => calibrated CSS-pixel pitch (mm); drives SCAMIN gating this._ver = 0; // chart-tile cache-bust token (see refresh) + this._srcEncoding = {}; // source id ("chart-") → the tile encoding ("mvt"/"mlt") BAKED into the live style. MapLibre reads a vector source's `encoding` only at CREATION, so a decoder switch (an MLT archive loading after the initial mvt-default style) needs a full rebuild, not an in-place mutation (see _updateSourceZoom → rebuild). this._bands = {}; // band slug → MultiArchive of that band's loaded packs (chart- source) this._scaminValues = []; // distinct SCAMIN denominators seen in tiles → per-SCAMIN bucket layers this._scaminLat = null; // latitude the bucket minzooms were computed at (rebuild on big change) @@ -175,7 +221,7 @@ export class ChartSources { // GENERATION (?g=) — re-fetching this JSON (it's no-cache) after a re-bake // yields a new URL, so pointing the source at it bypasses every tile cache by // content. Falls back to the plain URL if the server omits it. - const meta = { name, band: bandOfSet(name), min: 0, max: 18, bounds: null, scamin: [], tiles: this._serverTilesUrl(name) }; + const meta = { name, band: bandOfSet(name), min: 0, max: 18, bounds: null, scamin: [], tiles: this._serverTilesUrl(name), encoding: "mvt", engine: "" }; try { const base = new URL(this.assets, location.href).href; const tj = await fetch(`${base}tiles/${name}.json`).then((r) => (r.ok ? r.json() : null)); @@ -185,6 +231,12 @@ export class ChartSources { if (Array.isArray(tj.bounds) && tj.bounds.length === 4) meta.bounds = tj.bounds; // [w,s,e,n] — host zoom-cap if (Array.isArray(tj.scamin)) meta.scamin = tj.scamin; // SCAMIN manifest → per-set bucket layers (no runtime collect) if (Array.isArray(tj.tiles) && tj.tiles[0]) meta.tiles = tj.tiles[0]; + if (tj.encoding === "mlt") meta.encoding = "mlt"; // MLT set → source `encoding` hint (native MLT decode) + // The tile57 engine commit behind this set's tiles: bake-time for packs + // ("pre-stamp" for pre-stamping bakes), the running binary for live sets. + // Drives the attribution engine stamp (engineStamp below); "" = an older + // server that doesn't report it (the stamp hides). + if (typeof tj.engine === "string") meta.engine = tj.engine; } } catch (e) { /* keep defaults */ } return meta; @@ -383,6 +435,28 @@ export class ChartSources { // Convenience: render a single server set (or none). See setServerSets. setServerSet(name) { return this.setServerSets(name ? [name] : []); } + // Deepest zoom the loaded prebaked archives hold a tile at over (lng,lat) — + // the widget's per-location data depth. The merged composite archive is a + // SPARSE pyramid (each band bakes to its native max + the overscale fill-up), + // so a global source maxzoom can't say where tiles end; the camera cap probes + // the directory instead (root/leaf lookups, cached — no tile reads). null in + // server mode or before an archive loads. + async dataMaxZoomAt(lng, lat) { + if (this._server) return null; + const arc = this._bands.all; + if (!arc || !arc.hasTile || arc.maxZoom == null) return null; + const wx = (lng + 180) / 360; + const latRad = (lat * Math.PI) / 180; + const wy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2; + for (let z = arc.maxZoom; z >= (arc.minZoom || 0); z--) { + const n = 2 ** z; + const x = Math.max(0, Math.min(n - 1, Math.floor(wx * n))); + const y = Math.max(0, Math.min(n - 1, Math.floor(wy * n))); + if (await arc.hasTile(z, x, y)) return z; + } + return null; + } + // The active server tile-set names ([] when not in server mode). serverSets() { return this._server ? this._serverSets.map((s) => s.name) : []; } @@ -432,8 +506,7 @@ export class ChartSources { if (!this._bands[b]) this._bands[b] = new MultiArchive(); a = await this._bands[b].add(resolved); } - this._updateSourceZoom(); - this.refresh(); + if (this._updateSourceZoom()) this.rebuild(); else this.refresh(); return a; } @@ -461,8 +534,7 @@ export class ChartSources { this._bands[b] = new MultiArchive(); a = await this._bands[b].add(resolved); } - this._updateSourceZoom(); - this.refresh(); + if (this._updateSourceZoom()) this.rebuild(); else this.refresh(); return a; } @@ -479,17 +551,25 @@ export class ChartSources { if (!this._bands[band]) this._bands[band] = new MultiArchive(); return this._bands[band].add(this._resolveSrc(e.src)).catch((err) => { console.warn("[chartplotter] archive", e.src, err); return null; }); })); - this._updateSourceZoom(); - this.refresh(); + if (this._updateSourceZoom()) this.rebuild(); else this.refresh(); return arcs.filter(Boolean); } // NOAA-band sources have fixed zoom ranges (from CHART_BANDS), so only the // merged-upload `all` source needs its max synced to the loaded archive (an // upload may bake to <18; requesting above its max would read blank). + // + // Returns TRUE when a loaded archive needs a tile encoding the live style + // doesn't have — the caller must then rebuild() (recreate the sources) rather + // than refresh(), because MapLibre reads a vector source's `encoding` only at + // CREATION. Archives load AFTER the initial (mvt-default) style is built, so an + // MLT archive (the tile57 default bake format) has no way to switch a live + // source's decoder in place; a stale mvt decoder parses MLT bytes as MVT and + // MapLibre throws "Unable to parse the tile". zoom ranges, by contrast, ARE + // honoured in place, so they stay here. _updateSourceZoom() { const map = this.getMap(); - if (!map) return; + if (!map) return false; // Hold every loaded band source's maxzoom at its archive's REAL deepest baked // zoom (PMTiles header), in place — so MapLibre overzooms the deepest tile it // has instead of requesting empty tiles past the bake (which read as a blank @@ -497,13 +577,23 @@ export class ChartSources { // band-range change before a re-bake). No restyle. The merged "all" source has // no per-band overzoom so its minzoom tracks the archive too; the per-band // sources keep minzoom 0 for the sub-band SCAMIN features. + let encodingChanged = false; for (const slug of Object.keys(this._bands)) { const arc = this._bands[slug]; + if (!arc) continue; + // Does this archive's decoder match what the live style committed? Checked + // BEFORE the live-source guard: the comparison is against our own + // bookkeeping (_srcEncoding), so a style that is still loading — or that + // hasn't materialized this source yet — must still flag the rebuild, or + // the stale decoder survives until some unrelated restyle. + const want = arc.tileType === "mlt" ? "mlt" : "mvt"; + if (this._srcEncoding["chart-" + slug] !== want) encodingChanged = true; const src = map.getSource("chart-" + slug); - if (!src || !arc || src.maxzoom === undefined) continue; + if (!src || src.maxzoom === undefined) continue; src.maxzoom = arc.maxZoom; if (slug === "all") src.minzoom = arc.minZoom; } + return encodingChanged; } // Render a hosted `.pmtiles` by URL — read incrementally via HTTP Range (NOT @@ -531,10 +621,11 @@ export class ChartSources { // Open every archive CONCURRENTLY. Each open is two range round-trips (header // + root directory); doing ~50 districts serially was the slow initial load. - // Each unique file is opened ONCE — a bandless ("all") pack FANS across every - // per-band source (each overzooms its own [min,max]) so a coarse-only spot - // shows the coarser chart overscale instead of a high-zoom hole, but the - // underlying archive handle is shared, not re-fetched six times. + // A bandless entry (the merged composite archive — one per district) loads + // into the single "all" source ONLY: best-available is resolved inside the + // archive, and the pmtiles protocol errors absent tiles so MapLibre + // stretches an ancestor where the pyramid runs out (per-area overzoom). + // Legacy per-band entries still land in their chart- sources. const opened = new Map(); // url → Promise const openOnce = (u) => { let p = opened.get(u); @@ -545,7 +636,7 @@ export class ChartSources { for (const d of districts) { if (!d.file) continue; const u = new URL(d.file, base).href; - for (const slug of this._fanBands(d.band || "all")) { + for (const slug of (d.band ? this._fanBands(d.band) : ["all"])) { if (!this._bands[slug]) this._bands[slug] = new MultiArchive(); const band = this._bands[slug]; tasks.push(openOnce(u) @@ -576,6 +667,8 @@ export class ChartSources { // for free). Falls back to band.bake until an archive is loaded. for (const band of CHART_BANDS) { const archive = this._bands[band.slug]; + const enc = archive && archive.tileType === "mlt" ? "mlt" : "mvt"; + this._srcEncoding["chart-" + band.slug] = enc; // record what THIS style commits (see _updateSourceZoom) sources["chart-" + band.slug] = { type: "vector", tiles: [`chart-${band.slug}://${v}/{z}/{x}/{y}`], @@ -586,16 +679,28 @@ export class ChartSources { // tiles), so it's cheap. Per-SCAMIN bucket layers gate the exact display scale. minzoom: 0, maxzoom: (archive && archive.maxZoom) || band.bake, + // MLT archives (the tile57 default bake format) hint MapLibre's native MLT + // decoder; MVT (the MapLibre default) adds nothing. Bytes serve verbatim. + ...(enc === "mlt" ? { encoding: "mlt" } : {}), }; } if (this._server) { - // One source per active pack, MVT pulled live from /tiles/{set}. minzoom/ - // maxzoom are the set's REAL range (from its TileJSON) so MapLibre overzooms - // the deepest baked tile instead of requesting empty tiles past the bake. With - // no packs we add no chart sources (a vector source with an empty `tiles` array - // makes MapLibre crash); the no-data hatch shows through. + // One source per active pack, tiles pulled live from /tiles/{set} in the + // set's stored encoding (the TileJSON `encoding` hint selects the decoder). + // minzoom/maxzoom are the set's REAL range (from its TileJSON) so MapLibre + // overzooms the deepest baked tile instead of requesting empty tiles past the + // bake. With no packs we add no chart sources (a vector source with an empty + // `tiles` array makes MapLibre crash); the no-data hatch shows through. for (const set of this._serverSets) { - sources["chart-" + set.name] = { type: "vector", tiles: [set.tiles || this._serverTilesUrl(set.name)], minzoom: set.min, maxzoom: set.max }; + const enc = set.encoding === "mlt" ? "mlt" : "mvt"; + this._srcEncoding["chart-" + set.name] = enc; + sources["chart-" + set.name] = { + type: "vector", + tiles: [set.tiles || this._serverTilesUrl(set.name)], + minzoom: set.min, + maxzoom: set.max, + ...(enc === "mlt" ? { encoding: "mlt" } : {}), + }; } } return sources; diff --git a/web/src/chart-canvas/chart-style.mjs b/web/src/chart-canvas/chart-style.mjs index 55590d2..f8574a2 100644 --- a/web/src/chart-canvas/chart-style.mjs +++ b/web/src/chart-canvas/chart-style.mjs @@ -172,7 +172,12 @@ function buildLayers(mariner, palette, atlasPpu, osm, sizeScale) { { id: "areas", type: "fill", source: "chart", "source-layer": "areas", ...(osm ? { filter: notLand } : {}), layout: { "fill-sort-key": ["-", ["*", ["coalesce", ["get", "draw_prio"], 0], 1000], ["coalesce", ["get", "drval1"], 0]] }, paint: { "fill-color": S52.areasFillColor(palette, mariner) } }, - { id: "area_patterns", type: "fill", source: "chart", "source-layer": "area_patterns", paint: { "fill-pattern": ["concat", PAT_PREFIX, ["coalesce", ["get", "pattern_name"], ""]] } }, + // Exclude OVERSC01: tile57 now bakes the S-52 §10.1.10 overscale hatch (each + // cell's M_COVR coverage, tagged `oscl`) into area_patterns. The engine style + // gates it on a dedicated `overscale` layer; this generic pattern layer must + // not paint it ungated over everything. On this (JS-builder) path the per-band + // _pushOverscale layers keep providing the hatch. + { id: "area_patterns", type: "fill", source: "chart", "source-layer": "area_patterns", filter: ["!=", ["get", "pattern_name"], "OVERSC01"], paint: { "fill-pattern": ["concat", PAT_PREFIX, ["coalesce", ["get", "pattern_name"], ""]] } }, // SHALLOW_PATTERN (SEABED01, client-side): DIAMOND1 over depth areas on // the shallow side of the live safety contour, shown only when the // mariner toggle is on. Filter/visibility update on safetyContour / @@ -200,6 +205,20 @@ function buildLayers(mariner, palette, atlasPpu, osm, sizeScale) { // navigational purpose changes, baked into the scale_boundaries layer. // Standard display, on by default; toggled via mariner.showScaleBoundaries. { id: "scale-boundaries", type: "line", source: "chart", "source-layer": "scale_boundaries", layout: { visibility: mariner.showScaleBoundaries === false ? "none" : "visible" }, paint: { "line-color": S52.colorExpr("color_token", undefined, palette), "line-width": ["coalesce", ["get", "width_px"], 1.5] } }, + // Chart-coverage outline when ZOOMED OUT (to ~z3.5): the meta-object region + // boundary, so a pack still shows WHERE it covers once SCAMIN has suppressed + // every in-cell feature. Today the engine emits only M_NSYS (nav-system / + // IALA A↔B) among the meta classes; the canonical M_COVR data-coverage edge + // is requested in ../tile57 specs/host-canonical-backend.md ("Still needed + // from the engine" §6 — coverage edge baked to z0 for every pack). _rawFilter + // makes it bypass combineFilters AND the SCAMIN bucketing, so it draws + // regardless of the showMetaBounds gate. Capped at z8 → a pure zoom-out + // indicator that yields to the full chart at detail zooms, and hidden when + // showMetaBounds is on (the lines layers draw these classes then — no double). + { id: "meta-boundary", type: "line", source: "chart", "source-layer": "lines", _rawFilter: true, maxzoom: 8, + filter: ["==", ["coalesce", ["get", "class"], ""], "M_NSYS"], + layout: { visibility: mariner.showMetaBounds ? "none" : "visible" }, + paint: { "line-color": S52.colorExpr("color_token", S52.token("CHMGD", "#bf30bf", palette), palette), "line-width": 1.4, "line-dasharray": [4, 2], "line-opacity": 0.85 } }, ]; const top = [ // Point symbols split by ROTATION REFERENCE FRAME (S-52 6.1.1 §3.1.6 / PresLib @@ -279,7 +298,14 @@ function buildLayers(mariner, palette, atlasPpu, osm, sizeScale) { // also hits the clone, so SCAMIN features restyle/toggle identically. (e.g. // contour-labels for DEPCNT, which now live in lines_scamin; safety-contour / // shallow-pattern reading areas_scamin; danger-boundary reading lines_scamin.) - const SCAMIN_SRC = new Set(["areas", "area_patterns", "lines", "complex_lines"]); + // Source-layers whose SCAMIN-bearing primitives live in a SEPARATE "_scamin" + // source-layer (vs. an in-layer `scamin` property). The Go baker splits the four + // area/line layers; the native tile57 engine ALSO splits point_symbols + text + // (its SCAMIN buoys/beacons/lights/labels ride point_symbols_scamin / text_scamin), + // so they must be cloned + bucketed too or every SCAMIN'd symbol/label is invisible + // under tile57. The clones read source-layers that simply don't exist in a Go-baked + // pack, so they render nothing there — safe for both bakers. + const SCAMIN_SRC = new Set(["areas", "area_patterns", "lines", "complex_lines", "point_symbols", "text"]); const withScamin = []; for (const L of tmpl) { withScamin.push(L); @@ -445,12 +471,12 @@ export function buildChartLayers({ // native minzoom, and mirrors the coarse-band maxzoom cap. const mk = (suffix, baseFilter, minzoom) => { const id = L.id + "@" + set.name + suffix; - layerBase[id] = baseFilter; + if (!L._rawFilter) layerBase[id] = baseFilter; // raw layers keep their verbatim filter — exclude from the live re-combine // Register under the ORIGINAL base id for a *_scamin clone (L._baseId), // so every restyle/toggle keyed on the original id reaches the clone too. (variants[L._baseId || L.id] ||= []).push(id); - const { _baseId, ...tmplL } = L; // _baseId is internal — keep it out of the MapLibre layer - const v = { ...tmplL, id, source: "chart-" + set.name, filter: S52.combineFilters(baseFilter, mariner), layout: _variantLayout(L, set.band, id, bandsHidden, layerVis) }; + const { _baseId, _rawFilter, ...tmplL } = L; // internal — keep out of the MapLibre layer + const v = { ...tmplL, id, source: "chart-" + set.name, filter: _rawFilter ? baseFilter : S52.combineFilters(baseFilter, mariner), layout: _variantLayout(L, set.band, id, bandsHidden, layerVis) }; if (minzoom != null) v.minzoom = minzoom; // band appears at its scale, not the baked floor if (capped) v.maxzoom = CHART_BANDS.find((b) => b.slug === set.band).max; out.push(v); @@ -465,7 +491,7 @@ export function buildChartLayers({ // natively (zero JS/frame). The per-band archive is FLOOR-GATED at bake, so // tile CONTENT controls appearance: client layers need no band minzoom. const scaminVals = set.scamin || []; - if (!ignoreScamin && SCAMIN_BUCKET_LAYERS.has(L["source-layer"]) && scaminVals.length) { + if (!ignoreScamin && !L._rawFilter && SCAMIN_BUCKET_LAYERS.has(L["source-layer"]) && scaminVals.length) { // Only materialize a per-value bucket where the SCAMIN cutoff zoom is // ABOVE this set's source floor (set.min). The set's tiles don't load // below set.min, so any SCAMIN whose cutoff is ≤ set.min shows from the @@ -495,7 +521,7 @@ export function buildChartLayers({ // interleaved per band, so a finer band's opaque fill covers it where finer // data exists — the hatch is left only on the coarse-only (overscale) patches // such as open water shown enlarged. S-52 §10.1.10.2. - if (L.id === "areas") _pushOverscale(out, "chart-" + set.name, set.band, layerVis, undefined, bandsHidden, finerBandPresent(set.band)); + if (L.id === "areas") _pushOverscale(out, "chart-" + set.name, set.band, layerVis, mariner.showOverscale, bandsHidden, finerBandPresent(set.band)); } } } @@ -523,12 +549,12 @@ export function buildChartLayers({ // is mirrored from the unbucketed path. const mk = (suffix, baseFilter, minzoom) => { const id = L.id + "@" + band.slug + suffix; - layerBase[id] = baseFilter; + if (!L._rawFilter) layerBase[id] = baseFilter; // raw layers keep their verbatim filter — exclude from the live re-combine // Register under the ORIGINAL base id for a *_scamin clone (L._baseId), // so every restyle/toggle keyed on the original id reaches the clone too. (variants[L._baseId || L.id] ||= []).push(id); - const { _baseId, ...tmplL } = L; // _baseId is internal — keep it out of the MapLibre layer - const v = { ...tmplL, id, source: "chart-" + band.slug, filter: S52.combineFilters(baseFilter, mariner), layout: _variantLayout(L, band.slug, id, bandsHidden, layerVis) }; + const { _baseId, _rawFilter, ...tmplL } = L; // internal — keep out of the MapLibre layer + const v = { ...tmplL, id, source: "chart-" + band.slug, filter: _rawFilter ? baseFilter : S52.combineFilters(baseFilter, mariner), layout: _variantLayout(L, band.slug, id, bandsHidden, layerVis) }; if (minzoom != null) v.minzoom = minzoom; if (capped) v.maxzoom = band.max; out.push(v); @@ -541,7 +567,7 @@ export function buildChartLayers({ // SCAMIN value (collected from the tiles). Out-of-zoom buckets are skipped by // MapLibre for free, so the extra layers cost nothing at runtime. Features // WITHOUT SCAMIN take the band-gated `#no` variant. Other layers: one variant. - if (!ignoreScamin && SCAMIN_BUCKET_LAYERS.has(L["source-layer"]) && scaminValues && scaminValues.length) { + if (!ignoreScamin && !L._rawFilter && SCAMIN_BUCKET_LAYERS.has(L["source-layer"]) && scaminValues && scaminValues.length) { // Only bucket SCAMIN values whose cutoff is ABOVE this band's display floor // (dmin) — values at/below dmin show from the floor anyway (the band isn't // displayed below it), so fold them into the dmin-floored `#no` bucket. Cuts @@ -559,7 +585,7 @@ export function buildChartLayers({ } else { mk("", base, dmin || undefined); } - if (L.id === "areas") _pushOverscale(out, "chart-" + band.slug, band.slug, layerVis, undefined, bandsHidden, finerBandPresent(band.slug)); + if (L.id === "areas") _pushOverscale(out, "chart-" + band.slug, band.slug, layerVis, mariner.showOverscale, bandsHidden, finerBandPresent(band.slug)); } } } diff --git a/web/src/chart-canvas/chart-style.overscale.test.mjs b/web/src/chart-canvas/chart-style.overscale.test.mjs index f4a7232..cb00e3e 100644 --- a/web/src/chart-canvas/chart-style.overscale.test.mjs +++ b/web/src/chart-canvas/chart-style.overscale.test.mjs @@ -38,3 +38,30 @@ test("a single band present (best-available) never hatches", () => { test("no bands present (default) emits no overscale layers", () => { assert.equal(overscaleLayers([]).length, 0); }); + +test("mariner.showOverscale=false hides the hatch layers", () => { + const layers = buildChartLayers({ + mariner: { showOverscale: false }, palette: {}, atlasPpu: 0.08, osm: false, scheme: "day", + server: false, serverSets: [], scaminValues: [], scaminLat: 0, + bandsHidden: new Set(), bandsPresent: new Set(["coastal", "harbor"]), + ignoreScamin: true, sizeScale: 1, + }).layers.filter((L) => L.id.startsWith("overscale@")); + assert.ok(layers.length > 0, "the hatch layers still exist (toggle restores them)"); + for (const L of layers) assert.equal(L.layout.visibility, "none"); +}); + +test("the generic area_patterns layer excludes the baked OVERSC01 hatch", () => { + // tile57 bakes the S-52 overscale hatch (pattern OVERSC01, tagged `oscl`) into + // area_patterns; ungated here it would paint over everything at every zoom. + const layers = buildChartLayers({ + mariner: {}, palette: {}, atlasPpu: 0.08, osm: false, scheme: "day", + server: false, serverSets: [], scaminValues: [], scaminLat: 0, + bandsHidden: new Set(), bandsPresent: new Set(["coastal"]), + ignoreScamin: true, sizeScale: 1, + }).layers.filter((L) => (L._baseId || L.id).startsWith("area_patterns")); + assert.ok(layers.length > 0); + for (const L of layers) { + assert.ok(JSON.stringify(L.filter).includes('["!=",["get","pattern_name"],"OVERSC01"]'), + `${L.id} must exclude OVERSC01`); + } +}); diff --git a/web/src/chart-canvas/pmtiles-source.mjs b/web/src/chart-canvas/pmtiles-source.mjs index 893a54d..2fb08d3 100644 --- a/web/src/chart-canvas/pmtiles-source.mjs +++ b/web/src/chart-canvas/pmtiles-source.mjs @@ -144,6 +144,10 @@ export class PMTilesArchive { if (!okComp(ic) || !okComp(tc)) throw new Error("unsupported PMTiles compression (only none/gzip)"); this._internalGz = ic === 2; this._tileGz = tc === 2; + // Stored tile encoding (header byte 99): 1=MVT, 6=MLT (tile57's MLT-default + // bake). Drives the vector source's `encoding` hint so maplibre-gl (>=5.12) + // decodes MLT natively — tiles always serve bytes-verbatim. + this.tileType = head.getUint8(99) === 6 ? "mlt" : "mvt"; this.minZoom = head.getUint8(100); this.maxZoom = head.getUint8(101); // Data extent straight from the header (the writer stores it), so we never @@ -183,6 +187,19 @@ export class PMTilesArchive { return entries; } + // Directory-only presence probe: does the archive hold a tile at (z,x,y)? + // Same root→leaf resolution as getTile without reading tile data — used by + // the protocol's sparse-pyramid fallback to decide whether an absent tile + // should ERROR (stretch an ancestor) or read as genuinely empty. + async hasTile(z, x, y) { + const key = z + "/" + x + "/" + y; + if (this._misses.has(key)) return false; + const id = zxyToTileId(z, x, y); + let e = floorEntry(this._root, id); + if (e && e.runLength === 0) e = floorEntry(await this._leaf(e.offset, e.length), id); + return !!(e && id < e.tileId + e.runLength && e.length !== 0); + } + // Tile bytes (Uint8Array) for (z,x,y), or null when the archive has no such // tile (reads as blank — MapLibre overzooms from a present parent). Resolves // root → leaf (fetched on demand) → tile, so the whole directory is never held. @@ -245,6 +262,7 @@ export class MultiArchive { this.maxZoom = 16; this.bounds = null; this.scamin = []; // union of the packs' published SCAMIN manifests + this.tileType = "mvt"; // "mlt" when every loaded archive stores MLT tiles } // Add (open) an archive from a Blob/File or a URL string. Returns the opened @@ -279,10 +297,26 @@ export class MultiArchive { this.maxZoom = this.archives.length ? mx : 16; this.bounds = b; this.scamin = [...sc].sort((x, y) => x - y); + // The source `encoding` hint is per-SOURCE, so it can only be "mlt" when + // every archive in this band agrees (archives of one bake always do). A + // mixed band keeps "mvt" and the MLT archives in it would not decode — + // re-bake to one format rather than mixing. + this.tileType = this.archives.length && this.archives.every((a) => a.tileType === "mlt") ? "mlt" : "mvt"; + if (this.archives.some((a) => a.tileType === "mlt") && this.tileType !== "mlt") { + console.warn("[chartplotter] mixed MVT/MLT archives in one band source; MLT archives will not render — re-bake to a single format"); + } } get tileCount() { return this.archives.reduce((s, a) => s + a.tileCount, 0); } + async hasTile(z, x, y) { + for (const a of this.archives) { + if (!boundsCoverTile(a.bounds, z, x, y)) continue; + if (await a.hasTile(z, x, y)) return true; + } + return false; + } + async getTile(z, x, y) { for (const a of this.archives) { if (!boundsCoverTile(a.bounds, z, x, y)) continue; @@ -306,12 +340,25 @@ export function registerPmtilesProtocol(maplibregl, scheme, getArchive) { const m = params.url.match(/(\d+)\/(\d+)\/(\d+)$/); if (!m) return { data: new ArrayBuffer(0) }; const [, z, x, y] = m; + let bytes = null; try { - const bytes = await archive.getTile(+z, +x, +y); - return { data: bytes ? bytes.buffer : new ArrayBuffer(0) }; + bytes = await archive.getTile(+z, +x, +y); } catch (e) { console.warn("[pmtiles] tile", z, x, y, "failed:", e.message); return { data: new ArrayBuffer(0) }; } + if (bytes) return { data: bytes.buffer }; + // Sparse-pyramid fallback: the merged composite archive's depth varies by + // AREA (each band bakes to its native max + the overscale fill-up), so an + // absent tile INSIDE charted water must ERROR — MapLibre retains/loads an + // ancestor and renders it stretched (per-area overzoom) — while a + // delivered-empty tile would paint permanent blank. True no-data (no + // ancestor holds a tile either) stays a quiet empty tile. + if (archive.hasTile) { + for (let pz = +z - 1, px = +x >> 1, py = +y >> 1; pz >= 0; pz--, px >>= 1, py >>= 1) { + if (await archive.hasTile(pz, px, py)) throw new Error(`no tile at ${z}/${x}/${y}; ancestor z${pz} present (stretch)`); + } + } + return { data: new ArrayBuffer(0) }; }); } diff --git a/web/src/chart-canvas/s52-style.mjs b/web/src/chart-canvas/s52-style.mjs index 1b0a592..d179de8 100644 --- a/web/src/chart-canvas/s52-style.mjs +++ b/web/src/chart-canvas/s52-style.mjs @@ -91,12 +91,39 @@ export function seabedTokenExpr(mariner) { "DEPIT"]; } +// A colour token may carry a "," suffix: S-101 ColorFill emits "TOKEN,alpha" +// (e.g. "TRFCF,0.75" for traffic-separation zones; also "CHGRF,0.5", "NODTA,0.5"), +// and the alpha rides in the same color_token property. Match the palette on the +// BASE token (before the comma) — else "TRFCF,0.75" != "TRFCF" and the fill falls to +// the opaque magenta FALLBACK — and fold the alpha into an rgba fill colour +// (fill-opacity is not data-driven in MapLibre; fill-color is). A token with no comma +// is matched whole, exactly as before (no regression for opaque fills). +export function colorTokenFill(prop, palette) { + const t = palette || {}; + const cases = []; + let n = 0; + for (const tok in t) { cases.push(tok, t[tok]); n++; } + if (!n) return FALLBACK; + // Both `case` branches must be the same type, and to-rgba needs a `color`; palette + // values are hex strings, so wrap the match in to-color. + return ["let", "ct", ["coalesce", ["get", prop], ""], + ["let", "ci", ["index-of", ",", ["var", "ct"]], + ["case", + ["<", ["var", "ci"], 0], ["to-color", ["match", ["var", "ct"], ...cases, FALLBACK]], + ["let", "c", ["to-color", ["match", ["slice", ["var", "ct"], 0, ["var", "ci"]], ...cases, FALLBACK]], + ["rgba", + ["at", 0, ["to-rgba", ["var", "c"]]], + ["at", 1, ["to-rgba", ["var", "c"]]], + ["at", 2, ["to-rgba", ["var", "c"]]], + ["to-number", ["slice", ["var", "ct"], ["+", ["var", "ci"], 1]]]]]]]]; +} + // Fill colour for the `areas` layer: depth areas (carry drval1) shade live via -// SEABED01; everything else uses its baked colour token. +// SEABED01; everything else uses its baked colour token (which may carry a ",alpha"). export function areasFillColor(palette, mariner) { return ["case", ["has", "drval1"], colorMatch(seabedTokenExpr(mariner), undefined, palette), - colorExpr("color_token", undefined, palette)]; + colorTokenFill("color_token", palette)]; } // SHALLOW_PATTERN filter: depth areas on the shallow side of the live safety @@ -307,11 +334,26 @@ export function dateFilter(mariner) { true]]]; } +// Viewing-group selection (S-52 §14.5, fine-grained content control): each feature +// is baked with its raw viewing-group number `vg` (the most-visible draw's VG, so +// band(vg) === cat). The mariner DENY-LIST mariner.viewingGroupsOff lists the vg ids +// turned off; a feature is hidden iff its vg is in that set. A feature with no `vg` +// (unbanded, or a tile baked before vg existed) always shows. Empty deny-list = no +// filter. Byte-identical to tile57's viewingGroupFilter (src/chartstyle/chartstyle.zig). +export function viewingGroupFilter(mariner) { + const off = mariner.viewingGroupsOff; + if (!off || !off.length) return null; + return ["any", ["!", ["has", "vg"]], ["!", ["in", ["get", "vg"], ["literal", off]]]]; +} + // Combine a layer's intrinsic (base) filter with the live category + // boundary-style filters (the two client-side portrayal axes baked as // per-feature `cat`/`bnd`). export function combineFilters(base, mariner) { const parts = ["all", categoryFilter(mariner), boundaryFilter(mariner), pointStyleFilter(mariner), sectorLegFilter(mariner)]; + // Fine-grained viewing-group deny-list (S-52 §14.5) — null when nothing is off. + const vgf = viewingGroupFilter(mariner); + if (vgf) parts.push(vgf); // Date-dependent display (S-52 §10.4.1.1, mandatory + default-on): hide a // dated feature outside its validity period for the viewing date. The escape // valve mariner.dateDependent === false shows all dates (only dated features diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 9b67998..0421001 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -17,11 +17,14 @@ // listCharts/setScheme/setMariner and its `map` handle) plus the server chart API. import "./chart-canvas/chart-canvas.mjs"; // defines (the renderer we wrap) +import { engineStamp } from "./chart-canvas/chart-sources.mjs"; // engine-commit stamp for the attribution corner import "./plugins/pick-report.mjs"; // defines (the ECDIS cursor-pick panel) +import { featureDebugSnapshot } from "./lib/debug-snapshot.mjs"; // pick report copy-feature JSON (shared with dev-tools Inspect) import "./plugins/chart-library.mjs"; // defines (the "Charts library" domain) import "./plugins/settings-dialog.mjs"; // defines (the settings panel host) import { SettingsRegistry } from "./core/settings-registry.mjs"; // contribution registry for the settings panel -import { coreSettingsContributions } from "./core/core-settings.mjs"; // the app's own display settings as contributions +import { coreSettingsContributions, vgGroupOn, vgSetGroupOn } from "./core/core-settings.mjs"; // the app's own display settings as contributions + the shared viewing-group toggle path +import { VgRail } from "./plugins/vg-rail.mjs"; // mid-left viewing-group quick-toggle pill rail import { calibrationContribution } from "./plugins/calibration.mjs"; // "Calibration" tab — ruler-measure the 5 mm box → true physical scale import { DevTools } from "./plugins/dev-tools.mjs"; // the slim contributed Advanced-tab dev tools (rebake + feature inspector) import { ConnectionsController } from "./plugins/connections.mjs"; // NMEA0183 data-source manager (Connections tab) @@ -42,8 +45,9 @@ import { HudController } from "./plugins/hud.mjs"; // status readout + overscale import { WheelZoom } from "./plugins/wheel-zoom.mjs"; // scroll-wheel zoom: band detent + elastic floor import { CoverageBoxes } from "./plugins/coverage-boxes.mjs"; // installed-chart coverage overlay import { OrientationControl } from "./plugins/orientation-control.mjs"; // compass: north-up / free orientation +import { FpsMeter } from "./plugins/fps-meter.mjs"; // ?fps — live FPS/frame-time overlay import { SearchBox } from "./plugins/search-box.mjs"; // offline catalog + chart-feature search -import { BANDS, BAND_LABEL, BAND_COLOR, BAND_MINZOOM, DEV_BANDS, bandForScale } from "./lib/bands.mjs"; +import { BANDS, BAND_LABEL, BAND_COLOR, BAND_MINZOOM, BAND_MAXZOOM, OVERSCALE_MARGIN, DEV_BANDS, bandForScale, bandForCellName } from "./lib/bands.mjs"; import { loadJSON, maxZoomForScaleFloor, FLOOR_GIVE, freshness, fmtIssue, fmtMB, isShareUrl, parseViewHash, copyText, flashBtn } from "./lib/util.mjs"; import { archivePut, archiveGet } from "./data/archive-store.mjs"; import { STYLE, CHROME } from "./chartplotter.view.mjs"; // shell chrome (CSS + static markup) @@ -65,11 +69,13 @@ const DEFAULT_MARINER = { deepContour: 30, depthUnit: "ft", // US/NOAA preference (engine default DepthUnitFeet) // Display categories (S-52 §10.2). Base is the minimum safe-navigation set and - // can NEVER be deselected by the mariner — it is forced on at boot. Default - // display is Standard; Other is opt-in. + // can NEVER be deselected by the mariner — it is forced on at boot. We default + // to the full "Other" display (all charted detail) — friendlier for a + // recreational plotter than the ECDIS Standard default; the mariner can drop + // back to Standard/Base in Display settings (detailLevel). displayBase: true, displayStandard: true, - displayOther: false, + displayOther: true, boundaryStyle: "symbolized", // IMO/S-52 default (vs "plain") simplifiedPoints: false, // paper-chart point symbols (engine SimplifiedPoints=false) fourShadeWater: true, // four depth shades (engine TwoShades=false) @@ -105,6 +111,14 @@ const DEFAULT_MARINER = { showContourLabels: false, dataQuality: false, showMetaBounds: false, + // S-52 §10.1.10 overscale indication (ON by default): the AP(OVERSC01) + // vertical-line hatch over regions whose best displayed data is enlarged past + // its compilation scale (engine `oscl` gate / per-band JS overscale layers). + showOverscale: true, + // Fine-grained viewing-group selection (S-52 §14.5): a DENY-LIST of raw viewing- + // group ids (the baked `vg` tag) the mariner has turned off. Empty = every group + // shown (default). Driven by the "Viewing groups" settings tab; see viewing-groups.mjs. + viewingGroupsOff: [], // Display units for non-depth quantities (distance/height/speed/wind/temp). // Depth has its own metric/imperial toggle (depthUnit, above). See units.mjs. ...UNIT_DEFAULTS, @@ -171,15 +185,26 @@ const INSPECT_LAYER_LABEL = { point_symbols: "Symbol", soundings: "Sounding", li // Geometry-primitive rank for pick-report sorting (rule 9): points, then lines, // then areas; labels last. (Decode/render lives in .) function pickGeomRank(layer) { - return { point_symbols: 0, soundings: 0, lines: 1, complex_lines: 1, areas: 2, area_patterns: 2, text: 3 }[layer] ?? 9; + // Strip the SCAMIN-variant suffix so tile57's split source-layers (point_symbols_scamin, + // lines_scamin, areas_scamin, text_scamin, …) rank like their base layer — otherwise + // every SCAMIN feature falls to the unknown rank (9) and the point { - // Widget/hosted: a companion aux.zip named in the manifest (fetched whole once). - // Server: per-file on demand via GET api/aux — the raw zip is never exposed. - if (this._dl.auxUrl) await this._aux.load(this._dl.auxUrl); - else await this._aux.loadApi(this._assets); + // One aux path, online or off: a static index.json manifest whose files sit + // beside it, each fetched per-pick as a plain static GET (no zip, no /api). + // Hosted/widget: the manifest URL the charts index names. Server: /aux/index.json. + await this._aux.load(this._dl.auxUrl || `${this._assets}aux/index.json`); return r; }); // Pick-report class/attribute name lookup. The old S-57 PresLib catalogue @@ -624,6 +649,26 @@ export class ChartPlotter extends HTMLElement { plotter: this._plotter, }); + // ?fps — opt-in live FPS/frame-time overlay for perf measurement. + if (new URLSearchParams(location.search).has("fps")) { + this._fps = new FpsMeter({ host: this.shadowRoot.getElementById("map") }); + } + + // Viewing-group quick toggles: the collapsible mid-left pill rail. Reads and + // writes through the SAME path as the Settings "Viewing groups" tab + // (vgGroupOn/vgSetGroupOn → applyMariner + server persist), so a pill tap + // restyles instantly and syncs to other screens; applyMariner calls + // _vgRail.refresh() on every viewingGroupsOff change (either writer). Shell + // chrome — not mounted in the hermetic widget viewer (it uses localStorage + // for its own fold state). + if (!this._widget) { + this._vgRail = new VgRail({ + host: this.shadowRoot.getElementById("vg-rail"), + isOn: (id) => vgGroupOn(this, id), + setOn: (id, on) => vgSetGroupOn(this, id, on), + }); + } + // Developer tools (Advanced tab) — the first NON-core contributor to the // settings registry. A plain class (like the map controllers), built now that // the map exists; it registers itself as the Advanced-tab contribution and owns @@ -692,6 +737,17 @@ export class ChartPlotter extends HTMLElement { this.saveView(); this._assessCoverage(); this._hud.updateZoomCap(); // clamp zoom-in to the finest band covering the new view + if (!this._widget && this._showCellBounds && this._coverage) this._refreshInstalledBounds(); + }); + // The rendered-cell coverage query needs tiles LOADED, so re-run once the map goes + // idle (tiles settled). Throttled — idle can fire often — and a no-op when the + // winning-cell set is unchanged (setFeatures de-dups). + map.on("idle", () => { + if (this._widget || !this._showCellBounds || !this._coverage) return; + const now = performance.now(); + if (now - (this._covIdleAt || 0) < 250) return; + this._covIdleAt = now; + this._refreshInstalledBounds(); }); // Off-screen chart pointers: edge pointers to installed charts not in view @@ -960,6 +1016,7 @@ export class ChartPlotter extends HTMLElement { this._setProgress(null); try { await this._renderInstalledSets(); } catch (e) { /* ignore */ } if (this._plotter && this._plotter.flushTiles) { try { await this._plotter.flushTiles(); } catch (e) { /* ignore */ } } + this._updateEngineStamp(); // flushTiles re-fetched the set metas (a re-bake can change the engine) if (this._chartLib) this._chartLib.refresh(); }); } @@ -1195,13 +1252,8 @@ export class ChartPlotter extends HTMLElement { // The dev feature inspector (DevTools) owns clicks while it's armed — defer to // it so a pick/coverage tap doesn't fire under an active inspect lock. if (this._devTools && this._devTools.inspecting) return; - // (The Charts cell-picker tap-to-preview-a-district branch was removed with - // the main-map cell picker; the panel is the chart surface.) - // Zoomed out over an installed-chart coverage marker → fly to that chart at - // its detail zoom (so you can find + open installed charts without knowing - // where/at what zoom they live). Otherwise the default ECDIS cursor pick. - if (this._coverage && this._coverage.tapFlyTo(e.point)) return; - // Default chart-view interaction: ECDIS cursor pick (S-52 PresLib §10.8). + // The coverage/cell-boundary overlay is a passive debug layer — a tap always + // runs the default ECDIS cursor pick (S-52 PresLib §10.8), never flies. this._pickReportAt(e.point, e.originalEvent); }); } @@ -1270,9 +1322,10 @@ export class ChartPlotter extends HTMLElement { // --- ECDIS cursor pick (S-52 PresLib §10.8) ------------------------------- // Report on the chart feature(s) under a tapped point. Queries the rendered // tiles (so it returns only visible objects — rule 7), dedupes, and sorts by - // drawing priority then geometry primitive (rule 9), then hands the stack to - // the panel, which decodes + renders it. `ev` is the originating - // DOM event (its clientX/Y anchor the panel's out-of-the-way placement). + // geometry primitive then drawing priority (see pickCmp — the discrete point + // target under the cursor outranks the polygon it sits on), then hands the + // stack to the panel, which decodes + renders it. `ev` is the + // originating DOM event (its clientX/Y anchor the panel's placement). _pickReportAt(point, ev) { const map = this._map; if (!map) return; @@ -1284,7 +1337,9 @@ export class ChartPlotter extends HTMLElement { // target than a mouse. The box is screen-space, so it's zoom-independent. const r = ev && ev.pointerType === "touch" ? 14 : 6; const area = [[point.x - r, point.y - r], [point.x + r, point.y + r]]; - const feats = map.queryRenderedFeatures(area).filter((f) => isChartSource(f.source) && !f.layer.id.startsWith("scaminprobe")); + const only = this._plotter && this._plotter.chartLayerIds ? this._plotter.chartLayerIds() : null; + const feats = (only && only.length ? map.queryRenderedFeatures(area, { layers: only }) : map.queryRenderedFeatures(area)) + .filter((f) => isChartSource(f.source) && !f.layer.id.startsWith("scaminprobe")); // Collapse the per-source-layer representations of one S-57 object — its area // fill, boundary line and centred symbol arrive as separate features that all // share class/cell/objnam/s57 — into a single pick entry, so stepping the @@ -1309,7 +1364,7 @@ export class ChartPlotter extends HTMLElement { const key = (p.class || "") + "|" + (p.cell || "") + "|" + (p.s57 || "") + "|" + (p.objnam || ""); const g = groups.get(key); if (!g) { groups.set(key, { feat: f, hi: f }); continue; } - if (pickCmp(f, g.feat) < 0) g.feat = f; // higher drawing priority wins the report row + if (pickCmp(f, g.feat) < 0) g.feat = f; // best pick rank wins the report row if (hiGeomRank(f) > hiGeomRank(g.hi)) g.hi = f; // richer primitive wins the highlight } const uniq = []; @@ -1345,6 +1400,9 @@ export class ChartPlotter extends HTMLElement { const el = document.createElement("pick-report"); if (typeof el.show !== "function") { console.warn("[pick] not loaded"); return null; } this.shadowRoot.appendChild(el); + // Copy-feature button: the dev Inspect debug-copy shape, scoped to the selected + // feature (includes the live layer gates, so a pasted report is self-diagnosing). + el.setSnapshot((f) => featureDebugSnapshot(this._map, f)); el.addEventListener("pick-feature", (e) => { const f = e.detail && e.detail.feature; const geom = f ? (f._hiGeom || f.geometry) : null; // trace the object's extent, not the symbol anchor @@ -1446,15 +1504,46 @@ export class ChartPlotter extends HTMLElement { } // Cap the map's max zoom at the 1:MIN_DETAIL_SCALE scale for the current centre - // latitude — a consistent scale floor rather than a per-location data cap. - _applyScaleFloor() { + // latitude (a consistent scale floor), AND at the finest covering band's data + // depth: the bake carries each band OVERSCALE_MARGIN zooms past its native max + // (the composite's capped overscale fill-up, bake_enc.FILLUP_DZ) — past that + // there are NO vector tiles at all and MapLibre cannot stretch absent tiles, + // so the camera stops where the data stops instead of panning blank water. + async _applyScaleFloor() { if (!this._map) return; // FLOOR_GIVE headroom above the floor so WheelZoom can let a hard-in scroll // over-pull a hair past it and settle back (a stop with give, not a wall). - const mz = maxZoomForScaleFloor(this._map.getCenter().lat) + FLOOR_GIVE; + let mz = maxZoomForScaleFloor(this._map.getCenter().lat) + FLOOR_GIVE; + const c = this._map.getCenter(); + const band = this._finestBandAt(c.lng, c.lat); + if (band) { + mz = Math.min(mz, BAND_MAXZOOM[band] + OVERSCALE_MARGIN + FLOOR_GIVE); + } else if (this._plotter && this._plotter.dataMaxZoomAt) { + // Prebaked/widget: no active-cell index — probe the archives' own + // directory for the deepest tile at the centre. +1 for the one stretched + // level MapLibre's parent retention renders past the data (the pmtiles + // protocol errors absent-with-ancestor tiles to trigger it). + const dz = await this._plotter.dataMaxZoomAt(c.lng, c.lat); + if (dz != null) mz = Math.min(mz, dz + 1 + FLOOR_GIVE); + } if (Math.abs(this._map.getMaxZoom() - mz) > 1e-3) this._map.setMaxZoom(mz); } + // The finest navigational band among the ACTIVE installed cells whose bbox + // covers (lng,lat) — the per-location data depth for the zoom cap. null when + // no active cell covers (open ocean / widget mode / nothing installed yet), + // which leaves the uniform scale floor alone. + _finestBandAt(lng, lat) { + let best = -1; + for (const cell of this._activeCells || []) { + const b = cell.bb; + if (!b || lng < b[0] || lng > b[2] || lat < b[1] || lat > b[3]) continue; + const r = BANDS.indexOf(bandForCellName(cell.n || "")); + if (r > best) best = r; + } + return best >= 0 ? BANDS[best] : null; + } + // Broker for WheelZoom: the geographic point wheel-zoom should keep fixed while // zooming, composed from the plugins that can contribute one (first non-null // wins; null → cursor-anchored). Today only own-ship anchors on the vessel while @@ -1546,29 +1635,51 @@ export class ChartPlotter extends HTMLElement { const packs = await this._api.packs(); // Re-index aux content (server mode): a just-imported district's TXTDSC/PICREP // files become resolvable in the pick report without a page reload. - if (this._aux && !this._dl.auxUrl) this._aux.loadApi(this._assets).catch(() => {}); + if (this._aux && !this._dl.auxUrl) this._aux.load(`${this._assets}aux/index.json`).catch(() => {}); const cells = await this._api.cells(); if (cells) this._installed = cells; // null → keep current view // Active (enabled-pack) cells WITH bounds — the search catalog, so you can find // an installed chart by name and fly to it (esp. on a blank/no-basemap map). this._activeCells = await this._api.activeCells(); - // Management keys on the DISTRICT name (noaa-d5); enable/disable/remove hit the - // district and the server fans to its band-sets. + // Provider-enc-root: /api/packs keys on the PROVIDER (noaa/ienc/user) — one baked + // archive per provider, districts are subfolders inside it. Enable/disable/remove + // operate at provider (+ per-district delete) level. this._installedSets = new Set(packs.map((p) => p.name)); this._disabled = new Set(packs.filter((p) => !p.enabled).map((p) => p.name)); - this._packsMeta = packs; // {name,enabled,bands,bounds} — drives the coverage boxes (incl. disabled packs) - // Rendering needs each enabled district's PER-BAND tile sets (noaa-d5-general …), - // listed in `bands` ("all" for a bandless/merged pack → the bare set name). + this._packsMeta = packs; // {name,enabled,districts,bounds} — drives the coverage boxes (incl. disabled) + // Rendering: ONE vector source per enabled provider (chart-). Best-available + // across cells/districts is resolved inside the single archive by the baker, so there + // are no per-band or per-district sub-sources to composite. (`bands` is legacy — a + // provider pack has none, so this collapses to the bare provider name.) const active = packs.filter((p) => p.enabled).flatMap((p) => (p.bands && p.bands.length ? p.bands : ["all"]).map((b) => (b === "all" ? p.name : `${p.name}-${b}`))); if (this._plotter) await this._plotter.setServerSets(active); this._hasArchive = active.length > 0; this.updateEmptyState(); this._refreshInstalledBounds(); + this._updateEngineStamp(); // fresh set metas → re-render the engine-commit stamp if (this._chartFinder) this._chartFinder.update(); // packs changed → recompute off-screen pointers return active; } + // Engine-commit stamp beside the NOAA attribution: which tile57 engine commit + // baked the ACTIVE sets' visible tiles (each set's TileJSON `engine` — bake-time + // truth for packs, the running binary for live sets). One muted commit when every + // set agrees; a warn-tinted per-pack list with ✱ markers when they differ (a + // partially re-baked cache). Hidden when no set reports one (pmtiles mode, an + // older server) and in widget/spec modes (CSS). + _updateEngineStamp() { + const el = this.shadowRoot && this.shadowRoot.getElementById("engine-stamp"); + if (!el) return; + const metas = (this._plotter && this._plotter.serverSetMetas) ? this._plotter.serverSetMetas() : []; + const stamp = engineStamp(metas); + el.hidden = !stamp; + if (!stamp) return; + el.textContent = stamp.text; + el.title = stamp.title; + el.classList.toggle("mixed", stamp.mixed); + } + // Wait for a server job (download/bake) to complete, surfacing progress through // prog({label,sub,frac}). Prefers a single Server-Sent-Events stream (one // connection, server pushes on change) and falls back to polling if EventSource @@ -1610,12 +1721,8 @@ export class ChartPlotter extends HTMLElement { _refreshInstalledBounds() { if (!this._coverage) return; // coverage overlay not set up yet const feats = []; - // Per-CELL footprints are the widget (pmtiles) path only. In SERVER mode we draw - // one box per ENABLED pack (below) instead — a full NOAA install has thousands - // of cells, and a box per cell (re-projected to its min on-screen size on every - // zoom frame) would freeze the map. Per-cell boxes also ignore the enabled flag, - // so they'd keep showing a disabled district's coverage. Per-pack boxes fix both. if (this._widget) { + // Widget (pmtiles) path: per-cell footprints from the local catalogue. for (const name of this._installed) { const bb = this._cellLocation(name); // catalog footprint if (!bb) continue; @@ -1629,32 +1736,83 @@ export class ChartPlotter extends HTMLElement { geometry: { type: "Polygon", coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]] }, }); } + } else { + // SERVER mode: only the cells currently rendering in view (best-available + // winners), each as a band-coloured, name-labelled footprint box — so you can see + // which chart owns a spot. Refreshed on moveend + idle (tiles must be loaded for + // the rendered-cell query to be accurate). + for (const f of this._serverCellFeatures()) feats.push(f); } - // Server mode has no per-cell footprints, so the list above is empty here. - // Add ONE coverage box per ENABLED pack from /api/packs - // (which carries each pack's union bounds + bands). Tag with the pack's COARSEST - // band (bands[0], coarse→fine from the server) for the click-to-fly zoom + the - // band-capped fill. DISABLED packs render nothing on the map, so they get no - // boundary either. An enabled full NOAA stack (overview/general band) hides its - // fill at coarse zoom (no stray box); a standalone set keeps its box until you - // zoom into its detail. - for (const p of this._packsMeta || []) { - if (!p.enabled) continue; // disabled packs aren't drawn → no coverage box - const bb = p.bounds; - if (!Array.isArray(bb) || bb.length !== 4) continue; - const coarsest = (p.bands && p.bands[0]) || "harbor"; - const band = BANDS.includes(coarsest) ? coarsest : "harbor"; // "all"/unknown → large-scale + // Hand the TRUE footprints to the coverage controller, which pushes them with a + // per-zoom minimum on-screen size so a tiny cell never shrinks to an invisible + // speck when zoomed out. + if (this._coverage) this._coverage.setFeatures(feats); + } + + // The cell names ACTUALLY RENDERING in the current view — the best-available + // WINNERS. Every baked feature carries a `cell` pick-attr, so the distinct `cell` + // values under the chart layers are exactly the cells whose data is on screen (a + // coarse cell fully covered by finer ones contributes nothing → not listed). Returns + // a Set, or null when the chart layers aren't queryable yet (style not ready). + _renderedCellNames() { + const map = this._map; + if (!map || !map.getStyle) return null; + let style; + try { style = map.getStyle(); } catch { return null; } + if (!style) return null; // getStyle() RETURNS undefined (not throws) before the style loads + const layers = (style.layers || []) + .filter((l) => l.source && (l.source === "chart" || String(l.source).startsWith("chart-"))) + .map((l) => l.id) + .filter((id) => map.getLayer(id)); + if (!layers.length) return null; + let feats; + try { feats = map.queryRenderedFeatures({ layers }); } catch { return null; } + const set = new Set(); + for (const f of feats) { const c = f.properties && f.properties.cell; if (c) set.add(c); } + return set; + } + + // Per-CELL coverage features for SERVER mode: ONLY the cells currently rendering + // (best-available winners in view), each as its full footprint box, band-coloured + + // name-labelled so you can see exactly which chart owns a spot (e.g. which cell won + // where a light drops out). Footprints come from the active-cell index; band from + // the cell's compilation scale (the baker's bandForScale) when known, else its + // usage-band name digit. Falls back to one coarse provider box only while the chart + // layers aren't queryable yet. + _serverCellFeatures() { + const rendered = this._renderedCellNames(); + if (rendered === null) return this._providerBoxFeatures(); // style not ready → coarse box + const byName = new Map((this._activeCells || []).map((c) => [c.n, c.bb])); + const feats = []; + for (const name of rendered) { + const bb = byName.get(name); + if (!Array.isArray(bb) || bb.length !== 4) continue; // footprint not indexed yet + const cat = this._byName.get(name); + const scale = (cat && typeof cat.s === "number" && cat.s) || 0; + const band = scale ? bandForScale(scale) : bandForCellName(name); const [w, s, e, n] = bb; feats.push({ type: "Feature", - properties: { name: p.name, band, status: "ready" }, + properties: { name, band }, geometry: { type: "Polygon", coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]] }, }); } - // Hand the TRUE footprints to the coverage controller, which pushes them with a - // per-zoom minimum on-screen size so a tiny cell never shrinks to an invisible - // speck when zoomed out. - if (this._coverage) this._coverage.setFeatures(feats); + return feats; + } + + // One coarse box per enabled provider (the fallback when per-cell isn't available). + _providerBoxFeatures() { + const feats = []; + for (const p of this._packsMeta || []) { + if (!p.enabled || !Array.isArray(p.bounds) || p.bounds.length !== 4) continue; + const [w, s, e, n] = p.bounds; + feats.push({ + type: "Feature", + properties: { name: p.name, band: "general", status: "ready" }, + geometry: { type: "Polygon", coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]] }, + }); + } + return feats; } // Show/hide the installed-chart coverage overlay. @@ -1866,6 +2024,12 @@ export class ChartPlotter extends HTMLElement { // Switching units relabels + reconverts the depth fields (still in metres // under the hood), so redraw the settings panel. if ("depthUnit" in patch) this._settingsDlg && this._settingsDlg.refresh(); + // Viewing-group deny-list changed (the quick-toggle rail OR the Settings tab — + // both write through here): re-sync both surfaces so they never disagree. + if ("viewingGroupsOff" in patch) { + this._vgRail && this._vgRail.refresh(); + this._settingsDlg && this._settingsDlg.refresh(); + } } // -- chrome / panels ----------------------------------------------------- diff --git a/web/src/chartplotter.view.mjs b/web/src/chartplotter.view.mjs index 5f6d14f..daa1cd1 100644 --- a/web/src/chartplotter.view.mjs +++ b/web/src/chartplotter.view.mjs @@ -77,6 +77,51 @@ export const STYLE = ` .rbtn:active { transform:scale(.94); } .rbtn.on { background:var(--ui-accent); color:var(--ui-accent-text); border-color:var(--ui-accent); } .rbtn svg { width:21px; height:21px; display:block; } + /* Viewing-group quick-toggle rail (plugins/vg-rail.mjs) — a mid-left + vertical stack, clear of the top-left search button and the bottom-left + scalebar/attribution. Ghosted until hovered/focused so it doesn't + compete with the chart; folds to the single grip button. */ + #vg-rail { position:absolute; left:calc(10px + env(safe-area-inset-left,0px)); top:50%; + transform:translateY(-50%); z-index:6; display:flex; flex-direction:column; + align-items:flex-start; gap:6px; opacity:.55; transition:opacity .15s ease; } + #vg-rail:hover, #vg-rail:focus-within { opacity:1; } + #vg-rail:empty { display:none; } /* widget/spec: never mounted → no dead hover target */ + .vg-grip { position:relative; flex:none; width:32px; height:32px; border-radius:50%; cursor:pointer; padding:0; + display:flex; align-items:center; justify-content:center; color:var(--ui-text-dim); + background:color-mix(in srgb, var(--ui-surface) 90%, transparent); border:1px solid var(--ui-border); + box-shadow:0 2px 8px rgba(0,0,0,.16); backdrop-filter:blur(6px); + touch-action:manipulation; -webkit-touch-callout:none; -webkit-user-select:none; user-select:none; + transition:background .12s, color .12s, transform .08s; } + .vg-grip svg { width:17px; height:17px; display:block; } + @media (hover:hover) { .vg-grip:hover { color:var(--ui-accent); border-color:var(--ui-accent); } } + .vg-grip:active { transform:scale(.94); } + .vg-grip.on { background:var(--ui-accent); color:var(--ui-accent-text); border-color:var(--ui-accent); } + /* Folded rail with groups hidden: amber dot so "the chart is filtered" stays visible. */ + .vg-grip.filtered:not(.on)::after { content:""; position:absolute; top:1px; right:1px; width:8px; height:8px; + border-radius:50%; background:#f0a500; box-shadow:0 0 0 1.5px var(--ui-surface); } + .vg-pills { display:grid; grid-template-columns:repeat(2, 1fr); gap:3px; padding:6px; + background:color-mix(in srgb, var(--ui-surface) 88%, transparent); border:1px solid var(--ui-border); + border-radius:12px; backdrop-filter:blur(6px); box-shadow:0 3px 14px rgba(0,0,0,.2); + max-height:min(66vh, calc(100dvh - 220px)); overflow-y:auto; overscroll-behavior:contain; + scrollbar-width:thin; } + .vg-pills[hidden] { display:none; } + .vg-pill { display:flex; align-items:center; gap:4px; min-width:0; padding:2px 7px 2px 5px; + border-radius:999px; cursor:pointer; font:600 10px/1.7 system-ui,sans-serif; + touch-action:manipulation; -webkit-touch-callout:none; -webkit-user-select:none; user-select:none; + transition:background .1s, color .1s; } + .vg-pill .vg-glyph { flex:none; font-size:8.5px; line-height:1; } + .vg-pill .vg-abbr { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + /* ON = green + ✓; OFF = red + ✕ + struck label — the glyph/strike carry the + state for colour-blind users (never colour alone). color-mix over the + scheme surface keeps both readable in day/dusk/night. */ + .vg-pill.on { background:color-mix(in srgb, #1f9d4d 26%, var(--ui-surface)); color:var(--ui-text); + border:1px solid color-mix(in srgb, #1f9d4d 55%, var(--ui-surface)); } + .vg-pill.on .vg-glyph { color:#1f9d4d; } + .vg-pill.off { background:color-mix(in srgb, #c0392b 22%, var(--ui-surface)); color:var(--ui-text-dim); + border:1px solid color-mix(in srgb, #c0392b 50%, var(--ui-surface)); } + .vg-pill.off .vg-glyph { color:#c0392b; } + .vg-pill.off .vg-abbr { text-decoration:line-through; } + @media (hover:hover) { .vg-pill:hover { filter:brightness(1.08); } } /* Widget mode: a read-only, embeddable chart viewer (a "CDN"/widget deploy). Charts load from a configured hosted archive (pmtiles="…" / catalog="…"); there's no backend, no NOAA download, no in-browser baking, no Dev tools. @@ -90,7 +135,7 @@ export const STYLE = ` the status readout, the attribution and the load bar so only the chart shows. */ :host([spec]) #tl-controls, :host([spec]) #tr-controls, :host([spec]) #br-controls, :host([spec]) #databox, :host([spec]) #noaa-attr, :host([spec]) #load-bar, - :host([spec]) #toasts { display:none; } + :host([spec]) #toasts, :host([spec]) #vg-rail { display:none; } .box-sel { position:absolute; z-index:5; border:2px solid var(--ui-accent); background:rgba(21,101,192,.12); pointer-events:none; } /* charts panel: action header + "your charts" cards */ .charts-actions { display:flex; gap:8px; margin-bottom:10px; } @@ -242,6 +287,15 @@ export const STYLE = ` text-decoration:underline; text-decoration-color:var(--ui-text-faint); text-underline-offset:2px; } #noaa-attr a:hover, #noaa-attr .attr-link:hover { color:var(--ui-accent); } #noaa-attr .attr-link { background:none; border:none; padding:0; font:inherit; } + /* Engine-commit stamp: which tile57 engine commit baked the visible tiles + (from each active set's TileJSON "engine"). Muted beside the disclaimer; + warn-tinted when the active sets DISAGREE (a partially re-baked cache). + Hidden with no data ([hidden]) and in the widget viewer (dev chrome; + spec mode already hides the whole attribution line). */ + #engine-stamp { color:var(--ui-text-faint); } + #engine-stamp::before { content:" · ⚙ "; } + #engine-stamp.mixed { color:#c0392b; font-weight:600; } + :host([widget]) #engine-stamp { display:none; } /* NOAA ENC user-agreement gate (shown before the first download). */ .modal { position:absolute; inset:0; z-index:30; display:flex; align-items:center; justify-content:center; background:rgba(15,20,26,.55); backdrop-filter:blur(2px); } @@ -276,6 +330,12 @@ export const STYLE = ` box-shadow:0 4px 18px rgba(0,0,0,.18); font:11px system-ui,sans-serif; color:var(--ui-text); } #databox[hidden] { display:none; } + /* ?fps overlay — top-right, out of the way, monospace, amber when low. */ + .fps-meter { position:absolute; bottom:calc(var(--botbar-h) + 8px); right:8px; z-index:9; + pointer-events:none; font:11px ui-monospace,SFMono-Regular,Menlo,monospace; white-space:nowrap; + padding:3px 7px; border-radius:7px; color:#8fe3a0; + background:rgba(0,0,0,.62); } + .fps-meter[data-low="1"] { color:#ffb454; } /* Live band·scale·zoom·position readout — fixed-width fields + tabular figures so panning/zooming never reflows the card. The card width is FIXED (above) so it never grows/shrinks as the message changes; the @@ -525,6 +585,9 @@ export const CHROME = `
+ +
-
NOAA ENC® · · not for navigation
+
NOAA ENC® · · not for navigation