Skip to content

Major refactor of composition model, and API#3

Merged
beetlebugorg merged 140 commits into
mainfrom
feat/per-cell-composite
Jul 11, 2026
Merged

Major refactor of composition model, and API#3
beetlebugorg merged 140 commits into
mainfrom
feat/per-cell-composite

Conversation

@beetlebugorg

Copy link
Copy Markdown
Owner

No description provided.

beetlebugorg and others added 30 commits July 8, 2026 09:39
Adds src/scene/partition_adapt.zig: widen a cell's M_COVR(CATCOV=1) coverage into
geo.plane.Cell (the pure partition input), plus a corpus-gated probe that
validates the ownership partition on a real ENC district (NOAA d01):
  - area-exact sliver check via boolean symmetric difference (union(faces) vs
    union(coverage)) — the gate point-sampling cannot provide
  - per-point owner agreement vs the float M_COVR oracle (s57.coverageContains),
    tie-broken by the same (cscl, DSID order) rule the partition uses
  - naive-kernel build timing

Result on d01: symdiff 0 ppm (exact partition, zero slivers) across the coarse
(US2/US3) and the dense harbor (US5) bands; 100% owner agreement; zero overlap and
zero gap. Confirms border-dissolve via integer coordinate set algebra works on
real, independently-digitised cells. The naive plane.ownedAtTier is O(cells^2)
(~5.5 ms/eligible cell) -> an indexed variant is needed at national scale.

Wires a dedicated `zig build partition-probe` step (s57+geo only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The specs/cross-band-composition-redesign.md doc that geo.zig / plane.zig /
boolean.zig referenced no longer exists (specs/ is never committed, so the refs
dangle), and "E7 lattice" reads like a protocol name. Remove the dead refs and
describe the encoding plainly: integer coordinates (degrees x 10^7). Comment and
test-name only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… global union)

plane.ownedAtTierIndexed produces the same partition as ownedAtTier, but each cell
is differenced only against the finer eligible cells whose bounding box OVERLAPS
it, instead of against a global running union of all finer coverage. A finer cell
whose bbox is disjoint cannot remove any area, so the result is identical
(cross-checked at sample points over a set with both nested overlap and abutting
adjacency). Charts overlap locally, so per-cell subtrahends stay small — this is
the variant for national-scale data, where the naive global-union kernel is
O(cells^2) in operand size (~1.4s / 250 harbor cells).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ speedup

The d01 probe now runs BOTH plane.ownedAtTier and ownedAtTierIndexed, compares
their owner at every sample point (0 disagreements on real harbor cells — proves
the indexed variant is correct at scale, not just on synthetic boxes), and reports
the speedup: 7.1x on 250 US5 cells (1362 ms -> 192 ms). The gap widens with cell
count since the indexed builder drops the O(cells^2) global union.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… + queries)

geo.partition.build() derives the distinct band floors, runs ownedAtTierIndexed
per tier, and holds the results as a stack sorted finest-band-first. Queries:
mapForZoom (the finest band whose floor <= z; a zoom below every floor resolves to
the coarsest map so zooming out never falls off the bottom), facesForBand (iterate
the owners at a band), and ownerAt (the cell owning a point, or a true gap the
compositor resolves from a coarser band). This is the artifact the compositor clips
each cell's tiles against, and the debug view renders directly. Pure geometry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… PMTiles

A debug PMTiles of the composited ownership partition ONLY — one polygon per cell's
owned region at each band, tagged with cell/cscl/band/tier/oi/color, and NO portrayed
chart content — so the quilt is eyeballable in a viewer. Reuses existing code: the
ENC_ROOT discovery + readParseCell loader, bake_enc.bandOf/ordersBeforeKeys, geo.partition,
and the mvt.encode + tile.project/clipPolygon + pmtiles.write emit. The partition adapter
just converts a parsed s57.Cell; it does not load.

bake_enc: ordersBefore -> pub ordersBeforeKeys(da,na,db,nb) so the partition uses the same
tie-break as the bake. bake_root: expose `geo` through the engine module.

Verified on NOAA d01 (867 cells): the coastal cells own their basins and the overview cell
fills the gaps (cross-band gap-fill visible per tile); z0-12, layer "partition".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…zoom

Three reasons the debug archive looked empty in a viewer, all fixed:
- Winding: the boolean partition emits rings with unspecified orientation, but
  MVT/MapLibre fills by winding. Run each face's clipped rings through
  scene.orientAreaRings (now pub) — the same winding authority every real area
  tile uses — so exteriors are +area and holes -area.
- Bounds: the archive header defaulted to the whole world, so a viewer opened at
  world scale where the district is an invisible speck. Set the header lon/lat
  bounds from the cells' union bbox.
- Center zoom: pmtiles.write hardcoded center_zoom = min_zoom (0 for a z0-12 bake)
  -> opened at world zoom. WriteOptions gains an optional center_zoom; the debug
  bake frames the data (~2 tiles wide). Also fixed write() ignoring opts.tile_type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iewers)

A MapLibre-based viewer (pmtiles.io, the client) builds its render style from the
archive's TileJSON `vector_layers`; the debug metadata omitted it, so no layer was
known and the map stayed blank regardless of geometry/winding/bounds. Declare the
`partition` layer + its fields, matching what the real bake writes. This was the
actual reason the debug output looked empty.

