Major refactor of composition model, and API#3
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.