Adds `tile57 partdbg-png <file> z x y out.png`: renders one partition tile to a PNG
(fills each face by its `color` prop, nonzero rule) as a viewer-independent
self-check. Verified d01 tiles render — distinct colored faces cleanly partitioning
each tile, no gaps/overlaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ompositor

Adds an O(1) per-BandMap index (pos: cell index -> face slot) and ownedFace(ci, z):
the rings a cell owns at the band governing zoom z, or null if it owns nothing
there. This is the geometry the compositor clips a cell's features to before
merging (clip-to-face subsumes finer-coverage point-drop and line-cut). Tested via
the band-stack fixture: harbor owns its box, coarse owns the surround minus the
hole, below-floor and out-of-range return null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on-debug

Consolidates on the single-path principle (one API per action, composed):
- loadCells: THE eager ENC_ROOT->cells loader (walk + readParseCell + mcovrCoverage +
  bbox + name + date, dedup by stem). loadContextCoverage is now a thin ContextCell
  projection of it — no second walk.
- toPlaneCells: THE s57-coverage -> geo.plane.Cell adapter (widen + bandOf + DSID order),
  shared by the debug bake and (next) the compositor.
- bakePartitionDebug composes loadCells + toPlaneCells + geo.plane + the mvt/tile/pmtiles
  primitives and STREAMS tiles (StreamWriter; one tier + one zoom resident) instead of
  accumulating everything.

Adds a `band` selector (--band <0..5|name>): <0 emits the band governing each zoom (the
natural view); 0..5 (berthing..overview) emits that band's own map at every zoom. Builds
only the tiers a zoom range needs (merged z0-9 never builds the harbor tier).

Fixes the OOM: whole US (3528 cells) z0-9 now 8.8s / 260KB (was OOM). d01 unchanged;
band selector verified (same z9 tile: coastal=6 faces, harbor=49).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exposes the ownership-partition debug bake through the C ABI and the Go binding so a
host (chartplotter-go) can generate partition-debug PMTiles for a visual-debug UI:

  int tile57_bake_partition_debug(const char *enc_root, const char *out_path,
                                  uint8_t minzoom, uint8_t maxzoom, int8_t band,
                                  uint32_t *out_cell_count);
  func BakePartitionDebug(encRoot, outPath string, minZoom, maxZoom uint8,
                          band PartitionBand) (cellCount int, err error)

band < 0 = the band governing each zoom (the natural view); 0..5 (berthing..overview,
the PartitionBand consts) = one band's own map at every zoom. Wraps the streaming
bundle.bakePartitionDebug, so it scales to the full corpus. Purely additive — no
existing ABI changed. Go test bakes testdata merged + per-band and checks the PMTiles
magic; capi builds into libtile57.a and the Go binding links + passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a second vector layer "labels" to the partition-debug tiles: one point per face
per tile, at the centre of the face's clipped extent, carrying the owning cell's name.
A MapLibre symbol layer (text-field = ["get","cell"]) then renders the owner directly
in the map, so you can read which cell owns which region without clicking. Labelled in
every tile the face touches (collision-deduped by the renderer); vector_layers metadata
declares the new layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…compositor

Refactors clipLineOutsidePolys into a shared clipLineByCoverage(keep_inside) and adds
the INSIDE complement. With ownedFace (areas → boolean.intersect, points →
pointInEvenOdd), this is the last geometry primitive the compose core needs to clip a
cell's line features to the ground it owns. One shared impl, two wrappers; tested as the
exact complement of the outside clip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The atomic Stage-2 compose op (scene/compose.zig): clip one cell's decoded tile feature
to the ground it owns (partition.ownedFace, projected to tile-pixel space). Composes the
geometry primitives — areas via boolean.compute(.intersect), lines via
plane.clipLineInsidePolys, points via pointInEvenOdd — with only mvt<->integer adapters
and per-geometry-type dispatch; no new set algebra. Tested: area intersected to the face,
point kept iff inside, line clipped to the inside part, wholly-outside feature dropped.
Wired as `zig build compose-test` + the main suite.

Also adds specs/per-cell-composite.md: the rework's status + design (foundation + debug
tooling done; compositor Steps 2-5 remain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s metadata)

The per-cell-composite bake needs each per-cell PMTiles to carry its own M_COVR
coverage + cscl + (date,name) tie-break keys so the compositor rebuilds the
ownership partition without re-parsing the .000. This adds the serialization:
CellCoverage round-trips through the metadata "coverage" key, points stored as
integer [lon_e7,lat_e7] pairs (never f64) so seam-dissolve stays exact. band is a
derived convenience; cov2 (CATCOV=2) is a reserved, empty slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bakeCellBytes now parses the cell once (as openCellBaked already does) to capture
its M_COVR coverage + cscl + date/name, encodes them via scene.coverage, and
bakeArchive splices that object under the metadata "coverage" key. metadataJson
gains an optional coverage arg (null for multi-cell root bakes, which carry no
single coverage). cellCoverageFromArchive reads it back for the stitcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a read path for the embedded coverage: chart.pmtilesMetadata returns an
archive's metadata JSON (cellCoverageFromArchive decodes it for the Zig stitcher).
Exposed as tile57_pmtiles_metadata; the Go binding gains BakeCell (the per-cell
bake had no wrapper) + PMTilesMetadata. A real-cell integration test bakes
US5MD1MC and asserts the full round-trip (bake -> embed -> read -> decode): name,
cscl, non-degenerate bbox, and every M_COVR vertex inside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The composite model bakes each cell at its own compilation scale and nothing else;
the stitcher combines the per-cell archives and handles any cross-band zoom. So the
per-cell bake now derives its range from bandZooms(bandOf(cscl)) instead of taking
minzoom/maxzoom — this also avoids overscaling a coarse cell to fine zooms (a lone
overview cell baked to z16 was an unbounded tile explosion).

Cleaner ABI (both consumers are ours): tile57_bake_cell_bytes / Go BakeCell drop
their zoom params. Common-case harbor/approach cells bake in ~1-2s; large overview
cells are heavier by extent (the real per-band cost, bounded to native zooms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… prep)

The compositor clips each cell's decoded tile features to the ground it owns.
The owned face comes from partition.ownedFace in integer lon/lat (degrees × 1e7);
the features live in tile-pixel space. projectFace bridges them, mirroring EXACTLY
the per-cell baker's tile.project + tile.clipPolygon (same Box.default(EXTENT,BUFFER)
and round) so the face and features share one pixel space and clipFeatureToFace's
intersect is seam-exact.

Re-export compose from scene.zig so the bundle-level compositor can reach it
(one-directional; compose doesn't import scene, no cycle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ories)

prefix() copies the run-merged entry directory (root + leaves) and the merged
Entry list into the returned archive prefix, but never freed them — a small leak
on every finishBytes()/prefix() call (surfaced by the compositor's leak-checked
tests: 6 leaks = merged + dirs.root across 3 finishBytes calls). They are always
copied into `out`, so freeing them once the prefix is assembled is safe for any
allocator (a no-op under an arena). leaves may be the empty sentinel — guard it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Combine N per-cell PMTiles (each native-band-scale, coverage embedded) into one
merged PMTiles driven by the ownership partition. Per output tile, each owning
cell's decoded features are clipped to the ground it owns (partition.ownedFace,
projected into the tile via compose.projectFace) and concatenated per layer. The
faces are a disjoint partition, so there's no double-draw at a seam and no z-order
re-sort — S-52 draw priority rides the per-feature draw_prio property that the
style sorts client-side, so within-tile feature order is cosmetic.

- Rebuilds the partition from each archive's embedded coverage (no .000 re-parse),
  via the canonical toPlaneCells adapter (bandOf floor + ordersBeforeKeys tie-break).
- Streams one governing band per zoom (partition.mapForZoom), scattering each owner's
  native tiles; MLT decode → clip → merge → re-orient polygons → MLT encode. Native
  scale only — cross-band zoom expansion (overscale/fill-down) is a later stage.
- Merges the SCAMIN ladder across cells into the composed metadata.

Hermetic test: two abutting harbor cells split a shared z13 tile at the vertical
mid-line — left/right quarters each owned by exactly one cell, neither by both
(disjoint partition, no double-draw). New bundle-test build target (libc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`tile57 compose <cell.000 | ENC_ROOT> -o <out.pmtiles> [--rules DIR]`: bake each
cell to its own native-scale PMTiles (coverage embedded) via chart.bakeCellBytes,
then combine them through the ownership partition into one merged PMTiles
(bundle.composeArchives). The end-to-end composite path (retires the streaming
in-bake cross-cell combiner) and the driver for real-cell validation.

Validated on a 13-cell Baltimore cross-band cluster (10 harbor US5BAL + 2 approach
US4MD + 1 coastal US3DE): 8.3 MB PMTiles, zoom 9-16, all 6 VECTOR_LAYERS present
with S-52 props (draw_prio/color_token/drval/class/scamin) preserved through the
clip. Cross-band holes at z12 (coastal outer, no native z12) confirm overscale as
the next stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… a band

composeArchives was native-scale only: a cell owning ground outside its native
band left it empty at that zoom (real holes: z12 coastal outer, z14 approach).
ownerTile now falls back to overscale — when z is within one fill-up zoom past the
cell's band native max (bounded by bake_enc.FILLUP_DZ/FILLUP_CEIL, matching the
oracle), fetch the cell's deepest native ancestor tile and scale its features up
into the descendant (scaleUpTile); the existing clip-to-owned-face drops the
out-of-tile overflow. The zoom loop runs to the deepest native band + FILLUP_DZ.

Deeper coarse-only zooms are left to the client camera + MapLibre overzoom (same
as the oracle) — this bounds the tile count instead of overscaling a coarse cell
across a whole fine-zoom footprint.

Real 13-cell Baltimore cluster: z12 35 -> 2561 tiles (coastal fill-up), z14 186 ->
718 (approach fill-up), z13/z15/z16 unchanged (beyond fill-up); overscaled tiles
decode to valid multi-layer content. Hermetic test: a lone coastal cell yields a
z12 tile overscaled from its z11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…site

C ABI tile57_compose combines N per-cell PMTiles archives into one merged PMTiles
via bundle.composeArchives. Archives are passed CONCATENATED (blob + lengths) so C
receives single pointers to pointer-free memory (CGo-safe). Go Compose([][]byte)
marshals + frees. Mirrors tile57_bake_cell_bytes / BakeCell; result freed with
tile57_free.

NOTE: in-memory — holds all archives + the composed output resident. A streaming
disk-to-disk variant is next for whole-district composites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
composeArchives held every per-cell archive + the composed output in memory — a
whole district would load all cells at once. composeArchivesToFile mmaps each
per-cell PMTiles (the OS pages tiles on demand; the cell set is never fully
resident) and streams the merged archive to disk via StreamWriter.initFile (tile
data to a temp file, header/dirs/metadata prepended after). Peak memory is the
partition + one zoom's composed tiles + the touched-page working set — independent
of cell count. The fd is closed right after mmap so hundreds of cells don't exhaust
the fd limit.

Refactor: extract composeInto (the shared partition-build + zoom loop + framing);
composeArchives (in-memory) and composeArchivesToFile (streaming) are thin wrappers
differing only in reader source (bytes vs mmap) and output sink (finishBytes vs
initFile + prefix/concat).

The `tile57 compose` CLI is now disk-to-disk: bake each cell to a temp PMTiles file
(freeing the bytes right after), then stream-compose the files (--keep-cells retains
them as a reusable cache). Validated: streaming produces byte-identical tile counts
to the in-memory path on the 13-cell Baltimore cluster; hermetic streaming test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…undle compiler-rt

C ABI tile57_compose_files streams the per-cell PMTiles at N file paths into one
merged PMTiles at out_path via bundle.composeArchivesToFile (mmap in, streamed out
— bounded memory for a whole district). Stands up a threaded std.Io like
tile57_bake_partition_debug. Go ComposeFiles([]string, outPath) marshals the paths
(CGo-safe: a Go slice of C string pointers) and reports the cell count.

Also bundle compiler-rt INTO libtile57.a (lib.bundle_compiler_rt = true). A non-Zig
linker (the CGO host's gcc/clang, `go test`) can't reach Zig's compiler-rt, so
builtins the code references were undefined at link time — notably `roundq` (f128
@round, pulled in by std.json's number->int coercion in the coverage-sidecar
decode, present since the Step-2 coverage work). Static libs default to not
bundling it; forcing it on makes the archive self-contained for C consumers (this
was a latent link break for the C++ host too, not specific to compose).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ty + speed

For a tile an owner covers ENTIRELY (its render buffer too, no seam crossing), copy
the per-cell bake's tile blob VERBATIM (getCompressed -> addCompressed) instead of
decode -> clip -> re-encode. composeZoom builds a geo.plane.EdgeGrid over each
owner's owned face (integer lon/lat) per zoom and classifies each tile's
buffer-expanded box: EMPTY (center inside, no edge) = fully owned -> passthrough the
native blob (or fall to overscale/clip if no native tile); FULL = owns none -> skip
(also prunes bbox-overlap non-owners); SEAM = the geometry path. Disjoint faces mean
a FULL tile has exactly one owner, so the verbatim copy is the whole composed tile.

Interior tiles are now byte-identical to the per-cell bakes — a strong correctness
property (only seam tiles are recomputed) on top of the already area-exact partition
— and skip the decode/clip/encode work. Hermetic test: a lone cell owning a 3x3
block yields a centre tile byte-identical to its per-cell bake. Real cluster: tile
set unchanged, byte totals drop (verbatim blobs replace re-encoded ones).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…), not bake-to-reader

tile57_chart_open went through openCellBaked, which produces a .reader (baked-pmtiles)
backend with no per-cell list — so Open(cell).Cells() returned 0 and the streaming
metadata a host needs (name/scale/M_COVR/bounds) was unavailable go-linked. Repoint it to
the streaming Chart.openPath (the chart-api default): cell metadata is enumerated up front
and tiles bake lazily. This is the path chartplotter-go's ExtractCellMeta needs.

Retarget the cell-backed-open tests to the current contract (a chart-api change that
predates this branch, only now visible because go test finally links — see the compiler-rt
fix): cell-backed / streaming charts are METADATA-ONLY (Tile() is refused by design — "bake
first, serve the archive"). TestOpenCellAndTile → TestOpenCellMetadata (asserts metadata +
that Tile() is refused); TestOpenPath likewise; TestPickAttrs now bakes to PMTiles and scans
the BAKED tiles for the pick-report attrs (the supported path). Cell metadata (TestCells/
TestFeatures) now passes via the streaming open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TestIgnoreScamin, TestScaminBuckets, TestScaminFilterGate assert the per-value #sm
bucket layers + the log2 per-feature zoom-gate. Those were dropped in f9887c9 (the
merged band-independent SCAMIN gate is now the only mode; e1cc2b7 deleted the
scamin_pts overlay), so the assertions test gone behavior — a style-model change
unrelated to the composite work, only now visible because go test finally links.
Skip with a pointer to the migration (specs/scamin-layers.md, host-side pending)
rather than rewrite scamin assertions blind; revisit when the filter-gate lands
host-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ll bake

BakeWork.run (the composite bakeArchive path) parsed + portrayed each cell but never
built cell.label_cache, so the per-tile emit re-ran the pole-of-inaccessibility
(polylabel) search for every tile a labelled area/line feature touches. On large coarse
cells (huge extent × many native-band tiles) that per-tile recompute dominated: a single
US1GC09M (1:2.16M, 23°×17°) took 6m17s. Build the cache once per cell (scene.buildLabelCache,
as bakeRoot does), reusing the geo arena. Byte-identical output. US1GC09M 6m17s → 3m29s;
a second (uncached) hotspot remains under investigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
beetlebugorg and others added 28 commits July 10, 2026 13:15
Fine-band cells leave SCAMIN off most lights (cell selection is the intended
gate), so the fill-up quilting carried their fixed-display-size portrayal —
flare, characteristic text, 20/25 mm sector legs and arcs — down to z0, where
a 20 mm arc reads as an 800-mile circle (the reported giant sector arcs over
the Chesapeake at overview zooms). LIGHTS is now the one class whose
SCAMIN-less features are culled from sub-band tiles; ground features keep
riding, and the true small-scale lights arrive SCAMIN-carrying from the
overview/general cells. Validated on US3EC08M (mid-Chesapeake sector light,
SECTR1 39 / SECTR2 331.5, no SCAMIN): z3 tile carries no LIGHTS lines,
z9-z11 native tiles keep the sector figures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hey drew 2x

emitAugFigures converted display-mm figure geometry (sector legs/arcs, range
rings) to tile fractions against the 256-unit MVT world, but the engine's
physical-scale model — style scaminGateK's M_PER_PX_Z0, and MapLibre's vector
tile layout — is the 512-CSS-px tile, so every figure rendered at exactly
twice its catalogue size: 20 mm sector arcs read 40 mm on the map. Convert
against a 512·2^z world; ground-metre directional legs are a world-fraction
ratio and are unaffected. US3EC08M z9: arc radius 1209 -> 604 tile units
(= 20 mm at 0.2645 mm/CSS-px).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…heir cell owns no ground in

The compositor consulted a cell only where it owned partition faces, so a
light's fixed-size sector legs/arcs amputated exactly at any composed-tile
boundary the cell didn't own past — the bake had addressed those neighbour
tiles for exactly that reach (buildTileMap's lightReachTiles ring), but
compose never read them.

- lightReachTiles/LIGHT_AUG_REACH_TILES move to the tiles leaf (re-exported
  from scene.lightreach) so the compositor applies the SAME ring the baker
  used without depending on the scene.
- Per-cell archives publish a "light_reach" metadata key (union bbox of
  figure-bearing anchors + max ground-leg metres, folded across backends at
  bake); coverage decode carries it into LoadedCov -> plane.Cell.
- composeTile scans tier cells whose widened light bbox touches the tile,
  and composeSeamTile takes ONLY their constructed LIGHTS figures, whole
  (the clipFeatureToFace exception, minus a face). The verbatim fast path
  defers until the reach scan proves no figures sweep in; archives without
  the metadata behave exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pect -o raw-tile dump

Both grew out of hunting a degenerate composed polygon: tiledump gains a
per-feature geometry mode (ring point counts, bboxes, signed areas, full
coordinates) filtered by class, and inspect can write the raw decompressed
tile bytes so other tools can decode the exact same tile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge artifacts in composed tiles

A baked MVT polygon's rings all run along the same tile clip edge, so the
compose clip's SUBJECT operand self-overlaps collinearly. The sweep ignored
same-operand overlaps entirely (no subdivision), and its pairwise transition
typing cannot resolve 3-way coincident bundles (two subject runs + the face
edge): the second typing pass overwrites the first, the surviving edge
multiset gets odd parity, the Hierholzer walk dead-ends, and the open ring
renders with a straight chord — a pale wedge with sliver tails slicing across
Chesapeake Bay at z8 (US2EC03M DEPVS clipped to its owned face in 8/73/98),
plus smaller wedges in neighbouring tiles.

Three layers, root cause first:
  * addOperand/reduceEdges — each operand's edge multiset is reduced mod 2
    before the sweep (collinear groups split to one common subdivision,
    exactly coincident pieces cancelled): a doubled edge flips even-odd
    membership twice and is no boundary, so the region is preserved exactly
    and the sweep never sees more than one subject + one clip edge coincident
    — the pair case its typing was built for.
  * possibleIntersection — same-operand overlaps discovered mid-sweep (via
    snapped subdivisions) are now subdivided like the cross-operand case,
    minus the typing, so their pieces cancel in the result reduction.
  * connectEdges — the result-edge reduction reuses reduceEdges, making the
    mod-2 cancellation robust to inconsistently subdivided bundles.

New fuzz locks it in: raw even-odd operands (no unionAll cleaning) whose
rings overlap in area, cross, and share collinear runs, checked against the
membership oracle — the prior fuzzes always pre-cleaned operands, which is
exactly why this never fired. boolean_repro_test.zig carries the real-world
operands captured from the US2EC03M clip as a regression fixture.

Compose bench on the seam-heavy 3x3 around 8/73/98: 13.0 -> 15.6 ms/tile.
Verbatim-pass tiles are untouched; per-cell archives and partition sidecars
stay valid (the bug was serve-time only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge's heading

Second open-ring artifact class (a pale triangle punched into Long Island
land at z9): when a new edge's left endpoint lies exactly ON an active
segment — a subject vertex landing on a coincident seam edge, routine where
a baked ring runs along the cell coverage boundary the face shares — the
status order fell through to signedArea(seg, new.p), which is zero for the
on-segment point, so 'not below' put the toucher UNDER the touched edge.
It then computed its sweep fields from the wrong neighbour (the coincident
subject piece instead of the face piece above it), a whole band of edges
stacked above inherited other_in_out=true, in_result dropped them, and the
ring walk dead-ended into chord-closed open rings.

segBelow now decides a T-touch by where the touching edge HEADS (its other
endpoint), both for a new edge starting on an active segment and for the
mirrored earlier-queued case.

boolean_repro_test.zig gains the captured US3CT1AA land-ring ∩ owned-face
operands from composed tile z9/152/192 as the regression fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tated

The intro's headline paragraph was an arrow-chain pile-up; it now reads as
prose: what tile57 is, the tile+style output, the native render path, and the
one-library embedding story. State plainly (intro + a new Known Limitations
section) that S-57 has no perfect S-101 translation — the S-65-guided adapter
is best effort. Tie the pipeline diagram to the bake-then-compose model, and
sweep "seam" out of doc prose (contract / cell boundaries / where they meet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ault, not Mapbox-only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nterim

Correct the intro's output claim — the S-101 Portrayal Catalogue produces
S-101 symbology (S-52's successor), not S-52 itself — and state in both the
intro and Known Limitations that the S-57 → S-101 conversion is an interim
step: the goal is S-101 throughout, reading native S-101 cells directly as
hydrographic offices publish them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…heading

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… seam parity repair, certified stitching

The pairwise-typed Martinez result flags break under snapped-crossing
degeneracies faster than they can be patched case by case (crossing snapped
onto a face vertex beside a vertical, near-parallel diagonal seams grinding
into fragment soup, ...): every corrupted in_result dead-ends the ring walk
and the open chain renders as a chord slicing the region. Instead of more
flag surgery, connectEdges now verifies and repairs:

  * Robust retry — a dead-ended first walk triggers exact region sampling:
    every normal edge's membership is recomputed from the ORIGINAL operands
    at the edge midpoint (doubled-scale integer even-odd, no rounding). The
    sweep has already subdivided all crossings, so the midpoint is
    unambiguous; edges whose midpoint lies ON the other boundary (undetected
    collinear overlap) are marked undecided.
  * Seam parity repair — typed seam edges and undecided edges are the only
    unknowns left; on a correct boundary every vertex has even degree, so a
    candidate whose BOTH endpoints are odd toggles membership (monotone: each
    toggle removes two odd vertices, so it terminates).
  * Certified stitching — remaining odd pairs are snap debris; each pair is
    closed by its nearest partner, blindly within 16 units, or up to 1024
    units when the endpoints AND the stitch midpoint all lie on input
    segments (the stitch tracks a real edge run across polyline vertices,
    never inventing a chord).

Diagnostics (open_chain_walks / large_open_chains / robust_retries /
stitched_gaps) make health observable, and "tile57 compose-tile <dir>
--scan Z0..Z1" composes every in-bounds tile and names each one whose clip
still dead-ends. The full NOAA set (4492 cells) scans CLEAN at z0..9: 2807
tiles, 0 open chains — down from 56 broken tiles before this series.

New fixtures carry the captured real operands: the micro-subdivided seam
with duplicate face vertices (crossing snapped onto a face vertex beside a
vertical) and the Pamlico Sound diagonal-seam DEPARE that resisted the
sampling retry alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ocabulary

The pages flipped between "cell" and "chart" for the same object (an ENC cell
IS one chart at one compilation scale): "bake a chart" beside "bakes ENC
cells", "chart cell", per-cell/per-chart within a page. Prose now says chart
throughout — the bake unit, the ownership-partition unit, the streaming-read
unit. "Cell" remains only where it is the spec's or API's own word: the
first-use definition in each page that needs it, verbatim symbol/CLI/property
names (tile57_enc_cells, bake.cellBytes, the `cell` tile property), quoted
header text, and sprite-atlas grid cells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every subcommand documented — bake/compose-tile, png/pdf/ascii, the
assets/style/sprite family (embedded catalogue by default), and the inspect
set (explore, cells/cell/features/catalog, inspect/tiledump, objlcount) —
verified against tools/ dispatch and usage text. Sidebar slot after Getting
Started; getting-started's inline subcommand list becomes a pointer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ounds view

The S-52 8.6.2 masking fix routed the M_NSYS fallback through
drawableLineParts, which is portrayal-correct — a cell-limit edge is not a
real IALA division — but it erased the meta-bounds inspection view: a
whole-cell M_NSYS has its ENTIRE boundary flagged as the cell limit, so
nothing was baked and the toggle had nothing to show.

The fallback now emits both: the drawn (unmasked) divisions exactly as
before, and the masked complement (s57.Cell.maskedLineParts, the inverse
walk of drawableLineParts) as a separate plain dashed CHGRD feature tagged
masked=1. The standard display is unchanged — the meta classes are filtered
out unless meta-bounds is on, and the native render path now mirrors that
class gate (categoryVisible hides M_NPUB/M_NSYS/M_COVR/M_CSCL unless
show_meta_bounds), which it previously never applied. Validated on
US5PHLDF: the cell-limit edge that used to draw as a false A/B division is
emitted masked-only, and US3EC08M's whole-cell M_NSYS outline is back for
the toggle.

Baked archives must be re-baked to carry the new features; the engine
version stamp forces that on the next server restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…abulary; v0.3.0

The bare namespace overloaded two meanings (chart methods beside library
plumbing), and the API said "cell" for what the docs now call a chart. Both
fixed in one breaking pass, pre-1.0 with no external consumers:

- The chart handle gets its noun: struct tile57 -> tile57_chart, and the whole
  bare method family moves under it (chart_open/_open_bytes/_close/_get_info/
  _scamin/_coverage/_tile/_query/_png/_pdf/_canvas/_surface). The two handles
  now mirror exactly, and compose_meta_get -> compose_get_meta aligns the verb.
  Bare tile57_ is library-level only: version, free, warmup.
- Chart vocabulary: bake_cell_bytes -> bake_chart_bytes, bake_cells ->
  bake_charts, enc_cells -> enc_charts, compose_meta.cells -> .charts, the
  query callback's cell param -> chart. Header comments sweep to chart prose
  (one first-use "a *cell*, in the spec's vocabulary" definition; the `cell`
  tile property and atlas grid cells keep their names) and drop "seam".
- Zig mirrors in lock-step: bake.chartBytes/chartsParallel/chartsToFiles,
  Chart.openCharts/openChartsStreaming/chartsJson/decodedCoverage,
  Chart{Input,Meta,Bytes,ReadFn}, Format.s57, coverage.ChartCoverage, and the
  compose family fixes its namespace stutter: ComposeSource.open/.openFiles
  (were compose.openComposeSource*), compose.tile (was compose.composeTile).
  s57/geometry module internals keep "cell" — that domain parses cells.
- CLI usage prose says chart (placeholders like <cell.000> keep the spec's
  word); the stale assets help now lists what it actually emits; tile57
  version reports 0.3.0 (was hardcoded 0.1.0).
- CLAUDE.md API-style rules rewritten to match: handles mirror under their
  nouns, stage/domain families lead with their token, bare = library only.

zig build test passes; bake output is byte-identical (US5MD1MC pmtiles +
partition sha256 unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BakeCell -> BakeChart, Cells -> Charts (element type CellInfo -> ChartRecord;
ChartInfo already names the handle metadata), ComposeMeta.Cells -> .Charts,
and the cgo layer follows the header: C.tile57 -> C.tile57_chart plus the
chart_*/bake_chart_bytes/bake_charts/enc_charts/compose_get_meta symbols
(including the raw C helper in coverage.go's preamble). Doc comments and
messages say chart. go build + go test pass against the rebuilt libtile57.a.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Header quotes and examples re-quoted from the new surface: tile57_chart handle
+ chart_* methods, bake_chart_bytes/bake_charts/enc_charts, compose_get_meta,
ComposeSource.open/.openFiles + compose.tile in the Zig examples and tables,
Chart{Meta,Bytes,ReadFn,Input}/chartsJson/decodedCoverage/Format.s57, version
0.3.0. The "the API keeps S-57's word: cell" bridge notes are gone — only the
CLI reference still explains the spec's word, for its <cell.000> placeholders
and cell/cells commands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the bump

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…handle

The view renderers re-portray on every zoom/pan settle, and profiling the
surface render put ~35% of every re-render in gzip+MLT decode of the SAME
tiles, ~3% in re-parsing the palette XML and the SVG symbol catalogue, and
~9% in a per-VERTEX pow(2,tz) in the tile->world projection. All of it is
immutable for an open handle:

  * Decoded-tile cache — viewTileLayers keys (z,x,y) to decoded layers;
    each entry owns its arena (eviction frees exactly one tile), a
    generation-evicted map bounds memory at 192 entries (a 2560px view
    touches ~96 tiles, so nothing evicts mid-render). Absent tiles are not
    cached (the miss is a directory binary search, no decompression).
    Replay is read-only over decoded layers, so entries replay repeatedly.
  * Palette + symbol stores — Colors and the per-palette CatalogStore build
    once per handle in a handle-lifetime arena (viewColorsRef/viewStoreFor);
    renderView and renderSurfaceView stop rebuilding them per call.
  * worldOf — setTile precomputes 1/2^tz and 1/(2^tz·EXTENT); the per-vertex
    projection is two multiplies.

Same threading rule as the handle (not synchronized). Measured on real
archives at 2560x1440 (surface render, no-op callbacks): coastal US3EC08M
z10.5 31ms -> 10ms warm; harbour US5PHLDF z14.5 15ms -> 2.8ms warm; first
render unchanged (cold decode happens once).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…plore)

The meta-object boundaries — M_NSYS (IALA buoyage system), M_COVR/M_CSCL
coverage, M_NPUB — are filtered out of the standard display and only shown
under the meta-bounds inspection toggle (resolve.categoryVisible and
mariner.commonChartFilters). The native render CLI and explore had no way to
turn that toggle on, so the inspection view was un-renderable from the tools.

`--meta` sets show_meta_bounds on the render Settings for `tile57 png|pdf`
and `tile57 explore`, so a chart's coverage / nav-system outlines resolve and
draw. Used to validate the boundary-compositing fix on real cells.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A chart's meta-bounds inspection outline (the `masked`-tagged §8.6.2 boundary
complement — its own coverage edge, drawn dashed for the meta-bounds toggle)
was run through the compositor's ownedFace clip like any ground feature. That
carved it apart: wherever a finer chart owns the ground on top of a coarser
one, the coarse chart's outline was cut, so its coverage rectangle came out in
disjoint fragments — the "missing edges" a meta-bounds inspection showed.

The outline traces one chart's OWN extent, so it must be kept whole, exactly
like a LIGHTS sector figure: compose.clip.isMaskedBoundary now exempts it from
the face clip. Validated on US2EC02M with US3SC10M overlapping its boundary —
the coarse chart's outline is byte-identical whether the finer chart is present
or not (was two fragments with a gap at the finer chart's edge). The emit path
(scene.emitMaskedBoundary) drops its own cover_clip for the same reason, so the
outline is never carved at any layer.

Note: this is the coverage-extent outline only. There are no A/B buoyage
letters in NOAA data — the entire US is IALA System B (MARSYS=2), so no
A-meets-B division exists for the MARSYS51 linestyle to letter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Portray ONE (z,x,y) tile through the same S-52 portrayal and the same
tile57_surface_cb as tile57_chart_surface, but for a single tile instead of
a whole view. Lets a host (the OpenCPN plugin) portray+tessellate each tile
ONCE, cache the geometry, and compose the view from cached tiles — the
MapLibre tile model, reusing tile57's portrayal — so pan/zoom/chart-load no
longer re-tessellate the whole view on the render thread.

Chart.renderSurfaceTile mirrors renderSurfaceView's per-tile loop body
(viewTileLayers -> replayTile for reader charts, appendTile for cells) with
view_zoom = the tile's own zoom. Decluttering is per-tile; cross-tile label
suppression stays a host concern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Buoy/beacon name labels were reduced to their trailing chart designation
("Chesapeake Channel Lighted Buoy 78A" -> "78A"), quoted. Show the full name
instead: shortenName is replaced by stripNameTag, which only strips the
"by "/"bn " EncodeString tag the S-101 aid rules add and returns the full name.
Untagged text (depth labels, light elevations, place names) still passes through.
Drops the now-unused lastIndexOfCI helper; test updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `lib` step that installs only libtile57.a for the resolved target,
without the host-only bake CLI (which force-links static musl and can't
cross-compile). This lets an embedder cross-build just the C library for
another platform — e.g. a Qt6 viewer for the reMarkable tablet
(arm-linux-gnueabihf / aarch64-linux-gnu).

Make the MLT tile decoder 32-bit clean: stream counts, lengths and indices
are sizes of in-memory data, so they are usize (u32 on 32-bit targets)
rather than the wire varint's u64. StreamMeta.{num_values,byte_length} and
the geometry/string length arrays become usize, range-checked from the wire
at their boundary. No behaviour change on 64-bit; the encode→decode
round-trip tests pass built for x86-linux-musl (true 32-bit), alongside the
native, compose and bundle suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
openPmtilesPath (chart.zig) and the compositor (compose.zig) mapped PMTiles
archives with std.posix.mmap, which is POSIX-only — its PROT/MAP flag types are
void on Windows, so `zig build -Dtarget=*-windows-*` failed to compile
("type 'void' does not support struct initialization syntax").

Add tiles/filemap.zig: mapReadonly/unmap that use std.posix.mmap on POSIX and
CreateFileMapping/MapViewOfFile (declared here, std doesn't bind them) on Windows
— the same lazily-paged, page-cache-shared read-only view on both, so a whole
chart library stays openable without being resident. Swap the two mmap sites and
their munmap calls over to it. std.posix.fd_t is already HANDLE on Windows, so the
file handle passes through unchanged.

Verified: native + aarch64-windows-gnu build, zig build test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run `zig fmt` over the 7 files that weren't formatter-clean (cosmetic: stray
blank lines / spacing, net -8 lines) and add a Format-check step to the build-test
job so drift is caught going forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- .github/workflows/security.yml: Trivy filesystem scan (known-vulnerable deps +
  committed secrets, HIGH/CRITICAL) on push/PR + weekly, reporting SARIF to the
  Security tab (non-blocking).
- .github/dependabot.yml: keep the CI actions patched (Zig package deps aren't
  Dependabot-supported).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep Dependabot (native dependency + Actions scanning); drop the Trivy workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@beetlebugorg beetlebugorg merged commit b2dbc5f into main Jul 11, 2026
5 checks passed
@beetlebugorg beetlebugorg deleted the feat/per-cell-composite branch July 11, 2026 22:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant