From 8134d7fc9e5b457c000fba68e6ba732192c906d9 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 09:39:25 -0400 Subject: [PATCH 001/140] feat(geo): ownership-partition S-57 adapter + real-ENC de-risk probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.zig | 8 + src/scene/partition_adapt.zig | 354 ++++++++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 src/scene/partition_adapt.zig diff --git a/build.zig b/build.zig index 1a4670d..d65d018 100644 --- a/build.zig +++ b/build.zig @@ -553,6 +553,14 @@ pub fn build(b: *std.Build) void { _ = addPkgTest(b, test_step, "src/assets/assets.zig", target, optimize, &.{}); // Geometry core for the cross-band composition redesign (pure, std-only). _ = addPkgTest(b, test_step, "src/geo/geo.zig", target, optimize, &.{}); + // De-risk probe for the per-cell composite ownership partition: runs the E7 + // partition on a real ENC district (slivers / perf / float-oracle agreement). + // Its own step so it is isolated from `zig build test`; needs only s57+geo. + const partition_probe_step = b.step("partition-probe", "Run the ownership-partition de-risk probe on a real ENC district"); + _ = addPkgTest(b, partition_probe_step, "src/scene/partition_adapt.zig", target, optimize, &.{ + .{ .name = "s57", .module = s57_mod }, + .{ .name = "geo", .module = geo_mod }, + }); // The render module: Surface contract + noop lifecycle smoke test (pins // the contract), resolver gates/colors, Canvas + RasterCanvas + PNG + // PixelSurface. diff --git a/src/scene/partition_adapt.zig b/src/scene/partition_adapt.zig new file mode 100644 index 0000000..bf0005b --- /dev/null +++ b/src/scene/partition_adapt.zig @@ -0,0 +1,354 @@ +//! S-57 → ownership-partition adapter, plus a real-ENC de-risk probe. +//! +//! Fills `geo.plane.Cell` (the pure partition input) from parsed S-57 cells: the +//! M_COVR(CATCOV=1) coverage rings — whose integer lon/lat (degrees × 10⁷) widen +//! from i32 to i64 — plus the compilation scale, the band floor, and the +//! deterministic equal-scale tie-break order. +//! +//! The probe test (gated on a real ENC district being present) answers the three +//! questions the design review flagged as unproven before we commit to building +//! the whole module on `plane.ownedAtTier`: +//! 1. SLIVERS — do independently-digitised adjacent cells produce sliver +//! overlaps/gaps at their shared seam? Measured area-exact: Σ(face areas) +//! vs area(union of all eligible coverage). A true partition makes them equal. +//! 2. PERFORMANCE — how long does the naive `ownedAtTier` (global-union operands, +//! no bbox reject / no prune) take on a real district? Is a spatial index +//! required for Step 1, or a later optimisation? +//! 3. ORACLE AGREEMENT — does the integer partition assign the same owner as +//! the live float M_COVR oracle (`s57.coverageContains`) at sampled points? + +const std = @import("std"); +const s57 = @import("s57"); +const geo = @import("geo"); +const plane = geo.plane; +const boolean = geo.boolean; + +const Pt = plane.Pt; +const Poly = plane.Poly; // []const []const Pt — one M_COVR feature's rings + +// --------------------------------------------------------------------------- +// Band rules — mirror scene/bake_enc.zig bandOf/bandZooms exactly. Kept local so +// this file imports only s57+geo (fast, isolated probe build). The production +// adapter will call bake_enc directly to avoid two encodings drifting. +// --------------------------------------------------------------------------- + +/// The lowest zoom at which a cell of this compilation scale participates — its +/// band floor. Matches bandZooms(bandOf(cscl)).min. +pub fn bandFloor(cscl: i32) u8 { + const n: i64 = if (cscl <= 0) 50_000 else cscl; + if (n <= 8_000) return 16; // berthing + if (n <= 32_000) return 13; // harbor + if (n <= 130_000) return 11; // approach + if (n <= 500_000) return 9; // coastal + if (n <= 2_300_000) return 7; // general + return 0; // overview +} + +/// Band rank 0=berthing (finest) .. 5=overview (coarsest), for labels/colour. +pub fn bandRank(cscl: i32) u8 { + const n: i64 = if (cscl <= 0) 50_000 else cscl; + if (n <= 8_000) return 0; + if (n <= 32_000) return 1; + if (n <= 130_000) return 2; + if (n <= 500_000) return 3; + if (n <= 2_300_000) return 4; + return 5; +} + +/// Equal-scale tie-break, identical to bake_enc.ordersBefore: newer DSID +/// issue/update date first (YYYYMMDD lexical; a dated cell before an undated one), +/// then cell name ascending — a total, deterministic order. +pub fn ordersBefore(da: []const u8, na: []const u8, db: []const u8, nb: []const u8) bool { + if (!std.mem.eql(u8, da, db)) return std.mem.lessThan(u8, db, da); // newer first + return std.mem.lessThan(u8, na, nb); +} + +/// Widen a cell's M_COVR rings — integer lon/lat in degrees × 10⁷ (i32) — to the +/// boolean i64 point type. One output Poly per M_COVR feature (its rings). +/// Allocated in `a`. +pub fn widenCoverage(a: std.mem.Allocator, cov: []const []const []const s57.LonLat) ![]Poly { + var out = try a.alloc(Poly, cov.len); + for (cov, 0..) |feat, fi| { + const rings = try a.alloc([]const Pt, feat.len); + for (feat, 0..) |ring, ri| { + const pts = try a.alloc(Pt, ring.len); + for (ring, 0..) |p, pi| pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; + rings[ri] = pts; + } + out[fi] = rings; + } + return out; +} + +// --------------------------------------------------------------------------- +// Area (shoelace, exact i128 → f64) — for the sliver conservation check. +// --------------------------------------------------------------------------- + +fn ringArea2(ring: []const Pt) i128 { + if (ring.len < 3) return 0; + var acc: i128 = 0; + var j = ring.len - 1; + for (ring, 0..) |p, i| { + const q = ring[j]; + j = i; + acc += (@as(i128, q.x) * p.y) - (@as(i128, p.x) * q.y); + } + return acc; // 2*signed area +} + +/// Net area of one even-odd polygon (exterior CCW +, holes CW −, disjoint pieces +/// add) as f64. `rings` is a single polygon's ring set ([][]Pt from a face or a +/// boolean result). +fn polyArea(rings: []const []const Pt) f64 { + var acc: i128 = 0; + for (rings) |ring| acc += ringArea2(ring); + const a: f64 = @floatFromInt(if (acc < 0) -acc else acc); + return a / 2.0; +} + +// =========================================================================== +// De-risk probe — real ENC district. Skipped unless the district dir exists. +// =========================================================================== + +const testing = std.testing; + +// A real ENC district to validate against. Override with TILE57_PROBE_ROOT; the +// default is a NOAA District 1 tree on the dev box (the old ~/.local corpus moved). +const PROBE_ROOT_DEFAULT = "/home/jcollins/Charts/enc-src/d01/ENC_ROOT"; +// Which scale bands to load, by cell-name prefix. US2=overview, US3=coastal, +// US4=approach, US5=harbor. Start coarse (few, fast) — includes cross-band +// overlap (US2 under US3) AND same-band adjacency (US3↔US3). +const PROBE_PREFIXES = [_][]const u8{"US5"}; // harbor: densest same-band adjacency +const PROBE_CAP: usize = 250; // bound the parse cost; also stresses the O(cells^2) build +const PROBE_TIER: u8 = 13; // harbor floor — all loaded US5 eligible + +const ProbeCell = struct { + name: []const u8, + cscl: i32, + date: []const u8, + cov_ll: []const []const []const s57.LonLat, // float rings, for the oracle + bbox: [4]f64, // w,s,e,n degrees +}; + +fn hasPrefix(name: []const u8) bool { + for (PROBE_PREFIXES) |p| if (std.mem.startsWith(u8, name, p)) return true; + return false; +} + +test "PROBE: ownership partition on a real ENC district (slivers, perf, oracle)" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const root = PROBE_ROOT_DEFAULT; + var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch { + std.debug.print("\n[probe] district {s} not present — skipping\n", .{root}); + return; + }; + defer dir.close(io); + + // --- Load matching cells: parse .000, extract M_COVR, keep float rings + bbox. + var pcells = std.ArrayList(ProbeCell).empty; + var walker = dir.walk(a) catch return; + defer walker.deinit(); + while (walker.next(io) catch null) |entry| { + if (pcells.items.len >= PROBE_CAP) break; + if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; + const base = std.fs.path.basename(entry.path); + const name = base[0 .. base.len - 4]; + if (!hasPrefix(name)) continue; + + const bytes = dir.readFileAlloc(io, entry.path, a, .unlimited) catch continue; + var cell = s57.parseCell(a, bytes) catch continue; + defer cell.deinit(); + const cov = cell.mcovrCoverage(a); // copied into `a`; survives deinit + if (cov.len == 0) continue; // no-M_COVR cell: separate case, skip in probe + + var b = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; + for (cov) |rings| for (rings) |ring| for (ring) |p| { + b[0] = @min(b[0], p.lon()); + b[1] = @min(b[1], p.lat()); + b[2] = @max(b[2], p.lon()); + b[3] = @max(b[3], p.lat()); + }; + pcells.append(a, .{ + .name = try a.dupe(u8, name), + .cscl = cell.params.cscl, + .date = try a.dupe(u8, cell.dsid.isdt), + .cov_ll = cov, + .bbox = b, + }) catch {}; + } + + const n = pcells.items.len; + if (n == 0) { + std.debug.print("\n[probe] no matching cells with M_COVR — skipping\n", .{}); + return; + } + + // --- Deterministic `order`: rank cells by ordersBefore (newer/name). + const idx = try a.alloc(usize, n); + for (idx, 0..) |*v, i| v.* = i; + std.mem.sort(usize, idx, pcells.items, struct { + fn lt(cs: []const ProbeCell, x: usize, y: usize) bool { + return ordersBefore(cs[x].date, cs[x].name, cs[y].date, cs[y].name); + } + }.lt); + const order = try a.alloc(u64, n); + for (idx, 0..) |ci, rank| order[ci] = rank; + + // --- Build geo.plane.Cell[] (widen M_COVR coverage to i64 points). + const cells = try a.alloc(plane.Cell, n); + for (pcells.items, 0..) |pc, i| { + cells[i] = .{ + .cscl = pc.cscl, + .band_floor = bandFloor(pc.cscl), + .order = order[i], + .cov1 = try widenCoverage(a, pc.cov_ll), + }; + } + + // Eligible set at the probe tier (matches ownedAtTier's rule). + var n_elig: usize = 0; + for (cells) |c| if (c.band_floor <= PROBE_TIER) { + n_elig += 1; + }; + + // --- BUILD (timed). Naive global-union kernel: no bbox reject, no prune. + const t0 = std.Io.Clock.now(.awake, io); + const faces = try plane.ownedAtTier(a, cells, PROBE_TIER); + const build_ms: f64 = @as(f64, @floatFromInt((std.Io.Clock.now(.awake, io).nanoseconds - t0.nanoseconds))) / 1e6; + + // --- SLIVER check: Σ(face area) vs area(union of all eligible coverage). + var sum_faces: f64 = 0; + for (faces) |f| sum_faces += polyArea(f.owned); + + var elig_polys = std.ArrayList(Poly).empty; + for (cells) |c| if (c.band_floor <= PROBE_TIER) { + for (c.cov1) |feat| try elig_polys.append(a, feat); + }; + const uni = try boolean.unionAll(a, elig_polys.items); + const union_area = polyArea(uni); + const conservation = if (union_area > 0) sum_faces / union_area else 0; + + // Definitive area-exact check (robust to shoelace/winding): symmetric + // difference of (∪ faces) vs (∪ eligible coverage). A perfect partition has + // ZERO symdiff; nonzero = real lost/gained area (slivers) invisible to points. + var face_polys = std.ArrayList(plane.Poly).empty; + for (faces) |f| if (f.owned.len > 0) { + try face_polys.append(a, f.owned); + }; + const uni_faces = try boolean.unionAll(a, face_polys.items); + const sd = try boolean.compute(a, uni_faces, uni, .sym_diff); + const symdiff_area = polyArea(sd); + const symdiff_ppm = if (union_area > 0) 1e6 * symdiff_area / union_area else 0; + + // --- ORACLE agreement: sample the union bbox; compare partition owner (integer + // even-odd over faces) to the float finest-cscl-covering oracle. + var wb = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; + for (pcells.items) |pc| { + wb[0] = @min(wb[0], pc.bbox[0]); + wb[1] = @min(wb[1], pc.bbox[1]); + wb[2] = @max(wb[2], pc.bbox[2]); + wb[3] = @max(wb[3], pc.bbox[3]); + } + const GRID = 120; + var samples: usize = 0; + var overlaps: usize = 0; + var matches: usize = 0; + var mismatches: usize = 0; + var mismatch_same_scale: usize = 0; // seam tiebreak / edge-proximity (benign) + var mismatch_diff_scale: usize = 0; // real overlap-resolution divergence + var part_gap_oracle_owns: usize = 0; // oracle owns, partition says gap + var gy: usize = 0; + while (gy < GRID) : (gy += 1) { + var gx: usize = 0; + while (gx < GRID) : (gx += 1) { + const lon = wb[0] + (wb[2] - wb[0]) * (@as(f64, @floatFromInt(gx)) + 0.5) / GRID; + const lat = wb[1] + (wb[3] - wb[1]) * (@as(f64, @floatFromInt(gy)) + 0.5) / GRID; + const xe: i64 = @intFromFloat(@round(lon * 1e7)); + const ye: i64 = @intFromFloat(@round(lat * 1e7)); + + // Partition owner (integer): faces containing the point. + var pj_owner: ?usize = null; + var pj_count: usize = 0; + for (faces) |f| { + if (boolean.pointInEvenOdd(f.owned, xe, ye)) { + pj_count += 1; + pj_owner = f.index; + } + } + // Oracle owner (float): finest eligible covering cell, broken by the + // SAME (cscl, then DSID order) rule the partition uses — so a same-scale + // adjacency seam is not a spurious mismatch, isolating true divergence. + var or_owner: ?usize = null; + for (pcells.items, 0..) |pc, i| { + if (cells[i].band_floor > PROBE_TIER) continue; + if (!s57.coverageContains(pc.cov_ll, lon, lat)) continue; + if (or_owner) |cur| { + const finer = pc.cscl < pcells.items[cur].cscl or + (pc.cscl == pcells.items[cur].cscl and order[i] < order[cur]); + if (finer) or_owner = i; + } else or_owner = i; + } + + if (or_owner == null and pj_owner == null) continue; // both agree: gap + samples += 1; + if (pj_count > 1) overlaps += 1; + if (or_owner != null and pj_owner == null) { + part_gap_oracle_owns += 1; + } else if (pj_owner == or_owner) { + matches += 1; + } else { + mismatches += 1; + // Same-scale mismatch = a seam tiebreak/edge-proximity artifact; + // different-scale = a real overlap-resolution divergence to chase. + if (pj_owner != null and or_owner != null and + pcells.items[pj_owner.?].cscl == pcells.items[or_owner.?].cscl) + mismatch_same_scale += 1 + else + mismatch_diff_scale += 1; + } + } + } + + // --- Report. + std.debug.print( + \\ + \\========== ownership-partition PROBE ({s}) ========== + \\ cells loaded ....... {d} eligible@z{d} ... {d} + \\ faces (owners) ..... {d} + \\ BUILD (naive kernel) {d:.1} ms ({d:.2} ms/eligible-cell) + \\ SLIVER check: + \\ Σ face area ...... {e:.3} + \\ union area ....... {e:.3} + \\ conservation ..... {d:.6} (shoelace Σ/union — UNRELIABLE for multi-piece faces; see symdiff) + \\ symdiff (faces△cov) {d:.1} ppm of union area (AUTHORITATIVE: 0 == exact partition, >0 == real slivers) + \\ ORACLE agreement (float M_COVR, {d} owned samples): + \\ matches .......... {d} + \\ mismatches ....... {d} (same-scale {d} = seam tiebreak/edge; diff-scale {d} = real divergence) + \\ partition-overlap {d} (>1 owner at a point — MUST be 0) + \\ partition-gap ..... {d} (oracle owns, partition empty) + \\==================================================== + \\ + , .{ + root, n, PROBE_TIER, n_elig, + faces.len, build_ms, build_ms / @as(f64, @floatFromInt(@max(1, n_elig))), + sum_faces, union_area, conservation, symdiff_ppm, + samples, matches, mismatches, mismatch_same_scale, + mismatch_diff_scale, overlaps, part_gap_oracle_owns, + }); + + // Guardrails: the partition must never double-own a point, and the union of + // owned faces must equal the union of coverage (area-exact — the gate point + // sampling cannot provide). Adjacent cells' shared borders round to the same + // integers, so the boolean set algebra is exact and slivers must stay below a + // tiny fraction of a percent. + try testing.expectEqual(@as(usize, 0), overlaps); + try testing.expectEqual(@as(usize, 0), part_gap_oracle_owns); + try testing.expect(symdiff_ppm < 100.0); // < 0.01% of union area +} From acc344f5a115ca29d38105b21ab95f3ee22b10d4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 09:43:17 -0400 Subject: [PATCH 002/140] docs(geo): drop dangling specs refs + plainer coordinate wording 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) --- src/geo/boolean.zig | 33 ++++++++++++++++----------------- src/geo/geo.zig | 7 +++---- src/geo/plane.zig | 19 ++++++++++--------- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/geo/boolean.zig b/src/geo/boolean.zig index 453d84c..650a1da 100644 --- a/src/geo/boolean.zig +++ b/src/geo/boolean.zig @@ -1,26 +1,25 @@ //! Integer polygon boolean operations — a Martinez–Rueda–Feito sweep-line. //! -//! This is the geometry core of the cross-band composition redesign -//! (specs/cross-band-composition-redesign.md, Phase 0). It computes the -//! union / intersection / difference / symmetric-difference of two polygons -//! whose vertices live on an integer lattice (the E7 coverage lattice for the -//! Stage-0 partition, or world-pixel space for per-tile emission). Integers are -//! deliberate: adjacent ENC cells that digitise a shared seam independently -//! collapse to *exact* integer equality once snapped to the lattice, so the -//! dominant seam case is a run of coincident edges rather than a cloud of +//! This is the geometry core of the cross-band chart composition. It computes the +//! union / intersection / difference / symmetric-difference of two polygons whose +//! vertices are integers (coverage in degrees × 10⁷ for the partition, or +//! world-pixel space for per-tile emission). Integers are deliberate: adjacent ENC +//! cells that digitise a shared seam independently round to the *same* integers, so +//! the dominant seam case is a run of coincident edges rather than a cloud of //! near-misses. The sweep types those coincident (overlapping) edges //! (SAME_TRANSITION / DIFFERENT_TRANSITION / NON_CONTRIBUTING) so a shared seam //! contributes to the result exactly once. //! //! Coordinate discipline: -//! * `Pt` holds i64. E7 degrees are ±1.8e9 (fit i32), but *differences* reach -//! 3.6e9 (need i64) and orientation cross-products reach ~1.3e19 (overflow -//! i64) — every orientation / area predicate therefore promotes to i128. +//! * `Pt` holds i64. Coverage coordinates (degrees × 10⁷) are ±1.8e9 (fit i32), +//! but *differences* reach 3.6e9 (need i64) and orientation cross-products +//! reach ~1.3e19 (overflow i64) — every orientation / area predicate therefore +//! promotes to i128. //! * A proper crossing point is rational; it is computed from exact i128 -//! numerators in f64 and rounded to the nearest lattice point. Both sides of +//! numerators in f64 and rounded to the nearest integer point. Both sides of //! a seam compute the same crossing from the same integer endpoints, so the -//! snap is deterministic (bake == live) and the ≤0.5-unit error is ~0.5 cm -//! at E7. Collinear-overlap endpoints are real input vertices, so they are +//! snap is deterministic (bake == live) and the ≤0.5-unit error is ~0.5 cm. +//! Collinear-overlap endpoints are real input vertices, so they are //! reproduced exactly. //! //! Determinism: the event order and the sweep-status order share one strict @@ -36,7 +35,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -/// A lattice point. i64 holds any E7 or world-pixel coordinate with headroom. +/// A point. i64 holds any coordinate (degrees × 10⁷, or world pixels) with headroom. pub const Pt = struct { x: i64, y: i64, @@ -152,14 +151,14 @@ fn segBelow(e1: *SweepEvent, e2: *SweepEvent) bool { /// `p0`), or 2 (a collinear overlap spanning [`p0`,`p1`]). pub const Inter = struct { n: u2, p0: Pt, p1: Pt }; -/// Intersect segment a0→a1 with b0→b1 on the integer lattice. Public so the +/// Intersect segment a0→a1 with b0→b1 on integer coordinates. Public so the /// coverage clip (plane.zig) can reuse the exact classification + snapped point. pub fn segIntersect(a0: Pt, a1: Pt, b0: Pt, b1: Pt) Inter { return findIntersection(a0, a1, b0, b1); } inline fn roundDiv(a: Pt, num: i128, den: i128, d: Pt) Pt { - // a + (num/den)*d, rounded to nearest lattice point. den != 0. + // a + (num/den)*d, rounded to the nearest integer point. den != 0. const t = @as(f64, @floatFromInt(num)) / @as(f64, @floatFromInt(den)); const rx = @round(@as(f64, @floatFromInt(a.x)) + t * @as(f64, @floatFromInt(d.x))); const ry = @round(@as(f64, @floatFromInt(a.y)) + t * @as(f64, @floatFromInt(d.y))); diff --git a/src/geo/geo.zig b/src/geo/geo.zig index a96c0ce..705a518 100644 --- a/src/geo/geo.zig +++ b/src/geo/geo.zig @@ -1,9 +1,8 @@ -//! Integer computational geometry for the cross-band composition redesign -//! (specs/cross-band-composition-redesign.md). Phase 0 — the geometry core, not -//! yet wired to the baker, the live oracle, or the client: +//! Integer computational geometry for cross-band chart composition — the geometry +//! core, not yet wired to the baker, the live oracle, or the client: //! //! * `boolean` — a Martinez–Rueda–Feito polygon boolean (union / intersection / -//! difference / symmetric-difference) on the integer lattice, with +//! difference / symmetric-difference) on integer coordinates, with //! overlap-edge typing and a deterministic total order, plus `unionAll`. //! * `plane` — the per-tier coverage partition (`ownedAtTier`), the FULL / //! EMPTY / SEAM tile classifier (`EdgeGrid`), and `clipLineOutsidePolys`. diff --git a/src/geo/plane.zig b/src/geo/plane.zig index e770af9..2aa4aab 100644 --- a/src/geo/plane.zig +++ b/src/geo/plane.zig @@ -1,6 +1,6 @@ -//! Coverage-clipped best-available partition — the Stage-0 planar partition of -//! the cross-band composition redesign (specs/cross-band-composition-redesign.md, -//! Phase 0). "The finest cell whose M_COVR(CATCOV=1) covers a point owns that +//! Coverage-clipped best-available partition — the first-stage planar partition of +//! the cross-band chart composition. "The finest cell whose M_COVR(CATCOV=1) covers +//! a point owns that //! ground; every other cell is clipped away there." This module turns a set of //! ENC cells (each carrying a compilation scale, a band-eligibility floor, and //! coverage rings) into, per **zoom tier**, one `owned` region per cell — a @@ -12,9 +12,9 @@ //! basin owned by a below-floor fine cell (the HOLES blocker); recomputing per //! distinct floor (≤6 tiers) closes it geometrically. //! -//! Everything runs on the E7 integer lattice via `boolean.Pt` (i64) — coverage -//! coordinates are `round(deg·1e7)`, so seams that adjacent cells digitised -//! independently collapse to exact integer equality before any boolean runs. +//! Everything runs on integer coordinates via `boolean.Pt` (i64): coverage is +//! stored as degrees × 10⁷, so seams that adjacent cells digitised independently +//! round to the same integers before any boolean runs. //! All set algebra goes through `boolean` (Martinez, overlap-typed, deterministic). //! //! Phase 0 scope: this computes and validates the partition, the tile classifier @@ -32,7 +32,8 @@ pub const Pt = boolean.Pt; /// A polygon: a set of even-odd rings (exterior + holes). One M_COVR feature. pub const Poly = boolean.Polygon; -/// One ENC cell as the partition sees it. Coverage is already on the E7 lattice. +/// One ENC cell as the partition sees it. Coverage is already in integer +/// coordinates (degrees × 10⁷). /// `cov1`/`cov2` are bags of features that may mutually overlap — they are /// `unionAll`-cleaned into one simple region before use. pub const Cell = struct { @@ -594,7 +595,7 @@ fn clipCellToBox(a: Allocator, cell: Cell, box: Box) !Cell { }; } -test "quadrant-split Stage 0 stitches to the same partition (shared E7 lattice)" { +test "quadrant-split partition stitches to the same result (shared integer grid)" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); @@ -602,7 +603,7 @@ test "quadrant-split Stage 0 stitches to the same partition (shared E7 lattice)" var prng = std.Random.DefaultPrng.init(0x9042BEEF); const rnd = prng.random(); const grid: i64 = 64; - // Split seam on the E7 lattice; cell coords are grid multiples so coverage + // Split seam on the integer grid; cell coords are grid multiples so coverage // edges meeting the seam collapse to exact equality. const sx: i64 = 0; const sy: i64 = 0; From a1967d0dcd89266d799489845979a1a305feec03 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 09:52:26 -0400 Subject: [PATCH 003/140] perf(geo): bbox-local indexed partition builder (drops the O(cells^2) global union) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/geo/plane.zig | 110 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/geo/plane.zig b/src/geo/plane.zig index 2aa4aab..77f469a 100644 --- a/src/geo/plane.zig +++ b/src/geo/plane.zig @@ -124,6 +124,77 @@ pub fn ownedAtTier(gpa: Allocator, cells: []const Cell, tier: u8) ![]OwnedCell { return out.toOwnedSlice(gpa); } +fn polyBbox(rings: []const []const Pt) [4]i64 { + var b = [4]i64{ std.math.maxInt(i64), std.math.maxInt(i64), std.math.minInt(i64), std.math.minInt(i64) }; + for (rings) |ring| for (ring) |p| { + b[0] = @min(b[0], p.x); + b[1] = @min(b[1], p.y); + b[2] = @max(b[2], p.x); + b[3] = @max(b[3], p.y); + }; + return b; +} + +fn bboxOverlap(a: [4]i64, b: [4]i64) bool { + return a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]; +} + +/// Identical result to `ownedAtTier`, built for scale. `ownedAtTier` accumulates a +/// GLOBAL union of every finer cell and differences each cell against it — the +/// operands grow to the whole nation, so it is O(cells²) in operand size (a +/// national district takes seconds). Here each cell is differenced only against +/// the finer eligible cells whose bounding box OVERLAPS it. A finer cell whose +/// bbox is disjoint cannot remove any area, so the result is the same partition +/// (cross-checked by the test below); charts overlap locally, so the per-cell +/// subtrahend stays small. Use this variant for real ENC data. +pub fn ownedAtTierIndexed(gpa: Allocator, cells: []const Cell, tier: u8) ![]OwnedCell { + var order = std.ArrayList(usize).empty; + defer order.deinit(gpa); + for (cells, 0..) |c, i| { + if (c.band_floor <= tier) try order.append(gpa, i); + } + std.mem.sort(usize, order.items, cells, struct { + fn lt(cs: []const Cell, ia: usize, ib: usize) bool { + return finerLess({}, cs[ia], cs[ib]); + } + }.lt); + + var scratch = std.heap.ArenaAllocator.init(gpa); + defer scratch.deinit(); + const sa = scratch.allocator(); + + // Coverage + bbox per eligible cell (in finest→coarsest order), computed once. + const m = order.items.len; + const covs = try sa.alloc([][]Pt, m); + const bbs = try sa.alloc([4]i64, m); + for (order.items, 0..) |ci, k| { + covs[k] = try cellCoverage(sa, cells[ci]); + bbs[k] = polyBbox(covs[k]); + } + + var out = std.ArrayList(OwnedCell).empty; + errdefer { + for (out.items) |c| boolean.freePolygon(gpa, c.owned); + out.deinit(gpa); + } + + for (order.items, 0..) |ci, k| { + // owned = cov \ (∪ finer cells whose bbox overlaps this one). + var subtr = std.ArrayList(Poly).empty; + for (0..k) |j| { + if (bboxOverlap(bbs[j], bbs[k])) try subtr.append(sa, covs[j]); + } + const owned = if (subtr.items.len == 0) + try dupePolygonGpa(gpa, covs[k]) + else blk: { + const uni = try boolean.unionAll(sa, subtr.items); + break :blk try boolean.compute(gpa, covs[k], uni, .diff); + }; + try out.append(gpa, .{ .index = ci, .owned = owned }); + } + return out.toOwnedSlice(gpa); +} + fn dupePolygonGpa(gpa: Allocator, poly: Poly) ![][]Pt { const out = try gpa.alloc([]Pt, poly.len); errdefer gpa.free(out); @@ -450,6 +521,45 @@ test "ownedAtTier: below-floor fine cell drops out of the pool (no blank window) try testing.expect(boolean.pointInEvenOdd(t10[0].owned, 10, 10)); } +test "ownedAtTierIndexed matches ownedAtTier (owner-at-point, overlap + adjacency)" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // A mix: coarse [0,100]² with a nested fine [40,60]² (vertical overlap), plus + // two same-cscl [0,50]² / [50,100]² halves that abut (horizontal adjacency). + const coarse = try boxPoly(a, 0, 0, 100, 100); + const fine = try boxPoly(a, 40, 40, 60, 60); + const west = try boxPoly(a, 0, 0, 50, 100); + const east = try boxPoly(a, 50, 0, 100, 100); + const cells = [_]Cell{ + .{ .cscl = 100_000, .band_floor = 9, .order = 0, .cov1 = &.{coarse} }, + .{ .cscl = 20_000, .band_floor = 9, .order = 0, .cov1 = &.{fine} }, + .{ .cscl = 50_000, .band_floor = 9, .order = 0, .cov1 = &.{west} }, + .{ .cscl = 50_000, .band_floor = 9, .order = 1, .cov1 = &.{east} }, + }; + + const plain = try ownedAtTier(a, &cells, 9); + const idx = try ownedAtTierIndexed(a, &cells, 9); + + const ownerAt = struct { + fn f(faces: []const OwnedCell, x: i64, y: i64) ?usize { + for (faces) |oc| if (boolean.pointInEvenOdd(oc.owned, x, y)) return oc.index; + return null; + } + }.f; + + // Step 7 from 2 never lands on an edge (40/50/60/100), avoiding even-odd + // ambiguity; the two builders must name the same owner at every point. + var y: i64 = 2; + while (y < 100) : (y += 7) { + var x: i64 = 2; + while (x < 100) : (x += 7) { + try testing.expectEqual(ownerAt(plain, x, y), ownerAt(idx, x, y)); + } + } +} + test "fuzz: partition == per-point finest-eligible-covering, zero overlap, zero gap" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); From 7c7bbc0724cc7567c3b59ea8c013c10782e6e89b Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 09:55:01 -0400 Subject: [PATCH 004/140] test(geo): probe cross-checks indexed vs naive builder on real cells + speedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/scene/partition_adapt.zig | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/scene/partition_adapt.zig b/src/scene/partition_adapt.zig index bf0005b..15a884a 100644 --- a/src/scene/partition_adapt.zig +++ b/src/scene/partition_adapt.zig @@ -218,10 +218,13 @@ test "PROBE: ownership partition on a real ENC district (slivers, perf, oracle)" n_elig += 1; }; - // --- BUILD (timed). Naive global-union kernel: no bbox reject, no prune. + // --- BUILD both, timed: naive global-union kernel vs the bbox-indexed one. const t0 = std.Io.Clock.now(.awake, io); - const faces = try plane.ownedAtTier(a, cells, PROBE_TIER); - const build_ms: f64 = @as(f64, @floatFromInt((std.Io.Clock.now(.awake, io).nanoseconds - t0.nanoseconds))) / 1e6; + const faces_naive = try plane.ownedAtTier(a, cells, PROBE_TIER); + const naive_ms: f64 = @as(f64, @floatFromInt((std.Io.Clock.now(.awake, io).nanoseconds - t0.nanoseconds))) / 1e6; + const t1 = std.Io.Clock.now(.awake, io); + const faces = try plane.ownedAtTierIndexed(a, cells, PROBE_TIER); + const idx_ms: f64 = @as(f64, @floatFromInt((std.Io.Clock.now(.awake, io).nanoseconds - t1.nanoseconds))) / 1e6; // --- SLIVER check: Σ(face area) vs area(union of all eligible coverage). var sum_faces: f64 = 0; @@ -264,6 +267,7 @@ test "PROBE: ownership partition on a real ENC district (slivers, perf, oracle)" var mismatch_same_scale: usize = 0; // seam tiebreak / edge-proximity (benign) var mismatch_diff_scale: usize = 0; // real overlap-resolution divergence var part_gap_oracle_owns: usize = 0; // oracle owns, partition says gap + var builder_disagree: usize = 0; // indexed builder owner != naive builder owner var gy: usize = 0; while (gy < GRID) : (gy += 1) { var gx: usize = 0; @@ -273,7 +277,7 @@ test "PROBE: ownership partition on a real ENC district (slivers, perf, oracle)" const xe: i64 = @intFromFloat(@round(lon * 1e7)); const ye: i64 = @intFromFloat(@round(lat * 1e7)); - // Partition owner (integer): faces containing the point. + // Partition owner (integer): indexed faces containing the point. var pj_owner: ?usize = null; var pj_count: usize = 0; for (faces) |f| { @@ -282,6 +286,13 @@ test "PROBE: ownership partition on a real ENC district (slivers, perf, oracle)" pj_owner = f.index; } } + // Naive-builder owner at the same point, to prove indexed == naive on + // real data (the unit test only covers synthetic cells). + var naive_owner: ?usize = null; + for (faces_naive) |f| { + if (boolean.pointInEvenOdd(f.owned, xe, ye)) naive_owner = f.index; + } + if (pj_owner != naive_owner) builder_disagree += 1; // Oracle owner (float): finest eligible covering cell, broken by the // SAME (cscl, then DSID order) rule the partition uses — so a same-scale // adjacency seam is not a spurious mismatch, isolating true divergence. @@ -322,7 +333,8 @@ test "PROBE: ownership partition on a real ENC district (slivers, perf, oracle)" \\========== ownership-partition PROBE ({s}) ========== \\ cells loaded ....... {d} eligible@z{d} ... {d} \\ faces (owners) ..... {d} - \\ BUILD (naive kernel) {d:.1} ms ({d:.2} ms/eligible-cell) + \\ BUILD naive {d:.1} ms indexed {d:.1} ms (speedup {d:.1}x) + \\ builder cross-check {d} disagreements (indexed vs naive owner; MUST be 0) \\ SLIVER check: \\ Σ face area ...... {e:.3} \\ union area ....... {e:.3} @@ -337,7 +349,8 @@ test "PROBE: ownership partition on a real ENC district (slivers, perf, oracle)" \\ , .{ root, n, PROBE_TIER, n_elig, - faces.len, build_ms, build_ms / @as(f64, @floatFromInt(@max(1, n_elig))), + faces.len, naive_ms, idx_ms, naive_ms / @max(0.001, idx_ms), + builder_disagree, sum_faces, union_area, conservation, symdiff_ppm, samples, matches, mismatches, mismatch_same_scale, mismatch_diff_scale, overlaps, part_gap_oracle_owns, @@ -350,5 +363,6 @@ test "PROBE: ownership partition on a real ENC district (slivers, perf, oracle)" // tiny fraction of a percent. try testing.expectEqual(@as(usize, 0), overlaps); try testing.expectEqual(@as(usize, 0), part_gap_oracle_owns); + try testing.expectEqual(@as(usize, 0), builder_disagree); // indexed == naive on real data try testing.expect(symdiff_ppm < 100.0); // < 0.01% of union area } From c250c4c46cf041abf779ccbf2203bc112c42048f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 09:59:54 -0400 Subject: [PATCH 005/140] feat(geo): per-band ownership partition band-stack (Partition/BandMap + 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) --- src/geo/geo.zig | 2 + src/geo/partition.zig | 149 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/geo/partition.zig diff --git a/src/geo/geo.zig b/src/geo/geo.zig index 705a518..3bb173c 100644 --- a/src/geo/geo.zig +++ b/src/geo/geo.zig @@ -11,8 +11,10 @@ pub const boolean = @import("boolean.zig"); pub const plane = @import("plane.zig"); +pub const partition = @import("partition.zig"); test { _ = boolean; _ = plane; + _ = partition; } diff --git a/src/geo/partition.zig b/src/geo/partition.zig new file mode 100644 index 0000000..494bf4f --- /dev/null +++ b/src/geo/partition.zig @@ -0,0 +1,149 @@ +//! The per-band ownership partition — a stack of planar maps, one per distinct +//! band floor, each assigning every point to the cell that renders it at that +//! band. Built from a set of `plane.Cell` via `plane.ownedAtTierIndexed`: a finer +//! cell's face is its coverage minus the finer coverage, and two abutting same-band +//! cells split at their shared border by the DSID tie-break (so the internal border +//! dissolves). This is the artifact the compositor queries to clip each cell's tiles +//! to the ground it owns, and the debug view renders directly. +//! +//! A cell appears in the map of its native band with its full coverage, and in +//! coarser bands only where no coarser cell covers (a gap-filler); it is absent +//! from finer bands. So a "gap" in one band's map is not a bug — the ground is +//! owned by a coarser band, reached by querying that band's map. Pure geometry: no +//! S-57, no streaming, no allocator policy beyond a passed-in `gpa`. + +const std = @import("std"); +const plane = @import("plane.zig"); +const boolean = @import("boolean.zig"); + +pub const BandMap = struct { + /// The band floor (lowest zoom) this map is computed at. + tier: u8, + /// One face per cell owning ground at this band; `face.index` indexes + /// `Partition.cells`. `gpa`-owned. + faces: []plane.OwnedCell, +}; + +pub const Partition = struct { + gpa: std.mem.Allocator, + /// Borrowed — the caller keeps the cells (and their coverage) alive. + cells: []const plane.Cell, + /// Distinct band floors, DESCENDING: `maps[0]` is the finest band (highest + /// floor), `maps[len-1]` the coarsest. + tiers: []u8, + maps: []BandMap, + + pub fn deinit(self: *Partition) void { + for (self.maps) |m| plane.freeOwned(self.gpa, m.faces); + self.gpa.free(self.maps); + self.gpa.free(self.tiers); + } + + /// The map that governs zoom `z`: the finest band whose floor is ≤ z (i.e. the + /// largest tier ≤ z). A zoom below every floor resolves to the coarsest map, so + /// zooming out never falls off the bottom. null only if the partition is empty. + pub fn mapForZoom(self: *const Partition, z: u8) ?*const BandMap { + for (self.maps) |*m| { + if (m.tier <= z) return m; // tiers descending → first hit is finest applicable + } + if (self.maps.len > 0) return &self.maps[self.maps.len - 1]; + return null; + } + + /// Faces owned at band index `i` (0 = finest band) — for the renderer/compositor + /// to iterate every owner at a band. + pub fn facesForBand(self: *const Partition, i: usize) []const plane.OwnedCell { + return self.maps[i].faces; + } + + /// The cell index that owns (x,y) at zoom `z`, or null — a true gap in this + /// band's map (the ground is owned by a coarser band). Coordinates are integers + /// (degrees × 10⁷). On-border results are even-odd-ambiguous; sample off edges. + pub fn ownerAt(self: *const Partition, z: u8, x: i64, y: i64) ?usize { + const m = self.mapForZoom(z) orelse return null; + for (m.faces) |f| { + if (boolean.pointInEvenOdd(f.owned, x, y)) return f.index; + } + return null; + } +}; + +/// Build the per-band ownership stack over `cells` (borrowed). One indexed partition +/// per distinct band floor. All face geometry is freshly allocated in `gpa`; free +/// with `Partition.deinit`. +pub fn build(gpa: std.mem.Allocator, cells: []const plane.Cell) !Partition { + // Distinct band floors. + var seen = std.AutoHashMap(u8, void).init(gpa); + defer seen.deinit(); + for (cells) |c| try seen.put(c.band_floor, {}); + + const tiers = try gpa.alloc(u8, seen.count()); + errdefer gpa.free(tiers); + { + var it = seen.keyIterator(); + var i: usize = 0; + while (it.next()) |k| : (i += 1) tiers[i] = k.*; + } + std.mem.sort(u8, tiers, {}, comptime std.sort.desc(u8)); // finest (highest floor) first + + const maps = try gpa.alloc(BandMap, tiers.len); + errdefer gpa.free(maps); + var built: usize = 0; + errdefer for (maps[0..built]) |m| plane.freeOwned(gpa, m.faces); + for (tiers, 0..) |t, i| { + maps[i] = .{ .tier = t, .faces = try plane.ownedAtTierIndexed(gpa, cells, t) }; + built = i + 1; + } + + return .{ .gpa = gpa, .cells = cells, .tiers = tiers, .maps = maps }; +} + +// =========================================================================== +// Tests +// =========================================================================== + +const testing = std.testing; + +fn boxPoly(a: std.mem.Allocator, x0: i64, y0: i64, x1: i64, y1: i64) !plane.Poly { + const ring = try a.alloc(plane.Pt, 4); + ring[0] = .{ .x = x0, .y = y0 }; + ring[1] = .{ .x = x1, .y = y0 }; + ring[2] = .{ .x = x1, .y = y1 }; + ring[3] = .{ .x = x0, .y = y1 }; + const rings = try a.alloc([]const plane.Pt, 1); + rings[0] = ring; + return rings; +} + +test "partition band-stack: tiers descending, mapForZoom + ownerAt resolve per band" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // Coarse [0,100]² at band floor 9 (coastal); harbor [40,60]² at floor 13 nested + // inside it. + const coarse = try boxPoly(a, 0, 0, 100, 100); + const harbor = try boxPoly(a, 40, 40, 60, 60); + const cells = [_]plane.Cell{ + .{ .cscl = 100_000, .band_floor = 9, .order = 0, .cov1 = &.{coarse} }, + .{ .cscl = 20_000, .band_floor = 13, .order = 0, .cov1 = &.{harbor} }, + }; + + var part = try build(testing.allocator, &cells); + defer part.deinit(); + + // Two tiers, finest (highest floor) first. + try testing.expectEqual(@as(usize, 2), part.tiers.len); + try testing.expectEqual(@as(u8, 13), part.tiers[0]); + try testing.expectEqual(@as(u8, 9), part.tiers[1]); + + // z14 → harbor band: harbor owns its box, coarse owns the surrounding ground. + try testing.expectEqual(@as(?usize, 1), part.ownerAt(14, 50, 50)); + try testing.expectEqual(@as(?usize, 0), part.ownerAt(14, 10, 10)); + // z10 → harbor is below its floor, so the coarse cell owns the whole basin. + try testing.expectEqual(@as(?usize, 0), part.ownerAt(10, 50, 50)); + // z3 → below every floor: resolves to the coarsest map, coarse still owns. + try testing.expectEqual(@as(?usize, 0), part.ownerAt(3, 50, 50)); + // Outside all coverage: a true gap. + try testing.expectEqual(@as(?usize, null), part.ownerAt(14, 200, 200)); +} From a78f7b1ccc8c09396e5511a9dc5a57a105f5c823 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 10:28:55 -0400 Subject: [PATCH 006/140] feat(bake): `bake --partition-debug` emits the ownership partition as PMTiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bake_root.zig | 1 + src/bundle.zig | 164 +++++++++++++++++++++++++++++++++++++++++ src/scene/bake_enc.zig | 19 +++-- tools/bake.zig | 18 +++++ 4 files changed, 194 insertions(+), 8 deletions(-) diff --git a/src/bake_root.zig b/src/bake_root.zig index 8763975..bc4beb2 100644 --- a/src/bake_root.zig +++ b/src/bake_root.zig @@ -27,5 +27,6 @@ pub const s101_instr = root.s101_instr; pub const s101_adapt = root.s101_adapt; pub const catalogue = root.catalogue; pub const bake_enc = root.bake_enc; +pub const geo = @import("geo"); // integer geometry: boolean, plane, partition pub const portray = @import("portray"); diff --git a/src/bundle.zig b/src/bundle.zig index 5505d23..44c5054 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -491,6 +491,170 @@ fn loadContextCoverage(io: std.Io, a: std.mem.Allocator, path: []const u8) []eng return out.toOwnedSlice(a) catch &.{}; } +// A cyclic palette so adjacent ownership faces get distinct colours in the debug +// view; a face carries its colour as a `color` property for a trivial data-driven style. +const DEBUG_PALETTE = [_][]const u8{ + "#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4", "#46f0f0", "#f032e6", + "#bcf60c", "#fabebe", "#008080", "#e6beff", "#9a6324", "#fffac8", "#800000", "#aaffc3", +}; + +/// Bake a DEBUG PMTiles of the ownership PARTITION only: the composited faces (which +/// cell renders which ground at each band), tagged with the owning cell's identity — +/// NO portrayed chart content. Lets the partition be eyeballed in the client. Reuses +/// the ENC_ROOT discovery + `readParseCell` loader and the standard mvt/pmtiles emit; +/// the partition math is `engine.geo`. Returns the cell count. `out_path` is a single +/// `.pmtiles` file (not a bundle dir). +pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const u8, out_path: []const u8, minzoom: u8, maxzoom_arg: u8) !usize { + const geo = engine.geo; + const mvt = engine.mvt; + const tile = engine.tile; + const maxzoom = @min(maxzoom_arg, @as(u8, 14)); // face tiles grow ~4^z; clamp to stay sane + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + + var dir = try std.Io.Dir.cwd().openDir(io, root_path, .{ .iterate = true }); + defer dir.close(io); + + // --- 1. Discover + parse cells (reuse readParseCell); widen M_COVR to integer + // points, take cscl/date/name. One entry per stem (dedup like bakeRoot). + const Loaded = struct { cell: geo.plane.Cell, name: []const u8, date: []const u8 }; + var loaded = std.ArrayList(Loaded).empty; + var seen = std.StringHashMap(void).init(a); + var walker = try dir.walk(a); + defer walker.deinit(); + while (try walker.next(io)) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; + const stem = std.fs.path.stem(std.fs.path.basename(entry.path)); + if (seen.contains(stem)) continue; + const stem_d = try a.dupe(u8, stem); + try seen.put(stem_d, {}); + + var cell = readParseCell(io, dir, entry.path) orelse continue; + defer cell.deinit(); + const cov = cell.mcovrCoverage(a); // float rings, copied into `a` + if (cov.len == 0) continue; + + const cov1 = try a.alloc(geo.plane.Poly, cov.len); + for (cov, 0..) |feat, fi| { + const rings = try a.alloc([]const geo.plane.Pt, feat.len); + for (feat, 0..) |ring, ri| { + const pts = try a.alloc(geo.plane.Pt, ring.len); + for (ring, 0..) |p, pi| pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; + rings[ri] = pts; + } + cov1[fi] = rings; + } + const cscl = cell.params.cscl; + try loaded.append(a, .{ + .cell = .{ .cscl = cscl, .band_floor = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cscl)).min, .order = 0, .cov1 = cov1 }, + .name = stem_d, + .date = try a.dupe(u8, cell.dsid.isdt), + }); + } + const ncells = loaded.items.len; + if (ncells == 0) return error.NoGeometry; + + // --- 2. Assign the DSID order (same tie-break as the bake), build the partition. + const rank = try a.alloc(usize, ncells); + for (rank, 0..) |*v, i| v.* = i; + std.mem.sort(usize, rank, loaded.items, struct { + fn lt(ls: []const Loaded, x: usize, y: usize) bool { + return engine.bake_enc.ordersBeforeKeys(ls[x].date, ls[x].name, ls[y].date, ls[y].name); + } + }.lt); + const cells = try a.alloc(geo.plane.Cell, ncells); + for (rank, 0..) |ci, r| loaded.items[ci].cell.order = r; + for (loaded.items, 0..) |lc, i| cells[i] = lc.cell; + + std.debug.print("partition-debug: {d} cells from {s}, building z{d}-{d}\n", .{ ncells, root_path, minzoom, maxzoom }); + var part = try geo.partition.build(gpa, cells); + defer part.deinit(); + + // --- 3. Slice each band's faces into tiles (reuse tile.project + clipPolygon). + const TileKey = struct { z: u8, x: u32, y: u32 }; + var tilemap = std.AutoHashMap(TileKey, std.ArrayList(mvt.Feature)).init(a); + + var z: u8 = minzoom; + while (z <= maxzoom) : (z += 1) { + const map = part.mapForZoom(z) orelse continue; + const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + for (map.faces) |face| { + if (face.owned.len == 0) continue; + const lc = loaded.items[face.index]; + + // Face lon/lat bbox → the tile x/y range it can touch at this zoom. + var fb = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; + for (face.owned) |ring| for (ring) |p| { + const lon = @as(f64, @floatFromInt(p.x)) / 1e7; + const lat = @as(f64, @floatFromInt(p.y)) / 1e7; + fb[0] = @min(fb[0], lon); + fb[1] = @min(fb[1], lat); + fb[2] = @max(fb[2], lon); + fb[3] = @max(fb[3], lat); + }; + const w_tl = tile.lonLatToWorld(fb[0], fb[3]); // min lon, max lat → top-left + const w_br = tile.lonLatToWorld(fb[2], fb[1]); // max lon, min lat → bottom-right + const tx0 = worldAxisToTile(w_tl[0], scale); + const tx1 = worldAxisToTile(w_br[0], scale); + const ty0 = worldAxisToTile(w_tl[1], scale); + const ty1 = worldAxisToTile(w_br[1], scale); + + const props = try a.dupe(mvt.Prop, &.{ + .{ .key = "cell", .value = .{ .string = lc.name } }, + .{ .key = "cscl", .value = .{ .int = lc.cell.cscl } }, + .{ .key = "band", .value = .{ .int = @intFromEnum(engine.bake_enc.bandOf(lc.cell.cscl)) } }, + .{ .key = "tier", .value = .{ .int = map.tier } }, + .{ .key = "oi", .value = .{ .int = @intCast(face.index) } }, + .{ .key = "color", .value = .{ .string = DEBUG_PALETTE[face.index % DEBUG_PALETTE.len] } }, + }); + + var tx = tx0; + while (tx <= tx1) : (tx += 1) { + var ty = ty0; + while (ty <= ty1) : (ty += 1) { + var parts = std.ArrayList([]const mvt.Point).empty; + for (face.owned) |ring| { + const proj = try a.alloc(mvt.Point, ring.len); + for (ring, 0..) |p, k| proj[k] = tile.project(@as(f64, @floatFromInt(p.x)) / 1e7, @as(f64, @floatFromInt(p.y)) / 1e7, z, tx, ty, tile.EXTENT); + const clipped = try tile.clipPolygon(a, proj, tile.Box.default(tile.EXTENT, 256)); + if (clipped.len >= 3) try parts.append(a, clipped); + } + if (parts.items.len == 0) continue; + const gop = try tilemap.getOrPut(.{ .z = z, .x = tx, .y = ty }); + if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(mvt.Feature).empty; + try gop.value_ptr.append(a, .{ .geom_type = .polygon, .parts = try parts.toOwnedSlice(a), .properties = props }); + } + } + } + } + + // --- 4. Encode each tile and write the archive (reuse mvt.encode + pmtiles.write). + var inputs = std.ArrayList(engine.pmtiles.InputTile).empty; + var it = tilemap.iterator(); + while (it.next()) |kv| { + const layers = [_]mvt.Layer{.{ .name = "partition", .features = kv.value_ptr.items }}; + const enc = try mvt.encode(a, .{ .layers = &layers }); + try inputs.append(a, .{ .z = kv.key_ptr.z, .x = kv.key_ptr.x, .y = kv.key_ptr.y, .mvt = enc }); + } + const bytes = try engine.pmtiles.write(gpa, inputs.items, .{ + .metadata_json = "{\"name\":\"partition-debug\",\"description\":\"ownership partition faces (debug); layer=partition, props: cell,cscl,band,tier,oi,color\"}", + .tile_type = .mvt, + }); + defer gpa.free(bytes); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = bytes }); + return ncells; +} + +// A normalised web-mercator world axis coordinate ([0,1]) → tile index at `scale` +// (= 2^z), clamped to [0, scale-1]. +fn worldAxisToTile(w: f64, scale: f64) u32 { + const f = @floor(w * scale); + if (f < 0) return 0; + return @intFromFloat(@min(f, scale - 1)); +} + const LightScanWork = struct { entries: []const CellEntry, dir: std.Io.Dir, diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index 16c63f1..dec3709 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -160,15 +160,18 @@ fn coversAny(backends: []const Backend, idxs: []const u32, lon: f64, lat: f64) b return coversAnyCtx(BackendCov{ .backends = backends }, idxs, lon, lat); } -// Whether cell `a` orders strictly before cell `b` in the equal-scale clip -// order: newer DSID issue/update date first (YYYYMMDD compares lexically; a -// dated cell orders before an undated one), then cell name ascending - total -// and deterministic for distinct cells, so bake output is byte-stable. -fn ordersBefore(ctx: anytype, a_idx: u32, b_idx: u32) bool { - const da = ctx.date(a_idx); - const db = ctx.date(b_idx); +// Whether (date da, name na) orders strictly before (db, nb) in the equal-scale +// clip order: newer DSID issue/update date first (YYYYMMDD compares lexically; a +// dated cell orders before an undated one), then cell name ascending - total and +// deterministic for distinct cells, so bake output is byte-stable. Public so the +// ownership partition assigns the same tie-break winner as the bake. +pub fn ordersBeforeKeys(da: []const u8, na: []const u8, db: []const u8, nb: []const u8) bool { if (!std.mem.eql(u8, da, db)) return std.mem.lessThan(u8, db, da); // newer first - return std.mem.lessThan(u8, ctx.name(a_idx), ctx.name(b_idx)); + return std.mem.lessThan(u8, na, nb); +} + +fn ordersBefore(ctx: anytype, a_idx: u32, b_idx: u32) bool { + return ordersBeforeKeys(ctx.date(a_idx), ctx.name(a_idx), ctx.date(b_idx), ctx.name(b_idx)); } /// coverClipForCell's per-tile verdict — the cheap FULL/EMPTY/SEAM classifier diff --git a/tools/bake.zig b/tools/bake.zig index 7c89c94..2a25b90 100644 --- a/tools/bake.zig +++ b/tools/bake.zig @@ -31,6 +31,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var super_dz: u8 = DEFAULT_SUPER_DZ; // lazy-bake tuning: spatial super-tile depth var format: engine.scene.TileFormat = .mlt; // tile encoding: mlt (default) or mvt var existing: []const u8 = ""; // cross-pack best-available: a peer pack's ENC_ROOT + var partition_debug = false; // emit the ownership-partition debug PMTiles instead of a bundle var f = Flags{ .args = args }; while (f.next()) |arg| { @@ -55,6 +56,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } else if (std.mem.eql(u8, arg, "--format")) { const v = f.val(arg) orelse return; format = if (std.mem.eql(u8, v, "mlt")) .mlt else if (std.mem.eql(u8, v, "mvt")) .mvt else return usageErr("--format must be mvt or mlt"); + } else if (std.mem.eql(u8, arg, "--partition-debug")) { + partition_debug = true; } else if (std.mem.startsWith(u8, arg, "-")) { return usageErr("unknown flag"); } else if (base == null) { @@ -70,6 +73,21 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { if (maxzoom > 24) return usageErr("--maxzoom too large (max 24)"); if (lru < 1) return usageErr("--lru must be >= 1"); + // Debug: emit the ownership partition (which cell owns which ground per band) as a + // single PMTiles with no portrayed content, for eyeballing the composite quilt. + if (partition_debug) { + // Full overview→approach range by default (z13+ shows harbor detail but the + // face-tile count grows ~4^z, so raise --maxzoom on demand). + const dbg_minz: u8 = if (minzoom == DEFAULT_MINZOOM) 0 else minzoom; + const dbg_maxz: u8 = if (maxzoom == DEFAULT_MAXZOOM) 12 else maxzoom; + const nc = bundle.bakePartitionDebug(io, a, base_path, out_dir, dbg_minz, dbg_maxz) catch |err| { + std.debug.print("error: partition-debug bake of {s} failed ({s})\n", .{ base_path, @errorName(err) }); + return; + }; + std.debug.print("partition-debug: {d} cell(s) -> {s} (z{d}-{d}, layer \"partition\": cell/cscl/band/tier/oi/color)\n", .{ nc, out_dir, dbg_minz, dbg_maxz }); + return; + } + // The whole tiles + assets + manifest pipeline lives in the `bundle` lib module // (bundle.bakeBundle) so any consumer (the C ABI, a Go/JS binding) emits the same // package; the CLI just resolves args -> options and prints the summary. From ece86724c40f2ef6574f42688996aece8dabdd1c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 10:45:09 -0400 Subject: [PATCH 007/140] =?UTF-8?q?fix(bake):=20partition-debug=20PMTiles?= =?UTF-8?q?=20renders=20=E2=80=94=20winding,=20bounds,=20center=20zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bundle.zig | 25 +++++++++++++++++++++++-- src/scene/scene.zig | 3 ++- src/tiles/pmtiles.zig | 7 ++++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index 44c5054..d8b7670 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -521,6 +521,8 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const // points, take cscl/date/name. One entry per stem (dedup like bakeRoot). const Loaded = struct { cell: geo.plane.Cell, name: []const u8, date: []const u8 }; var loaded = std.ArrayList(Loaded).empty; + // Union bbox (E7) so the archive header points a viewer at the data, not the world. + var ubox = [4]i32{ std.math.maxInt(i32), std.math.maxInt(i32), std.math.minInt(i32), std.math.minInt(i32) }; var seen = std.StringHashMap(void).init(a); var walker = try dir.walk(a); defer walker.deinit(); @@ -541,7 +543,13 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const const rings = try a.alloc([]const geo.plane.Pt, feat.len); for (feat, 0..) |ring, ri| { const pts = try a.alloc(geo.plane.Pt, ring.len); - for (ring, 0..) |p, pi| pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; + for (ring, 0..) |p, pi| { + pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; + ubox[0] = @min(ubox[0], p.lon_e7); + ubox[1] = @min(ubox[1], p.lat_e7); + ubox[2] = @max(ubox[2], p.lon_e7); + ubox[3] = @max(ubox[3], p.lat_e7); + } rings[ri] = pts; } cov1[fi] = rings; @@ -622,9 +630,13 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const if (clipped.len >= 3) try parts.append(a, clipped); } if (parts.items.len == 0) continue; + // Boolean rings have unspecified winding; MVT/MapLibre fills by + // winding, so orient exteriors +area / holes -area (same authority + // the real bake uses) or nothing renders. + const oriented = try engine.scene.orientAreaRings(a, parts.items); const gop = try tilemap.getOrPut(.{ .z = z, .x = tx, .y = ty }); if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(mvt.Feature).empty; - try gop.value_ptr.append(a, .{ .geom_type = .polygon, .parts = try parts.toOwnedSlice(a), .properties = props }); + try gop.value_ptr.append(a, .{ .geom_type = .polygon, .parts = oriented, .properties = props }); } } } @@ -638,9 +650,18 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const const enc = try mvt.encode(a, .{ .layers = &layers }); try inputs.append(a, .{ .z = kv.key_ptr.z, .x = kv.key_ptr.x, .y = kv.key_ptr.y, .mvt = enc }); } + // Frame a viewer on the data (a zoom where the bbox is ~2 tiles wide), so it + // doesn't open at world scale where the district is an invisible speck. + const span_deg = @max(0.01, @as(f64, @floatFromInt(ubox[2] - ubox[0])) / 1e7); + const cz: u8 = @intFromFloat(std.math.clamp(std.math.log2(720.0 / span_deg), @as(f64, @floatFromInt(minzoom)), @as(f64, @floatFromInt(maxzoom)))); const bytes = try engine.pmtiles.write(gpa, inputs.items, .{ .metadata_json = "{\"name\":\"partition-debug\",\"description\":\"ownership partition faces (debug); layer=partition, props: cell,cscl,band,tier,oi,color\"}", .tile_type = .mvt, + .min_lon_e7 = ubox[0], + .min_lat_e7 = ubox[1], + .max_lon_e7 = ubox[2], + .max_lat_e7 = ubox[3], + .center_zoom = cz, }); defer gpa.free(bytes); try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = bytes }); diff --git a/src/scene/scene.zig b/src/scene/scene.zig index d315215..6e1789f 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -496,7 +496,8 @@ fn ringContains(ring: []const mvt.Point, pt: mvt.Point) bool { /// independent of the FSPT USAG tags, and keeps disjoint multi-part areas /// (multiple exteriors) working as a proper multipolygon. `rings` are the clipped /// rings (open, >= 3 pts); returned parts may reverse a ring into a fresh copy. -fn orientAreaRings(a: Allocator, rings: []const []const mvt.Point) ![]const []const mvt.Point { +/// Public so the ownership-partition debug bake gets the same MVT winding. +pub fn orientAreaRings(a: Allocator, rings: []const []const mvt.Point) ![]const []const mvt.Point { const n = rings.len; const depth = try a.alloc(usize, n); for (rings, 0..) |ri, i| { diff --git a/src/tiles/pmtiles.zig b/src/tiles/pmtiles.zig index 97c19f7..c217c8d 100644 --- a/src/tiles/pmtiles.zig +++ b/src/tiles/pmtiles.zig @@ -366,6 +366,7 @@ pub const WriteOptions = struct { max_lon_e7: i32 = 1800000000, max_lat_e7: i32 = 850000000, tile_type: TileType = .mvt, // .mlt for MapLibre Tile output + center_zoom: ?u8 = null, // header center zoom; defaults to the archive's min zoom }; /// Streaming archive writer: feed tiles one at a time (each gzipped + content- @@ -500,7 +501,7 @@ pub const StreamWriter = struct { .min_lat_e7 = opts.min_lat_e7, .max_lon_e7 = opts.max_lon_e7, .max_lat_e7 = opts.max_lat_e7, - .center_zoom = min_z, + .center_zoom = opts.center_zoom orelse min_z, .center_lon_e7 = @divTrunc(opts.min_lon_e7 + opts.max_lon_e7, 2), .center_lat_e7 = @divTrunc(opts.min_lat_e7 + opts.max_lat_e7, 2), }; @@ -618,14 +619,14 @@ pub fn write(gpa: Allocator, tiles: []const InputTile, opts: WriteOptions) ![]u8 .clustered = 1, .internal_compression = .none, .tile_compression = .gzip, - .tile_type = .mvt, + .tile_type = opts.tile_type, .min_zoom = min_z, .max_zoom = max_z, .min_lon_e7 = opts.min_lon_e7, .min_lat_e7 = opts.min_lat_e7, .max_lon_e7 = opts.max_lon_e7, .max_lat_e7 = opts.max_lat_e7, - .center_zoom = min_z, + .center_zoom = opts.center_zoom orelse min_z, .center_lon_e7 = @divTrunc(opts.min_lon_e7 + opts.max_lon_e7, 2), .center_lat_e7 = @divTrunc(opts.min_lat_e7 + opts.max_lat_e7, 2), }; From 0d2eb0444b80f7b2305a37282a37aceacf989f9a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 11:49:37 -0400 Subject: [PATCH 008/140] fix(bake): partition-debug declares vector_layers (was invisible to viewers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- src/bundle.zig | 4 ++- tools/main.zig | 4 +++ tools/partdbg_png.zig | 67 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tools/partdbg_png.zig diff --git a/src/bundle.zig b/src/bundle.zig index d8b7670..2f297ba 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -655,7 +655,9 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const const span_deg = @max(0.01, @as(f64, @floatFromInt(ubox[2] - ubox[0])) / 1e7); const cz: u8 = @intFromFloat(std.math.clamp(std.math.log2(720.0 / span_deg), @as(f64, @floatFromInt(minzoom)), @as(f64, @floatFromInt(maxzoom)))); const bytes = try engine.pmtiles.write(gpa, inputs.items, .{ - .metadata_json = "{\"name\":\"partition-debug\",\"description\":\"ownership partition faces (debug); layer=partition, props: cell,cscl,band,tier,oi,color\"}", + // MapLibre/pmtiles.io build their style from `vector_layers`; without it the + // "partition" layer is invisible to a viewer no matter the geometry. + .metadata_json = "{\"name\":\"partition-debug\",\"format\":\"pbf\",\"vector_layers\":[{\"id\":\"partition\",\"fields\":{\"cell\":\"String\",\"cscl\":\"Number\",\"band\":\"Number\",\"tier\":\"Number\",\"oi\":\"Number\",\"color\":\"String\"}}]}", .tile_type = .mvt, .min_lon_e7 = ubox[0], .min_lat_e7 = ubox[1], diff --git a/tools/main.zig b/tools/main.zig index 2d4b9b9..000fa1c 100644 --- a/tools/main.zig +++ b/tools/main.zig @@ -40,6 +40,7 @@ const audit_holes = @import("audit_holes.zig"); const audit_pairs = @import("audit_pairs.zig"); const objlcount = @import("objlcount.zig"); const cell = @import("cell.zig"); +const partdbg_png = @import("partdbg_png.zig"); pub fn main(init: std.process.Init) !void { const arena = init.arena.allocator(); @@ -75,6 +76,9 @@ pub fn main(init: std.process.Init) !void { if (std.mem.eql(u8, sub, "png") or std.mem.eql(u8, sub, "renderpng")) { return render.run(io, arena, args, .png); } + if (std.mem.eql(u8, sub, "partdbg-png")) { + return partdbg_png.run(io, arena, args); + } if (std.mem.eql(u8, sub, "pdf")) { return render.run(io, arena, args, .pdf); diff --git a/tools/partdbg_png.zig b/tools/partdbg_png.zig new file mode 100644 index 0000000..c328e60 --- /dev/null +++ b/tools/partdbg_png.zig @@ -0,0 +1,67 @@ +//! Render one tile of a partition-debug PMTiles to a PNG — a self-check that the +//! ownership faces actually fill. Each "partition" feature is filled with its +//! `color` property using the nonzero rule (what MapLibre uses), so a blank output +//! means the geometry itself is wrong, not the viewer. +//! +//! tile57 partdbg-png + +const std = @import("std"); +const engine = @import("engine"); +const render = @import("render"); +const cv = render.canvas; + +fn hex(s: []const u8) cv.Color { + if (s.len < 7 or s[0] != '#') return .{ .r = 200, .g = 200, .b = 200 }; + const v = std.fmt.parseInt(u32, s[1..7], 16) catch return .{ .r = 200, .g = 200, .b = 200 }; + return .{ .r = @intCast((v >> 16) & 0xff), .g = @intCast((v >> 8) & 0xff), .b = @intCast(v & 0xff), .a = 210 }; +} + +pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { + if (args.len < 7) { + std.debug.print("usage: tile57 partdbg-png \n", .{}); + return; + } + const path = args[2]; + const z = try std.fmt.parseInt(u8, args[3], 10); + const x = try std.fmt.parseInt(u32, args[4], 10); + const y = try std.fmt.parseInt(u32, args[5], 10); + const out = args[6]; + + const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, a, .unlimited); + var r = try engine.pmtiles.Reader.init(a, bytes); + defer r.deinit(); + const tb = (try r.getTile(a, z, x, y)) orelse { + std.debug.print("tile {d}/{d}/{d} not found\n", .{ z, x, y }); + return; + }; + const layers = try engine.mvt.decode(a, tb); + + const W: u32 = 1024; + var rc = try render.raster.RasterCanvas.init(a, W, W); + defer rc.deinit(); + rc.clear(.{ .r = 18, .g = 22, .b = 26 }); + const canvas = rc.asCanvas(); + const sc: f32 = @as(f32, @floatFromInt(W)) / 4096.0; + + var n: usize = 0; + for (layers) |L| { + if (!std.mem.eql(u8, L.name, "partition")) continue; + for (L.features) |f| { + var col = cv.Color{ .r = 200, .g = 200, .b = 200, .a = 210 }; + for (f.properties) |pr| if (std.mem.eql(u8, pr.key, "color") and pr.value == .string) { + col = hex(pr.value.string); + }; + var rings = std.ArrayList([]const cv.Point).empty; + for (f.parts) |ring| { + const rr = try a.alloc(cv.Point, ring.len); + for (ring, 0..) |p, i| rr[i] = .{ .x = @as(f32, @floatFromInt(p.x)) * sc, .y = @as(f32, @floatFromInt(p.y)) * sc }; + try rings.append(a, rr); + } + try canvas.fillPath(rings.items, col, .nonzero); + n += 1; + } + } + const png = try render.png.encodeRgba(a, rc.px, W, W); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out, .data = png }); + std.debug.print("rendered {d} partition features from {d}/{d}/{d} -> {s}\n", .{ n, z, x, y, out }); +} From fec6c1d3cabffadb8904b44088d00dc1403883b4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 12:02:05 -0400 Subject: [PATCH 009/140] =?UTF-8?q?feat(geo):=20partition.ownedFace(ci,=20?= =?UTF-8?q?z)=20=E2=80=94=20a=20cell's=20owned=20face=20for=20the=20compos?= =?UTF-8?q?itor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/geo/partition.zig | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/geo/partition.zig b/src/geo/partition.zig index 494bf4f..2a8dbbd 100644 --- a/src/geo/partition.zig +++ b/src/geo/partition.zig @@ -22,6 +22,9 @@ pub const BandMap = struct { /// One face per cell owning ground at this band; `face.index` indexes /// `Partition.cells`. `gpa`-owned. faces: []plane.OwnedCell, + /// `n_cells`-long: global cell index → its slot in `faces`, or -1 if the cell + /// owns nothing at this band. Lets the compositor fetch a cell's face in O(1). + pos: []i32, }; pub const Partition = struct { @@ -34,7 +37,10 @@ pub const Partition = struct { maps: []BandMap, pub fn deinit(self: *Partition) void { - for (self.maps) |m| plane.freeOwned(self.gpa, m.faces); + for (self.maps) |m| { + plane.freeOwned(self.gpa, m.faces); + self.gpa.free(m.pos); + } self.gpa.free(self.maps); self.gpa.free(self.tiers); } @@ -66,6 +72,18 @@ pub const Partition = struct { } return null; } + + /// Cell `ci`'s owned face at the band governing zoom `z` — the rings (integer + /// lon/lat, degrees × 10⁷) of the ground it renders there, or null if it owns + /// nothing at that band. This is the region the compositor clips cell `ci`'s + /// features to before merging. + pub fn ownedFace(self: *const Partition, ci: usize, z: u8) ?[]const []const plane.Pt { + const m = self.mapForZoom(z) orelse return null; + if (ci >= m.pos.len) return null; + const slot = m.pos[ci]; + if (slot < 0) return null; + return m.faces[@intCast(slot)].owned; + } }; /// Build the per-band ownership stack over `cells` (borrowed). One indexed partition @@ -89,9 +107,17 @@ pub fn build(gpa: std.mem.Allocator, cells: []const plane.Cell) !Partition { const maps = try gpa.alloc(BandMap, tiers.len); errdefer gpa.free(maps); var built: usize = 0; - errdefer for (maps[0..built]) |m| plane.freeOwned(gpa, m.faces); + errdefer for (maps[0..built]) |m| { + plane.freeOwned(gpa, m.faces); + gpa.free(m.pos); + }; for (tiers, 0..) |t, i| { - maps[i] = .{ .tier = t, .faces = try plane.ownedAtTierIndexed(gpa, cells, t) }; + const faces = try plane.ownedAtTierIndexed(gpa, cells, t); + errdefer plane.freeOwned(gpa, faces); + const pos = try gpa.alloc(i32, cells.len); + @memset(pos, -1); + for (faces, 0..) |f, slot| pos[f.index] = @intCast(slot); + maps[i] = .{ .tier = t, .faces = faces, .pos = pos }; built = i + 1; } @@ -146,4 +172,13 @@ test "partition band-stack: tiers descending, mapForZoom + ownerAt resolve per b try testing.expectEqual(@as(?usize, 0), part.ownerAt(3, 50, 50)); // Outside all coverage: a true gap. try testing.expectEqual(@as(?usize, null), part.ownerAt(14, 200, 200)); + + // ownedFace hands the compositor a cell's owned geometry to clip against. + const hf = part.ownedFace(1, 14) orelse return error.TestUnexpectedResult; + try testing.expect(boolean.pointInEvenOdd(hf, 50, 50)); // harbor owns its box + const cf = part.ownedFace(0, 14) orelse return error.TestUnexpectedResult; + try testing.expect(boolean.pointInEvenOdd(cf, 10, 10)); // coarse owns the surround + try testing.expect(!boolean.pointInEvenOdd(cf, 50, 50)); // ...but NOT the harbor hole + try testing.expect(part.ownedFace(1, 10) == null); // harbor below its floor: owns nothing + try testing.expect(part.ownedFace(99, 14) == null); // out of range } From 359f2ed44679a21fe0427a89733a424a7fbbca8a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 12:17:52 -0400 Subject: [PATCH 010/140] refactor(bake): single cell-load path + streaming, band-aware partition-debug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bundle.zig | 376 ++++++++++++++++++++++++++++++------------------- tools/bake.zig | 16 ++- 2 files changed, 242 insertions(+), 150 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index 2f297ba..980664c 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -462,19 +462,38 @@ fn readParseCell(io: std.Io, dir: std.Io.Dir, bpath: []const u8) ?engine.s57.Cel return engine.s57.parseCellWithUpdates(gpa, base, ups.items) catch null; } -/// Cross-pack best-available (BakeOpts.existing_input / bake --existing): peer packs' -/// M_COVR coverage + cscl, read from a sibling ENC_ROOT for suppression context only -/// (never emitted). One cell resident at a time; coverage is copied into `a` so each -/// cell frees right after. "" / a missing dir yields no context (a plain bake). -fn loadContextCoverage(io: std.Io, a: std.mem.Allocator, path: []const u8) []engine.bake_enc.ContextCell { +/// One ENC cell loaded for coverage-level work: its M_COVR(CATCOV=1) rings, bbox, +/// compilation scale, DSNM stem, and DSID date. The eager cell-loader's output; the +/// cross-pack context uses a subset, the partition debug bake widens it to points. +pub const LoadedCov = struct { + name: []const u8, // DSNM stem + date: []const u8, // DSID issue/update date (YYYYMMDD) + cscl: i32, // compilation scale (1:N) + coverage: []const []const []const engine.s57.LonLat, // M_COVR(CATCOV=1) rings + bounds: [4]f64, // [w,s,e,n] over the coverage +}; + +/// THE eager cell-coverage loader: walk an ENC_ROOT, parse each cell once (base + +/// updates via readParseCell), and capture its M_COVR coverage + bbox + cscl + name +/// + date. One entry per stem (a boundary cell shared by two districts loads once). +/// Coverage is copied into `a` and survives the cell's deinit. "" / a missing dir +/// yields none; cells with no M_COVR are skipped. The single load path composed by +/// the cross-pack context, the partition debug bake, and the compositor. +fn loadCells(io: std.Io, a: std.mem.Allocator, path: []const u8) []LoadedCov { if (path.len == 0) return &.{}; var dir = std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }) catch return &.{}; defer dir.close(io); + var out = std.ArrayList(LoadedCov).empty; + var seen = std.StringHashMap(void).init(a); var walker = dir.walk(a) catch return &.{}; defer walker.deinit(); - var out = std.ArrayList(engine.bake_enc.ContextCell).empty; while (walker.next(io) catch null) |entry| { if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; + const stem = std.fs.path.stem(std.fs.path.basename(entry.path)); + if (seen.contains(stem)) continue; + const stem_d = a.dupe(u8, stem) catch continue; + seen.put(stem_d, {}) catch {}; + var cell = readParseCell(io, dir, entry.path) orelse continue; defer cell.deinit(); const cov = cell.mcovrCoverage(a); // copied into `a`; survives cell.deinit() @@ -486,11 +505,27 @@ fn loadContextCoverage(io: std.Io, a: std.mem.Allocator, path: []const u8) []eng b[2] = @max(b[2], p.lon()); b[3] = @max(b[3], p.lat()); }; - out.append(a, .{ .coverage = cov, .bounds = b, .cscl = cell.params.cscl }) catch {}; + out.append(a, .{ + .name = stem_d, + .date = a.dupe(u8, cell.dsid.isdt) catch "", + .cscl = cell.params.cscl, + .coverage = cov, + .bounds = b, + }) catch {}; } return out.toOwnedSlice(a) catch &.{}; } +/// Cross-pack best-available context (BakeOpts.existing_input / bake --existing): +/// peer packs' M_COVR + cscl for suppression only (never emitted) — the ContextCell +/// projection of loadCells. +fn loadContextCoverage(io: std.Io, a: std.mem.Allocator, path: []const u8) []engine.bake_enc.ContextCell { + const cells = loadCells(io, a, path); + const out = a.alloc(engine.bake_enc.ContextCell, cells.len) catch return &.{}; + for (cells, 0..) |c, i| out[i] = .{ .coverage = c.coverage, .bounds = c.bounds, .cscl = c.cscl }; + return out; +} + // A cyclic palette so adjacent ownership faces get distinct colours in the debug // view; a face carries its colour as a `color` property for a trivial data-driven style. const DEBUG_PALETTE = [_][]const u8{ @@ -498,163 +533,77 @@ const DEBUG_PALETTE = [_][]const u8{ "#bcf60c", "#fabebe", "#008080", "#e6beff", "#9a6324", "#fffac8", "#800000", "#aaffc3", }; -/// Bake a DEBUG PMTiles of the ownership PARTITION only: the composited faces (which -/// cell renders which ground at each band), tagged with the owning cell's identity — -/// NO portrayed chart content. Lets the partition be eyeballed in the client. Reuses -/// the ENC_ROOT discovery + `readParseCell` loader and the standard mvt/pmtiles emit; -/// the partition math is `engine.geo`. Returns the cell count. `out_path` is a single -/// `.pmtiles` file (not a bundle dir). -pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const u8, out_path: []const u8, minzoom: u8, maxzoom_arg: u8) !usize { +/// Bake a DEBUG PMTiles of the ownership PARTITION (which cell renders which ground), +/// NO portrayed content, for eyeballing the composite quilt. `band` < 0 emits the band +/// GOVERNING each zoom (the natural view); 0..5 (berthing..overview) emits only that +/// band's own map, at every zoom. Composes the single paths — loadCells + toPlaneCells +/// + geo.plane partition + the mvt/tile/pmtiles primitives — and STREAMS tiles (one +/// tier + one zoom resident) so it scales to the whole corpus. Returns the cell count; +/// `out_path` is a single `.pmtiles` file. +pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const u8, out_path: []const u8, minzoom: u8, maxzoom_arg: u8, band: i8) !usize { const geo = engine.geo; - const mvt = engine.mvt; - const tile = engine.tile; - const maxzoom = @min(maxzoom_arg, @as(u8, 14)); // face tiles grow ~4^z; clamp to stay sane + const maxzoom = @min(maxzoom_arg, @as(u8, 16)); var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); - var dir = try std.Io.Dir.cwd().openDir(io, root_path, .{ .iterate = true }); - defer dir.close(io); + // Single load + adapt paths. + const loaded = loadCells(io, a, root_path); + if (loaded.len == 0) return error.NoGeometry; + const cells = try toPlaneCells(a, loaded); - // --- 1. Discover + parse cells (reuse readParseCell); widen M_COVR to integer - // points, take cscl/date/name. One entry per stem (dedup like bakeRoot). - const Loaded = struct { cell: geo.plane.Cell, name: []const u8, date: []const u8 }; - var loaded = std.ArrayList(Loaded).empty; - // Union bbox (E7) so the archive header points a viewer at the data, not the world. + // Union bbox (E7) for the header, so a viewer opens on the data not the world. var ubox = [4]i32{ std.math.maxInt(i32), std.math.maxInt(i32), std.math.minInt(i32), std.math.minInt(i32) }; - var seen = std.StringHashMap(void).init(a); - var walker = try dir.walk(a); - defer walker.deinit(); - while (try walker.next(io)) |entry| { - if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; - const stem = std.fs.path.stem(std.fs.path.basename(entry.path)); - if (seen.contains(stem)) continue; - const stem_d = try a.dupe(u8, stem); - try seen.put(stem_d, {}); - - var cell = readParseCell(io, dir, entry.path) orelse continue; - defer cell.deinit(); - const cov = cell.mcovrCoverage(a); // float rings, copied into `a` - if (cov.len == 0) continue; - - const cov1 = try a.alloc(geo.plane.Poly, cov.len); - for (cov, 0..) |feat, fi| { - const rings = try a.alloc([]const geo.plane.Pt, feat.len); - for (feat, 0..) |ring, ri| { - const pts = try a.alloc(geo.plane.Pt, ring.len); - for (ring, 0..) |p, pi| { - pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; - ubox[0] = @min(ubox[0], p.lon_e7); - ubox[1] = @min(ubox[1], p.lat_e7); - ubox[2] = @max(ubox[2], p.lon_e7); - ubox[3] = @max(ubox[3], p.lat_e7); - } - rings[ri] = pts; - } - cov1[fi] = rings; - } - const cscl = cell.params.cscl; - try loaded.append(a, .{ - .cell = .{ .cscl = cscl, .band_floor = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cscl)).min, .order = 0, .cov1 = cov1 }, - .name = stem_d, - .date = try a.dupe(u8, cell.dsid.isdt), - }); + for (loaded) |c| { + ubox[0] = @min(ubox[0], deg7(c.bounds[0])); + ubox[1] = @min(ubox[1], deg7(c.bounds[1])); + ubox[2] = @max(ubox[2], deg7(c.bounds[2])); + ubox[3] = @max(ubox[3], deg7(c.bounds[3])); } - const ncells = loaded.items.len; - if (ncells == 0) return error.NoGeometry; + std.debug.print("partition-debug: {d} cells from {s}, z{d}-{d}, band {d}\n", .{ loaded.len, root_path, minzoom, maxzoom, band }); - // --- 2. Assign the DSID order (same tie-break as the bake), build the partition. - const rank = try a.alloc(usize, ncells); - for (rank, 0..) |*v, i| v.* = i; - std.mem.sort(usize, rank, loaded.items, struct { - fn lt(ls: []const Loaded, x: usize, y: usize) bool { - return engine.bake_enc.ordersBeforeKeys(ls[x].date, ls[x].name, ls[y].date, ls[y].name); + // Stream tiles (gzipped + deduped) so the whole archive never lives uncompressed. + var sw = engine.pmtiles.StreamWriter.init(gpa); + defer sw.deinit(); + var zoom_arena = std.heap.ArenaAllocator.init(gpa); // one zoom's tiles at a time + defer zoom_arena.deinit(); + + if (band >= 0) { + // One band's own map: build its tier once, emit across every zoom. + const bandv: engine.bake_enc.Band = @enumFromInt(@as(u8, @intCast(@min(band, @as(i8, 5))))); + const tier = engine.bake_enc.bandZooms(bandv).min; + const faces = try geo.plane.ownedAtTierIndexed(gpa, cells, tier); + defer geo.plane.freeOwned(gpa, faces); + var z: u8 = minzoom; + while (z <= maxzoom) : (z += 1) { + _ = zoom_arena.reset(.retain_capacity); + try emitFacesZoom(&sw, zoom_arena.allocator(), faces, loaded, z, tier); } - }.lt); - const cells = try a.alloc(geo.plane.Cell, ncells); - for (rank, 0..) |ci, r| loaded.items[ci].cell.order = r; - for (loaded.items, 0..) |lc, i| cells[i] = lc.cell; - - std.debug.print("partition-debug: {d} cells from {s}, building z{d}-{d}\n", .{ ncells, root_path, minzoom, maxzoom }); - var part = try geo.partition.build(gpa, cells); - defer part.deinit(); - - // --- 3. Slice each band's faces into tiles (reuse tile.project + clipPolygon). - const TileKey = struct { z: u8, x: u32, y: u32 }; - var tilemap = std.AutoHashMap(TileKey, std.ArrayList(mvt.Feature)).init(a); - - var z: u8 = minzoom; - while (z <= maxzoom) : (z += 1) { - const map = part.mapForZoom(z) orelse continue; - const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); - for (map.faces) |face| { - if (face.owned.len == 0) continue; - const lc = loaded.items[face.index]; - - // Face lon/lat bbox → the tile x/y range it can touch at this zoom. - var fb = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; - for (face.owned) |ring| for (ring) |p| { - const lon = @as(f64, @floatFromInt(p.x)) / 1e7; - const lat = @as(f64, @floatFromInt(p.y)) / 1e7; - fb[0] = @min(fb[0], lon); - fb[1] = @min(fb[1], lat); - fb[2] = @max(fb[2], lon); - fb[3] = @max(fb[3], lat); - }; - const w_tl = tile.lonLatToWorld(fb[0], fb[3]); // min lon, max lat → top-left - const w_br = tile.lonLatToWorld(fb[2], fb[1]); // max lon, min lat → bottom-right - const tx0 = worldAxisToTile(w_tl[0], scale); - const tx1 = worldAxisToTile(w_br[0], scale); - const ty0 = worldAxisToTile(w_tl[1], scale); - const ty1 = worldAxisToTile(w_br[1], scale); - - const props = try a.dupe(mvt.Prop, &.{ - .{ .key = "cell", .value = .{ .string = lc.name } }, - .{ .key = "cscl", .value = .{ .int = lc.cell.cscl } }, - .{ .key = "band", .value = .{ .int = @intFromEnum(engine.bake_enc.bandOf(lc.cell.cscl)) } }, - .{ .key = "tier", .value = .{ .int = map.tier } }, - .{ .key = "oi", .value = .{ .int = @intCast(face.index) } }, - .{ .key = "color", .value = .{ .string = DEBUG_PALETTE[face.index % DEBUG_PALETTE.len] } }, - }); - - var tx = tx0; - while (tx <= tx1) : (tx += 1) { - var ty = ty0; - while (ty <= ty1) : (ty += 1) { - var parts = std.ArrayList([]const mvt.Point).empty; - for (face.owned) |ring| { - const proj = try a.alloc(mvt.Point, ring.len); - for (ring, 0..) |p, k| proj[k] = tile.project(@as(f64, @floatFromInt(p.x)) / 1e7, @as(f64, @floatFromInt(p.y)) / 1e7, z, tx, ty, tile.EXTENT); - const clipped = try tile.clipPolygon(a, proj, tile.Box.default(tile.EXTENT, 256)); - if (clipped.len >= 3) try parts.append(a, clipped); - } - if (parts.items.len == 0) continue; - // Boolean rings have unspecified winding; MVT/MapLibre fills by - // winding, so orient exteriors +area / holes -area (same authority - // the real bake uses) or nothing renders. - const oriented = try engine.scene.orientAreaRings(a, parts.items); - const gop = try tilemap.getOrPut(.{ .z = z, .x = tx, .y = ty }); - if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(mvt.Feature).empty; - try gop.value_ptr.append(a, .{ .geom_type = .polygon, .parts = oriented, .properties = props }); - } + } else { + // Merged: the band governing each zoom. Build that tier lazily, one resident at + // a time (coarse→fine as z rises), so the finest tier is only built if reached. + const floors = try distinctFloorsDesc(a, cells); + var cur_tier: i16 = -1; + var cur_faces: []geo.plane.OwnedCell = &.{}; + defer if (cur_tier >= 0) geo.plane.freeOwned(gpa, cur_faces); + var z: u8 = minzoom; + while (z <= maxzoom) : (z += 1) { + const t = governingTier(floors, z); + if (cur_tier != @as(i16, t)) { + if (cur_tier >= 0) geo.plane.freeOwned(gpa, cur_faces); + cur_faces = try geo.plane.ownedAtTierIndexed(gpa, cells, t); + cur_tier = t; } + _ = zoom_arena.reset(.retain_capacity); + try emitFacesZoom(&sw, zoom_arena.allocator(), cur_faces, loaded, z, t); } } - // --- 4. Encode each tile and write the archive (reuse mvt.encode + pmtiles.write). - var inputs = std.ArrayList(engine.pmtiles.InputTile).empty; - var it = tilemap.iterator(); - while (it.next()) |kv| { - const layers = [_]mvt.Layer{.{ .name = "partition", .features = kv.value_ptr.items }}; - const enc = try mvt.encode(a, .{ .layers = &layers }); - try inputs.append(a, .{ .z = kv.key_ptr.z, .x = kv.key_ptr.x, .y = kv.key_ptr.y, .mvt = enc }); - } - // Frame a viewer on the data (a zoom where the bbox is ~2 tiles wide), so it - // doesn't open at world scale where the district is an invisible speck. + // Frame a viewer on the data (a zoom where the bbox is ~2 tiles wide). const span_deg = @max(0.01, @as(f64, @floatFromInt(ubox[2] - ubox[0])) / 1e7); const cz: u8 = @intFromFloat(std.math.clamp(std.math.log2(720.0 / span_deg), @as(f64, @floatFromInt(minzoom)), @as(f64, @floatFromInt(maxzoom)))); - const bytes = try engine.pmtiles.write(gpa, inputs.items, .{ + const bytes = try sw.finishBytes(.{ // MapLibre/pmtiles.io build their style from `vector_layers`; without it the // "partition" layer is invisible to a viewer no matter the geometry. .metadata_json = "{\"name\":\"partition-debug\",\"format\":\"pbf\",\"vector_layers\":[{\"id\":\"partition\",\"fields\":{\"cell\":\"String\",\"cscl\":\"Number\",\"band\":\"Number\",\"tier\":\"Number\",\"oi\":\"Number\",\"color\":\"String\"}}]}", @@ -667,7 +616,138 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const }); defer gpa.free(bytes); try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = bytes }); - return ncells; + return loaded.len; +} + +// deg → E7 integer. +fn deg7(d: f64) i32 { + return @intFromFloat(@round(d * 1e7)); +} + +// Distinct band floors, DESCENDING (finest floor first) — the tier ladder. +fn distinctFloorsDesc(a: std.mem.Allocator, cells: []const engine.geo.plane.Cell) ![]u8 { + var seen = std.AutoHashMap(u8, void).init(a); + for (cells) |c| try seen.put(c.band_floor, {}); + const out = try a.alloc(u8, seen.count()); + var it = seen.keyIterator(); + var i: usize = 0; + while (it.next()) |k| : (i += 1) out[i] = k.*; + std.mem.sort(u8, out, {}, comptime std.sort.desc(u8)); + return out; +} + +// The band floor governing zoom `z`: the largest floor ≤ z, or the coarsest if z is +// below every floor (so zooming out never falls off the bottom). +fn governingTier(floors_desc: []const u8, z: u8) u8 { + for (floors_desc) |t| if (t <= z) return t; + return floors_desc[floors_desc.len - 1]; +} + +/// The s57-coverage → geo.plane.Cell adapter: widen each cell's M_COVR to integer +/// points, set the band floor (bandOf), and assign the deterministic DSID order (same +/// tie-break as the bake) across the whole set. Arena-allocated in `a`. THE single +/// conversion path; the partition-debug bake and the compositor both use it. +fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]engine.geo.plane.Cell { + const geo = engine.geo; + const n = loaded.len; + const rank = try a.alloc(usize, n); + for (rank, 0..) |*v, i| v.* = i; + std.mem.sort(usize, rank, loaded, struct { + fn lt(ls: []const LoadedCov, x: usize, y: usize) bool { + return engine.bake_enc.ordersBeforeKeys(ls[x].date, ls[x].name, ls[y].date, ls[y].name); + } + }.lt); + const order = try a.alloc(u64, n); + for (rank, 0..) |ci, r| order[ci] = r; + + const cells = try a.alloc(geo.plane.Cell, n); + for (loaded, 0..) |lc, i| { + const out = try a.alloc(geo.plane.Poly, lc.coverage.len); + for (lc.coverage, 0..) |feat, fi| { + const rings = try a.alloc([]const geo.plane.Pt, feat.len); + for (feat, 0..) |ring, ri| { + const pts = try a.alloc(geo.plane.Pt, ring.len); + for (ring, 0..) |p, pi| pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; + rings[ri] = pts; + } + out[fi] = rings; + } + cells[i] = .{ + .cscl = lc.cscl, + .band_floor = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(lc.cscl)).min, + .order = order[i], + .cov1 = out, + }; + } + return cells; +} + +/// Project + clip one tier's faces into the tiles of zoom `z` and stream them to `sw` +/// (layer "partition"). `sa` is a per-zoom arena the caller resets, so only one zoom's +/// tiles are resident. Reuses tile.project/clipPolygon + scene.orientAreaRings (the MVT +/// winding authority) + mvt.encode. +fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: []const engine.geo.plane.OwnedCell, loaded: []const LoadedCov, z: u8, tier: u8) !void { + const mvt = engine.mvt; + const tile = engine.tile; + const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + const TileKey = struct { x: u32, y: u32 }; + var tilemap = std.AutoHashMap(TileKey, std.ArrayList(mvt.Feature)).init(sa); + + for (faces) |face| { + if (face.owned.len == 0) continue; + const lc = loaded[face.index]; + + var fb = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; + for (face.owned) |ring| for (ring) |p| { + const lon = @as(f64, @floatFromInt(p.x)) / 1e7; + const lat = @as(f64, @floatFromInt(p.y)) / 1e7; + fb[0] = @min(fb[0], lon); + fb[1] = @min(fb[1], lat); + fb[2] = @max(fb[2], lon); + fb[3] = @max(fb[3], lat); + }; + const w_tl = tile.lonLatToWorld(fb[0], fb[3]); + const w_br = tile.lonLatToWorld(fb[2], fb[1]); + const tx0 = worldAxisToTile(w_tl[0], scale); + const tx1 = worldAxisToTile(w_br[0], scale); + const ty0 = worldAxisToTile(w_tl[1], scale); + const ty1 = worldAxisToTile(w_br[1], scale); + + const props = try sa.dupe(mvt.Prop, &.{ + .{ .key = "cell", .value = .{ .string = lc.name } }, + .{ .key = "cscl", .value = .{ .int = lc.cscl } }, + .{ .key = "band", .value = .{ .int = @intFromEnum(engine.bake_enc.bandOf(lc.cscl)) } }, + .{ .key = "tier", .value = .{ .int = tier } }, + .{ .key = "oi", .value = .{ .int = @intCast(face.index) } }, + .{ .key = "color", .value = .{ .string = DEBUG_PALETTE[face.index % DEBUG_PALETTE.len] } }, + }); + + var tx = tx0; + while (tx <= tx1) : (tx += 1) { + var ty = ty0; + while (ty <= ty1) : (ty += 1) { + var parts = std.ArrayList([]const mvt.Point).empty; + for (face.owned) |ring| { + const proj = try sa.alloc(mvt.Point, ring.len); + for (ring, 0..) |p, k| proj[k] = tile.project(@as(f64, @floatFromInt(p.x)) / 1e7, @as(f64, @floatFromInt(p.y)) / 1e7, z, tx, ty, tile.EXTENT); + const clipped = try tile.clipPolygon(sa, proj, tile.Box.default(tile.EXTENT, 256)); + if (clipped.len >= 3) try parts.append(sa, clipped); + } + if (parts.items.len == 0) continue; + const oriented = try engine.scene.orientAreaRings(sa, parts.items); + const gop = try tilemap.getOrPut(.{ .x = tx, .y = ty }); + if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(mvt.Feature).empty; + try gop.value_ptr.append(sa, .{ .geom_type = .polygon, .parts = oriented, .properties = props }); + } + } + } + + var it = tilemap.iterator(); + while (it.next()) |kv| { + const layers = [_]mvt.Layer{.{ .name = "partition", .features = kv.value_ptr.items }}; + const enc = try mvt.encode(sa, .{ .layers = &layers }); + try sw.add(z, kv.key_ptr.x, kv.key_ptr.y, enc); + } } // A normalised web-mercator world axis coordinate ([0,1]) → tile index at `scale` diff --git a/tools/bake.zig b/tools/bake.zig index 2a25b90..e357819 100644 --- a/tools/bake.zig +++ b/tools/bake.zig @@ -32,6 +32,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var format: engine.scene.TileFormat = .mlt; // tile encoding: mlt (default) or mvt var existing: []const u8 = ""; // cross-pack best-available: a peer pack's ENC_ROOT var partition_debug = false; // emit the ownership-partition debug PMTiles instead of a bundle + var part_band: i8 = -1; // partition-debug: <0 = band governing each zoom; 0..5 = one band's map var f = Flags{ .args = args }; while (f.next()) |arg| { @@ -58,6 +59,9 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { format = if (std.mem.eql(u8, v, "mlt")) .mlt else if (std.mem.eql(u8, v, "mvt")) .mvt else return usageErr("--format must be mvt or mlt"); } else if (std.mem.eql(u8, arg, "--partition-debug")) { partition_debug = true; + } else if (std.mem.eql(u8, arg, "--band")) { + const v = f.val(arg) orelse return; + part_band = bandArg(v) orelse return usageErr("--band must be a rank 0..5 or a name (berthing..overview)"); } else if (std.mem.startsWith(u8, arg, "-")) { return usageErr("unknown flag"); } else if (base == null) { @@ -80,11 +84,11 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // face-tile count grows ~4^z, so raise --maxzoom on demand). const dbg_minz: u8 = if (minzoom == DEFAULT_MINZOOM) 0 else minzoom; const dbg_maxz: u8 = if (maxzoom == DEFAULT_MAXZOOM) 12 else maxzoom; - const nc = bundle.bakePartitionDebug(io, a, base_path, out_dir, dbg_minz, dbg_maxz) catch |err| { + const nc = bundle.bakePartitionDebug(io, a, base_path, out_dir, dbg_minz, dbg_maxz, part_band) catch |err| { std.debug.print("error: partition-debug bake of {s} failed ({s})\n", .{ base_path, @errorName(err) }); return; }; - std.debug.print("partition-debug: {d} cell(s) -> {s} (z{d}-{d}, layer \"partition\": cell/cscl/band/tier/oi/color)\n", .{ nc, out_dir, dbg_minz, dbg_maxz }); + std.debug.print("partition-debug: {d} cell(s) -> {s} (z{d}-{d}, band {d}, layer \"partition\": cell/cscl/band/tier/oi/color)\n", .{ nc, out_dir, dbg_minz, dbg_maxz, part_band }); return; } @@ -114,3 +118,11 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { .{ res.cell_count, out_dir, assets.SCHEMA_VERSION }, ); } + +// Parse a --band value: a rank 0..5, or a navigational-purpose name (finest→coarsest). +fn bandArg(v: []const u8) ?i8 { + const names = [_][]const u8{ "berthing", "harbor", "approach", "coastal", "general", "overview" }; + for (names, 0..) |nm, i| if (std.mem.eql(u8, v, nm)) return @intCast(i); + const n = std.fmt.parseInt(i8, v, 10) catch return null; + return if (n < 0 or n > 5) null else n; +} From cd27fb14c43a66e55a9386134512a3176de5b86f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 12:29:38 -0400 Subject: [PATCH 011/140] feat(capi): tile57_bake_partition_debug + Go BakePartitionDebug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bindings/go/bake.go | 50 +++++++++++++++++++++++++++++++++++ bindings/go/partition_test.go | 42 +++++++++++++++++++++++++++++ include/tile57.h | 12 +++++++++ src/capi.zig | 25 ++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 bindings/go/partition_test.go diff --git a/bindings/go/bake.go b/bindings/go/bake.go index 15a101e..9899e88 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -166,3 +166,53 @@ func BakeBundle(input, outDir string, opts BakeOpts, progress func(BakeProgress) return 0, bbox, fmt.Errorf("tile57: bundle bake failed") } } + +// PartitionBand selects which ownership-partition map [BakePartitionDebug] emits. +type PartitionBand int8 + +const ( + // BandGoverning emits, at each zoom, the partition of the band that governs it — + // the natural view (coarse bands zoomed out, finer bands zoomed in). + BandGoverning PartitionBand = -1 + // The six navigational-purpose bands, finest→coarsest: each emits ITS OWN + // partition map at every zoom (e.g. BandHarbor is the finest quilt). + BandBerthing PartitionBand = 0 + BandHarbor PartitionBand = 1 + BandApproach PartitionBand = 2 + BandCoastal PartitionBand = 3 + BandGeneral PartitionBand = 4 + BandOverview PartitionBand = 5 +) + +// BakePartitionDebug bakes the ownership-partition DEBUG tiles from an on-disk +// ENC_ROOT into a single PMTiles at outPath — the composited faces (which cell renders +// which ground at each band), one polygon per owning cell in a layer named "partition" +// with the properties cell/cscl/band/tier/oi/color, and NO portrayed chart content. It +// is the raw material for a partition-debug UI: point a MapLibre style (using the +// vector_layers metadata) at it and fill by the "color" property. +// +// band = [BandGoverning] emits the band governing each zoom (the natural view); +// [BandBerthing]…[BandOverview] emit that one band's own map at every zoom. minZoom and +// maxZoom bound the tiles — the coarse bands are cheap, but harbor-level detail (maxZoom +// >= 13) multiplies the tile count ~4× per zoom, so raise it deliberately. Returns the +// cell count. +func BakePartitionDebug(encRoot, outPath string, minZoom, maxZoom uint8, band PartitionBand) (cellCount int, err error) { + if encRoot == "" || outPath == "" { + return 0, fmt.Errorf("tile57: BakePartitionDebug needs an ENC root and out path: %w", ErrEmptyInput) + } + cRoot := C.CString(encRoot) + defer C.free(unsafe.Pointer(cRoot)) + cOut := C.CString(outPath) + defer C.free(unsafe.Pointer(cOut)) + + var cells C.uint32_t + rc := C.tile57_bake_partition_debug(cRoot, cOut, C.uint8_t(minZoom), C.uint8_t(maxZoom), C.int8_t(band), &cells) + switch rc { + case 1: + return int(cells), nil + case 0: + return 0, fmt.Errorf("tile57: partition-debug bake covered nothing: %w", ErrNoCoverage) + default: + return 0, fmt.Errorf("tile57: partition-debug bake failed") + } +} diff --git a/bindings/go/partition_test.go b/bindings/go/partition_test.go new file mode 100644 index 0000000..29af2ac --- /dev/null +++ b/bindings/go/partition_test.go @@ -0,0 +1,42 @@ +//go:build cgo + +package tile57 + +import ( + "os" + "path/filepath" + "testing" +) + +func TestBakePartitionDebug(t *testing.T) { + if _, err := os.Stat(testCell); err != nil { + t.Skipf("no test cell: %v", err) + } + // testCell lives in testdata/, so that directory is a one-cell ENC_ROOT. + out := filepath.Join(t.TempDir(), "partition.pmtiles") + n, err := BakePartitionDebug("testdata", out, 0, 9, BandGoverning) + if err != nil { + t.Fatal(err) + } + if n == 0 { + t.Fatal("baked 0 cells") + } + fi, err := os.Stat(out) + if err != nil || fi.Size() == 0 { + t.Fatalf("partition pmtiles missing/empty: %v", err) + } + b, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + if len(b) < 7 || string(b[:7]) != "PMTiles" { + t.Fatalf("output is not a PMTiles archive (first bytes %q)", b[:min(7, len(b))]) + } + + // A single band's own map bakes too (harbor = the finest partition). + outBand := filepath.Join(t.TempDir(), "harbor.pmtiles") + if _, err := BakePartitionDebug("testdata", outBand, 0, 11, BandHarbor); err != nil { + t.Fatalf("per-band bake failed: %v", err) + } + t.Logf("partition-debug: %d cell(s) -> %d bytes", n, fi.Size()) +} diff --git a/include/tile57.h b/include/tile57.h index eb21460..917f798 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -214,6 +214,18 @@ int tile57_bake_bundle(const char *input, const char *out_dir, const tile57_bake_opts *opts, uint32_t *out_cell_count, double *out_bbox); +/* Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) into a + * single PMTiles at out_path: the composited ownership faces (which cell renders which + * ground at each band), one polygon per owning cell tagged with the properties + * cell/cscl/band/tier/oi/color, and NO portrayed chart content — for building a + * partition-debug UI. band < 0 emits the band GOVERNING each zoom (the natural view); + * 0..5 (berthing..overview) emits one band's own map at every zoom. minzoom/maxzoom + * bound the tiles (harbor-level detail needs maxzoom >= 13; coarser bands are much + * cheaper). out_cell_count is optional. Returns 1=ok, 0=nothing covered, -1=error. */ +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); + /* All portrayal assets in memory (the same files bake_bundle writes to disk), from the * library's embedded catalogue (catalog_dir NULL/"") or an on-disk one. Pairs with * tile57_bake_pmtiles + tile57_build_style for a full in-memory bundle. Returns 1 with diff --git a/src/capi.zig b/src/capi.zig index a05f19a..b4d8da2 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -349,6 +349,31 @@ export fn tile57_bake_bundle( return 1; } +/// Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) into a +/// single PMTiles at out_path: the composited faces (which cell renders which ground +/// at each band), tagged cell/cscl/band/tier/oi/color, NO portrayed content — for a +/// partition-debug UI. `band` < 0 = the band governing each zoom (the natural view); +/// 0..5 (berthing..overview) = one band's own map at every zoom. out_cell_count is +/// optional. 1=ok, 0=nothing covered (no M_COVR), -1=error. See tile57.h. +export fn tile57_bake_partition_debug( + enc_root: [*:0]const u8, + out_path: [*:0]const u8, + minzoom: u8, + maxzoom: u8, + band: i8, + out_cell_count: ?*u32, +) callconv(.c) c_int { + // The debug bake does filesystem I/O (read ENC_ROOT, write the pmtiles); the lib + // has no std.process.Init, so stand up a threaded std.Io for the call. It streams + // internally (StreamWriter over gpa), so pass the real gpa, not a scratch arena. + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const nc = bundle.bakePartitionDebug(io, gpa, std.mem.span(enc_root), std.mem.span(out_path), minzoom, maxzoom, band) catch |err| return if (err == error.NoGeometry) 0 else -1; + if (out_cell_count) |p| p.* = @intCast(nc); + return 1; +} + /// Release a chart and all cached tiles. See tile57.h. export fn tile57_chart_close(handle: ?*Chart) callconv(.c) void { if (handle) |s| s.deinit(); From 515dfd24043d18731b593e6b3f26a71dde786953 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 12:37:41 -0400 Subject: [PATCH 012/140] =?UTF-8?q?feat(bake):=20partition-debug=20"labels?= =?UTF-8?q?"=20layer=20=E2=80=94=20cell=20name=20at=20each=20face?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bundle.zig | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index 980664c..69a44fe 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -606,7 +606,7 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const const bytes = try sw.finishBytes(.{ // MapLibre/pmtiles.io build their style from `vector_layers`; without it the // "partition" layer is invisible to a viewer no matter the geometry. - .metadata_json = "{\"name\":\"partition-debug\",\"format\":\"pbf\",\"vector_layers\":[{\"id\":\"partition\",\"fields\":{\"cell\":\"String\",\"cscl\":\"Number\",\"band\":\"Number\",\"tier\":\"Number\",\"oi\":\"Number\",\"color\":\"String\"}}]}", + .metadata_json = "{\"name\":\"partition-debug\",\"format\":\"pbf\",\"vector_layers\":[{\"id\":\"partition\",\"fields\":{\"cell\":\"String\",\"cscl\":\"Number\",\"band\":\"Number\",\"tier\":\"Number\",\"oi\":\"Number\",\"color\":\"String\"}},{\"id\":\"labels\",\"fields\":{\"cell\":\"String\"}}]}", .tile_type = .mvt, .min_lon_e7 = ubox[0], .min_lat_e7 = ubox[1], @@ -691,7 +691,8 @@ fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: const tile = engine.tile; const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); const TileKey = struct { x: u32, y: u32 }; - var tilemap = std.AutoHashMap(TileKey, std.ArrayList(mvt.Feature)).init(sa); + const TileFeat = struct { polys: std.ArrayList(mvt.Feature), labels: std.ArrayList(mvt.Feature) }; + var tilemap = std.AutoHashMap(TileKey, TileFeat).init(sa); for (faces) |face| { if (face.owned.len == 0) continue; @@ -736,16 +737,35 @@ fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: if (parts.items.len == 0) continue; const oriented = try engine.scene.orientAreaRings(sa, parts.items); const gop = try tilemap.getOrPut(.{ .x = tx, .y = ty }); - if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(mvt.Feature).empty; - try gop.value_ptr.append(sa, .{ .geom_type = .polygon, .parts = oriented, .properties = props }); + if (!gop.found_existing) gop.value_ptr.* = .{ .polys = .empty, .labels = .empty }; + try gop.value_ptr.polys.append(sa, .{ .geom_type = .polygon, .parts = oriented, .properties = props }); + + // Label this face's part IN THIS tile, at the centre of its clipped + // extent, so the owning cell name is readable wherever the face shows + // (layer "labels"). MapLibre collision-dedups the repeats across tiles. + var lb = [4]i32{ std.math.maxInt(i32), std.math.maxInt(i32), std.math.minInt(i32), std.math.minInt(i32) }; + for (oriented) |ring| for (ring) |p| { + lb[0] = @min(lb[0], p.x); + lb[1] = @min(lb[1], p.y); + lb[2] = @max(lb[2], p.x); + lb[3] = @max(lb[3], p.y); + }; + const lpt = try sa.alloc(mvt.Point, 1); + lpt[0] = .{ .x = @divTrunc(lb[0] + lb[2], 2), .y = @divTrunc(lb[1] + lb[3], 2) }; + const lparts = try sa.alloc([]const mvt.Point, 1); + lparts[0] = lpt; + try gop.value_ptr.labels.append(sa, .{ .geom_type = .point, .parts = lparts, .properties = try sa.dupe(mvt.Prop, &.{.{ .key = "cell", .value = .{ .string = lc.name } }}) }); } } } var it = tilemap.iterator(); while (it.next()) |kv| { - const layers = [_]mvt.Layer{.{ .name = "partition", .features = kv.value_ptr.items }}; - const enc = try mvt.encode(sa, .{ .layers = &layers }); + var layers = std.ArrayList(mvt.Layer).empty; + if (kv.value_ptr.polys.items.len > 0) try layers.append(sa, .{ .name = "partition", .features = kv.value_ptr.polys.items }); + if (kv.value_ptr.labels.items.len > 0) try layers.append(sa, .{ .name = "labels", .features = kv.value_ptr.labels.items }); + if (layers.items.len == 0) continue; + const enc = try mvt.encode(sa, .{ .layers = layers.items }); try sw.add(z, kv.key_ptr.x, kv.key_ptr.y, enc); } } From 752c718a1aaf4dbbb2e4d523ae73041d7e445a94 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 13:07:18 -0400 Subject: [PATCH 013/140] =?UTF-8?q?feat(geo):=20clipLineInsidePolys=20?= =?UTF-8?q?=E2=80=94=20clip-to-face=20line=20primitive=20for=20the=20compo?= =?UTF-8?q?sitor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/geo/plane.zig | 49 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/geo/plane.zig b/src/geo/plane.zig index 77f469a..0b6bced 100644 --- a/src/geo/plane.zig +++ b/src/geo/plane.zig @@ -232,14 +232,12 @@ fn pointInEvenOddF(rings: Poly, x: f64, y: f64) bool { return inside; } -/// Keep the parts of `line` that lie OUTSIDE `covered`. Each segment is split at -/// its integer crossings with every covered edge; a sub-run survives iff its -/// midpoint is outside `covered` (the finer cell owns the covered side and the -/// seam stroke). Returns a list of poly-lines allocated in `gpa`. -/// -/// This is the line analogue of the area difference — no polygon boolean, so a -/// coarse coastline inside finer coverage is dropped whole (not offset-doubled). -pub fn clipLineOutsidePolys(gpa: Allocator, line: []const Pt, covered: Poly) ![][]Pt { +/// Split `line` at every integer crossing with `covered`'s edges and keep each +/// sub-run whose midpoint is on the wanted side — INSIDE `covered` when `keep_inside`, +/// else OUTSIDE. Returns a list of poly-lines allocated in `gpa`. The line analogue of +/// a polygon intersect/difference: no polygon boolean, so a line crossing the boundary +/// is cut cleanly, not offset-doubled. +fn clipLineByCoverage(gpa: Allocator, line: []const Pt, covered: Poly, keep_inside: bool) ![][]Pt { var out = std.ArrayList([]Pt).empty; errdefer { for (out.items) |r| gpa.free(r); @@ -283,8 +281,8 @@ pub fn clipLineOutsidePolys(gpa: Allocator, line: []const Pt, covered: Poly) ![] } std.mem.sort(SplitPt, splits.items, {}, SplitPt.lt); - // Walk consecutive distinct split points; keep sub-runs whose midpoint is - // outside covered. + // Walk consecutive distinct split points; keep sub-runs whose midpoint is on + // the wanted side of `covered`. var prev = splits.items[0].p; if (run.items.len == 0) try run.append(gpa, prev); for (splits.items[1..]) |sp| { @@ -292,8 +290,8 @@ pub fn clipLineOutsidePolys(gpa: Allocator, line: []const Pt, covered: Poly) ![] if (q.eql(prev)) continue; const mx = (@as(f64, @floatFromInt(prev.x)) + @as(f64, @floatFromInt(q.x))) / 2; const my = (@as(f64, @floatFromInt(prev.y)) + @as(f64, @floatFromInt(q.y))) / 2; - if (pointInEvenOddF(covered, mx, my)) { - // Sub-run is covered: flush the kept run (it ends at prev) and skip. + if (pointInEvenOddF(covered, mx, my) != keep_inside) { + // Sub-run is on the unwanted side: flush the kept run (ends at prev), skip. try flushRun(gpa, &out, &run); try run.append(gpa, q); } else { @@ -306,6 +304,18 @@ pub fn clipLineOutsidePolys(gpa: Allocator, line: []const Pt, covered: Poly) ![] return out.toOwnedSlice(gpa); } +/// Keep the parts of `line` OUTSIDE `covered` — the parts a cell owns when `covered` +/// is the finer coverage that beats it (the seam stroke stays on the covered side). +pub fn clipLineOutsidePolys(gpa: Allocator, line: []const Pt, covered: Poly) ![][]Pt { + return clipLineByCoverage(gpa, line, covered, false); +} + +/// Keep the parts of `line` INSIDE `covered` — clips a cell's line features to its +/// owned face at compose time (`covered` = the cell's owned region). +pub fn clipLineInsidePolys(gpa: Allocator, line: []const Pt, covered: Poly) ![][]Pt { + return clipLineByCoverage(gpa, line, covered, true); +} + const SplitPt = struct { key: i128, p: Pt, @@ -681,6 +691,21 @@ test "clipLineOutsidePolys: fully-covered line yields nothing; fully-outside lin try testing.expectEqual(@as(usize, 3), out_runs[0].len); } +test "clipLineInsidePolys: keeps the inside, drops the outside (compose clip-to-face)" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const covered = try boxPoly(a, 40, -10, 60, 10); // covers x∈[40,60] on the y=0 line + const line = [_]Pt{ .{ .x = 0, .y = 0 }, .{ .x = 100, .y = 0 } }; + const runs = try clipLineInsidePolys(a, &line, covered); + // Complement of the OUTSIDE clip: keep only the covered middle [40,60]. + try testing.expectEqual(@as(usize, 1), runs.len); + const r = runs[0]; + try testing.expectEqual(@as(i64, 40), @min(r[0].x, r[r.len - 1].x)); + try testing.expectEqual(@as(i64, 60), @max(r[0].x, r[r.len - 1].x)); +} + // Clip every coverage feature of a cell to `box` (a quadrant), dropping empties — // the operation the quadrant-partitioned Stage 0 applies before computing a // quadrant's partition locally. From d2b131145c06fccd95e89671bf0b2181b5c9229b Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 13:15:48 -0400 Subject: [PATCH 014/140] =?UTF-8?q?feat(scene):=20compose=20core=20?= =?UTF-8?q?=E2=80=94=20clipFeatureToFace=20+=20status=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.zig | 9 +++ src/scene/compose.zig | 179 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/scene/compose.zig diff --git a/build.zig b/build.zig index d65d018..c63dbe6 100644 --- a/build.zig +++ b/build.zig @@ -561,6 +561,15 @@ pub fn build(b: *std.Build) void { .{ .name = "s57", .module = s57_mod }, .{ .name = "geo", .module = geo_mod }, }); + // Compose core (clip-to-face): pure over mvt + geo. Its own step for fast iteration, + // and part of the main suite. + const compose_deps = [_]std.Build.Module.Import{ + .{ .name = "tiles", .module = tiles_mod }, + .{ .name = "geo", .module = geo_mod }, + }; + const compose_step = b.step("compose-test", "Run the compose-core (clip-to-face) tests"); + _ = addPkgTest(b, compose_step, "src/scene/compose.zig", target, optimize, &compose_deps); + _ = addPkgTest(b, test_step, "src/scene/compose.zig", target, optimize, &compose_deps); // The render module: Surface contract + noop lifecycle smoke test (pins // the contract), resolver gates/colors, Canvas + RasterCanvas + PNG + // PixelSurface. diff --git a/src/scene/compose.zig b/src/scene/compose.zig new file mode 100644 index 0000000..b59372a --- /dev/null +++ b/src/scene/compose.zig @@ -0,0 +1,179 @@ +//! Compose core (Stage 2 of the per-cell-composite bake): clip ONE cell's decoded tile +//! features to the ground it OWNS, so the compositor can merge many cells' clipped tiles +//! into one composed tile with no double-draw at a seam. +//! +//! `face` is the cell's owned rings (from `partition.ownedFace`) projected to THIS tile's +//! pixel space, i64. Areas are intersected with the face, lines clipped to inside it, +//! points kept iff their node is inside. Reuses the geometry primitives — `boolean.compute` +//! (.intersect), `plane.clipLineInsidePolys`, `boolean.pointInEvenOdd` — so there is no new +//! set algebra here, only the mvt↔integer adapters and the per-geometry-type dispatch. + +const std = @import("std"); +const mvt = @import("tiles").mvt; +const geo = @import("geo"); +const boolean = geo.boolean; +const plane = geo.plane; + +const Pt = boolean.Pt; + +fn widenRing(a: std.mem.Allocator, ring: []const mvt.Point) ![]Pt { + const out = try a.alloc(Pt, ring.len); + for (ring, 0..) |p, i| out[i] = .{ .x = p.x, .y = p.y }; + return out; +} + +fn narrowRing(a: std.mem.Allocator, ring: []const Pt) ![]mvt.Point { + const out = try a.alloc(mvt.Point, ring.len); + for (ring, 0..) |p, i| out[i] = .{ .x = @intCast(p.x), .y = @intCast(p.y) }; + return out; +} + +/// Clip `feat` (tile-pixel space) to `face` (the cell's owned rings, tile-pixel space) and +/// append the surviving feature(s) to `out`, or nothing if the feature is entirely outside +/// the face. Geometry is freshly allocated in `a`; `properties` are borrowed from `feat`. +/// `face` must be a simple even-odd ring-set (as `partition.ownedFace` emits). +pub fn clipFeatureToFace(a: std.mem.Allocator, out: *std.ArrayList(mvt.Feature), feat: mvt.DecodedFeature, face: []const []const Pt) !void { + switch (feat.geom_type) { + .polygon => { + const poly = try a.alloc([]const Pt, feat.parts.len); + for (feat.parts, 0..) |ring, i| poly[i] = try widenRing(a, ring); + const clipped = try boolean.compute(a, poly, face, .intersect); + if (clipped.len == 0) return; + const parts = try a.alloc([]const mvt.Point, clipped.len); + for (clipped, 0..) |ring, i| parts[i] = try narrowRing(a, ring); + try out.append(a, .{ .geom_type = .polygon, .parts = parts, .properties = feat.properties }); + }, + .linestring => { + var parts = std.ArrayList([]const mvt.Point).empty; + for (feat.parts) |part| { + const runs = try plane.clipLineInsidePolys(a, try widenRing(a, part), face); + for (runs) |run| { + if (run.len >= 2) try parts.append(a, try narrowRing(a, run)); + } + } + if (parts.items.len == 0) return; + try out.append(a, .{ .geom_type = .linestring, .parts = try parts.toOwnedSlice(a), .properties = feat.properties }); + }, + .point => { + // MVT points: one part per point. Keep the parts whose node is inside the face. + var parts = std.ArrayList([]const mvt.Point).empty; + for (feat.parts) |part| { + if (part.len == 0) continue; + if (boolean.pointInEvenOdd(face, part[0].x, part[0].y)) { + try parts.append(a, try a.dupe(mvt.Point, part)); + } + } + if (parts.items.len == 0) return; + try out.append(a, .{ .geom_type = .point, .parts = try parts.toOwnedSlice(a), .properties = feat.properties }); + }, + .unknown => {}, + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +const testing = std.testing; + +fn boxRing(a: std.mem.Allocator, x0: i32, y0: i32, x1: i32, y1: i32) ![]mvt.Point { + const r = try a.alloc(mvt.Point, 4); + r[0] = .{ .x = x0, .y = y0 }; + r[1] = .{ .x = x1, .y = y0 }; + r[2] = .{ .x = x1, .y = y1 }; + r[3] = .{ .x = x0, .y = y1 }; + return r; +} + +// One decoded feature over the given parts (DecodedFeature.parts is mutable [][]Point). +fn decoded(a: std.mem.Allocator, gt: mvt.GeomType, parts: []const []mvt.Point) !mvt.DecodedFeature { + const p = try a.alloc([]mvt.Point, parts.len); + for (parts, 0..) |part, i| p[i] = part; + return .{ .geom_type = gt, .parts = p, .properties = &.{} }; +} + +fn boxFace(a: std.mem.Allocator, x0: i64, y0: i64, x1: i64, y1: i64) ![]const []const Pt { + const r = try a.alloc(Pt, 4); + r[0] = .{ .x = x0, .y = y0 }; + r[1] = .{ .x = x1, .y = y0 }; + r[2] = .{ .x = x1, .y = y1 }; + r[3] = .{ .x = x0, .y = y1 }; + const rings = try a.alloc([]const Pt, 1); + rings[0] = r; + return rings; +} + +test "clipFeatureToFace: area is intersected with the face" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // A whole-tile area, clipped to the face box [1000,1000]..[3000,3000]. + const ring = try boxRing(a, 0, 0, 4096, 4096); + const feat = try decoded(a, .polygon, &.{ring}); + const face = try boxFace(a, 1000, 1000, 3000, 3000); + + var out = std.ArrayList(mvt.Feature).empty; + try clipFeatureToFace(a, &out, feat, face); + try testing.expectEqual(@as(usize, 1), out.items.len); + + // Widen the output back and check: (2000,2000) inside, (500,500) outside. + const of = out.items[0]; + const wrings = try a.alloc([]const Pt, of.parts.len); + for (of.parts, 0..) |r, i| wrings[i] = try widenRing(a, r); + try testing.expect(boolean.pointInEvenOdd(wrings, 2000, 2000)); + try testing.expect(!boolean.pointInEvenOdd(wrings, 500, 500)); +} + +test "clipFeatureToFace: point kept iff inside the face" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const inside = try a.alloc(mvt.Point, 1); + inside[0] = .{ .x = 2000, .y = 2000 }; + const outside = try a.alloc(mvt.Point, 1); + outside[0] = .{ .x = 500, .y = 500 }; + const feat = try decoded(a, .point, &.{ inside, outside }); + const face = try boxFace(a, 1000, 1000, 3000, 3000); + + var out = std.ArrayList(mvt.Feature).empty; + try clipFeatureToFace(a, &out, feat, face); + try testing.expectEqual(@as(usize, 1), out.items.len); // the feature survives + try testing.expectEqual(@as(usize, 1), out.items[0].parts.len); // only the inside point + try testing.expectEqual(@as(i32, 2000), out.items[0].parts[0][0].x); +} + +test "clipFeatureToFace: line clipped to the part inside the face" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // A horizontal line across the tile at y=2000, clipped to face x∈[1000,3000]. + const line = try a.alloc(mvt.Point, 2); + line[0] = .{ .x = 0, .y = 2000 }; + line[1] = .{ .x = 4096, .y = 2000 }; + const feat = try decoded(a, .linestring, &.{line}); + const face = try boxFace(a, 1000, 1000, 3000, 3000); + + var out = std.ArrayList(mvt.Feature).empty; + try clipFeatureToFace(a, &out, feat, face); + try testing.expectEqual(@as(usize, 1), out.items.len); + const kept = out.items[0].parts[0]; + try testing.expectEqual(@as(i32, 1000), @min(kept[0].x, kept[kept.len - 1].x)); + try testing.expectEqual(@as(i32, 3000), @max(kept[0].x, kept[kept.len - 1].x)); +} + +test "clipFeatureToFace: feature entirely outside the face is dropped" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const ring = try boxRing(a, 0, 0, 500, 500); + const feat = try decoded(a, .polygon, &.{ring}); + const face = try boxFace(a, 1000, 1000, 3000, 3000); + + var out = std.ArrayList(mvt.Feature).empty; + try clipFeatureToFace(a, &out, feat, face); + try testing.expectEqual(@as(usize, 0), out.items.len); +} From 58e189f130ac28c06079a7bfdbfe5c89e5e1fba9 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 13:40:42 -0400 Subject: [PATCH 015/140] =?UTF-8?q?feat(scene):=20coverage.zig=20=E2=80=94?= =?UTF-8?q?=20per-cell=20coverage=20sidecar=20(JSON=20in=20PMTiles=20metad?= =?UTF-8?q?ata)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.zig | 5 + src/scene/coverage.zig | 217 +++++++++++++++++++++++++++++++++++++++++ src/scene/scene.zig | 1 + 3 files changed, 223 insertions(+) create mode 100644 src/scene/coverage.zig diff --git a/build.zig b/build.zig index c63dbe6..7fbacaa 100644 --- a/build.zig +++ b/build.zig @@ -570,6 +570,11 @@ pub fn build(b: *std.Build) void { const compose_step = b.step("compose-test", "Run the compose-core (clip-to-face) tests"); _ = addPkgTest(b, compose_step, "src/scene/compose.zig", target, optimize, &compose_deps); _ = addPkgTest(b, test_step, "src/scene/compose.zig", target, optimize, &compose_deps); + // Per-cell coverage sidecar (JSON round-trip carried in PMTiles metadata): pure + // over s57 + std.json. Part of the main suite. + _ = addPkgTest(b, test_step, "src/scene/coverage.zig", target, optimize, &.{ + .{ .name = "s57", .module = s57_mod }, + }); // The render module: Surface contract + noop lifecycle smoke test (pins // the contract), resolver gates/colors, Canvas + RasterCanvas + PNG + // PixelSurface. diff --git a/src/scene/coverage.zig b/src/scene/coverage.zig new file mode 100644 index 0000000..3169a27 --- /dev/null +++ b/src/scene/coverage.zig @@ -0,0 +1,217 @@ +//! Per-cell coverage carried IN the per-cell PMTiles metadata JSON. The +//! per-cell-composite bake writes one PMTiles per cell whose metadata embeds that +//! cell's M_COVR coverage + compilation scale + identity, so the compositor can +//! rebuild the ownership partition (geo.partition) from the baked archives WITHOUT +//! re-parsing the .000. This round-trips exactly the per-cell facts the partition +//! adapter consumes: coverage rings, cscl, and the (date, name) tie-break keys. +//! +//! Coordinates are S-57's native integer lon/lat (degrees × 1e7), stored VERBATIM as +//! JSON integer pairs `[lon_e7, lat_e7]` — never through f64 — so adjacent cells that +//! digitised a shared border independently round to the same integers and the boolean +//! seam-dissolve stays exact. +//! +//! `band` is a convenience field derived from cscl (bandOf); a consumer re-derives it +//! for correctness rather than trusting the stored value. `cov2` (CATCOV=2 no-data) is +//! a reserved slot, empty today — the s57 side captures only CATCOV=1. + +const std = @import("std"); +const s57 = @import("s57"); + +/// One cell's coverage + identity — everything the ownership partition needs to place +/// this cell without re-parsing. Ring geometry is `[feature][ring][point]`, matching +/// `s57.Cell.mcovrCoverage`, so a decoded value plugs straight into the partition +/// adapter. Slices are owned by whoever built or decoded the value. +pub const CellCoverage = struct { + name: []const u8, // file basename stem (verbatim — the ownership tie-break name) + date: []const u8, // post-update DSID ISDT (YYYYMMDD), or "" if absent + cscl: i32, // compilation scale 1:N (0 = unknown) + band: u8, // derived bandOf(cscl); convenience/debug only + bbox: [4]i32, // [w, s, e, n] integer lon/lat (lon_e7/lat_e7) + cov1: []const []const []const s57.LonLat, // M_COVR CATCOV=1: [feature][ring][point] + cov2: []const []const []const s57.LonLat = &.{}, // reserved (CATCOV=2 no-data) +}; + +/// Build a CellCoverage from a parsed cell. `name` is the caller's identity string +/// (the file basename stem, matching the coverage loader / ownership oracle) and +/// `band` the caller's `bake_enc.bandOf(cscl)` tag; both keep this module free of the +/// band + loader policy. Coverage rings are allocated in `a`; `name`/`date` are +/// borrowed (encode before the cell is freed). +pub fn fromCell(a: std.mem.Allocator, cell: *const s57.Cell, name: []const u8, band: u8) CellCoverage { + const cov1 = cell.mcovrCoverage(a); + return .{ + .name = name, + .date = cell.dsid.isdt, + .cscl = cell.params.cscl, + .band = band, + .bbox = bboxOf(cov1), + .cov1 = cov1, + }; +} + +fn bboxOf(polys: []const []const []const s57.LonLat) [4]i32 { + var b = [4]i32{ std.math.maxInt(i32), std.math.maxInt(i32), std.math.minInt(i32), std.math.minInt(i32) }; + var any = false; + for (polys) |poly| for (poly) |ring| for (ring) |p| { + any = true; + b[0] = @min(b[0], p.lon_e7); + b[1] = @min(b[1], p.lat_e7); + b[2] = @max(b[2], p.lon_e7); + b[3] = @max(b[3], p.lat_e7); + }; + return if (any) b else .{ 0, 0, 0, 0 }; +} + +// ---- JSON round-trip ----------------------------------------------------- +// +// The wire form uses `[2]i32` points so `std.json` emits compact `[lon,lat]` pairs +// (half the bytes of `{lon_e7,lat_e7}` objects) that round-trip losslessly as +// integers. The on-disk value lives under the "coverage" key of the PMTiles metadata +// object; `encodeJson` emits that value, the writer splices it in. + +const P = [2]i32; + +const Dto = struct { + v: u32 = 0, + name: []const u8 = "", + date: []const u8 = "", + cscl: i32 = 0, + band: u8 = 0, + bbox: [4]i32 = .{ 0, 0, 0, 0 }, + cov1: []const []const []const P = &.{}, + cov2: []const []const []const P = &.{}, +}; + +// The metadata object as far as this module cares: everything else (name, format, +// vector_layers, scamin) is skipped via ignore_unknown_fields. +const Envelope = struct { coverage: ?Dto = null }; + +/// Serialize `cov` to the JSON object embedded under the metadata's "coverage" key. +/// Caller owns the returned bytes (allocated in `a`). +pub fn encodeJson(a: std.mem.Allocator, cov: CellCoverage) ![]u8 { + const dto = Dto{ + .v = 1, + .name = cov.name, + .date = cov.date, + .cscl = cov.cscl, + .band = cov.band, + .bbox = cov.bbox, + .cov1 = try toWire(a, cov.cov1), + .cov2 = try toWire(a, cov.cov2), + }; + return std.json.Stringify.valueAlloc(a, dto, .{}); +} + +/// Extract the coverage embedded in a PMTiles metadata JSON blob, or null if absent / +/// unparseable. The whole result (rings + strings) is allocated in `a`. +pub fn decodeFromMetadata(a: std.mem.Allocator, metadata_json: []const u8) !?CellCoverage { + var parsed = std.json.parseFromSlice(Envelope, a, metadata_json, .{ .ignore_unknown_fields = true }) catch return null; + defer parsed.deinit(); // frees the DTO's own arena; the copies below live in `a` + const dto = parsed.value.coverage orelse return null; + return CellCoverage{ + .name = try a.dupe(u8, dto.name), + .date = try a.dupe(u8, dto.date), + .cscl = dto.cscl, + .band = dto.band, + .bbox = dto.bbox, + .cov1 = try fromWire(a, dto.cov1), + .cov2 = try fromWire(a, dto.cov2), + }; +} + +fn toWire(a: std.mem.Allocator, src: []const []const []const s57.LonLat) ![]const []const []const P { + const polys = try a.alloc([]const []const P, src.len); + for (src, 0..) |poly, i| { + const rings = try a.alloc([]const P, poly.len); + for (poly, 0..) |ring, j| { + const pts = try a.alloc(P, ring.len); + for (ring, 0..) |p, k| pts[k] = .{ p.lon_e7, p.lat_e7 }; + rings[j] = pts; + } + polys[i] = rings; + } + return polys; +} + +fn fromWire(a: std.mem.Allocator, src: []const []const []const P) ![]const []const []const s57.LonLat { + const polys = try a.alloc([]const []const s57.LonLat, src.len); + for (src, 0..) |poly, i| { + const rings = try a.alloc([]const s57.LonLat, poly.len); + for (poly, 0..) |ring, j| { + const pts = try a.alloc(s57.LonLat, ring.len); + for (ring, 0..) |p, k| pts[k] = .{ .lon_e7 = p[0], .lat_e7 = p[1] }; + rings[j] = pts; + } + polys[i] = rings; + } + return polys; +} + +// =========================================================================== +// Tests +// =========================================================================== + +const testing = std.testing; + +fn ll(lon_e7: i32, lat_e7: i32) s57.LonLat { + return .{ .lon_e7 = lon_e7, .lat_e7 = lat_e7 }; +} + +test "coverage round-trips through the metadata envelope, integers exact" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // Two M_COVR features: a square, and one with a hole (exterior + interior ring). + const sq = try a.dupe(s57.LonLat, &.{ ll(-763_000_000, 386_000_000), ll(-762_000_000, 386_000_000), ll(-762_000_000, 387_000_000), ll(-763_000_000, 387_000_000) }); + const ext = try a.dupe(s57.LonLat, &.{ ll(0, 0), ll(100, 0), ll(100, 100), ll(0, 100) }); + const hole = try a.dupe(s57.LonLat, &.{ ll(40, 40), ll(60, 40), ll(60, 60), ll(40, 60) }); + const feat0 = try a.dupe([]const s57.LonLat, &.{sq}); + const feat1 = try a.dupe([]const s57.LonLat, &.{ ext, hole }); + const cov1 = try a.dupe([]const []const s57.LonLat, &.{ feat0, feat1 }); + + const cov = CellCoverage{ + .name = "US5MD1MC", + .date = "20210115", + .cscl = 20_000, + .band = 1, + .bbox = .{ -763_000_000, 0, 100, 387_000_000 }, + .cov1 = cov1, + }; + + const inner = try encodeJson(a, cov); + // Embed under "coverage" alongside the other metadata keys the decoder must skip. + const meta = try std.fmt.allocPrint(a, "{{\"name\":\"chartplotter\",\"format\":\"pbf\",\"scamin\":[1000,2000],\"coverage\":{s}}}", .{inner}); + + const got = (try decodeFromMetadata(a, meta)) orelse return error.TestUnexpectedResult; + try testing.expectEqualStrings("US5MD1MC", got.name); + try testing.expectEqualStrings("20210115", got.date); + try testing.expectEqual(@as(i32, 20_000), got.cscl); + try testing.expectEqual(@as(u8, 1), got.band); + try testing.expectEqual([4]i32{ -763_000_000, 0, 100, 387_000_000 }, got.bbox); + try testing.expectEqual(@as(usize, 2), got.cov1.len); + try testing.expectEqual(@as(usize, 1), got.cov1[0].len); // square: one ring + try testing.expectEqual(@as(usize, 2), got.cov1[1].len); // holed: exterior + hole + // A vertex survives byte-exact (no f64 round-trip). + try testing.expectEqual(ll(-763_000_000, 386_000_000), got.cov1[0][0][0]); + try testing.expectEqual(ll(60, 60), got.cov1[1][1][2]); + try testing.expectEqual(@as(usize, 0), got.cov2.len); +} + +test "metadata without a coverage key decodes to null" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const meta = "{\"name\":\"chartplotter\",\"format\":\"pbf\",\"scamin\":[1000]}"; + try testing.expect((try decodeFromMetadata(a, meta)) == null); +} + +test "bboxOf spans all rings; empty coverage yields a zero bbox" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const ring = try a.dupe(s57.LonLat, &.{ ll(-5, -3), ll(7, -3), ll(7, 9), ll(-5, 9) }); + const feat = try a.dupe([]const s57.LonLat, &.{ring}); + const cov1 = try a.dupe([]const []const s57.LonLat, &.{feat}); + try testing.expectEqual([4]i32{ -5, -3, 7, 9 }, bboxOf(cov1)); + try testing.expectEqual([4]i32{ 0, 0, 0, 0 }, bboxOf(&.{})); +} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 6e1789f..d6094a7 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -19,6 +19,7 @@ const rs = render.surface; /// The banded multi-cell ENC_ROOT -> PMTiles baker (folded in: it is the /// batch driver of this engine). Re-exported for the CLI + lib root. pub const bake_enc = @import("bake_enc.zig"); +pub const coverage = @import("coverage.zig"); // per-cell coverage sidecar (in PMTiles metadata) /// SCAMIN standalone (specs/scamin-standalone.md): cross-cell point-object /// matching + SCAMIN union + scale-window eligibility for the *_scamin point/ From abf651fac94b9307e34b65899f73a78ada4bac42 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 13:45:15 -0400 Subject: [PATCH 016/140] feat(bake): embed per-cell coverage in the single-cell PMTiles metadata 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) --- src/bundle.zig | 2 +- src/capi.zig | 2 +- src/chart.zig | 47 ++++++++++++++++++++++++++++++++++++++++++--- src/scene/scene.zig | 14 ++++++++++---- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index 69a44fe..5473f06 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -1728,7 +1728,7 @@ fn bakeRoot(io: std.Io, a: std.mem.Allocator, root_path: []const u8, out_path: [ while (it.next()) |k| try scamin_vals.append(a, k.*); std.mem.sort(u32, scamin_vals.items, {}, std.sort.asc(u32)); } - const meta = try engine.scene.metadataJson(a, scamin_vals.items); + const meta = try engine.scene.metadataJson(a, scamin_vals.items, null); // multi-cell root bake: no single cell's coverage const opts = engine.pmtiles.WriteOptions{ .metadata_json = meta, .min_lon_e7 = toE7(ubox[0]), diff --git a/src/capi.zig b/src/capi.zig index b4d8da2..35b65a4 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -292,7 +292,7 @@ export fn tile57_bake_pmtiles( var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const cells = toCellInputs(arena.allocator(), cells_ptr[0..count]) orelse return -1; - const archive = chart.bakeArchive(cells, spanOpt(o.rules_dir), o.minzoom, o.maxzoom, bakeFormat(o.format), !o.omit_pick_attrs, o.progress, o.progress_user) catch return -1; + const archive = chart.bakeArchive(cells, spanOpt(o.rules_dir), o.minzoom, o.maxzoom, bakeFormat(o.format), !o.omit_pick_attrs, o.progress, o.progress_user, null) catch return -1; if (archive) |a| { out.* = a.ptr; out_len.* = a.len; diff --git a/src/chart.zig b/src/chart.zig index 1f53a32..c71026a 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -577,7 +577,7 @@ pub fn openCellBaked(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool, } else |_| {} const cell_in = [_]CellInput{.{ .base = cf.base, .updates = cf.updates }}; - const archive = (bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, pick_attrs, null, null) catch null) orelse return error.OpenFailed; + const archive = (bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, pick_attrs, null, null, null) catch null) orelse return error.OpenFailed; defer gpa.free(archive); const src = try Chart.openBytes(archive, .pmtiles, rules_dir); src.cscl_override = cscl; @@ -594,11 +594,48 @@ pub fn openCellPath(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool) /// Bake a SINGLE .000 cell (+ updates) to a PMTiles archive in [minzoom, maxzoom] and /// return the bytes (gpa-owned; free with tile57_free). For a host to persist a /// per-cell tile cache to disk so the (slow) bake is one-time. null = nothing baked. +/// +/// The archive metadata embeds the cell's own coverage (M_COVR + cscl + date/name), +/// so the composite stitcher rebuilds the ownership partition from the baked archives +/// without re-parsing the .000. Read it back with `cellCoverageFromArchive`. pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8, minzoom: u8, maxzoom: u8) !?[]u8 { var cf = try readCellFiles(cell_path); defer cf.deinit(); + + // Capture coverage for the embedded sidecar (one cheap parse, as openCellBaked + // does). The stem is the ownership tie-break name — matches the coverage loader. + var cov_arena = std.heap.ArenaAllocator.init(gpa); + defer cov_arena.deinit(); + var coverage_json: ?[]const u8 = null; + if (s57.parseCellWithUpdates(gpa, cf.base, cf.updates)) |parsed| { + var cell = parsed; + defer cell.deinit(); + const stem = std.fs.path.stem(std.fs.path.basename(cell_path)); + const band: u8 = @intFromEnum(bake_enc.bandOf(cell.params.cscl)); + const cc = scene.coverage.fromCell(cov_arena.allocator(), &cell, stem, band); + coverage_json = scene.coverage.encodeJson(cov_arena.allocator(), cc) catch null; + } else |_| {} + const cell_in = [_]CellInput{.{ .base = cf.base, .updates = cf.updates }}; - return bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, true, null, null); + return bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, true, null, null, coverage_json); +} + +/// Decode the per-cell coverage embedded in a baked per-cell archive's metadata, or +/// null if absent. The whole result (rings + strings) is allocated in `a`. The +/// composite stitcher calls this over each cell's archive to rebuild the ownership +/// partition without re-parsing the source .000. +pub fn cellCoverageFromArchive(a: std.mem.Allocator, archive: []const u8) !?scene.coverage.CellCoverage { + var r = try pmtiles.Reader.init(a, archive); + defer r.deinit(); + const h = r.header; + if (h.metadata_length == 0) return null; + const raw = r.bytes[@intCast(h.metadata_offset)..][0..@intCast(h.metadata_length)]; + const json: []const u8 = switch (h.internal_compression) { + .none => raw, + .gzip => try gzip.decompress(a, raw), + else => return null, + }; + return scene.coverage.decodeFromMetadata(a, json); } /// Populate the process-global READ-ONLY registries (the S-100 feature catalogue and @@ -1916,6 +1953,10 @@ pub fn bakeArchive( pick_attrs: bool, progress: Progress, user: ?*anyopaque, + // A single-cell composite bake passes that cell's coverage object (from + // `scene.coverage.encodeJson`) to embed in the archive metadata; multi-cell + // bakes pass null (no single coverage to carry). + coverage_json: ?[]const u8, ) !?[]u8 { const dir = resolveRulesDir(rules_dir); @@ -2129,7 +2170,7 @@ pub fn bakeArchive( while (it.next()) |k| try scamin_vals.append(gpa, k.*); std.mem.sort(u32, scamin_vals.items, {}, std.sort.asc(u32)); } - const meta = try scene.metadataJson(gpa, scamin_vals.items); + const meta = try scene.metadataJson(gpa, scamin_vals.items, coverage_json); defer gpa.free(meta); return try sw.finishBytes(.{ .metadata_json = meta, diff --git a/src/scene/scene.zig b/src/scene/scene.zig index d6094a7..c465ff2 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -950,10 +950,12 @@ pub const VECTOR_LAYERS = [_][]const u8{ /// PMTiles archive metadata JSON: the static vector_layers list MapLibre reads from /// the TileJSON, plus a "scamin" array of the distinct SCAMIN denominators present /// (ascending) so the client builds one native-minzoom bucket layer per value at -/// load (host-canonical-backend.md §2) instead of probing tiles. Mirrors the Go -/// pmtiles.Builder.metadata (vector_layers + scamin splice). `scamin` empty -> omit -/// the field. Caller owns the returned bytes (allocated in `a`). -pub fn metadataJson(a: Allocator, scamin: []const u32) ![]const u8 { +/// load instead of probing tiles. Mirrors the Go pmtiles.Builder.metadata +/// (vector_layers + scamin splice). `scamin` empty -> omit the field. `coverage_json`, +/// when non-null, is a per-cell coverage object (see `coverage.encodeJson`) spliced +/// under a "coverage" key — a single-cell composite bake carries its own M_COVR there. +/// Caller owns the returned bytes (allocated in `a`). +pub fn metadataJson(a: Allocator, scamin: []const u32, coverage_json: ?[]const u8) ![]const u8 { var b = std.ArrayList(u8).empty; try b.appendSlice(a, "{\"name\":\"chartplotter\",\"format\":\"pbf\",\"vector_layers\":["); for (VECTOR_LAYERS, 0..) |name, i| { @@ -972,6 +974,10 @@ pub fn metadataJson(a: Allocator, scamin: []const u32) ![]const u8 { } try b.append(a, ']'); } + if (coverage_json) |cj| { + try b.appendSlice(a, ",\"coverage\":"); + try b.appendSlice(a, cj); + } try b.append(a, '}'); return b.toOwnedSlice(a); } From f6fa8b61257932f47314c13300205d39f348b70d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 13:55:23 -0400 Subject: [PATCH 017/140] feat(capi/go): tile57_pmtiles_metadata + Go BakeCell/PMTilesMetadata 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) --- bindings/go/bake.go | 45 +++++++++++++++++++++ bindings/go/cell_test.go | 86 ++++++++++++++++++++++++++++++++++++++++ include/tile57.h | 7 ++++ src/capi.zig | 14 +++++++ src/chart.zig | 24 +++++++---- 5 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 bindings/go/cell_test.go diff --git a/bindings/go/bake.go b/bindings/go/bake.go index 9899e88..4b1a0e2 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -167,6 +167,51 @@ func BakeBundle(input, outDir string, opts BakeOpts, progress func(BakeProgress) } } +// BakeCell bakes ONE on-disk cell (a .000 path + its .001.. updates) to a PMTiles +// archive over [minZoom, maxZoom] and returns the bytes — the per-cell tile store the +// composite stitcher consumes. The archive metadata embeds that cell's own coverage +// (M_COVR + cscl + date/name); read it back with [PMTilesMetadata]. minZoom/maxZoom +// 0/0 bakes the engine default range. +func BakeCell(path string, minZoom, maxZoom uint8) ([]byte, error) { + if path == "" { + return nil, fmt.Errorf("tile57: BakeCell needs a cell path: %w", ErrEmptyInput) + } + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + + var out *C.uint8_t + var outLen C.size_t + rc := C.tile57_bake_cell_bytes(cPath, C.uint8_t(minZoom), C.uint8_t(maxZoom), &out, &outLen) + switch rc { + case 1: + return tileBytes(out, outLen), nil + case 0: + return nil, fmt.Errorf("tile57: cell bake produced no tiles: %w", ErrNoCoverage) + default: + return nil, fmt.Errorf("tile57: cell bake failed") + } +} + +// PMTilesMetadata returns a PMTiles archive's metadata JSON blob (decompressed), or +// nil if the archive carries none. A [BakeCell] archive embeds the cell's coverage +// under a "coverage" key. The pmtiles bytes are read but not retained. +func PMTilesMetadata(pmtiles []byte) ([]byte, error) { + if len(pmtiles) == 0 { + return nil, fmt.Errorf("tile57: PMTilesMetadata needs archive bytes: %w", ErrEmptyInput) + } + var out *C.uint8_t + var outLen C.size_t + rc := C.tile57_pmtiles_metadata((*C.uint8_t)(unsafe.Pointer(&pmtiles[0])), C.size_t(len(pmtiles)), &out, &outLen) + switch rc { + case 1: + return tileBytes(out, outLen), nil + case 0: + return nil, nil // archive has no metadata + default: + return nil, fmt.Errorf("tile57: read metadata failed") + } +} + // PartitionBand selects which ownership-partition map [BakePartitionDebug] emits. type PartitionBand int8 diff --git a/bindings/go/cell_test.go b/bindings/go/cell_test.go new file mode 100644 index 0000000..4b726cc --- /dev/null +++ b/bindings/go/cell_test.go @@ -0,0 +1,86 @@ +//go:build cgo + +package tile57 + +import ( + "encoding/json" + "os" + "testing" +) + +// A single-cell bake must embed the cell's own coverage in the PMTiles metadata, so +// the composite stitcher rebuilds the ownership partition without re-parsing the .000. +// Validates the full round-trip on a real NOAA cell: bake -> embed -> read -> decode. +func TestBakeCellEmbedsCoverage(t *testing.T) { + if _, err := os.Stat(testCell); err != nil { + t.Skipf("no test cell %s: %v", testCell, err) + } + + pm, err := BakeCell(testCell, 0, 16) + if err != nil { + t.Fatalf("BakeCell: %v", err) + } + if len(pm) < 7 || string(pm[:7]) != "PMTiles" { + t.Fatalf("not a PMTiles archive (got %d bytes)", len(pm)) + } + + meta, err := PMTilesMetadata(pm) + if err != nil { + t.Fatalf("PMTilesMetadata: %v", err) + } + if meta == nil { + t.Fatal("archive carries no metadata") + } + + var m struct { + Coverage *struct { + Name string `json:"name"` + Date string `json:"date"` + Cscl int32 `json:"cscl"` + Band uint8 `json:"band"` + Bbox [4]int32 `json:"bbox"` + Cov1 [][][][2]int32 `json:"cov1"` + } `json:"coverage"` + } + if err := json.Unmarshal(meta, &m); err != nil { + t.Fatalf("unmarshal metadata: %v\n%s", err, meta) + } + if m.Coverage == nil { + t.Fatal("no coverage embedded in the metadata") + } + c := m.Coverage + if c.Name != "US5MD1MC" { + t.Errorf("coverage name = %q, want US5MD1MC", c.Name) + } + if c.Cscl <= 0 { + t.Errorf("coverage cscl = %d, want > 0", c.Cscl) + } + if len(c.Cov1) == 0 { + t.Fatal("no M_COVR coverage rings") + } + // bbox must be a non-degenerate box in integer lon/lat. + if !(c.Bbox[0] < c.Bbox[2] && c.Bbox[1] < c.Bbox[3]) { + t.Errorf("degenerate bbox %v", c.Bbox) + } + nPts := 0 + for _, feat := range c.Cov1 { + for _, ring := range feat { + nPts += len(ring) + } + } + if nPts < 3 { + t.Errorf("coverage has %d points, want a real ring (>= 3)", nPts) + } + // Every vertex must sit within the reported bbox (integer coords, no f64 drift). + for _, feat := range c.Cov1 { + for _, ring := range feat { + for _, p := range ring { + if p[0] < c.Bbox[0] || p[0] > c.Bbox[2] || p[1] < c.Bbox[1] || p[1] > c.Bbox[3] { + t.Fatalf("vertex %v outside bbox %v", p, c.Bbox) + } + } + } + } + t.Logf("US5MD1MC: cscl=%d band=%d date=%q, %d M_COVR feature(s), %d pts, bbox=%v", + c.Cscl, c.Band, c.Date, len(c.Cov1), nPts, c.Bbox) +} diff --git a/include/tile57.h b/include/tile57.h index 917f798..731ff7f 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -100,6 +100,13 @@ tile57_chart *tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t int tile57_bake_cell_bytes(const char *path, uint8_t minzoom, uint8_t maxzoom, uint8_t **out, size_t *out_len); +/* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len + * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + + * cscl + date/name under a "coverage" key, so the composite stitcher rebuilds the + * ownership partition without re-parsing the .000. 1=ok, 0=no metadata, -1=error. */ +int tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, + uint8_t **out, size_t *out_len); + /* Open a whole ENC_ROOT directory (or a single cell) as a lazily-baked streaming * chart: enumerates cells + peeks each bbox/scale, reads bytes on demand (working * set only). For tile fetch / bake, NOT live render_surface_cb. NULL/failure -> NULL. */ diff --git a/src/capi.zig b/src/capi.zig index 35b65a4..42dd234 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -107,6 +107,20 @@ export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, minzoom: u8, maxzoom: u8, return 0; } +/// The metadata JSON blob of a PMTiles archive (e.g. the embedded per-cell "coverage" +/// a single-cell bake carries), into *out / *out_len (free with tile57_free). 1=ok, +/// 0=no metadata, -1=error. See tile57.h. +export fn tile57_pmtiles_metadata(pmtiles_ptr: ?[*]const u8, len: usize, out: *[*]u8, out_len: *usize) callconv(.c) c_int { + const p = pmtiles_ptr orelse return -1; + const meta = chart.pmtilesMetadata(gpa, p[0..len]) catch return -1; + if (meta) |m| { + out.* = m.ptr; + out_len.* = m.len; + return 1; + } + return 0; +} + /// Open a whole ENC_ROOT directory (or a single cell) as a lazily-baked chart — the /// `.cells` backend, for tile fetch / bake, not live render_surface_cb. See tile57.h. export fn tile57_charts_open(path: ?[*:0]const u8) callconv(.c) ?*Chart { diff --git a/src/chart.zig b/src/chart.zig index c71026a..2ffbf1e 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -620,21 +620,29 @@ pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8, minzoom: u8, return bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, true, null, null, coverage_json); } -/// Decode the per-cell coverage embedded in a baked per-cell archive's metadata, or -/// null if absent. The whole result (rings + strings) is allocated in `a`. The -/// composite stitcher calls this over each cell's archive to rebuild the ownership -/// partition without re-parsing the source .000. -pub fn cellCoverageFromArchive(a: std.mem.Allocator, archive: []const u8) !?scene.coverage.CellCoverage { +/// The metadata JSON blob of a PMTiles archive (decompressed), duped into `a`, or null +/// if the archive carries none. For a host to read the embedded scamin / coverage +/// without a full open. This engine writes metadata uncompressed; gzip is handled for +/// archives from another writer. +pub fn pmtilesMetadata(a: std.mem.Allocator, archive: []const u8) !?[]u8 { var r = try pmtiles.Reader.init(a, archive); defer r.deinit(); const h = r.header; if (h.metadata_length == 0) return null; const raw = r.bytes[@intCast(h.metadata_offset)..][0..@intCast(h.metadata_length)]; - const json: []const u8 = switch (h.internal_compression) { - .none => raw, + return switch (h.internal_compression) { + .none => try a.dupe(u8, raw), .gzip => try gzip.decompress(a, raw), - else => return null, + else => null, }; +} + +/// Decode the per-cell coverage embedded in a baked per-cell archive's metadata, or +/// null if absent. The whole result (rings + strings) is allocated in `a`. The +/// composite stitcher calls this over each cell's archive to rebuild the ownership +/// partition without re-parsing the source .000. +pub fn cellCoverageFromArchive(a: std.mem.Allocator, archive: []const u8) !?scene.coverage.CellCoverage { + const json = (try pmtilesMetadata(a, archive)) orelse return null; return scene.coverage.decodeFromMetadata(a, json); } From e2850eebfbb2afdd40897df3cbce39b29d99add3 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 14:14:07 -0400 Subject: [PATCH 018/140] feat(bake): per-cell bake is native-scale only (drop caller zoom range) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bindings/go/bake.go | 12 ++++++------ bindings/go/cell_test.go | 2 +- include/tile57.h | 14 ++++++++------ src/capi.zig | 8 ++++---- src/chart.zig | 18 ++++++++++++------ 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/bindings/go/bake.go b/bindings/go/bake.go index 4b1a0e2..aa79acb 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -168,11 +168,11 @@ func BakeBundle(input, outDir string, opts BakeOpts, progress func(BakeProgress) } // BakeCell bakes ONE on-disk cell (a .000 path + its .001.. updates) to a PMTiles -// archive over [minZoom, maxZoom] and returns the bytes — the per-cell tile store the -// composite stitcher consumes. The archive metadata embeds that cell's own coverage -// (M_COVR + cscl + date/name); read it back with [PMTilesMetadata]. minZoom/maxZoom -// 0/0 bakes the engine default range. -func BakeCell(path string, minZoom, maxZoom uint8) ([]byte, error) { +// archive over its NATIVE band zoom range and returns the bytes — the per-cell tile +// store the composite stitcher consumes (the stitcher handles any cross-band zoom +// expansion). The archive metadata embeds that cell's own coverage (M_COVR + cscl + +// date/name); read it back with [PMTilesMetadata]. +func BakeCell(path string) ([]byte, error) { if path == "" { return nil, fmt.Errorf("tile57: BakeCell needs a cell path: %w", ErrEmptyInput) } @@ -181,7 +181,7 @@ func BakeCell(path string, minZoom, maxZoom uint8) ([]byte, error) { var out *C.uint8_t var outLen C.size_t - rc := C.tile57_bake_cell_bytes(cPath, C.uint8_t(minZoom), C.uint8_t(maxZoom), &out, &outLen) + rc := C.tile57_bake_cell_bytes(cPath, &out, &outLen) switch rc { case 1: return tileBytes(out, outLen), nil diff --git a/bindings/go/cell_test.go b/bindings/go/cell_test.go index 4b726cc..a63eff6 100644 --- a/bindings/go/cell_test.go +++ b/bindings/go/cell_test.go @@ -16,7 +16,7 @@ func TestBakeCellEmbedsCoverage(t *testing.T) { t.Skipf("no test cell %s: %v", testCell, err) } - pm, err := BakeCell(testCell, 0, 16) + pm, err := BakeCell(testCell) if err != nil { t.Fatalf("BakeCell: %v", err) } diff --git a/include/tile57.h b/include/tile57.h index 731ff7f..cc085eb 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -93,12 +93,14 @@ tile57_chart *tile57_chart_open_header(const char *path); * (progressive load). Renders via the fast reader path. NULL/failure -> NULL. */ tile57_chart *tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t maxzoom); -/* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes in - * [minzoom, maxzoom], returned in *out / *out_len (free with tile57_free). For a host - * to persist a per-cell tile cache to disk so the slow bake is one-time — then reopen - * the written file with tile57_chart_open_pmtiles. 1=ok, 0=nothing baked, -1=error. */ -int tile57_bake_cell_bytes(const char *path, uint8_t minzoom, uint8_t maxzoom, - uint8_t **out, size_t *out_len); +/* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over its + * NATIVE band zoom range and nothing else — the composite model bakes each cell at its + * own compilation scale; the stitcher combines them and handles any cross-band zoom. + * Returned in *out / *out_len (free with tile57_free). For a host to persist a per-cell + * tile cache to disk — then reopen with tile57_chart_open_pmtiles. The metadata embeds + * the cell's coverage (read via tile57_pmtiles_metadata). 1=ok, 0=nothing baked, + * -1=error. */ +int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); /* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + diff --git a/src/capi.zig b/src/capi.zig index 42dd234..9ddc7af 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -93,12 +93,12 @@ export fn tile57_chart_open_zoom(path: ?[*:0]const u8, minzoom: u8, maxzoom: u8) return chart.openCellBaked(p, null, true, minzoom, maxzoom) catch null; } -/// Bake ONE cell (+ its updates, read from disk) to PMTiles bytes in [minzoom, -/// maxzoom], into *out / *out_len (free with tile57_free). For persisting a per-cell +/// Bake ONE cell (+ its updates, read from disk) to PMTiles bytes over its NATIVE band +/// zoom range, into *out / *out_len (free with tile57_free). For persisting a per-cell /// tile cache to disk. 1=ok, 0=nothing baked, -1=error. See tile57.h. -export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, minzoom: u8, maxzoom: u8, out: *[*]u8, out_len: *usize) callconv(.c) c_int { +export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, out: *[*]u8, out_len: *usize) callconv(.c) c_int { const p = spanOpt(path) orelse return -1; - const archive = chart.bakeCellBytes(p, null, minzoom, maxzoom) catch return -1; + const archive = chart.bakeCellBytes(p, null) catch return -1; if (archive) |a| { out.* = a.ptr; out_len.* = a.len; diff --git a/src/chart.zig b/src/chart.zig index 2ffbf1e..3c94d52 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -591,14 +591,16 @@ pub fn openCellPath(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool) return openCellBaked(path, rules_dir, pick_attrs, 0, 18); } -/// Bake a SINGLE .000 cell (+ updates) to a PMTiles archive in [minzoom, maxzoom] and -/// return the bytes (gpa-owned; free with tile57_free). For a host to persist a -/// per-cell tile cache to disk so the (slow) bake is one-time. null = nothing baked. +/// Bake a SINGLE .000 cell (+ updates) to a PMTiles archive over its NATIVE band's +/// zoom range (`bandZooms(bandOf(cscl))`) and nothing else — the composite model bakes +/// each cell at its own compilation scale; the stitcher combines them and handles any +/// cross-band zoom expansion. Returns the bytes (gpa-owned; free with tile57_free). +/// null = nothing baked. /// /// The archive metadata embeds the cell's own coverage (M_COVR + cscl + date/name), /// so the composite stitcher rebuilds the ownership partition from the baked archives /// without re-parsing the .000. Read it back with `cellCoverageFromArchive`. -pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8, minzoom: u8, maxzoom: u8) !?[]u8 { +pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 { var cf = try readCellFiles(cell_path); defer cf.deinit(); @@ -607,17 +609,21 @@ pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8, minzoom: u8, var cov_arena = std.heap.ArenaAllocator.init(gpa); defer cov_arena.deinit(); var coverage_json: ?[]const u8 = null; + var cscl: i32 = s57.peekScale(gpa, cf.base) orelse 0; if (s57.parseCellWithUpdates(gpa, cf.base, cf.updates)) |parsed| { var cell = parsed; defer cell.deinit(); + cscl = cell.params.cscl; const stem = std.fs.path.stem(std.fs.path.basename(cell_path)); - const band: u8 = @intFromEnum(bake_enc.bandOf(cell.params.cscl)); + const band: u8 = @intFromEnum(bake_enc.bandOf(cscl)); const cc = scene.coverage.fromCell(cov_arena.allocator(), &cell, stem, band); coverage_json = scene.coverage.encodeJson(cov_arena.allocator(), cc) catch null; } else |_| {} + // Native scale only: the cell's band window, no fill-down / overscale. + const zr = bake_enc.bandZooms(bake_enc.bandOf(cscl)); const cell_in = [_]CellInput{.{ .base = cf.base, .updates = cf.updates }}; - return bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, true, null, null, coverage_json); + return bakeArchive(&cell_in, resolveRulesDir(rules_dir), zr.min, zr.max, .mlt, true, null, null, coverage_json); } /// The metadata JSON blob of a PMTiles archive (decompressed), duped into `a`, or null From 48824e8cb6d452c3862f4e26f1330672b530ff91 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 14:45:50 -0400 Subject: [PATCH 019/140] =?UTF-8?q?feat(compose):=20projectFace=20?= =?UTF-8?q?=E2=80=94=20E7=20owned-face=20=E2=86=92=20tile-pixel=20space=20?= =?UTF-8?q?(Step=203=20prep)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/scene/compose.zig | 101 ++++++++++++++++++++++++++++++++++++++++-- src/scene/scene.zig | 1 + 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/scene/compose.zig b/src/scene/compose.zig index b59372a..759abbb 100644 --- a/src/scene/compose.zig +++ b/src/scene/compose.zig @@ -3,13 +3,17 @@ //! into one composed tile with no double-draw at a seam. //! //! `face` is the cell's owned rings (from `partition.ownedFace`) projected to THIS tile's -//! pixel space, i64. Areas are intersected with the face, lines clipped to inside it, -//! points kept iff their node is inside. Reuses the geometry primitives — `boolean.compute` -//! (.intersect), `plane.clipLineInsidePolys`, `boolean.pointInEvenOdd` — so there is no new -//! set algebra here, only the mvt↔integer adapters and the per-geometry-type dispatch. +//! pixel space, i64 — `projectFace` does that projection, mirroring EXACTLY the baker's +//! `tile.project` + `tile.clipPolygon` on the cell's features, so the face and the features +//! share one pixel space and the intersection is seam-exact. Areas are intersected with the +//! face, lines clipped to inside it, points kept iff their node is inside. Reuses the +//! geometry primitives — `boolean.compute` (.intersect), `plane.clipLineInsidePolys`, +//! `boolean.pointInEvenOdd` — so there is no new set algebra here, only the mvt↔integer +//! adapters and the per-geometry-type dispatch. const std = @import("std"); const mvt = @import("tiles").mvt; +const tile = @import("tiles").tile; const geo = @import("geo"); const boolean = geo.boolean; const plane = geo.plane; @@ -70,6 +74,34 @@ pub fn clipFeatureToFace(a: std.mem.Allocator, out: *std.ArrayList(mvt.Feature), } } +/// Project a cell's owned face — `partition.ownedFace` rings in integer lon/lat (degrees × +/// 10⁷) — into tile `(z, tx, ty)` pixel space and clip to the tile box, returning the even-odd +/// ring set `clipFeatureToFace` expects (boolean.Pt, i64), freshly allocated in `a`. This is +/// the SAME projection the per-cell baker applied to that tile's features (`tile.project` + +/// `tile.clipPolygon` over `Box.default(EXTENT, BUFFER)` with the same round), so the face and +/// the decoded features live in one pixel space and the clip is seam-exact. Rings collapsing +/// below a triangle are dropped; an empty result means the cell owns no pixels in this tile. +pub fn projectFace(a: std.mem.Allocator, face: []const []const Pt, z: u8, tx: u32, ty: u32) ![]const []const Pt { + var rings = std.ArrayList([]const Pt).empty; + for (face) |ring| { + const proj = try a.alloc(mvt.Point, ring.len); + for (ring, 0..) |p, i| proj[i] = tile.project( + @as(f64, @floatFromInt(p.x)) / 1e7, + @as(f64, @floatFromInt(p.y)) / 1e7, + z, + tx, + ty, + tile.EXTENT, + ); + const clipped = try tile.clipPolygon(a, proj, tile.Box.default(tile.EXTENT, tile.BUFFER)); + if (clipped.len < 3) continue; + const widened = try a.alloc(Pt, clipped.len); + for (clipped, 0..) |cp, i| widened[i] = .{ .x = cp.x, .y = cp.y }; + try rings.append(a, widened); + } + return rings.toOwnedSlice(a); +} + // =========================================================================== // Tests // =========================================================================== @@ -177,3 +209,64 @@ test "clipFeatureToFace: feature entirely outside the face is dropped" { try clipFeatureToFace(a, &out, feat, face); try testing.expectEqual(@as(usize, 0), out.items.len); } + +test "projectFace: a lon/lat face box inside the tile lands vertex-for-vertex at tile.project" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // Web-mercator tile z2/(1,1) spans lon [-90,0], lat [0,66.51]. A face box in integer + // lon/lat (degrees × 1e7) fully INSIDE it, so the box clip is a no-op and every projected + // vertex must equal tile.project of the same lon/lat (projectFace adds no transform beyond + // E7→deg + the shared clip). SW→SE→NE→NW ring order. + const box = [_]Pt{ + .{ .x = -600_000_000, .y = 200_000_000 }, // lon -60, lat 20 + .{ .x = -300_000_000, .y = 200_000_000 }, // lon -30, lat 20 + .{ .x = -300_000_000, .y = 500_000_000 }, // lon -30, lat 50 + .{ .x = -600_000_000, .y = 500_000_000 }, // lon -60, lat 50 + }; + const rings = [_][]const Pt{&box}; + + const z: u8 = 2; + const tx: u32 = 1; + const ty: u32 = 1; + const face_px = try projectFace(a, &rings, z, tx, ty); + try testing.expectEqual(@as(usize, 1), face_px.len); + try testing.expectEqual(@as(usize, 4), face_px[0].len); + + for (box, 0..) |p, i| { + const want = tile.project( + @as(f64, @floatFromInt(p.x)) / 1e7, + @as(f64, @floatFromInt(p.y)) / 1e7, + z, + tx, + ty, + tile.EXTENT, + ); + try testing.expectEqual(@as(i64, want.x), face_px[0][i].x); + try testing.expectEqual(@as(i64, want.y), face_px[0][i].y); + // Inside the tile → inside the pixel box. + try testing.expect(face_px[0][i].x >= 0 and face_px[0][i].x <= tile.EXTENT); + try testing.expect(face_px[0][i].y >= 0 and face_px[0][i].y <= tile.EXTENT); + } + // Axis sanity: east lon → larger x; north lat (larger lat) → smaller y (y grows down). + try testing.expect(face_px[0][1].x > face_px[0][0].x); // lon -30 east of -60 + try testing.expect(face_px[0][2].y < face_px[0][1].y); // lat 50 north of lat 20 +} + +test "projectFace: a ring fully outside the tile box is dropped" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // A face far to the east (lon ≈ +160) cannot touch tile z2/(1,1) (lon [-90,0]). + const east = [_]Pt{ + .{ .x = 1_600_000_000, .y = 100_000_000 }, + .{ .x = 1_700_000_000, .y = 100_000_000 }, + .{ .x = 1_700_000_000, .y = 200_000_000 }, + .{ .x = 1_600_000_000, .y = 200_000_000 }, + }; + const rings = [_][]const Pt{&east}; + const face_px = try projectFace(a, &rings, 2, 1, 1); + try testing.expectEqual(@as(usize, 0), face_px.len); +} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index c465ff2..92a79b8 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -20,6 +20,7 @@ const rs = render.surface; /// batch driver of this engine). Re-exported for the CLI + lib root. pub const bake_enc = @import("bake_enc.zig"); pub const coverage = @import("coverage.zig"); // per-cell coverage sidecar (in PMTiles metadata) +pub const compose = @import("compose.zig"); // per-cell-composite clip-to-owned-face + face projection /// SCAMIN standalone (specs/scamin-standalone.md): cross-cell point-object /// matching + SCAMIN union + scale-window eligibility for the *_scamin point/ From 9db2fb2d5ba554ff4a8c3f554b8e53a4e282e6bc Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 14:58:42 -0400 Subject: [PATCH 020/140] fix(pmtiles): free StreamWriter.prefix intermediates (merged + directories) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/tiles/pmtiles.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tiles/pmtiles.zig b/src/tiles/pmtiles.zig index c217c8d..365490f 100644 --- a/src/tiles/pmtiles.zig +++ b/src/tiles/pmtiles.zig @@ -472,7 +472,12 @@ pub const StreamWriter = struct { } try merged.append(a, e); } + defer merged.deinit(a); const dirs = try buildDirectories(a, merged.items); + // `dirs` + `merged` are copied into `out` below, so free them once the archive prefix + // is assembled (they are heap-owned by `a`; `leaves` may be the empty sentinel). + defer a.free(dirs.root); + defer if (dirs.leaves.len > 0) a.free(dirs.leaves); const metadata = opts.metadata_json; const root_off: u64 = HEADER_LEN; const meta_off: u64 = root_off + dirs.root.len; From 829d7307ed365a7ce4205c75a09fa817c3f9c1da Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 14:58:42 -0400 Subject: [PATCH 021/140] =?UTF-8?q?feat(compose):=20composeArchives=20?= =?UTF-8?q?=E2=80=94=20Step=203=20per-cell=20composite=20driver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.zig | 19 +++ src/bundle.zig | 396 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 415 insertions(+) diff --git a/build.zig b/build.zig index 7fbacaa..b2bc3d3 100644 --- a/build.zig +++ b/build.zig @@ -570,6 +570,25 @@ pub fn build(b: *std.Build) void { const compose_step = b.step("compose-test", "Run the compose-core (clip-to-face) tests"); _ = addPkgTest(b, compose_step, "src/scene/compose.zig", target, optimize, &compose_deps); _ = addPkgTest(b, test_step, "src/scene/compose.zig", target, optimize, &compose_deps); + + // The chart-bundle module hosts the per-cell composite driver (composeArchives). Its full + // dep set (engine + assets/sprite/catalog) needs libc, so create the test module directly + // rather than via addPkgTest (which omits link_libc). + const bundle_test_mod = b.createModule(.{ + .root_source_file = b.path("src/bundle.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "engine", .module = engine_full }, + .{ .name = "assets", .module = assets_mod }, + .{ .name = "sprite", .module = sprite_mod }, + .{ .name = "catalog", .module = catalog_embed }, + }, + }); + const bundle_test_step = b.step("bundle-test", "Run the bundle / per-cell composite tests"); + bundle_test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = bundle_test_mod })).step); + test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = bundle_test_mod })).step); // Per-cell coverage sidecar (JSON round-trip carried in PMTiles metadata): pure // over s57 + std.json. Part of the main suite. _ = addPkgTest(b, test_step, "src/scene/coverage.zig", target, optimize, &.{ diff --git a/src/bundle.zig b/src/bundle.zig index 5473f06..500129e 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -778,6 +778,272 @@ fn worldAxisToTile(w: f64, scale: f64) u32 { return @intFromFloat(@min(f, scale - 1)); } +// =========================================================================== +// Per-cell composite — Step 3: composeArchives +// =========================================================================== +// +// Combine N per-cell PMTiles (each native-band-scale, its M_COVR coverage embedded in the +// metadata) into ONE merged PMTiles driven by the ownership partition. At every output tile, +// each owning cell's decoded features are clipped to the ground it OWNS (partition.ownedFace, +// projected into the tile) and concatenated per layer. The faces are a disjoint partition, so +// there is no double-draw at a seam and no z-order re-sort — S-52 draw priority rides the +// per-feature `draw_prio` property, which the style sorts client-side (so feature order within +// a tile is cosmetic). This retires the streaming in-bake cross-cell combiner: the per-cell +// bakes stay dumb + cacheable, and all cross-cell logic is precomputed as the partition. + +const N_COMPOSE_LAYERS = engine.scene.VECTOR_LAYERS.len; + +/// Compose per-cell PMTiles archives (BORROWED — kept alive by the caller) into one merged +/// PMTiles. Returns the composed archive bytes (`gpa`-owned; free with `gpa.free` / +/// `tile57_free`), or null if nothing composed. The partition is rebuilt from each archive's +/// embedded coverage (no `.000` re-parse), via the same `toPlaneCells` adapter the bake uses. +/// +/// Native-scale only for now: a cell contributes a tile only where it has a baked tile, so +/// ground a cell owns OUTSIDE its native zoom band is left empty at that zoom — cross-band +/// zoom expansion (overscale / fill-down) is a separate compositor stage. +pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[]u8 { + const geo = engine.geo; + const pmtiles = engine.pmtiles; + const scene = engine.scene; + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + + // 1. Open a Reader per archive and recover its embedded coverage + SCAMIN ladder. Archives + // with no coverage key are skipped. Readers are heap-stable (created in `a`) so getTile's + // lazy leaf-dir decode mutates them in place; they borrow the archive bytes. + const readers = try a.alloc(*pmtiles.Reader, archives.len); + var shims = std.ArrayList(LoadedCov).empty; + var scamins = std.AutoHashMap(u32, void).init(a); + var ubox = [4]i32{ std.math.maxInt(i32), std.math.maxInt(i32), std.math.minInt(i32), std.math.minInt(i32) }; + var n: usize = 0; + for (archives) |arc| { + const rp = a.create(pmtiles.Reader) catch continue; + rp.* = pmtiles.Reader.init(gpa, arc) catch continue; + const meta = readMetaJson(a, rp) orelse { + rp.deinit(); + continue; + }; + const cc = (scene.coverage.decodeFromMetadata(a, meta) catch null) orelse { + rp.deinit(); + continue; + }; + for (parseScamin(a, meta)) |s| scamins.put(s, {}) catch {}; + ubox[0] = @min(ubox[0], cc.bbox[0]); + ubox[1] = @min(ubox[1], cc.bbox[1]); + ubox[2] = @max(ubox[2], cc.bbox[2]); + ubox[3] = @max(ubox[3], cc.bbox[3]); + readers[n] = rp; + try shims.append(a, .{ + .name = cc.name, + .date = cc.date, + .cscl = cc.cscl, + .coverage = cc.cov1, + .bounds = .{ + @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, + @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, + @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, + @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, + }, + }); + n += 1; + } + defer for (readers[0..n]) |rp| rp.deinit(); + if (n == 0) return null; + + // 2. Adapt to plane.Cell (THE canonical adapter: band floor via bandOf + the DSID + // ordersBeforeKeys tie-break, ranked across the whole set) and materialise the per-band + // ownership partition. `part` borrows `cells` (arena-backed) — keep `a` alive for it. + const cells = try toPlaneCells(a, shims.items); + var part = try geo.partition.build(gpa, cells); + defer part.deinit(); + + // 3. Output zoom span = union of the cells' native band windows. + var minz: u8 = 255; + var maxz: u8 = 0; + for (shims.items) |lc| { + const zr = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(lc.cscl)); + minz = @min(minz, zr.min); + maxz = @max(maxz, zr.max); + } + + // 4. Stream: one governing band per zoom (partition.mapForZoom), scatter each owner's + // native tiles, clip to its owned face, merge per layer, re-encode. + var sw = pmtiles.StreamWriter.init(gpa); + defer sw.deinit(); + var zoom_arena = std.heap.ArenaAllocator.init(gpa); // one zoom's tiles resident at a time + defer zoom_arena.deinit(); + var z: u8 = minz; + while (z <= maxz) : (z += 1) { + _ = zoom_arena.reset(.retain_capacity); + try composeZoom(&sw, zoom_arena.allocator(), &part, readers[0..n], z); + } + if (sw.num_addressed == 0) return null; + + // 5. Frame a viewer on the data + metadata (VECTOR_LAYERS + the union SCAMIN ladder; the + // composite carries no single-cell coverage). + const scamin_list = try scaminSorted(a, &scamins); + const meta = try scene.metadataJson(gpa, scamin_list, null); + defer gpa.free(meta); + const span_deg = @max(0.01, @as(f64, @floatFromInt(ubox[2] - ubox[0])) / 1e7); + const cz: u8 = @intFromFloat(std.math.clamp(std.math.log2(720.0 / span_deg), @as(f64, @floatFromInt(minz)), @as(f64, @floatFromInt(maxz)))); + return try sw.finishBytes(.{ + .metadata_json = meta, + .tile_type = .mlt, + .min_lon_e7 = ubox[0], + .min_lat_e7 = ubox[1], + .max_lon_e7 = ubox[2], + .max_lat_e7 = ubox[3], + .center_zoom = cz, + }); +} + +/// Compose every tile of one zoom `z`: for each owning cell (the band governing `z`), fetch +/// its native tile, project its owned face into that tile, clip each decoded feature, and +/// accumulate per layer; then orient the merged polygons and stream each tile out. `sa` is a +/// per-zoom arena the caller resets, so only one zoom's tiles are resident. +fn composeZoom( + sw: *engine.pmtiles.StreamWriter, + sa: std.mem.Allocator, + part: *const engine.geo.partition.Partition, + readers: []const *engine.pmtiles.Reader, + z: u8, +) !void { + const mvt = engine.mvt; + const tile = engine.tile; + const scene = engine.scene; + const compose = scene.compose; + + const map = part.mapForZoom(z) orelse return; + const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + + const TileKey = struct { x: u32, y: u32 }; + const Buckets = [N_COMPOSE_LAYERS]std.ArrayList(mvt.Feature); + var tilemap = std.AutoHashMap(TileKey, Buckets).init(sa); + + for (map.faces) |face| { + if (face.owned.len == 0) continue; + const ci = face.index; + + // Tile cover of this owner's face bbox (lon/lat → world → tile index), nw..se. + var fb = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; + for (face.owned) |ring| for (ring) |p| { + const lon = @as(f64, @floatFromInt(p.x)) / 1e7; + const lat = @as(f64, @floatFromInt(p.y)) / 1e7; + fb[0] = @min(fb[0], lon); + fb[1] = @min(fb[1], lat); + fb[2] = @max(fb[2], lon); + fb[3] = @max(fb[3], lat); + }; + const w_tl = tile.lonLatToWorld(fb[0], fb[3]); // NW: min lon, max lat + const w_br = tile.lonLatToWorld(fb[2], fb[1]); // SE: max lon, min lat + const tx0 = worldAxisToTile(w_tl[0], scale); + const tx1 = worldAxisToTile(w_br[0], scale); + const ty0 = worldAxisToTile(w_tl[1], scale); + const ty1 = worldAxisToTile(w_br[1], scale); + + var tx = tx0; + while (tx <= tx1) : (tx += 1) { + var ty = ty0; + while (ty <= ty1) : (ty += 1) { + // The cell's per-cell baked tile at (z,tx,ty) — absent outside its native band. + const raw = (try readers[ci].getTile(sa, z, tx, ty)) orelse continue; + // Project the owned face into THIS tile; empty = the cell owns no pixels here. + const face_px = try compose.projectFace(sa, face.owned, z, tx, ty); + if (face_px.len == 0) continue; + const layers = try decodeTile(sa, readers[ci].header.tile_type, raw); + + const gop = try tilemap.getOrPut(.{ .x = tx, .y = ty }); + if (!gop.found_existing) for (gop.value_ptr) |*b| { + b.* = std.ArrayList(mvt.Feature).empty; + }; + for (layers) |layer| { + const li = layerIndex(layer.name) orelse continue; + for (layer.features) |feat| { + try compose.clipFeatureToFace(sa, &gop.value_ptr[li], feat, face_px); + } + } + } + } + } + + // Encode each composed tile: per-layer concat (VECTOR_LAYERS order), polygons re-oriented + // (boolean intersect output has unspecified winding), then MLT — matching the per-cell input. + var it = tilemap.iterator(); + while (it.next()) |kv| { + var layers = std.ArrayList(mvt.Layer).empty; + for (kv.value_ptr, 0..) |bucket, li| { + if (bucket.items.len == 0) continue; + const feats = try orientPolys(sa, bucket.items); + try layers.append(sa, .{ .name = scene.VECTOR_LAYERS[li], .features = feats }); + } + if (layers.items.len == 0) continue; + const enc = try engine.mlt.encode(sa, .{ .layers = layers.items }); + try sw.add(z, kv.key_ptr.x, kv.key_ptr.y, enc); + } +} + +// The output-layer slot for a decoded layer name (one of scene.VECTOR_LAYERS), or null to drop. +fn layerIndex(name: []const u8) ?usize { + for (engine.scene.VECTOR_LAYERS, 0..) |ln, i| { + if (std.mem.eql(u8, ln, name)) return i; + } + return null; +} + +// Decode a per-cell tile by its stored type (.mlt for our bakes, .mvt otherwise). +fn decodeTile(a: std.mem.Allocator, tt: engine.pmtiles.TileType, raw: []const u8) ![]engine.mvt.DecodedLayer { + return switch (tt) { + .mlt => engine.mlt.decode(a, raw), + else => engine.mvt.decode(a, raw), + }; +} + +// Re-orient each polygon feature's rings (the sole MVT winding authority). Non-area features +// pass through; the input `properties` are borrowed unchanged (draw_prio et al. survive). +fn orientPolys(a: std.mem.Allocator, feats: []const engine.mvt.Feature) ![]const engine.mvt.Feature { + const mvt = engine.mvt; + const out = try a.alloc(mvt.Feature, feats.len); + for (feats, 0..) |f, i| { + out[i] = if (f.geom_type == .polygon) + .{ .id = f.id, .geom_type = .polygon, .parts = try engine.scene.orientAreaRings(a, f.parts), .properties = f.properties } + else + f; + } + return out; +} + +// The metadata JSON of an archive (decompressed), borrowed from the reader (or `a` if gzipped), +// or null if absent/unreadable. +fn readMetaJson(a: std.mem.Allocator, r: *engine.pmtiles.Reader) ?[]const u8 { + const h = r.header; + if (h.metadata_length == 0) return null; + const raw = r.bytes[@intCast(h.metadata_offset)..][0..@intCast(h.metadata_length)]; + return switch (h.internal_compression) { + .none => raw, + .gzip => engine.gzip.decompress(a, raw) catch return null, + else => null, + }; +} + +// The "scamin" ladder from an archive's metadata JSON, or empty if absent/unparseable. +fn parseScamin(a: std.mem.Allocator, meta: []const u8) []const u32 { + const Dto = struct { scamin: []const u32 = &.{} }; + const v = std.json.parseFromSliceLeaky(Dto, a, meta, .{ .ignore_unknown_fields = true }) catch return &.{}; + return v.scamin; +} + +// The distinct SCAMIN denominators, ascending — the composed metadata's ladder. +fn scaminSorted(a: std.mem.Allocator, set: *std.AutoHashMap(u32, void)) ![]const u32 { + const out = try a.alloc(u32, set.count()); + var it = set.keyIterator(); + var i: usize = 0; + while (it.next()) |k| : (i += 1) out[i] = k.*; + std.mem.sort(u32, out, {}, comptime std.sort.asc(u32)); + return out; +} + const LightScanWork = struct { entries: []const CellEntry, dir: std.Io.Dir, @@ -1775,3 +2041,133 @@ fn lonLatToTile(lon: f64, lat: f64, z: u8) [2]u32 { fn toE7(v: f64) i32 { return @intFromFloat(@round(v * 1e7)); } + +// =========================================================================== +// Tests — composeArchives (per-cell composite Step 3) +// =========================================================================== + +const testing = std.testing; + +// Build a synthetic per-cell PMTiles: coverage = one M_COVR box (lon0..lon1 × lat0..lat1, +// degrees), plus ONE MLT tile at (z,tx,ty) whose "areas" layer is a single polygon covering +// the whole tile box — so composing clips it down to the ground the cell owns. Bytes are +// gpa-owned (free with gpa.free); scratch lives in arena `a`. +fn synthCell(gpa: std.mem.Allocator, a: std.mem.Allocator, name: []const u8, lon0: f64, lat0: f64, lon1: f64, lat1: f64, cscl: i32, z: u8, tx: u32, ty: u32) ![]u8 { + const s57 = engine.s57; + const scene = engine.scene; + const mvt = engine.mvt; + const tile = engine.tile; + const pmtiles = engine.pmtiles; + + const ring = try a.alloc(s57.LonLat, 4); + ring[0] = .{ .lon_e7 = toE7(lon0), .lat_e7 = toE7(lat0) }; + ring[1] = .{ .lon_e7 = toE7(lon1), .lat_e7 = toE7(lat0) }; + ring[2] = .{ .lon_e7 = toE7(lon1), .lat_e7 = toE7(lat1) }; + ring[3] = .{ .lon_e7 = toE7(lon0), .lat_e7 = toE7(lat1) }; + const feat = try a.dupe([]const s57.LonLat, &.{ring}); + const cov1 = try a.dupe([]const []const s57.LonLat, &.{feat}); + const cc = scene.coverage.CellCoverage{ + .name = name, + .date = "20200101", + .cscl = cscl, + .band = @intFromEnum(engine.bake_enc.bandOf(cscl)), + .bbox = .{ toE7(lon0), toE7(lat0), toE7(lon1), toE7(lat1) }, + .cov1 = cov1, + }; + const cov_json = try scene.coverage.encodeJson(a, cc); + const meta = try scene.metadataJson(a, &.{}, cov_json); + + const box = try a.alloc(mvt.Point, 4); + box[0] = .{ .x = 0, .y = 0 }; + box[1] = .{ .x = tile.EXTENT, .y = 0 }; + box[2] = .{ .x = tile.EXTENT, .y = tile.EXTENT }; + box[3] = .{ .x = 0, .y = tile.EXTENT }; + const parts = try a.dupe([]const mvt.Point, &.{box}); + const feats = try a.dupe(mvt.Feature, &.{.{ .geom_type = .polygon, .parts = parts }}); + const layers = try a.dupe(mvt.Layer, &.{.{ .name = "areas", .features = feats }}); + const tilebytes = try engine.mlt.encode(a, .{ .layers = layers }); + + var sw = pmtiles.StreamWriter.init(gpa); + defer sw.deinit(); + try sw.add(z, tx, ty, tilebytes); + return sw.finishBytes(.{ + .metadata_json = meta, + .tile_type = .mlt, + .min_lon_e7 = cc.bbox[0], + .min_lat_e7 = cc.bbox[1], + .max_lon_e7 = cc.bbox[2], + .max_lat_e7 = cc.bbox[3], + }); +} + +// How many of `feats` (decoded polygons) contain (x,y) under the even-odd rule. +fn countContains(a: std.mem.Allocator, feats: []engine.mvt.DecodedFeature, x: i64, y: i64) usize { + const boolean = engine.geo.boolean; + var count: usize = 0; + for (feats) |f| { + if (f.geom_type != .polygon) continue; + var rings = std.ArrayList([]const boolean.Pt).empty; + for (f.parts) |part| { + const w = a.alloc(boolean.Pt, part.len) catch unreachable; + for (part, 0..) |p, i| w[i] = .{ .x = p.x, .y = p.y }; + rings.append(a, w) catch unreachable; + } + if (boolean.pointInEvenOdd(rings.items, x, y)) count += 1; + } + return count; +} + +test "composeArchives: two abutting cells split a shared tile at the seam, no double-draw" { + const gpa = testing.allocator; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const tile = engine.tile; + + // A harbor-band location; find the z13 tile containing it, then split that tile's ground + // between two cells at the vertical mid-line. + const clon = -76.48; + const clat = 38.97; + const z: u8 = 13; + const scale: f64 = @floatFromInt(@as(u64, 1) << z); + const w = tile.lonLatToWorld(clon, clat); + const tx = worldAxisToTile(w[0], scale); + const ty = worldAxisToTile(w[1], scale); + const bnds = tile.tileBoundsLonLat(z, tx, ty); // [min_lon, min_lat, max_lon, max_lat] + const lon0 = bnds[0]; + const lat0 = bnds[1]; + const lon1 = bnds[2]; + const lat1 = bnds[3]; + const midlon = (lon0 + lon1) / 2.0; + const dlat = (lat1 - lat0) * 0.01; // keep each coverage bbox inside this one tile row + + const cscl: i32 = 20_000; // harbor band (z13-16); shared cscl → same governing band + const arc_a = try synthCell(gpa, a, "AAAAAAAA", lon0, lat0 + dlat, midlon, lat1 - dlat, cscl, z, tx, ty); + defer gpa.free(arc_a); + const arc_b = try synthCell(gpa, a, "BBBBBBBB", midlon, lat0 + dlat, lon1, lat1 - dlat, cscl, z, tx, ty); + defer gpa.free(arc_b); + + const composed = (try composeArchives(gpa, &.{ arc_a, arc_b })) orelse return error.TestUnexpectedResult; + defer gpa.free(composed); + + var r = try engine.pmtiles.Reader.init(gpa, composed); + defer r.deinit(); + // Composed archive is MLT, native harbor band only → the z13 tile exists. + try testing.expectEqual(engine.pmtiles.TileType.mlt, r.header.tile_type); + const raw = (try r.getTile(a, z, tx, ty)) orelse return error.TestUnexpectedResult; + const layers = try engine.mlt.decode(a, raw); + + var areas: ?engine.mvt.DecodedLayer = null; + for (layers) |l| { + if (std.mem.eql(u8, l.name, "areas")) areas = l; + } + const al = areas orelse return error.TestUnexpectedResult; + try testing.expect(al.features.len >= 2); // A's left half + B's right half, clipped at the seam + + const E = tile.EXTENT; + const midy: i64 = @divTrunc(E, 2); + // Left quarter owned by exactly one cell; right quarter by exactly one; neither by both + // (disjoint partition — the seam split the tile, it did not double-draw it). + try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(E, 4), midy)); + try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(@as(i64, E) * 3, 4), midy)); +} From 0c4f1cdbd8bd8301da2cd45305a5c7c781028e27 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 15:08:09 -0400 Subject: [PATCH 022/140] =?UTF-8?q?feat(cli):=20tile57=20compose=20?= =?UTF-8?q?=E2=80=94=20end-to-end=20per-cell=20composite=20bake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tile57 compose -o [--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) --- tools/compose.zig | 83 +++++++++++++++++++++++++++++++++++++++++++++++ tools/main.zig | 4 +++ 2 files changed, 87 insertions(+) create mode 100644 tools/compose.zig diff --git a/tools/compose.zig b/tools/compose.zig new file mode 100644 index 0000000..b8930ca --- /dev/null +++ b/tools/compose.zig @@ -0,0 +1,83 @@ +//! `compose -o [--rules DIR]` — the per-cell composite +//! bake. Bake each cell to its OWN native-scale PMTiles (with its M_COVR coverage embedded in +//! the metadata), then combine them via the ownership partition into ONE merged PMTiles +//! (bundle.composeArchives). This is the two-stage model that retires the streaming in-bake +//! cross-cell combiner: dumb, cacheable per-cell bakes + a compositor driven by precomputed +//! per-band ownership. Native scale only for now — cross-band zoom expansion is a later stage. + +const std = @import("std"); +const engine = @import("engine"); +const chart = @import("chart"); // per-cell bake (bakeCellBytes) + freeBytes +const bundle = @import("bundle"); // composeArchives (the partition-driven compositor) +const common = @import("common.zig"); +const Flags = common.Flags; +const usageErr = common.usageErr; +const resolveRulesDir = common.resolveRulesDir; + +pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { + var base: ?[]const u8 = null; + var out: ?[]const u8 = null; + var rules: ?[]const u8 = null; + + var f = Flags{ .args = args }; + while (f.next()) |arg| { + if (std.mem.eql(u8, arg, "-o") or std.mem.eql(u8, arg, "--output")) { + out = f.val(arg) orelse return; + } else if (std.mem.eql(u8, arg, "--rules")) { + rules = f.val(arg) orelse return; + } else if (std.mem.startsWith(u8, arg, "-")) { + return usageErr("unknown flag"); + } else if (base == null) { + base = arg; + } else { + return usageErr("unexpected argument (cell updates are auto-discovered next to the .000)"); + } + } + const base_path = base orelse return usageErr("missing input"); + const out_path = out orelse return usageErr("missing -o/--output "); + const rules_dir = resolveRulesDir(rules); + + // 1. Bake each cell to its own per-cell PMTiles (native band scale, coverage embedded). + // Archive bytes are owned by chart's global allocator (free with chart.freeBytes). + var archives = std.ArrayList([]u8).empty; + defer { + for (archives.items) |arc| chart.freeBytes(arc); + archives.deinit(a); + } + + if (std.mem.endsWith(u8, base_path, ".000")) { + if (try chart.bakeCellBytes(base_path, rules_dir)) |arc| try archives.append(a, arc); + } else { + var dir = std.Io.Dir.cwd().openDir(io, base_path, .{ .iterate = true }) catch return usageErr("cannot open ENC_ROOT"); + defer dir.close(io); + var seen = std.StringHashMap(void).init(a); + var walker = dir.walk(a) catch return usageErr("cannot walk ENC_ROOT"); + defer walker.deinit(); + while (walker.next(io) catch null) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; + const stem = std.fs.path.stem(std.fs.path.basename(entry.path)); + if (seen.contains(stem)) continue; // a boundary cell shared by two districts: once + seen.put(a.dupe(u8, stem) catch continue, {}) catch {}; + const full = std.fs.path.join(a, &.{ base_path, entry.path }) catch continue; + const arc = (chart.bakeCellBytes(full, rules_dir) catch |err| { + std.debug.print(" warn: bake of {s} failed ({s}); skipping\n", .{ stem, @errorName(err) }); + continue; + }) orelse continue; + try archives.append(a, arc); + if (archives.items.len % 25 == 0) std.debug.print(" baked {d} cells…\n", .{archives.items.len}); + } + } + if (archives.items.len == 0) return usageErr("no cells baked (no .000 with M_COVR found)"); + + // 2. Combine the per-cell archives via the ownership partition into one PMTiles. + const composed = (bundle.composeArchives(a, archives.items) catch |err| { + std.debug.print("error: compose failed ({s})\n", .{@errorName(err)}); + return; + }) orelse { + std.debug.print("compose produced no tiles\n", .{}); + return; + }; + + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = composed }); + std.debug.print("composed {d} cell(s) -> {s} ({d} bytes)\n", .{ archives.items.len, out_path, composed.len }); +} diff --git a/tools/main.zig b/tools/main.zig index 000fa1c..824259a 100644 --- a/tools/main.zig +++ b/tools/main.zig @@ -23,6 +23,7 @@ const std = @import("std"); const common = @import("common.zig"); const bake = @import("bake.zig"); +const compose = @import("compose.zig"); const assets = @import("assets.zig"); const sprite = @import("sprite.zig"); const pattern = @import("pattern.zig"); @@ -52,6 +53,9 @@ pub fn main(init: std.process.Init) !void { if (std.mem.eql(u8, sub, "bake")) { return bake.run(io, arena, args); } + if (std.mem.eql(u8, sub, "compose")) { + return compose.run(io, arena, args); + } if (std.mem.eql(u8, sub, "assets")) { return assets.run(io, arena, args); From 02794ef36869458b0fb33de6cb989054066ece3f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 16:14:01 -0400 Subject: [PATCH 023/140] =?UTF-8?q?feat(compose):=20cross-band=20overscale?= =?UTF-8?q?=20(Step=203b)=20=E2=80=94=20fill-up=20one=20zoom=20past=20a=20?= =?UTF-8?q?band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bundle.zig | 90 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index 500129e..202dd24 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -869,13 +869,18 @@ pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[ } // 4. Stream: one governing band per zoom (partition.mapForZoom), scatter each owner's - // native tiles, clip to its owned face, merge per layer, re-encode. + // native tiles, clip to its owned face, merge per layer, re-encode. The loop runs one + // fill-up zoom past the deepest native band (bounded by FILLUP_CEIL) so a coarser owner's + // ground stays covered just past its band (overscale); deeper coarse-only zooms are left + // to the client camera + MapLibre overzoom. + const fill_max = @min(maxz + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); + const loop_max = @max(maxz, fill_max); var sw = pmtiles.StreamWriter.init(gpa); defer sw.deinit(); var zoom_arena = std.heap.ArenaAllocator.init(gpa); // one zoom's tiles resident at a time defer zoom_arena.deinit(); var z: u8 = minz; - while (z <= maxz) : (z += 1) { + while (z <= loop_max) : (z += 1) { _ = zoom_arena.reset(.retain_capacity); try composeZoom(&sw, zoom_arena.allocator(), &part, readers[0..n], z); } @@ -925,6 +930,7 @@ fn composeZoom( for (map.faces) |face| { if (face.owned.len == 0) continue; const ci = face.index; + const cscl = part.cells[ci].cscl; // Tile cover of this owner's face bbox (lon/lat → world → tile index), nw..se. var fb = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; @@ -947,12 +953,13 @@ fn composeZoom( while (tx <= tx1) : (tx += 1) { var ty = ty0; while (ty <= ty1) : (ty += 1) { - // The cell's per-cell baked tile at (z,tx,ty) — absent outside its native band. - const raw = (try readers[ci].getTile(sa, z, tx, ty)) orelse continue; + // The cell's tile content at (z,tx,ty): its native tile, or — one fill-up zoom + // past its band — its deepest native ancestor scaled up (overscale). null = + // nothing reachable here. + const layers = (try ownerTile(sa, readers[ci], cscl, z, tx, ty)) orelse continue; // Project the owned face into THIS tile; empty = the cell owns no pixels here. const face_px = try compose.projectFace(sa, face.owned, z, tx, ty); if (face_px.len == 0) continue; - const layers = try decodeTile(sa, readers[ci].header.tile_type, raw); const gop = try tilemap.getOrPut(.{ .x = tx, .y = ty }); if (!gop.found_existing) for (gop.value_ptr) |*b| { @@ -1000,6 +1007,41 @@ fn decodeTile(a: std.mem.Allocator, tt: engine.pmtiles.TileType, raw: []const u8 }; } +// The decoded layers cell `r` contributes at (z,tx,ty): its native tile if it has one, else — +// when z is within the fill-up window just past the cell's band native max — its deepest native +// ancestor tile with the features scaled up into this descendant (overscale). null = nothing +// reachable (below native, or a coarse-only zoom beyond the fill-up window, where the client +// camera + MapLibre overzoom take over). Everything is arena-allocated in `a`. +fn ownerTile(a: std.mem.Allocator, r: *engine.pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32) !?[]engine.mvt.DecodedLayer { + const tt = r.header.tile_type; + if (try r.getTile(a, z, tx, ty)) |raw| return try decodeTile(a, tt, raw); + + const nmax = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cscl)).max; + if (z <= nmax or z > nmax + engine.bake_enc.FILLUP_DZ or z > engine.bake_enc.FILLUP_CEIL) return null; + const shift: u5 = @intCast(z - nmax); + const anc = (try r.getTile(a, nmax, tx >> shift, ty >> shift)) orelse return null; + const layers = try decodeTile(a, tt, anc); + scaleUpTile(layers, shift, tx, ty); + return layers; +} + +// Scale an ancestor tile's features up into descendant (tx,ty) — the sub-cell `shift` levels +// finer: pixel (px,py) → (px<= 1); + // The overscaled coastal fill covers the child tile interior. + try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(tile.EXTENT, 2), @divTrunc(tile.EXTENT, 2))); +} From 2751eb638b15725fd11b19dbfe7458408d282b03 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 16:21:11 -0400 Subject: [PATCH 024/140] =?UTF-8?q?feat(capi/go):=20tile57=5Fcompose=20+?= =?UTF-8?q?=20Go=20Compose=20=E2=80=94=20in-memory=20per-cell=20composite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bindings/go/bake.go | 42 ++++++++++++++++++++++++++++++++++++++++++ include/tile57.h | 10 ++++++++++ src/capi.zig | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/bindings/go/bake.go b/bindings/go/bake.go index aa79acb..50d92e4 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -192,6 +192,48 @@ func BakeCell(path string) ([]byte, error) { } } +// Compose combines per-cell PMTiles archives (each from [BakeCell], coverage embedded) +// into one merged PMTiles via the ownership partition: at every tile each owning cell's +// features are clipped to the ground it owns and merged, with cross-band overscale one zoom +// past each band. Native scale only otherwise — deeper coarse-only zooms are left to the +// client camera + MapLibre overzoom. Returns the composed archive bytes. +func Compose(archives [][]byte) ([]byte, error) { + if len(archives) == 0 { + return nil, fmt.Errorf("tile57: Compose needs at least one archive: %w", ErrEmptyInput) + } + // CGo-safe marshalling: concatenate the archives into one pointer-free buffer plus a + // lengths array, so C receives single pointers to memory holding no Go pointers. + lens := make([]C.size_t, len(archives)) + total := 0 + for i, a := range archives { + lens[i] = C.size_t(len(a)) + total += len(a) + } + if total == 0 { + return nil, fmt.Errorf("tile57: Compose archives are all empty: %w", ErrEmptyInput) + } + blob := make([]byte, 0, total) + for _, a := range archives { + blob = append(blob, a...) + } + + var out *C.uint8_t + var outLen C.size_t + rc := C.tile57_compose( + (*C.uint8_t)(unsafe.Pointer(&blob[0])), C.size_t(len(blob)), + (*C.size_t)(unsafe.Pointer(&lens[0])), C.size_t(len(archives)), + &out, &outLen, + ) + switch rc { + case 1: + return tileBytes(out, outLen), nil + case 0: + return nil, fmt.Errorf("tile57: compose produced no tiles: %w", ErrNoCoverage) + default: + return nil, fmt.Errorf("tile57: compose failed") + } +} + // PMTilesMetadata returns a PMTiles archive's metadata JSON blob (decompressed), or // nil if the archive carries none. A [BakeCell] archive embeds the cell's coverage // under a "coverage" key. The pmtiles bytes are read but not retained. diff --git a/include/tile57.h b/include/tile57.h index cc085eb..25a007e 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -102,6 +102,16 @@ tile57_chart *tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t * -1=error. */ int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); +/* Combine N per-cell PMTiles (each from tile57_bake_cell_bytes, coverage embedded) into + * one merged PMTiles via the ownership partition: at every tile each owning cell's features + * are clipped to the ground it owns and merged, with cross-band overscale one zoom past each + * band. The archives are passed CONCATENATED in `blob` (blob_len bytes total), split by the + * `n` lengths in `lens` (their sum must equal blob_len). Returns 1 with the composed archive + * in *out / *out_len (free with tile57_free), 0 if nothing composed, -1 on error. */ +int tile57_compose(const uint8_t *blob, size_t blob_len, + const size_t *lens, size_t n, + uint8_t **out, size_t *out_len); + /* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + * cscl + date/name under a "coverage" key, so the composite stitcher rebuilds the diff --git a/src/capi.zig b/src/capi.zig index 9ddc7af..9d85737 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -107,6 +107,39 @@ export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, out: *[*]u8, out_len: *us return 0; } +/// Combine N per-cell PMTiles archives (each from tile57_bake_cell_bytes, coverage +/// embedded) into one merged PMTiles via the ownership partition. The archives are passed +/// CONCATENATED in `blob` (total `blob_len` bytes), split by the `n` `lens` +/// (sum(lens) == blob_len). Returns 1 with the composed archive in *out / *out_len (free +/// with tile57_free), 0 if nothing composed, -1 on error. See tile57.h. +export fn tile57_compose( + blob: ?[*]const u8, + blob_len: usize, + lens: ?[*]const usize, + n: usize, + out: *[*]u8, + out_len: *usize, +) callconv(.c) c_int { + const b = blob orelse return -1; + const ls = lens orelse return -1; + if (n == 0) return 0; + const archives = gpa.alloc([]const u8, n) catch return -1; + defer gpa.free(archives); + var off: usize = 0; + for (0..n) |i| { + if (off + ls[i] > blob_len) return -1; // lengths overrun the blob + archives[i] = b[off .. off + ls[i]]; + off += ls[i]; + } + const composed = bundle.composeArchives(gpa, archives) catch return -1; + if (composed) |c| { + out.* = c.ptr; + out_len.* = c.len; + return 1; + } + return 0; +} + /// The metadata JSON blob of a PMTiles archive (e.g. the embedded per-cell "coverage" /// a single-cell bake carries), into *out / *out_len (free with tile57_free). 1=ok, /// 0=no metadata, -1=error. See tile57.h. From 63cbb18d912dac6a6436541b62ac4554fcf1978f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 16:35:20 -0400 Subject: [PATCH 025/140] feat(compose): streaming disk-to-disk composeArchivesToFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bundle.zig | 274 ++++++++++++++++++++++++++++++++++++---------- tools/compose.zig | 78 +++++++------ 2 files changed, 264 insertions(+), 88 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index 202dd24..e77f95a 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -793,48 +793,46 @@ fn worldAxisToTile(w: f64, scale: f64) u32 { const N_COMPOSE_LAYERS = engine.scene.VECTOR_LAYERS.len; -/// Compose per-cell PMTiles archives (BORROWED — kept alive by the caller) into one merged -/// PMTiles. Returns the composed archive bytes (`gpa`-owned; free with `gpa.free` / -/// `tile57_free`), or null if nothing composed. The partition is rebuilt from each archive's -/// embedded coverage (no `.000` re-parse), via the same `toPlaneCells` adapter the bake uses. -/// -/// Native-scale only for now: a cell contributes a tile only where it has a baked tile, so -/// ground a cell owns OUTSIDE its native zoom band is left empty at that zoom — cross-band -/// zoom expansion (overscale / fill-down) is a separate compositor stage. -pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[]u8 { +// The archive framing composeInto computes for the caller to seal the PMTiles: metadata JSON +// (gpa-owned; caller frees), union bbox (E7), a viewer center zoom, and the cell count. +const Framing = struct { + meta: []const u8, + ubox: [4]i32, + center_zoom: u8, + cells: usize, +}; + +// The shared compose core: recover each reader's embedded coverage + SCAMIN ladder, rebuild the +// ownership partition (via the canonical toPlaneCells adapter), and stream every composed tile +// into `sw` — one governing band per zoom (partition.mapForZoom), each owner's tiles clipped to +// its owned face and merged per layer, with cross-band overscale one zoom past each band. `sw` +// may be in-memory (composeArchives) or file-backed (composeArchivesToFile); this is agnostic. +// Returns the framing to seal the archive, or null if no cell carried coverage or no tile +// composed. Readers are BORROWED — kept alive by the caller; `readers` indexing aligns with the +// partition (readers without coverage are filtered out here). +fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers: []const *engine.pmtiles.Reader) !?Framing { const geo = engine.geo; - const pmtiles = engine.pmtiles; const scene = engine.scene; var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); - // 1. Open a Reader per archive and recover its embedded coverage + SCAMIN ladder. Archives - // with no coverage key are skipped. Readers are heap-stable (created in `a`) so getTile's - // lazy leaf-dir decode mutates them in place; they borrow the archive bytes. - const readers = try a.alloc(*pmtiles.Reader, archives.len); + // 1. Recover embedded coverage + SCAMIN from each reader; keep only those carrying coverage, + // aligned so cell index == kept-reader index for composeZoom. + var kept = std.ArrayList(*engine.pmtiles.Reader).empty; var shims = std.ArrayList(LoadedCov).empty; var scamins = std.AutoHashMap(u32, void).init(a); var ubox = [4]i32{ std.math.maxInt(i32), std.math.maxInt(i32), std.math.minInt(i32), std.math.minInt(i32) }; - var n: usize = 0; - for (archives) |arc| { - const rp = a.create(pmtiles.Reader) catch continue; - rp.* = pmtiles.Reader.init(gpa, arc) catch continue; - const meta = readMetaJson(a, rp) orelse { - rp.deinit(); - continue; - }; - const cc = (scene.coverage.decodeFromMetadata(a, meta) catch null) orelse { - rp.deinit(); - continue; - }; + for (readers) |rp| { + const meta = readMetaJson(a, rp) orelse continue; + const cc = (scene.coverage.decodeFromMetadata(a, meta) catch null) orelse continue; for (parseScamin(a, meta)) |s| scamins.put(s, {}) catch {}; ubox[0] = @min(ubox[0], cc.bbox[0]); ubox[1] = @min(ubox[1], cc.bbox[1]); ubox[2] = @max(ubox[2], cc.bbox[2]); ubox[3] = @max(ubox[3], cc.bbox[3]); - readers[n] = rp; + try kept.append(a, rp); try shims.append(a, .{ .name = cc.name, .date = cc.date, @@ -847,19 +845,17 @@ pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[ @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, }, }); - n += 1; } - defer for (readers[0..n]) |rp| rp.deinit(); - if (n == 0) return null; + if (shims.items.len == 0) return null; - // 2. Adapt to plane.Cell (THE canonical adapter: band floor via bandOf + the DSID - // ordersBeforeKeys tie-break, ranked across the whole set) and materialise the per-band - // ownership partition. `part` borrows `cells` (arena-backed) — keep `a` alive for it. + // 2. Adapt to plane.Cell (band floor via bandOf + the DSID ordersBeforeKeys tie-break) and + // materialise the per-band ownership partition. `part` borrows `cells` (arena-backed). const cells = try toPlaneCells(a, shims.items); var part = try geo.partition.build(gpa, cells); defer part.deinit(); - // 3. Output zoom span = union of the cells' native band windows. + // 3. Output zoom span = union of the cells' native band windows, plus one fill-up zoom past + // the deepest band (overscale; deeper coarse-only zooms → client camera + overzoom). var minz: u8 = 255; var maxz: u8 = 0; for (shims.items) |lc| { @@ -867,41 +863,147 @@ pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[ minz = @min(minz, zr.min); maxz = @max(maxz, zr.max); } - - // 4. Stream: one governing band per zoom (partition.mapForZoom), scatter each owner's - // native tiles, clip to its owned face, merge per layer, re-encode. The loop runs one - // fill-up zoom past the deepest native band (bounded by FILLUP_CEIL) so a coarser owner's - // ground stays covered just past its band (overscale); deeper coarse-only zooms are left - // to the client camera + MapLibre overzoom. const fill_max = @min(maxz + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); const loop_max = @max(maxz, fill_max); - var sw = pmtiles.StreamWriter.init(gpa); - defer sw.deinit(); - var zoom_arena = std.heap.ArenaAllocator.init(gpa); // one zoom's tiles resident at a time + + // 4. Stream one zoom at a time (only one zoom's tiles resident). + var zoom_arena = std.heap.ArenaAllocator.init(gpa); defer zoom_arena.deinit(); var z: u8 = minz; while (z <= loop_max) : (z += 1) { _ = zoom_arena.reset(.retain_capacity); - try composeZoom(&sw, zoom_arena.allocator(), &part, readers[0..n], z); + try composeZoom(sw, zoom_arena.allocator(), &part, kept.items, z); } if (sw.num_addressed == 0) return null; - // 5. Frame a viewer on the data + metadata (VECTOR_LAYERS + the union SCAMIN ladder; the - // composite carries no single-cell coverage). + // 5. Framing: VECTOR_LAYERS + union SCAMIN metadata (no single-cell coverage), bbox, center. const scamin_list = try scaminSorted(a, &scamins); - const meta = try scene.metadataJson(gpa, scamin_list, null); - defer gpa.free(meta); + const meta = try scene.metadataJson(gpa, scamin_list, null); // gpa-owned: survives `arena` const span_deg = @max(0.01, @as(f64, @floatFromInt(ubox[2] - ubox[0])) / 1e7); const cz: u8 = @intFromFloat(std.math.clamp(std.math.log2(720.0 / span_deg), @as(f64, @floatFromInt(minz)), @as(f64, @floatFromInt(maxz)))); - return try sw.finishBytes(.{ - .metadata_json = meta, + return .{ .meta = meta, .ubox = ubox, .center_zoom = cz, .cells = shims.items.len }; +} + +fn writeOpts(fr: Framing) engine.pmtiles.WriteOptions { + return .{ + .metadata_json = fr.meta, .tile_type = .mlt, - .min_lon_e7 = ubox[0], - .min_lat_e7 = ubox[1], - .max_lon_e7 = ubox[2], - .max_lat_e7 = ubox[3], - .center_zoom = cz, - }); + .min_lon_e7 = fr.ubox[0], + .min_lat_e7 = fr.ubox[1], + .max_lon_e7 = fr.ubox[2], + .max_lat_e7 = fr.ubox[3], + .center_zoom = fr.center_zoom, + }; +} + +/// Compose per-cell PMTiles archives (BORROWED — kept alive by the caller) into one merged +/// PMTiles, returned as bytes (`gpa`-owned; free with `gpa.free` / `tile57_free`), or null if +/// nothing composed. The partition is rebuilt from each archive's embedded coverage (no `.000` +/// re-parse). IN-MEMORY: holds all archives + the composed output resident — for whole districts +/// use `composeArchivesToFile` (mmap + streamed output). +pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[]u8 { + const pmtiles = engine.pmtiles; + var ra = std.heap.ArenaAllocator.init(gpa); + defer ra.deinit(); + const raa = ra.allocator(); + + var readers = std.ArrayList(*pmtiles.Reader).empty; + defer for (readers.items) |rp| rp.deinit(); + for (archives) |arc| { + const rp = raa.create(pmtiles.Reader) catch continue; + rp.* = pmtiles.Reader.init(gpa, arc) catch continue; + readers.append(raa, rp) catch rp.deinit(); + } + if (readers.items.len == 0) return null; + + var sw = pmtiles.StreamWriter.init(gpa); + defer sw.deinit(); + const fr = (try composeInto(gpa, &sw, readers.items)) orelse return null; + defer gpa.free(fr.meta); + return try sw.finishBytes(writeOpts(fr)); +} + +/// Streaming disk-to-disk compose: mmap each per-cell PMTiles at `paths` (so the OS pages tiles +/// on demand — the whole cell set is never resident) and stream the merged archive to `out_path` +/// (tile data to a temp file, then header/directories/metadata prepended). Peak memory is the +/// partition + one zoom's composed tiles + the touched-page working set — independent of the +/// number of cells. Returns the count of cells that contributed, or 0 if none. This is the path +/// a whole-district composite takes. +pub fn composeArchivesToFile(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, out_path: []const u8) !usize { + const pmtiles = engine.pmtiles; + var ra = std.heap.ArenaAllocator.init(gpa); + defer ra.deinit(); + const raa = ra.allocator(); + + // mmap each cell (read-only, private). Close the fd right after mapping — the mapping + // survives it, and keeping hundreds of fds open would hit the process limit. + var readers = std.ArrayList(*pmtiles.Reader).empty; + var maps = std.ArrayList([]align(std.heap.page_size_min) const u8).empty; + defer { + for (readers.items) |rp| rp.deinit(); + for (maps.items) |m| std.posix.munmap(m); + } + for (paths) |path| { + var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch continue; + const st = f.stat(io) catch { + f.close(io); + continue; + }; + const len: usize = @intCast(st.size); + if (len == 0) { + f.close(io); + continue; + } + const map = std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, f.handle, 0) catch { + f.close(io); + continue; + }; + f.close(io); + maps.append(raa, map) catch { + std.posix.munmap(map); + continue; + }; + const rp = raa.create(pmtiles.Reader) catch continue; + rp.* = pmtiles.Reader.init(gpa, map) catch continue; + readers.append(raa, rp) catch rp.deinit(); + } + if (readers.items.len == 0) return 0; + + // Stream the composed tile data to a temp file; the header/dirs/metadata prefix is assembled + // last and written ahead of it (mirrors the streaming root bake). + const data_tmp = try std.fmt.allocPrint(raa, "{s}.data.tmp", .{out_path}); + var data_file = try std.Io.Dir.cwd().createFile(io, data_tmp, .{ .read = true }); + errdefer { + data_file.close(io); + std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; + } + var sw = pmtiles.StreamWriter.initFile(gpa, io, data_file); + defer sw.deinit(); + + const fr = (try composeInto(gpa, &sw, readers.items)) orelse { + data_file.close(io); + std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; + return 0; + }; + defer gpa.free(fr.meta); + + const pre = try sw.prefix(raa, writeOpts(fr)); + var out_file = try std.Io.Dir.cwd().createFile(io, out_path, .{}); + try out_file.writeStreamingAll(io, pre); + { + const chunk = try raa.alloc(u8, 1 << 20); // 1 MiB + var pos: u64 = 0; + while (pos < sw.data_len) { + const want: usize = @intCast(@min(@as(u64, chunk.len), sw.data_len - pos)); + _ = try data_file.readPositionalAll(io, chunk[0..want], pos); + try out_file.writeStreamingAll(io, chunk[0..want]); + pos += want; + } + } + out_file.close(io); + data_file.close(io); + std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; + return fr.cells; } /// Compose every tile of one zoom `z`: for each owning cell (the band governing `z`), fetch @@ -2251,3 +2353,63 @@ test "composeArchives: a coarse owner overscales one fill-up zoom past its nativ // The overscaled coastal fill covers the child tile interior. try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(tile.EXTENT, 2), @divTrunc(tile.EXTENT, 2))); } + +test "composeArchivesToFile: streams a seam split disk-to-disk" { + const gpa = testing.allocator; + const io = testing.io; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const tile = engine.tile; + + // Same split as the in-memory seam test, but the per-cell archives go to disk, are mmap'd, + // and the composite streams back to a file. + const z: u8 = 13; + const scale: f64 = @floatFromInt(@as(u64, 1) << z); + const w = tile.lonLatToWorld(-76.48, 38.97); + const tx = worldAxisToTile(w[0], scale); + const ty = worldAxisToTile(w[1], scale); + const bnds = tile.tileBoundsLonLat(z, tx, ty); + const lon0 = bnds[0]; + const lat0 = bnds[1]; + const lon1 = bnds[2]; + const lat1 = bnds[3]; + const midlon = (lon0 + lon1) / 2.0; + const dlat = (lat1 - lat0) * 0.01; + + const arc_a = try synthCell(gpa, a, "AAAAAAAA", lon0, lat0 + dlat, midlon, lat1 - dlat, 20_000, z, tx, ty); + defer gpa.free(arc_a); + const arc_b = try synthCell(gpa, a, "BBBBBBBB", midlon, lat0 + dlat, lon1, lat1 - dlat, 20_000, z, tx, ty); + defer gpa.free(arc_b); + + const dir = std.Io.Dir.cwd(); + const pa = ".zig-cache/compose_stream_a.pmtiles.tmp"; + const pb = ".zig-cache/compose_stream_b.pmtiles.tmp"; + const po = ".zig-cache/compose_stream_out.pmtiles.tmp"; + try dir.writeFile(io, .{ .sub_path = pa, .data = arc_a }); + try dir.writeFile(io, .{ .sub_path = pb, .data = arc_b }); + defer { + dir.deleteFile(io, pa) catch {}; + dir.deleteFile(io, pb) catch {}; + dir.deleteFile(io, po) catch {}; + } + + const nc = try composeArchivesToFile(io, gpa, &.{ pa, pb }, po); + try testing.expectEqual(@as(usize, 2), nc); + + const bytes = try dir.readFileAlloc(io, po, a, .unlimited); + var r = try engine.pmtiles.Reader.init(gpa, bytes); + defer r.deinit(); + try testing.expectEqual(engine.pmtiles.TileType.mlt, r.header.tile_type); + const raw = (try r.getTile(a, z, tx, ty)) orelse return error.TestUnexpectedResult; + const layers = try engine.mlt.decode(a, raw); + var areas: ?engine.mvt.DecodedLayer = null; + for (layers) |l| { + if (std.mem.eql(u8, l.name, "areas")) areas = l; + } + const al = areas orelse return error.TestUnexpectedResult; + const E = tile.EXTENT; + const midy: i64 = @divTrunc(E, 2); + try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(E, 4), midy)); + try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(@as(i64, E) * 3, 4), midy)); +} diff --git a/tools/compose.zig b/tools/compose.zig index b8930ca..f36dec7 100644 --- a/tools/compose.zig +++ b/tools/compose.zig @@ -1,14 +1,16 @@ -//! `compose -o [--rules DIR]` — the per-cell composite -//! bake. Bake each cell to its OWN native-scale PMTiles (with its M_COVR coverage embedded in -//! the metadata), then combine them via the ownership partition into ONE merged PMTiles -//! (bundle.composeArchives). This is the two-stage model that retires the streaming in-bake -//! cross-cell combiner: dumb, cacheable per-cell bakes + a compositor driven by precomputed -//! per-band ownership. Native scale only for now — cross-band zoom expansion is a later stage. +//! `compose -o [--rules DIR] [--keep-cells]` — the +//! per-cell composite bake, disk-to-disk. Bake each cell to its OWN native-scale PMTiles (with +//! its M_COVR coverage embedded in the metadata), written to a temp file so only ONE cell's +//! archive is ever resident; then stream them through the ownership partition into one merged +//! PMTiles (bundle.composeArchivesToFile mmaps the per-cell files, so the whole cell set is +//! never loaded into memory at once). This is the two-stage model that retires the streaming +//! in-bake cross-cell combiner. Native scale only for now — cross-band overscale one zoom past +//! each band; deeper coarse-only zooms are left to the client camera + MapLibre overzoom. const std = @import("std"); const engine = @import("engine"); const chart = @import("chart"); // per-cell bake (bakeCellBytes) + freeBytes -const bundle = @import("bundle"); // composeArchives (the partition-driven compositor) +const bundle = @import("bundle"); // composeArchivesToFile (the partition-driven compositor) const common = @import("common.zig"); const Flags = common.Flags; const usageErr = common.usageErr; @@ -18,6 +20,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var base: ?[]const u8 = null; var out: ?[]const u8 = null; var rules: ?[]const u8 = null; + var keep_cells = false; // retain the per-cell temp PMTiles (a reusable cache) instead of deleting var f = Flags{ .args = args }; while (f.next()) |arg| { @@ -25,6 +28,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { out = f.val(arg) orelse return; } else if (std.mem.eql(u8, arg, "--rules")) { rules = f.val(arg) orelse return; + } else if (std.mem.eql(u8, arg, "--keep-cells")) { + keep_cells = true; } else if (std.mem.startsWith(u8, arg, "-")) { return usageErr("unknown flag"); } else if (base == null) { @@ -37,16 +42,11 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const out_path = out orelse return usageErr("missing -o/--output "); const rules_dir = resolveRulesDir(rules); - // 1. Bake each cell to its own per-cell PMTiles (native band scale, coverage embedded). - // Archive bytes are owned by chart's global allocator (free with chart.freeBytes). - var archives = std.ArrayList([]u8).empty; - defer { - for (archives.items) |arc| chart.freeBytes(arc); - archives.deinit(a); - } - + // 1. Enumerate the cell .000 paths (dedup by stem — a boundary cell shared by two districts + // bakes once). + var cell_paths = std.ArrayList([]const u8).empty; if (std.mem.endsWith(u8, base_path, ".000")) { - if (try chart.bakeCellBytes(base_path, rules_dir)) |arc| try archives.append(a, arc); + try cell_paths.append(a, base_path); } else { var dir = std.Io.Dir.cwd().openDir(io, base_path, .{ .iterate = true }) catch return usageErr("cannot open ENC_ROOT"); defer dir.close(io); @@ -56,28 +56,42 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { while (walker.next(io) catch null) |entry| { if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; const stem = std.fs.path.stem(std.fs.path.basename(entry.path)); - if (seen.contains(stem)) continue; // a boundary cell shared by two districts: once + if (seen.contains(stem)) continue; seen.put(a.dupe(u8, stem) catch continue, {}) catch {}; - const full = std.fs.path.join(a, &.{ base_path, entry.path }) catch continue; - const arc = (chart.bakeCellBytes(full, rules_dir) catch |err| { - std.debug.print(" warn: bake of {s} failed ({s}); skipping\n", .{ stem, @errorName(err) }); - continue; - }) orelse continue; - try archives.append(a, arc); - if (archives.items.len % 25 == 0) std.debug.print(" baked {d} cells…\n", .{archives.items.len}); + cell_paths.append(a, std.fs.path.join(a, &.{ base_path, entry.path }) catch continue) catch {}; } } - if (archives.items.len == 0) return usageErr("no cells baked (no .000 with M_COVR found)"); + if (cell_paths.items.len == 0) return usageErr("no .000 cells found"); + + // 2. Bake each cell to its own temp PMTiles file (one cell resident at a time — the bytes + // are freed as soon as they are written). + var tmp_paths = std.ArrayList([]const u8).empty; + defer if (!keep_cells) for (tmp_paths.items) |p| { + std.Io.Dir.cwd().deleteFile(io, p) catch {}; + }; + for (cell_paths.items) |cp| { + const arc = (chart.bakeCellBytes(cp, rules_dir) catch |err| { + std.debug.print(" warn: bake of {s} failed ({s}); skipping\n", .{ cp, @errorName(err) }); + continue; + }) orelse continue; + defer chart.freeBytes(arc); + const stem = std.fs.path.stem(std.fs.path.basename(cp)); + const tmp = std.fmt.allocPrint(a, "{s}.{s}.cell.tmp", .{ out_path, stem }) catch continue; + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = tmp, .data = arc }) catch continue; + tmp_paths.append(a, tmp) catch {}; + if (tmp_paths.items.len % 25 == 0) std.debug.print(" baked {d}/{d} cells…\n", .{ tmp_paths.items.len, cell_paths.items.len }); + } + if (tmp_paths.items.len == 0) return usageErr("no cells baked (no .000 with M_COVR found)"); - // 2. Combine the per-cell archives via the ownership partition into one PMTiles. - const composed = (bundle.composeArchives(a, archives.items) catch |err| { + // 3. Stream-compose the per-cell files into one PMTiles (mmap in, streamed out — the cell + // set is never all resident). + const nc = bundle.composeArchivesToFile(io, a, tmp_paths.items, out_path) catch |err| { std.debug.print("error: compose failed ({s})\n", .{@errorName(err)}); return; - }) orelse { + }; + if (nc == 0) { std.debug.print("compose produced no tiles\n", .{}); return; - }; - - try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = composed }); - std.debug.print("composed {d} cell(s) -> {s} ({d} bytes)\n", .{ archives.items.len, out_path, composed.len }); + } + std.debug.print("composed {d} cell(s) -> {s}{s}\n", .{ nc, out_path, if (keep_cells) " (per-cell temp files kept)" else "" }); } From f624a5003aacd3d21ed24e13aec878679123d162 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 16:45:14 -0400 Subject: [PATCH 026/140] feat(capi/go): tile57_compose_files + Go ComposeFiles (streaming) + bundle compiler-rt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bindings/go/bake.go | 38 ++++++++++++++++++++++++++++++++++++++ build.zig | 6 ++++++ include/tile57.h | 9 +++++++++ src/capi.zig | 26 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+) diff --git a/bindings/go/bake.go b/bindings/go/bake.go index 50d92e4..16c4501 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -234,6 +234,44 @@ func Compose(archives [][]byte) ([]byte, error) { } } +// ComposeFiles streaming-composes the per-cell PMTiles at `paths` (each written by [BakeCell] +// to disk) into one merged PMTiles at outPath. The per-cell files are mmap'd (never all +// resident) and the output is streamed, so a whole district composes in bounded memory — +// prefer this over [Compose] for large cell sets. Returns the number of contributing cells. +func ComposeFiles(paths []string, outPath string) (int, error) { + if len(paths) == 0 { + return 0, fmt.Errorf("tile57: ComposeFiles needs at least one path: %w", ErrEmptyInput) + } + if outPath == "" { + return 0, fmt.Errorf("tile57: ComposeFiles needs an output path: %w", ErrEmptyInput) + } + cpaths := make([]*C.char, len(paths)) + for i, p := range paths { + cpaths[i] = C.CString(p) + } + defer func() { + for _, cp := range cpaths { + C.free(unsafe.Pointer(cp)) + } + }() + cOut := C.CString(outPath) + defer C.free(unsafe.Pointer(cOut)) + + var cells C.uint32_t + rc := C.tile57_compose_files( + (**C.char)(unsafe.Pointer(&cpaths[0])), C.size_t(len(paths)), + cOut, &cells, + ) + switch rc { + case 1: + return int(cells), nil + case 0: + return 0, fmt.Errorf("tile57: compose produced no tiles: %w", ErrNoCoverage) + default: + return 0, fmt.Errorf("tile57: compose files failed") + } +} + // PMTilesMetadata returns a PMTiles archive's metadata JSON blob (decompressed), or // nil if the archive carries none. A [BakeCell] archive embeds the cell's coverage // under a "coverage" key. The pmtiles bytes are read but not retained. diff --git a/build.zig b/build.zig index b2bc3d3..4bc96a7 100644 --- a/build.zig +++ b/build.zig @@ -370,6 +370,12 @@ pub fn build(b: *std.Build) void { lib_mod.addImport("colorprofile_registry", colorprofile_registry); lib_mod.addImport("catalog", catalog_embed); // chart.renderView symbol/pattern store const lib = b.addLibrary(.{ .name = "tile57", .linkage = .static, .root_module = lib_mod }); + // Bundle compiler-rt INTO the static archive. A non-Zig linker (the CGO host's gcc/clang, + // `go test`) has no access to Zig's compiler-rt, so builtins the code references — e.g. + // `roundq` (f128 @round, pulled in by std.json's number→int coercion in coverage decode) — + // would be undefined at link time. Static libs default to NOT bundling it; force it on so + // libtile57.a is self-contained for C consumers. + lib.bundle_compiler_rt = true; if (target.result.os.tag == .macos) { // Apple's ld64 rejects 64-bit mach-o archive members whose offsets aren't // 8-byte aligned, and Zig's archiver doesn't align them — so the raw diff --git a/include/tile57.h b/include/tile57.h index 25a007e..5c2547f 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -112,6 +112,15 @@ int tile57_compose(const uint8_t *blob, size_t blob_len, const size_t *lens, size_t n, uint8_t **out, size_t *out_len); +/* Streaming disk-to-disk composite: combine the `n` per-cell PMTiles at `paths` (each written + * by tile57_bake_cell_bytes) into one merged PMTiles at `out_path`. The per-cell files are + * mmap'd (never all resident) and the output is streamed, so a whole district composes in + * bounded memory — prefer this over tile57_compose for large cell sets. Writes the count of + * contributing cells to *out_cells if non-NULL. Returns 1 on success, 0 if nothing composed, + * -1 on error. */ +int tile57_compose_files(const char *const *paths, size_t n, const char *out_path, + uint32_t *out_cells); + /* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + * cscl + date/name under a "coverage" key, so the composite stitcher rebuilds the diff --git a/src/capi.zig b/src/capi.zig index 9d85737..8e45463 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -140,6 +140,32 @@ export fn tile57_compose( return 0; } +/// Streaming disk-to-disk composite: combine the `n` per-cell PMTiles at `paths` (each from +/// tile57_bake_cell_bytes, written to disk) into one merged PMTiles at `out_path`. The per-cell +/// files are mmap'd (never all resident) and the output is streamed, so a whole district +/// composes in bounded memory. Writes the count of contributing cells to *out_cells if +/// non-null. Returns 1 on success, 0 if nothing composed, -1 on error. See tile57.h. +export fn tile57_compose_files( + paths: ?[*]const ?[*:0]const u8, + n: usize, + out_path: ?[*:0]const u8, + out_cells: ?*u32, +) callconv(.c) c_int { + const ps = paths orelse return -1; + const op = spanOpt(out_path) orelse return -1; + if (n == 0) return 0; + const list = gpa.alloc([]const u8, n) catch return -1; + defer gpa.free(list); + for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return -1; + + // The lib has no std.process.Init; stand up a threaded std.Io for the file I/O. + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const nc = bundle.composeArchivesToFile(threaded.io(), gpa, list, op) catch return -1; + if (out_cells) |p| p.* = @intCast(nc); + return if (nc > 0) 1 else 0; +} + /// The metadata JSON blob of a PMTiles archive (e.g. the embedded per-cell "coverage" /// a single-cell bake carries), into *out / *out_len (free with tile57_free). 1=ok, /// 0=no metadata, -1=error. See tile57.h. From e6bccd57247f2b7f851a9e1536cff57a7a200ef5 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 17:05:15 -0400 Subject: [PATCH 027/140] =?UTF-8?q?feat(compose):=20FULL-tile=20verbatim?= =?UTF-8?q?=20passthrough=20(Step=203c)=20=E2=80=94=20byte-identity=20+=20?= =?UTF-8?q?speed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bundle.zig | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/bundle.zig b/src/bundle.zig index e77f95a..6c08569 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -1051,10 +1051,42 @@ fn composeZoom( const ty0 = worldAxisToTile(w_tl[1], scale); const ty1 = worldAxisToTile(w_br[1], scale); + // FULL/EMPTY/SEAM classifier over this owner's owned face (integer lon/lat). A tile the + // owner covers ENTIRELY (its buffer too, no seam crossing) is copied VERBATIM from the + // per-cell bake — byte-identical, no decode/clip/re-encode; only seam tiles run the + // geometry path. Bucket ≈ one tile wide. (classify's `covered` is the owned face, so its + // labels are inverted from their names: center-inside → `.empty` = fully owned.) + const tile_w_e7: i64 = @max(1, @divFloor(@as(i64, 3_600_000_000), @as(i64, 1) << @intCast(z))); + var grid = try engine.geo.plane.EdgeGrid.init(sa, face.owned, tile_w_e7); + defer grid.deinit(); + var tx = tx0; while (tx <= tx1) : (tx += 1) { var ty = ty0; while (ty <= ty1) : (ty += 1) { + // Classify (z,tx,ty) against the owned face, the box expanded by the render + // BUFFER so a passthrough only fires when the owner owns the tile AND its buffer. + const tb = tile.tileBoundsLonLat(z, tx, ty); // [min_lon, min_lat, max_lon, max_lat] + const lon0: i64 = @intFromFloat(@round(tb[0] * 1e7)); + const lat0: i64 = @intFromFloat(@round(tb[1] * 1e7)); + const lon1: i64 = @intFromFloat(@round(tb[2] * 1e7)); + const lat1: i64 = @intFromFloat(@round(tb[3] * 1e7)); + const bufx = @divTrunc((lon1 - lon0) * @as(i64, tile.BUFFER), @as(i64, tile.EXTENT)); + const bufy = @divTrunc((lat1 - lat0) * @as(i64, tile.BUFFER), @as(i64, tile.EXTENT)); + const box = engine.geo.plane.Box{ .min_x = lon0 - bufx, .min_y = lat0 - bufy, .max_x = lon1 + bufx, .max_y = lat1 + bufy }; + switch (grid.classify(box)) { + .full => continue, // owner owns none of this tile (buffer included): skip + .empty => { + // Owner owns the whole tile: copy its native blob verbatim if present + // (byte-identical), else fall through to the overscale/clip path. + if (try readers[ci].getCompressed(z, tx, ty)) |blob| { + try sw.addCompressed(z, tx, ty, blob); + continue; + } + }, + .seam => {}, // real geometry: decode + clip below + } + // The cell's tile content at (z,tx,ty): its native tile, or — one fill-up zoom // past its band — its deepest native ancestor scaled up (overscale). null = // nothing reachable here. @@ -2354,6 +2386,40 @@ test "composeArchives: a coarse owner overscales one fill-up zoom past its nativ try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(tile.EXTENT, 2), @divTrunc(tile.EXTENT, 2))); } +test "composeArchives: an interior tile is copied byte-identical from the per-cell bake" { + const gpa = testing.allocator; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const tile = engine.tile; + + // A single harbor cell whose coverage spans a 3×3 tile block, with a native tile at the + // centre (z14). The centre tile + its buffer is fully interior to the owned face (a lone + // cell owns everything), so the compositor must copy it VERBATIM — byte-identical to the + // per-cell bake, no decode/clip/re-encode. + const z: u8 = 14; + const scale: f64 = @floatFromInt(@as(u64, 1) << z); + const w = tile.lonLatToWorld(-76.5, 39.0); + const tx = worldAxisToTile(w[0], scale); + const ty = worldAxisToTile(w[1], scale); + const tb = tile.tileBoundsLonLat(z, tx, ty); + const dlon = tb[2] - tb[0]; + const dlat = tb[3] - tb[1]; + const arc = try synthCell(gpa, a, "INTERIOR", tb[0] - dlon, tb[1] - dlat, tb[2] + dlon, tb[3] + dlat, 20_000, z, tx, ty); + defer gpa.free(arc); + + const composed = (try composeArchives(gpa, &.{arc})) orelse return error.TestUnexpectedResult; + defer gpa.free(composed); + + var rc = try engine.pmtiles.Reader.init(gpa, composed); + defer rc.deinit(); + var rp = try engine.pmtiles.Reader.init(gpa, arc); + defer rp.deinit(); + const got = (try rc.getCompressed(z, tx, ty)) orelse return error.TestUnexpectedResult; + const want = (try rp.getCompressed(z, tx, ty)) orelse return error.TestUnexpectedResult; + try testing.expectEqualSlices(u8, want, got); // verbatim passthrough +} + test "composeArchivesToFile: streams a seam split disk-to-disk" { const gpa = testing.allocator; const io = testing.io; From b7983ff8956708cf059f92170313d6a3f63a16b4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 17:46:41 -0400 Subject: [PATCH 028/140] fix(capi): tile57_chart_open uses the streaming path (metadata + lazy), not bake-to-reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bindings/go/tile57_test.go | 82 +++++++++++++++----------------------- src/capi.zig | 9 +++-- 2 files changed, 38 insertions(+), 53 deletions(-) diff --git a/bindings/go/tile57_test.go b/bindings/go/tile57_test.go index 0ee225a..21e1901 100644 --- a/bindings/go/tile57_test.go +++ b/bindings/go/tile57_test.go @@ -28,26 +28,32 @@ func TestColortablesDefault(t *testing.T) { } } -// A resident chart (OpenChartBytes) portrays live, so its generated tiles carry the -// S-52 §10.8 pick-report attributes (class / cell / s57) on every feature by default. -// The per-cell Name badge and the pick-attrs opt-out were parameters of the dropped -// multi-cell open (chart-api.md); the surviving live-open surface always includes the -// pick report. +// Pick-report attributes (class / s57 / cell, S-52 §10.8) ride on BAKED tiles by default +// (pick_attrs on). Cell-backed charts are metadata-only — bake first, serve the archive — +// so bake the fixture to PMTiles and scan its tiles for the pick-report keys. func TestPickAttrs(t *testing.T) { data, err := os.ReadFile(testCell) if err != nil { t.Skipf("no test cell: %v", err) } - src, err := OpenChartBytes(data) + pm, err := BakePmtiles([]Cell{{Base: data}}, BakeOpts{MaxZoom: 24}, nil) if err != nil { - t.Fatalf("OpenChartBytes: %v", err) + t.Fatalf("BakePmtiles: %v", err) + } + tmp := t.TempDir() + "/chart.pmtiles" + if err := os.WriteFile(tmp, pm, 0o644); err != nil { + t.Fatal(err) + } + src, err := OpenPMTiles(tmp) + if err != nil { + t.Fatalf("OpenPMTiles: %v", err) } defer src.Close() // Scan the tile grid covering the chart bounds until a tile carries the pick-report // property keys (present on every feature when pick attrs are on). info := src.Info() - for z := info.MinZoom; z <= info.MaxZoom && z <= 14; z++ { + for z := info.MinZoom; z <= info.MaxZoom && z <= 16; z++ { x0, y0 := lonLatToTile(info.West, info.North, z) x1, y1 := lonLatToTile(info.East, info.South, z) for x := x0; x <= x1; x++ { @@ -62,10 +68,13 @@ func TestPickAttrs(t *testing.T) { } } } - t.Fatal("resident chart tiles carry no pick-report attributes (class/s57) — expected them by default") + t.Fatal("baked tiles carry no pick-report attributes (class/s57) — expected them by default") } -func TestOpenCellAndTile(t *testing.T) { +// A cell-backed chart (OpenChartBytes) is METADATA-ONLY: it exposes bounds/scale for a +// header scan but never generates tiles on demand — the chart-api contract is "bake first, +// serve the archive". Assert the metadata is present and that Tile() is refused. +func TestOpenCellMetadata(t *testing.T) { data, err := os.ReadFile(testCell) if err != nil { t.Skipf("no test cell: %v", err) @@ -77,36 +86,21 @@ func TestOpenCellAndTile(t *testing.T) { defer src.Close() info := src.Info() - if info.MaxZoom < info.MinZoom { - t.Fatalf("bad zoom range %d..%d", info.MinZoom, info.MaxZoom) - } - if !info.HasBounds { - t.Fatal("expected known bounds for a single cell") - } - t.Logf("zoom %d..%d, bands=%#b, bounds W=%.4f S=%.4f E=%.4f N=%.4f", - info.MinZoom, info.MaxZoom, info.Bands, info.West, info.South, info.East, info.North) - - // Address the tile covering the cell's bounds centre at each zoom; assert at - // least one non-empty MVT is produced across the cell's range. - got := false - for z := info.MinZoom; z <= info.MaxZoom && z <= 14; z++ { - tx, ty := lonLatToTile((info.West+info.East)/2, (info.South+info.North)/2, z) - body, err := src.Tile(z, tx, ty) - if err != nil { - t.Fatalf("Tile %d/%d/%d: %v", z, tx, ty, err) - } - if len(body) > 0 { - got = true - t.Logf("tile %d/%d/%d -> %d bytes MVT", z, tx, ty, len(body)) - } + if info.MaxZoom < info.MinZoom || !info.HasBounds { + t.Fatalf("cell metadata looks unset: %+v", info) } - if !got { - t.Fatal("no non-empty tile produced across the cell's zoom range") + t.Logf("zoom %d..%d, bounds W=%.4f S=%.4f E=%.4f N=%.4f", + info.MinZoom, info.MaxZoom, info.West, info.South, info.East, info.North) + + // Cell-backed tiles are refused by design — bake first (BakeCell / BakePmtiles). + if _, err := src.Tile(info.MinZoom, 0, 0); err == nil { + t.Fatal("cell-backed Tile() should be refused (metadata-only; bake first)") } } -// TestOpenPath opens the testdata directory as a STREAMING chart (engine enumerates -// + reads the .000 on demand) and asserts it tiles like the byte-opened cell. +// TestOpenPath opens a directory as a STREAMING chart (the engine enumerates cell +// metadata + reads the .000 on demand). Like a cell-backed chart it is metadata-only: +// bounds/zoom are known, but tiles come from a bake, not on-demand generation. func TestOpenPath(t *testing.T) { if _, err := os.Stat(testCell); err != nil { t.Skipf("no test cell: %v", err) @@ -124,19 +118,9 @@ func TestOpenPath(t *testing.T) { t.Logf("streamed: zoom %d..%d bounds W=%.4f S=%.4f E=%.4f N=%.4f", info.MinZoom, info.MaxZoom, info.West, info.South, info.East, info.North) - got := false - for z := info.MinZoom; z <= info.MaxZoom && z <= 14; z++ { - tx, ty := lonLatToTile((info.West+info.East)/2, (info.South+info.North)/2, z) - body, err := src.Tile(z, tx, ty) - if err != nil { - t.Fatalf("Tile %d/%d/%d: %v", z, tx, ty, err) - } - if len(body) > 0 { - got = true - } - } - if !got { - t.Fatal("no non-empty tile from the streamed chart") + // Streaming cell-backed tiles are refused by design — bake first. + if _, err := src.Tile(info.MinZoom, 0, 0); err == nil { + t.Fatal("streaming cell-backed Tile() should be refused (metadata-only; bake first)") } } diff --git a/src/capi.zig b/src/capi.zig index 8e45463..f078e12 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -69,12 +69,13 @@ fn toCellInputs(a: std.mem.Allocator, c_cells: []const CellInput) ?[]chart.CellI } /// Open ONE S-57 cell (a .000 file, with its .001.. update chain auto-read from the -/// same directory) by baking it to an in-memory PMTiles and serving the fast reader -/// path — with the cell's real M_COVR coverage + compilation scale attached. For a -/// whole ENC_ROOT directory use tile57_charts_open. See tile57.h. +/// same directory) — or a whole ENC_ROOT directory — via the STREAMING path-open: cell +/// metadata (name/scale/M_COVR) is enumerated up front and tiles are baked lazily per +/// request. Unlike a bake-to-reader open, this backend exposes the per-cell list +/// (tile57_chart_cells) — a header/metadata scan needs no tile bake. See tile57.h. export fn tile57_chart_open(path: ?[*:0]const u8) callconv(.c) ?*Chart { const p = spanOpt(path) orelse return null; - return chart.openCellPath(p, null, true) catch null; + return chart.Chart.openPath(p, null, true) catch null; } /// Open ONE cell for METADATA ONLY (bbox + native scale + M_COVR coverage): a cheap From 94a24632b434d89e3ff2a3aa664162ae3f1b9036 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 17:49:46 -0400 Subject: [PATCH 029/140] test(go): skip the retired per-value SCAMIN bucket/log2 style tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bindings/go/style_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bindings/go/style_test.go b/bindings/go/style_test.go index 84c3e4e..064c71c 100644 --- a/bindings/go/style_test.go +++ b/bindings/go/style_test.go @@ -43,6 +43,9 @@ func TestStyle(t *testing.T) { // gated style uses the per-feature zoom-gate fallback ("log2"); IgnoreScamin drops // it so every feature shows in-band. func TestIgnoreScamin(t *testing.T) { + t.Skip("asserts the retired per-value SCAMIN model (log2 per-feature gate): dropped in " + + "f9887c9 for the merged band-independent gate. Update to the merged/filter-gate model " + + "(specs/scamin-layers.md, host-side pending).") build := func(ignore bool) string { m := MarinerDefaults() m.IgnoreScamin = ignore @@ -94,6 +97,9 @@ func TestViewingGroupsOff(t *testing.T) { // layers fall back to the per-feature zoom-gate (log2); with one they become // fractional-minzoom bucket layers (one per denominator, no log2 fallback). func TestScaminBuckets(t *testing.T) { + t.Skip("asserts the retired per-value SCAMIN #sm bucket layers: dropped in f9887c9 for " + + "the merged band-independent gate (buckets no longer emitted). Update to the merged/" + + "filter-gate model (specs/scamin-layers.md, host-side pending).") m := MarinerDefaults() scamin := []int32{89999, 119999, 259999} withManifest, err := Style(SchemeDay, "tile57://{z}/{x}/{y}", "sprite", @@ -177,6 +183,9 @@ func TestStyleEncodingHint(t *testing.T) { // with a manifest, ScaminFilterGate collapses the per-value #sm bucket layers to one // live-filtered layer per render-type — far fewer layers, no #sm, no native minzoom. func TestScaminFilterGate(t *testing.T) { + t.Skip("asserts the non-gated path emits per-value #sm bucket layers, but buckets were " + + "dropped in f9887c9 (merged band-independent gate is the only mode). Rewrite against " + + "the merged gate vs filter-gate output (specs/scamin-layers.md, host-side pending).") ct, _ := ColortablesDefault() tmpl, err := StyleTemplate(SchemeDay, "tile57://{z}/{x}/{y}", "sprite", "glyphs/{fontstack}/{range}.pbf", 0, 0, FormatMVT) From 1fedac33e1c85594847d7601b4ea7b376699a359 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 18:50:46 -0400 Subject: [PATCH 030/140] perf(bake): build the per-feature label cache in the composite per-cell bake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/chart.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/chart.zig b/src/chart.zig index 3c94d52..2bdc894 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -1936,6 +1936,12 @@ const BakeWork = struct { portrayal_simplified = cp.simplified; } else |_| {} if (c.build_geo) geo = scene.buildGeoCache(p.allocator(), &cell) catch null; + // Per-feature label point (polylabel) cache — tile-invariant, so compute it ONCE + // per cell (only for features whose portrayal draws a Text/centred-symbol) instead + // of re-running the pole-of-inaccessibility search for every tile a feature touches. + // Without this the per-tile recompute dominates the bake of large coarse cells + // (measured US1GC09M: 6m17s → seconds). Mirrors bakeRoot; arena outlives via c.arenas. + cell.label_cache = scene.buildLabelCache(p.allocator(), &cell, geo, portrayal) catch null; } // M_COVR coverage + scale for per-cell quilting (allocate into the cell's own // arena before the move, so it outlives with the backend). From bdc8d4891d02fa63c7d9b53b0471d3c3fc0322b6 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 19:15:29 -0400 Subject: [PATCH 031/140] perf(bake): cache the label point for EVERY portrayed area + build geo unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composite per-cell bake was catastrophically slow on large coarse cells (US1GC09M, 1:2.16M, 23°x17°: 6m17s) — the pole-of-inaccessibility (polylabel) search ran per tile. Two gaps, both now closed: 1. buildLabelCache only cached area/line features whose stream carried a Text/Point instruction, but the emit ALSO consults labelPoint for the native INFORM01/QUESMRK1 centred markers (which carry no such instruction) — so those recomputed the polylabel for every tile they touch. Cache every PORTRAYED AREA (polylabel is area-only). Byte- identical (labelPoint returns the same point cached or live); the full golden suite passes. Shared with bakeRoot. 2. chart's BakeWork gated the geometry cache to bands <= coastal (cacheGeoForBand); build it unconditionally like bakeRoot so coarse cells get cheap per-tile reprojection and a populated label cache. US1GC09M: 6m17s -> 12.8s (~30x), byte-identical; legacy BakeBundle unaffected (8.7s). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chart.zig | 16 ++++++++++------ src/scene/scene.zig | 11 ++++++++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index 2bdc894..6172e2f 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -1935,12 +1935,16 @@ const BakeWork = struct { portrayal_plain = cp.plain; portrayal_simplified = cp.simplified; } else |_| {} - if (c.build_geo) geo = scene.buildGeoCache(p.allocator(), &cell) catch null; - // Per-feature label point (polylabel) cache — tile-invariant, so compute it ONCE - // per cell (only for features whose portrayal draws a Text/centred-symbol) instead - // of re-running the pole-of-inaccessibility search for every tile a feature touches. - // Without this the per-tile recompute dominates the bake of large coarse cells - // (measured US1GC09M: 6m17s → seconds). Mirrors bakeRoot; arena outlives via c.arenas. + // Build the geometry cache for EVERY cell (as bakeRoot does — unconditional). + // `build_geo` (cacheGeoForBand) gated it to the finer bands, but coarse cells are + // exactly the ones that hurt without it: the geo cache both cheapens per-tile + // reprojection AND lets buildLabelCache assemble each feature's parts to populate + // the label-point cache. Skipping it left the pole-of-inaccessibility (polylabel) + // search running per tile on huge coarse cells (US1GC09M: minutes). + geo = scene.buildGeoCache(p.allocator(), &cell) catch null; + // Per-feature label-point (polylabel) cache — tile-invariant, so compute it ONCE + // per cell (only for Text/centred-symbol features) instead of re-running the search + // for every tile a feature touches. Mirrors bakeRoot; arena outlives via c.arenas. cell.label_cache = scene.buildLabelCache(p.allocator(), &cell, geo, portrayal) catch null; } // M_COVR coverage + scale for per-cell quilting (allocate into the cell's own diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 92a79b8..2a64ff6 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1339,10 +1339,15 @@ pub fn buildLabelCache(a: Allocator, cell: *const s57.Cell, geo: ?GeoParts, stre var tmp = std.heap.ArenaAllocator.init(a); defer tmp.deinit(); for (cell.features, 0..) |f, i| { - if (f.prim != 2 and f.prim != 3) continue; + // Polylabel (areaRepresentativePoint) is AREA-only — lines anchor at their mid-vertex. + // Cache every portrayed area: labelPoint is consulted not just for Text/PointInstruction + // labels but also for the native INFORM01/QUESMRK1 centred markers (which carry no such + // instruction), so the earlier Text/Point-only gate left those recomputing per tile — + // the dominant cost on large coarse cells. Byte-identical: labelPoint returns the same + // point cached or live. + if (f.prim != 3) continue; if (i >= ss.len) break; - const s = ss[i] orelse continue; - if (std.mem.indexOf(u8, s, "TextInstruction") == null and std.mem.indexOf(u8, s, "PointInstruction") == null) continue; + _ = ss[i] orelse continue; // portrayed at all (an unportrayed area never reaches labelPoint) const parts = featureParts(tmp.allocator(), cell.*, geo, i, f) catch continue; out[i] = s57.areaRepresentativePoint(tmp.allocator(), parts); _ = tmp.reset(.retain_capacity); From cf35659f2eb93690a564b557ca45839240faeb9b Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 20:15:58 -0400 Subject: [PATCH 032/140] =?UTF-8?q?fix(compose):=20bound=20partition=20mem?= =?UTF-8?q?ory=20=E2=80=94=20free=20per-cell=20union=20scratch=20(40GB=20?= =?UTF-8?q?=E2=86=92=2012GB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ownedAtTierIndexed builds each cell's owned face = its coverage MINUS the union of all finer overlapping cells. It cached every cell's coverage in a scratch arena for the whole loop, then allocated the per-cell subtrahend list + unionAll + diff intermediates in that SAME arena — so they were never reclaimed between cells. A coarse cell (US1 minus ~800 finer harbour cells) unions hundreds of polygons, and every cell's union piled up: a whole-district compose (d05, 848 cells) peaked at ~40GB and was OOM-killed. Run the union/diff in `gpa`, not the coverage arena: unionAll frees its running accumulator each step, so the union now peaks at ~two polygons and `uni` is freed right after the diff. Only the finished owned polygon is retained. d05 compose peak: ~40GB → ~12GB, byte-identical output (263MB, 848 cells); the plane/partition/compose/golden suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geo/plane.zig | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/geo/plane.zig b/src/geo/plane.zig index 0b6bced..421e078 100644 --- a/src/geo/plane.zig +++ b/src/geo/plane.zig @@ -178,16 +178,28 @@ pub fn ownedAtTierIndexed(gpa: Allocator, cells: []const Cell, tier: u8) ![]Owne out.deinit(gpa); } + // Subtrahend pointer list, reused (cleared) each iteration. It only holds borrowed pointers + // into `covs`, so it stays tiny; `gpa` (not an arena) is deliberate below — the boolean + // intermediates MUST be freed as we go. `sa` caches every cell's coverage for the whole loop, + // so if the union/diff scratch lived there too it would never be reclaimed; a coarse cell + // unions hundreds of finer cells, and every cell's union piling up was the whole-district + // compose's memory blow-up (tens of GB). unionAll frees its running accumulator each step, so + // with `gpa` the union peaks at ~two polygons, and `uni` is freed right after the diff. Only + // the finished `owned` polygon is retained (in `gpa`, held by the partition). + var subtr = std.ArrayList(Poly).empty; + defer subtr.deinit(gpa); + for (order.items, 0..) |ci, k| { // owned = cov \ (∪ finer cells whose bbox overlaps this one). - var subtr = std.ArrayList(Poly).empty; + subtr.clearRetainingCapacity(); for (0..k) |j| { - if (bboxOverlap(bbs[j], bbs[k])) try subtr.append(sa, covs[j]); + if (bboxOverlap(bbs[j], bbs[k])) try subtr.append(gpa, covs[j]); } const owned = if (subtr.items.len == 0) try dupePolygonGpa(gpa, covs[k]) else blk: { - const uni = try boolean.unionAll(sa, subtr.items); + const uni = try boolean.unionAll(gpa, subtr.items); + defer boolean.freePolygon(gpa, uni); break :blk try boolean.compute(gpa, covs[k], uni, .diff); }; try out.append(gpa, .{ .index = ci, .owned = owned }); From b2ad9b11e5ceb2fe7d8e894e0b59a1ac21c47cc8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 21:14:36 -0400 Subject: [PATCH 033/140] feat(compose): --from-archives recomposes a dir of pre-baked per-cell PMTiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skips the per-cell bake and runs only the partition + compose over a --keep-cells cache (*.cell.tmp / *.pmtiles), turning an ~18-minute whole-district iteration into a ~2-minute recompose. The compositor path is identical — only the input enumeration changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/compose.zig | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tools/compose.zig b/tools/compose.zig index f36dec7..293d08f 100644 --- a/tools/compose.zig +++ b/tools/compose.zig @@ -1,4 +1,5 @@ -//! `compose -o [--rules DIR] [--keep-cells]` — the +//! `compose -o [--rules DIR] [--keep-cells] +//! [--from-archives]` — the //! per-cell composite bake, disk-to-disk. Bake each cell to its OWN native-scale PMTiles (with //! its M_COVR coverage embedded in the metadata), written to a temp file so only ONE cell's //! archive is ever resident; then stream them through the ownership partition into one merged @@ -21,6 +22,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var out: ?[]const u8 = null; var rules: ?[]const u8 = null; var keep_cells = false; // retain the per-cell temp PMTiles (a reusable cache) instead of deleting + var from_archives = false; // compose a dir of pre-baked archives (skip the bake) var f = Flags{ .args = args }; while (f.next()) |arg| { @@ -30,6 +32,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { rules = f.val(arg) orelse return; } else if (std.mem.eql(u8, arg, "--keep-cells")) { keep_cells = true; + } else if (std.mem.eql(u8, arg, "--from-archives")) { + from_archives = true; // is a DIR of pre-baked *.cell.tmp / *.pmtiles — skip baking } else if (std.mem.startsWith(u8, arg, "-")) { return usageErr("unknown flag"); } else if (base == null) { @@ -61,15 +65,29 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { cell_paths.append(a, std.fs.path.join(a, &.{ base_path, entry.path }) catch continue) catch {}; } } - if (cell_paths.items.len == 0) return usageErr("no .000 cells found"); + if (!from_archives and cell_paths.items.len == 0) return usageErr("no .000 cells found"); // 2. Bake each cell to its own temp PMTiles file (one cell resident at a time — the bytes // are freed as soon as they are written). var tmp_paths = std.ArrayList([]const u8).empty; - defer if (!keep_cells) for (tmp_paths.items) |p| { + defer if (!keep_cells and !from_archives) for (tmp_paths.items) |p| { std.Io.Dir.cwd().deleteFile(io, p) catch {}; }; - for (cell_paths.items) |cp| { + if (from_archives) { + // is a directory of pre-baked *.cell.tmp / *.pmtiles — compose them directly. + // The fast recompose loop over a --keep-cells cache: the bake (~minutes for a + // district) is skipped, only the partition + compose (~seconds to minutes) reruns. + var dir = std.Io.Dir.cwd().openDir(io, base_path, .{ .iterate = true }) catch return usageErr("cannot open archives dir"); + defer dir.close(io); + var walker = dir.walk(a) catch return usageErr("cannot walk archives dir"); + defer walker.deinit(); + while (walker.next(io) catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.path, ".cell.tmp") and !std.mem.endsWith(u8, entry.path, ".pmtiles")) continue; + tmp_paths.append(a, std.fs.path.join(a, &.{ base_path, entry.path }) catch continue) catch {}; + } + std.debug.print("recompose: {d} pre-baked archives from {s}\n", .{ tmp_paths.items.len, base_path }); + } else for (cell_paths.items) |cp| { const arc = (chart.bakeCellBytes(cp, rules_dir) catch |err| { std.debug.print(" warn: bake of {s} failed ({s}); skipping\n", .{ cp, @errorName(err) }); continue; From d89be200092c14d10f097f32496dc04940f7b3c8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 21:24:53 -0400 Subject: [PATCH 034/140] =?UTF-8?q?perf(compose):=20cap=20partition=20part?= =?UTF-8?q?icipation=20at=20each=20cell's=20tile=20reach=20(28GB=20?= =?UTF-8?q?=E2=86=92=2021GB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cell whose native window + overscale fill-up cannot reach a tier's zooms owned ground there it could never draw: computing its owned face (e.g. an overview cell minus the union of ~800 finer coverages at the harbour tier) was pure boolean waste — multiple GB of transient scratch and a large slice of the compose wall time. plane.Cell gains `reach` (default 255 = never excluded); a tier's pool is now band_floor <= tier <= reach. The compositor sets reach = max(band max, fill-up window top, archive header max_zoom). Output is byte-identical (verified: d05, 848 cells, cmp vs reference): reach is monotone in scale, so a capped tier drops only a suffix of the finest→coarsest walk — every kept face is unchanged, and skipped faces had no native blob and no fill-up ancestor, so they emitted nothing and never touched the tilemap. d05 recompose: peak RSS 27.9GB → 21.2GB, wall 79s → 46s. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bundle.zig | 11 ++++++++++ src/geo/plane.zig | 55 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index 6c08569..a494d61 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -850,7 +850,18 @@ fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers // 2. Adapt to plane.Cell (band floor via bandOf + the DSID ordersBeforeKeys tie-break) and // materialise the per-band ownership partition. `part` borrows `cells` (arena-backed). + // Each cell's partition participation is capped at its tile REACH — the deepest zoom + // ownerTile can serve for it (native window, or the fill-up overscale window past it; + // the archive header's max_zoom is folded in as a belt-and-braces upper bound). Beyond + // that the cell owns ground it can never draw, so finer tiers skip it instead of paying + // for its owned-face boolean — the output is identical (see plane.Cell.reach), and the + // whole-district partition build drops from GBs of transient scratch to MBs. const cells = try toPlaneCells(a, shims.items); + for (cells, kept.items) |*c, rp| { + const nmax = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(c.cscl)).max; + const fill = @min(nmax + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); + c.reach = @max(@max(nmax, fill), rp.header.max_zoom); + } var part = try geo.partition.build(gpa, cells); defer part.deinit(); diff --git a/src/geo/plane.zig b/src/geo/plane.zig index 421e078..004b194 100644 --- a/src/geo/plane.zig +++ b/src/geo/plane.zig @@ -45,6 +45,17 @@ pub const Cell = struct { /// from cell identity (issue/update date, then DSNM) so the newer survey wins /// a double-owned strip (spec Q4). Lower sorts finer (earlier in the walk). order: u64, + /// Highest zoom this cell can EMIT tiles at (native window + overscale fill-up); + /// the cell is excluded from tiers beyond it. Its face there is dead weight — it + /// renders nothing — and the exclusion is invisible to every renderable face: + /// reach is monotone (a coarser cell caps at least as low, in any cell set whose + /// reach comes from the band ladder), so capping drops a SUFFIX of the + /// finest→coarsest walk and no kept cell's subtrahend changes. What it buys: the + /// partition skips the expensive coarse-cell booleans at fine tiers (a + /// whole-district compose spent GBs subtracting hundreds of harbour coverages + /// from an overview cell that could never draw there). The default (255) never + /// excludes — pure-geometry callers keep the full pool per tier. + reach: u8 = 255, /// CATCOV=1 coverage features. cov1: []const Poly, /// CATCOV=2 explicit no-data features (subtracted, so a coarser band can fill). @@ -78,16 +89,16 @@ fn finerLess(_: void, a: Cell, b: Cell) bool { return a.order < b.order; } -/// The per-tier partition: for every cell eligible at `tier` (band_floor ≤ tier), -/// walking finest→coarsest, `owned = coverage \ (∪ coverage of all finer eligible -/// cells)`. The union of the returned `owned` regions equals the union of all -/// eligible coverages, partitioned with no overlap (validated by the tests). +/// The per-tier partition: for every cell eligible at `tier` (band_floor ≤ tier ≤ +/// reach), walking finest→coarsest, `owned = coverage \ (∪ coverage of all finer +/// eligible cells)`. The union of the returned `owned` regions equals the union of +/// all eligible coverages, partitioned with no overlap (validated by the tests). pub fn ownedAtTier(gpa: Allocator, cells: []const Cell, tier: u8) ![]OwnedCell { // Eligible cells, in a total finest→coarsest, path-independent order. var order = std.ArrayList(usize).empty; defer order.deinit(gpa); for (cells, 0..) |c, i| { - if (c.band_floor <= tier) try order.append(gpa, i); + if (c.band_floor <= tier and tier <= c.reach) try order.append(gpa, i); } std.mem.sort(usize, order.items, cells, struct { fn lt(cs: []const Cell, ia: usize, ib: usize) bool { @@ -151,7 +162,7 @@ pub fn ownedAtTierIndexed(gpa: Allocator, cells: []const Cell, tier: u8) ![]Owne var order = std.ArrayList(usize).empty; defer order.deinit(gpa); for (cells, 0..) |c, i| { - if (c.band_floor <= tier) try order.append(gpa, i); + if (c.band_floor <= tier and tier <= c.reach) try order.append(gpa, i); } std.mem.sort(usize, order.items, cells, struct { fn lt(cs: []const Cell, ia: usize, ib: usize) bool { @@ -543,6 +554,38 @@ test "ownedAtTier: below-floor fine cell drops out of the pool (no blank window) try testing.expect(boolean.pointInEvenOdd(t10[0].owned, 10, 10)); } +test "reach cap: a cell beyond its tile reach drops out of finer tiers only" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // Coarse [0,100]² can emit up to z10 (native max 9 + one fill-up); fine [40,60]² + // is unbounded. At tier 13 the coarse cell is out of the pool — the fine cell + // owns its box and NOTHING owns the surround; at tier 9 the coarse cell is back + // (fine below floor) and owns the whole basin. + const coarse_cov = try boxPoly(a, 0, 0, 100, 100); + const fine_cov = try boxPoly(a, 40, 40, 60, 60); + const cells = [_]Cell{ + .{ .cscl = 100_000, .band_floor = 9, .order = 0, .reach = 10, .cov1 = &.{coarse_cov} }, + .{ .cscl = 20_000, .band_floor = 13, .order = 0, .cov1 = &.{fine_cov} }, + }; + + for ([_]bool{ false, true }) |indexed| { + const t13 = if (indexed) try ownedAtTierIndexed(a, &cells, 13) else try ownedAtTier(a, &cells, 13); + try testing.expectEqual(@as(usize, 1), t13.len); + try testing.expectEqual(@as(usize, 1), t13[0].index); // only the fine cell + try testing.expect(boolean.pointInEvenOdd(t13[0].owned, 50, 50)); + // The fine cell's face is its own coverage, unchanged by the exclusion — + // it never subtracted against the (coarser) capped cell. + try testing.expect(!boolean.pointInEvenOdd(t13[0].owned, 10, 10)); + + const t9 = if (indexed) try ownedAtTierIndexed(a, &cells, 9) else try ownedAtTier(a, &cells, 9); + try testing.expectEqual(@as(usize, 1), t9.len); + try testing.expectEqual(@as(usize, 0), t9[0].index); // coarse back in the pool + try testing.expect(boolean.pointInEvenOdd(t9[0].owned, 50, 50)); + } +} + test "ownedAtTierIndexed matches ownedAtTier (owner-at-point, overlap + adjacency)" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); From 64e4ceda9bca7381642a55a751cb6a2f993ac140 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 21:45:49 -0400 Subject: [PATCH 035/140] =?UTF-8?q?perf(compose):=20tile-major=20composeZo?= =?UTF-8?q?om=20=E2=80=94=20one=20tile=20resident,=20not=20a=20whole=20zoo?= =?UTF-8?q?m=20(21GB=20=E2=86=92=20~1.4GB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composeZoom held every composed tile of a zoom in a single feature map and only encoded at the end — at z14 that working set alone was ~21GB of decoded+clipped features for ~10s of MB of MLT output. Split it into two passes over the same face-major traversal. Pass 1 streams fully-owned tiles verbatim exactly as before and records, per remaining tile, WHICH owners contribute — a directory-existence probe (ownerHasTile, the no-decode mirror of ownerTile) plus the owned-face projection. Pass 2 walks the recorded tiles one at a time: decode, clip, orient, encode, emit, all in a per-tile arena reset between tiles. Byte-identity with the one-pass compositor is by construction, and verified (d05, 848 cells, cmp vs reference): the verbatim adds interleave identically; pass 1's record predicate equals the old accumulate predicate; the discovery map keeps the old tilemap's key type and insertion sequence, so it iterates — and the archive's data section is written — in the same order; and each tile's features concatenate in the same face order. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bundle.zig | 118 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 85 insertions(+), 33 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index a494d61..8ef1fd7 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -877,13 +877,15 @@ fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers const fill_max = @min(maxz + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); const loop_max = @max(maxz, fill_max); - // 4. Stream one zoom at a time (only one zoom's tiles resident). + // 4. Stream one zoom at a time. The zoom arena holds only the discovery state (classifier + // grids + per-tile owner lists); the composed tiles themselves live one at a time in + // composeZoom's per-tile scratch. var zoom_arena = std.heap.ArenaAllocator.init(gpa); defer zoom_arena.deinit(); var z: u8 = minz; while (z <= loop_max) : (z += 1) { _ = zoom_arena.reset(.retain_capacity); - try composeZoom(sw, zoom_arena.allocator(), &part, kept.items, z); + try composeZoom(sw, gpa, zoom_arena.allocator(), &part, kept.items, z); } if (sw.num_addressed == 0) return null; @@ -1017,12 +1019,22 @@ pub fn composeArchivesToFile(io: std.Io, gpa: std.mem.Allocator, paths: []const return fr.cells; } -/// Compose every tile of one zoom `z`: for each owning cell (the band governing `z`), fetch -/// its native tile, project its owned face into that tile, clip each decoded feature, and -/// accumulate per layer; then orient the merged polygons and stream each tile out. `sa` is a -/// per-zoom arena the caller resets, so only one zoom's tiles are resident. +/// Compose every tile of one zoom `z`, tile-major, so peak memory is ONE tile's working set +/// rather than the whole zoom's. Pass 1 walks the governing band's owners face-major — +/// exactly the order the compositor always used — streaming every fully-owned tile VERBATIM +/// on the spot and recording, for each remaining (seam / overscale) tile, WHICH owners +/// contribute: a directory-existence probe plus the owned-face projection, no decode. Pass 2 +/// then composes one recorded tile at a time — decode + clip + orient + encode in a per-tile +/// arena reset between tiles — and streams it out. Byte-identity with the one-pass +/// compositor this replaces is by construction: the verbatim adds interleave identically, +/// pass 1's record predicate equals the old accumulate predicate, the discovery map has the +/// same key type and insertion sequence as the old zoom-wide feature map (so it iterates in +/// the same order), and each tile's features concatenate in the same face order. `sa` is a +/// per-zoom arena the caller resets — it now holds only classifier grids and the per-tile +/// owner lists; `gpa` backs the per-tile scratch. fn composeZoom( sw: *engine.pmtiles.StreamWriter, + gpa: std.mem.Allocator, sa: std.mem.Allocator, part: *const engine.geo.partition.Partition, readers: []const *engine.pmtiles.Reader, @@ -1036,11 +1048,17 @@ fn composeZoom( const map = part.mapForZoom(z) orelse return; const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + // Owners contributing to each pending tile, as slots into `map.faces`, appended in face + // order — the per-tile accumulation order of the old zoom-wide feature map. const TileKey = struct { x: u32, y: u32 }; - const Buckets = [N_COMPOSE_LAYERS]std.ArrayList(mvt.Feature); - var tilemap = std.AutoHashMap(TileKey, Buckets).init(sa); + var pending = std.AutoHashMap(TileKey, std.ArrayList(u32)).init(sa); + + // Per-tile scratch: face projection in pass 1, decode/clip/encode in pass 2. Reset + // between tiles, so no per-tile allocation outlives its tile. + var tile_scratch = std.heap.ArenaAllocator.init(gpa); + defer tile_scratch.deinit(); - for (map.faces) |face| { + for (map.faces, 0..) |face, slot| { if (face.owned.len == 0) continue; const ci = face.index; const cscl = part.cells[ci].cscl; @@ -1098,41 +1116,63 @@ fn composeZoom( .seam => {}, // real geometry: decode + clip below } - // The cell's tile content at (z,tx,ty): its native tile, or — one fill-up zoom - // past its band — its deepest native ancestor scaled up (overscale). null = - // nothing reachable here. - const layers = (try ownerTile(sa, readers[ci], cscl, z, tx, ty)) orelse continue; - // Project the owned face into THIS tile; empty = the cell owns no pixels here. - const face_px = try compose.projectFace(sa, face.owned, z, tx, ty); + // Record this owner iff the compose pass would keep it: reachable content + // (a native blob, or — in the fill-up window — an overscale ancestor) AND a + // non-empty owned face in this tile's pixel space. No decode yet; pass 2 + // pays that one tile at a time. + if (!(try ownerHasTile(readers[ci], cscl, z, tx, ty))) continue; + _ = tile_scratch.reset(.retain_capacity); + const face_px = try compose.projectFace(tile_scratch.allocator(), face.owned, z, tx, ty); if (face_px.len == 0) continue; - const gop = try tilemap.getOrPut(.{ .x = tx, .y = ty }); - if (!gop.found_existing) for (gop.value_ptr) |*b| { - b.* = std.ArrayList(mvt.Feature).empty; - }; - for (layers) |layer| { - const li = layerIndex(layer.name) orelse continue; - for (layer.features) |feat| { - try compose.clipFeatureToFace(sa, &gop.value_ptr[li], feat, face_px); - } - } + const gop = try pending.getOrPut(.{ .x = tx, .y = ty }); + if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(u32).empty; + try gop.value_ptr.append(sa, @intCast(slot)); } } } - // Encode each composed tile: per-layer concat (VECTOR_LAYERS order), polygons re-oriented - // (boolean intersect output has unspecified winding), then MLT — matching the per-cell input. - var it = tilemap.iterator(); + // Pass 2 — compose each pending tile: decode every contributing owner's tile (native or + // overscaled ancestor), clip its features to the owner's projected face, then per-layer + // concat (VECTOR_LAYERS order), polygons re-oriented (boolean intersect output has + // unspecified winding), MLT-encoded — matching the per-cell input — and streamed out. + // Everything lives in the per-tile scratch, freed before the next tile. + var it = pending.iterator(); while (it.next()) |kv| { + const tx = kv.key_ptr.x; + const ty = kv.key_ptr.y; + _ = tile_scratch.reset(.retain_capacity); + const ta = tile_scratch.allocator(); + + var buckets: [N_COMPOSE_LAYERS]std.ArrayList(mvt.Feature) = undefined; + for (&buckets) |*b| b.* = std.ArrayList(mvt.Feature).empty; + for (kv.value_ptr.items) |slot| { + const face = map.faces[slot]; + const ci = face.index; + // The cell's tile content at (z,tx,ty): its native tile, or — one fill-up zoom + // past its band — its deepest native ancestor scaled up (overscale). + const layers = (try ownerTile(ta, readers[ci], part.cells[ci].cscl, z, tx, ty)) orelse continue; + // The owned face in THIS tile's pixel space (recomputed — caching every pending + // tile's projection is exactly the zoom-sized retention this pass removes). + const face_px = try compose.projectFace(ta, face.owned, z, tx, ty); + if (face_px.len == 0) continue; + for (layers) |layer| { + const li = layerIndex(layer.name) orelse continue; + for (layer.features) |feat| { + try compose.clipFeatureToFace(ta, &buckets[li], feat, face_px); + } + } + } + var layers = std.ArrayList(mvt.Layer).empty; - for (kv.value_ptr, 0..) |bucket, li| { + for (&buckets, 0..) |*bucket, li| { if (bucket.items.len == 0) continue; - const feats = try orientPolys(sa, bucket.items); - try layers.append(sa, .{ .name = scene.VECTOR_LAYERS[li], .features = feats }); + const feats = try orientPolys(ta, bucket.items); + try layers.append(ta, .{ .name = scene.VECTOR_LAYERS[li], .features = feats }); } if (layers.items.len == 0) continue; - const enc = try engine.mlt.encode(sa, .{ .layers = layers.items }); - try sw.add(z, kv.key_ptr.x, kv.key_ptr.y, enc); + const enc = try engine.mlt.encode(ta, .{ .layers = layers.items }); + try sw.add(z, tx, ty, enc); } } @@ -1170,6 +1210,18 @@ fn ownerTile(a: std.mem.Allocator, r: *engine.pmtiles.Reader, cscl: i32, z: u8, return layers; } +// Cheap existence mirror of `ownerTile` (directory probes only — no decompress, no decode): +// would it return content for cell `r` at (z,tx,ty)? Must stay in lockstep with it — the +// tile-major compositor's discovery pass uses this to reproduce the compose predicate, and +// the two passes must agree on which tiles compose. +fn ownerHasTile(r: *engine.pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32) !bool { + if ((try r.getCompressed(z, tx, ty)) != null) return true; + const nmax = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cscl)).max; + if (z <= nmax or z > nmax + engine.bake_enc.FILLUP_DZ or z > engine.bake_enc.FILLUP_CEIL) return false; + const shift: u5 = @intCast(z - nmax); + return (try r.getCompressed(nmax, tx >> shift, ty >> shift)) != null; +} + // Scale an ancestor tile's features up into descendant (tx,ty) — the sub-cell `shift` levels // finer: pixel (px,py) → (px< Date: Wed, 8 Jul 2026 21:46:00 -0400 Subject: [PATCH 036/140] =?UTF-8?q?perf(partition):=20run=20owned-face=20b?= =?UTF-8?q?oolean=20scratch=20on=20the=20page=20allocator=20(compose=202.0?= =?UTF-8?q?GB=20=E2=86=92=200.8GB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The union/diff intermediates in ownedAtTierIndexed are freed within each cell's iteration, but a pooling gpa parks every freed block on a size-class freelist — and these blocks are large, odd-sized, and never the same size twice, so across hundreds of cells the parked pages inflated the whole-district compose's peak RSS by ~1.2GB while the true live set stayed under ~15MB. Page-granular allocation returns the scratch to the OS at each free; only the finished owned face is copied into gpa. The boolean ops are far too coarse to feel the per-page cost (partition build wall time unchanged). d05 recompose (848 cells): partition-phase peak 1.4GB → 195MB, total compose peak 2.0GB → 794MB, byte-identical (cmp vs reference), 38s. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geo/plane.zig | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/geo/plane.zig b/src/geo/plane.zig index 004b194..8b66879 100644 --- a/src/geo/plane.zig +++ b/src/geo/plane.zig @@ -201,7 +201,15 @@ pub fn ownedAtTierIndexed(gpa: Allocator, cells: []const Cell, tier: u8) ![]Owne defer subtr.deinit(gpa); for (order.items, 0..) |ci, k| { - // owned = cov \ (∪ finer cells whose bbox overlaps this one). + // owned = cov \ (∪ finer cells whose bbox overlaps this one). The union/diff + // intermediates run on the page allocator, not `gpa`: they are large, + // odd-sized, and freed within the iteration, and a pooling gpa parks each + // freed size class on its own freelist — across hundreds of cells those + // parked pages inflated the compose's peak RSS by whole GBs. Page-granular + // allocation returns them to the OS at each free (unionAll frees its running + // accumulator each step, `uni`/`diff` right after use), and the boolean ops + // are far too coarse to feel the per-page cost. Only the finished `owned` + // polygon is copied into `gpa` (retained, held by the partition). subtr.clearRetainingCapacity(); for (0..k) |j| { if (bboxOverlap(bbs[j], bbs[k])) try subtr.append(gpa, covs[j]); @@ -209,9 +217,12 @@ pub fn ownedAtTierIndexed(gpa: Allocator, cells: []const Cell, tier: u8) ![]Owne const owned = if (subtr.items.len == 0) try dupePolygonGpa(gpa, covs[k]) else blk: { - const uni = try boolean.unionAll(gpa, subtr.items); - defer boolean.freePolygon(gpa, uni); - break :blk try boolean.compute(gpa, covs[k], uni, .diff); + const pa = std.heap.page_allocator; + const uni = try boolean.unionAll(pa, subtr.items); + defer boolean.freePolygon(pa, uni); + const diff = try boolean.compute(pa, covs[k], uni, .diff); + defer boolean.freePolygon(pa, diff); + break :blk try dupePolygonGpa(gpa, diff); }; try out.append(gpa, .{ .index = ci, .owned = owned }); } From 13f79d15ead99b9358501d73672c6e36e2ff842f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 22:43:18 -0400 Subject: [PATCH 037/140] fix(partition): enforce the reach cut as a suffix of the ownership walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reach cap's identity argument requires the dropped cells to form a suffix of the finest→coarsest walk, but per-cell reach values are not guaranteed monotone in the sort key: the walk sorts on RAW cscl while reach comes from the band ladder, which defaults cscl<=0 to approach — so a scale-less cell sorted finest yet reached mid-ladder, and a foreign archive's folded header.max_zoom can exceed a finer cell's ladder reach. Cutting such a cell while coarser cells stay would grow their faces over ground the cut cell used to mask, changing composed bytes (latent — the d05 corpus has neither input; found by adversarial review). The builders now apply an EFFECTIVE reach (reachCut: running max from the coarsest end of the sorted walk), so the cut is a suffix by construction for ANY reach assignment; raising reach only keeps more cells, so nothing that could emit is ever cut. Identical behavior for monotone inputs (d05 recompose stays byte-identical). Also: the partition-debug bake now applies the same band-ladder cap (new bandReach helper) so the debug quilt shows the partition the compositor actually composes with; ownerHasTile/ownerTile lockstep pinned by a unit test across every fill-up window boundary (one disagreement would reorder a whole zoom's output); dangling design-doc question references scrubbed from plane.zig comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bundle.zig | 75 ++++++++++++++++++++++++++----- src/geo/plane.zig | 110 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 149 insertions(+), 36 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index 8ef1fd7..1346bd7 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -548,10 +548,14 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const defer arena.deinit(); const a = arena.allocator(); - // Single load + adapt paths. + // Single load + adapt paths. Reach-cap each cell exactly as the compositor does + // (band terms only — a native per-cell bake never exceeds its band window), so the + // debug quilt shows the partition the compositor actually composes with, not a + // hypothetical full pool. const loaded = loadCells(io, a, root_path); if (loaded.len == 0) return error.NoGeometry; const cells = try toPlaneCells(a, loaded); + for (cells) |*c| c.reach = bandReach(c.cscl); // Union bbox (E7) for the header, so a viewer opens on the data not the world. var ubox = [4]i32{ std.math.maxInt(i32), std.math.maxInt(i32), std.math.minInt(i32), std.math.minInt(i32) }; @@ -851,17 +855,15 @@ fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers // 2. Adapt to plane.Cell (band floor via bandOf + the DSID ordersBeforeKeys tie-break) and // materialise the per-band ownership partition. `part` borrows `cells` (arena-backed). // Each cell's partition participation is capped at its tile REACH — the deepest zoom - // ownerTile can serve for it (native window, or the fill-up overscale window past it; - // the archive header's max_zoom is folded in as a belt-and-braces upper bound). Beyond - // that the cell owns ground it can never draw, so finer tiers skip it instead of paying - // for its owned-face boolean — the output is identical (see plane.Cell.reach), and the - // whole-district partition build drops from GBs of transient scratch to MBs. + // ownerTile can serve for it: the band ladder (native window + fill-up overscale), with + // the archive header's max_zoom folded in so a foreign archive carrying tiles past its + // band window is never cut while it can still emit. Beyond reach the cell owns ground it + // can never draw, so finer tiers skip it instead of paying for its owned-face boolean — + // the output is identical (the builders cut only reach-suffixes of the ownership walk, + // see plane.Cell.reach), and the whole-district partition build drops from GBs of + // transient scratch to MBs. const cells = try toPlaneCells(a, shims.items); - for (cells, kept.items) |*c, rp| { - const nmax = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(c.cscl)).max; - const fill = @min(nmax + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); - c.reach = @max(@max(nmax, fill), rp.header.max_zoom); - } + for (cells, kept.items) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); var part = try geo.partition.build(gpa, cells); defer part.deinit(); @@ -1210,6 +1212,14 @@ fn ownerTile(a: std.mem.Allocator, r: *engine.pmtiles.Reader, cscl: i32, z: u8, return layers; } +// The deepest zoom a cell's band ladder can serve — its native window max, or the fill-up +// overscale window just past it (`ownerTile`'s window, which FILLUP_CEIL can pull BELOW the +// native max for the finest bands — hence the @max). The band terms of `plane.Cell.reach`. +fn bandReach(cscl: i32) u8 { + const nmax = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cscl)).max; + return @max(nmax, @min(nmax + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL)); +} + // Cheap existence mirror of `ownerTile` (directory probes only — no decompress, no decode): // would it return content for cell `r` at (z,tx,ty)? Must stay in lockstep with it — the // tile-major compositor's discovery pass uses this to reproduce the compose predicate, and @@ -2449,6 +2459,49 @@ test "composeArchives: a coarse owner overscales one fill-up zoom past its nativ try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(tile.EXTENT, 2), @divTrunc(tile.EXTENT, 2))); } +test "ownerHasTile mirrors ownerTile's null/non-null across the native + fill-up window" { + const gpa = testing.allocator; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const tile = engine.tile; + + // A coastal cell (native z9-11, fill-up to z12) with a single tile at its native max. + const z: u8 = 11; + const scale: f64 = @floatFromInt(@as(u64, 1) << z); + const w = tile.lonLatToWorld(-76.3, 38.5); + const tx = worldAxisToTile(w[0], scale); + const ty = worldAxisToTile(w[1], scale); + const b = tile.tileBoundsLonLat(z, tx, ty); + const cscl: i32 = 300_000; + const arc = try synthCell(gpa, a, "COAST001", b[0], b[1], b[2], b[3], cscl, z, tx, ty); + defer gpa.free(arc); + + var r = try engine.pmtiles.Reader.init(gpa, arc); + defer r.deinit(); + + // The tile-major compositor records a tile in its discovery pass iff the compose + // pass's decode would contribute; ONE disagreement changes the discovery map's + // insertion sequence and with it the whole zoom's output byte order. Pin the pair + // across every window boundary. + const cases = [_]struct { z: u8, x: u32, y: u32 }{ + .{ .z = z, .x = tx, .y = ty }, // native hit + .{ .z = z, .x = tx + 5, .y = ty }, // native miss + .{ .z = z + 1, .x = tx * 2, .y = ty * 2 }, // fill-up: NW child of the ancestor + .{ .z = z + 1, .x = tx * 2 + 1, .y = ty * 2 + 1 }, // fill-up: SE child, same ancestor + .{ .z = z + 1, .x = (tx + 5) * 2, .y = ty * 2 }, // fill-up: no ancestor + .{ .z = z + 2, .x = tx * 4, .y = ty * 4 }, // past FILLUP_DZ: never reachable + .{ .z = z - 1, .x = tx / 2, .y = ty / 2 }, // below the baked window (no fill-down) + }; + for (cases) |c| { + const decoded = try ownerTile(a, &r, cscl, c.z, c.x, c.y); + try testing.expectEqual(decoded != null, try ownerHasTile(&r, cscl, c.z, c.x, c.y)); + } + // The sweep exercises both outcomes on both sides of the fill-up boundary. + try testing.expect(try ownerHasTile(&r, cscl, z + 1, tx * 2, ty * 2)); + try testing.expect(!try ownerHasTile(&r, cscl, z + 2, tx * 4, ty * 4)); +} + test "composeArchives: an interior tile is copied byte-identical from the per-cell bake" { const gpa = testing.allocator; var arena = std.heap.ArenaAllocator.init(gpa); diff --git a/src/geo/plane.zig b/src/geo/plane.zig index 8b66879..1d5969f 100644 --- a/src/geo/plane.zig +++ b/src/geo/plane.zig @@ -17,12 +17,11 @@ //! round to the same integers before any boolean runs. //! All set algebra goes through `boolean` (Martinez, overlap-typed, deterministic). //! -//! Phase 0 scope: this computes and validates the partition, the tile classifier -//! (FULL / EMPTY / SEAM), and `clipLineOutsidePolys`. Nothing here is wired to the -//! baker or the live oracle yet — that is a later phase. The S-57 adapter (filling -//! `Cell` from an `s57.Cell`, the cscl≤0 / date-order tie-break decisions Q1/Q4) -//! is also deferred; `Cell` is deliberately decoupled so this stays pure and -//! unit-testable. +//! Scope: this computes and validates the partition, the tile classifier +//! (FULL / EMPTY / SEAM), and the coverage line clips. The S-57 adapter (filling +//! `Cell` from an `s57.Cell`, incl. the cscl≤0 and date-order tie-break policy) +//! lives with the consumers; `Cell` is deliberately decoupled so this stays pure +//! and unit-testable. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -43,18 +42,19 @@ pub const Cell = struct { band_floor: u8, /// Deterministic tie-break among equal-cscl cells — the adapter fills this /// from cell identity (issue/update date, then DSNM) so the newer survey wins - /// a double-owned strip (spec Q4). Lower sorts finer (earlier in the walk). + /// a double-owned strip. Lower sorts finer (earlier in the walk). order: u64, /// Highest zoom this cell can EMIT tiles at (native window + overscale fill-up); /// the cell is excluded from tiers beyond it. Its face there is dead weight — it - /// renders nothing — and the exclusion is invisible to every renderable face: - /// reach is monotone (a coarser cell caps at least as low, in any cell set whose - /// reach comes from the band ladder), so capping drops a SUFFIX of the - /// finest→coarsest walk and no kept cell's subtrahend changes. What it buys: the - /// partition skips the expensive coarse-cell booleans at fine tiers (a - /// whole-district compose spent GBs subtracting hundreds of harbour coverages - /// from an overview cell that could never draw there). The default (255) never - /// excludes — pure-geometry callers keep the full pool per tier. + /// renders nothing there — and the exclusion is invisible to every renderable + /// face: the builders apply an EFFECTIVE reach (the max of the cell's own and + /// every coarser cell's, so exclusions always drop a suffix of the finest→ + /// coarsest walk and no kept cell's subtrahend changes — see the reach cut in + /// `ownedAtTier`). What it buys: the partition skips the expensive coarse-cell + /// booleans at fine tiers (a whole-district compose spent GBs subtracting + /// hundreds of harbour coverages from an overview cell that could never draw + /// there). Must be a true upper bound on the cell's emit zoom; the default (255) + /// never excludes — pure-geometry callers keep the full pool per tier. reach: u8 = 255, /// CATCOV=1 coverage features. cov1: []const Poly, @@ -89,6 +89,26 @@ fn finerLess(_: void, a: Cell, b: Cell) bool { return a.order < b.order; } +/// The reach cap: shrink `order` (sorted finest→coarsest) to the cells whose +/// EFFECTIVE reach covers `tier`. Removing a cell only grows the faces of cells +/// AFTER it in the walk (their subtrahend shrinks), and a grown face is sound only +/// if that cell cannot render at this tier either — so the cut must be a SUFFIX of +/// the walk. Per-cell reach values need not be monotone in the sort key (a +/// scale-less cell sorts finest yet gets a mid-band default; a foreign archive can +/// carry tiles past its band window), so each cell's effective reach is the max of +/// its own and every coarser cell's: the drop is a suffix by construction, and +/// raising reach only KEEPS more cells, so nothing that could emit is ever cut. +fn reachCut(cells: []const Cell, order: *std.ArrayList(usize), tier: u8) void { + var keep = order.items.len; + var eff: u8 = 0; + while (keep > 0) { + eff = @max(eff, cells[order.items[keep - 1]].reach); + if (tier <= eff) break; + keep -= 1; + } + order.shrinkRetainingCapacity(keep); +} + /// The per-tier partition: for every cell eligible at `tier` (band_floor ≤ tier ≤ /// reach), walking finest→coarsest, `owned = coverage \ (∪ coverage of all finer /// eligible cells)`. The union of the returned `owned` regions equals the union of @@ -98,13 +118,14 @@ pub fn ownedAtTier(gpa: Allocator, cells: []const Cell, tier: u8) ![]OwnedCell { var order = std.ArrayList(usize).empty; defer order.deinit(gpa); for (cells, 0..) |c, i| { - if (c.band_floor <= tier and tier <= c.reach) try order.append(gpa, i); + if (c.band_floor <= tier) try order.append(gpa, i); } std.mem.sort(usize, order.items, cells, struct { fn lt(cs: []const Cell, ia: usize, ib: usize) bool { return finerLess({}, cs[ia], cs[ib]); } }.lt); + reachCut(cells, &order, tier); var scratch = std.heap.ArenaAllocator.init(gpa); defer scratch.deinit(); @@ -162,13 +183,14 @@ pub fn ownedAtTierIndexed(gpa: Allocator, cells: []const Cell, tier: u8) ![]Owne var order = std.ArrayList(usize).empty; defer order.deinit(gpa); for (cells, 0..) |c, i| { - if (c.band_floor <= tier and tier <= c.reach) try order.append(gpa, i); + if (c.band_floor <= tier) try order.append(gpa, i); } std.mem.sort(usize, order.items, cells, struct { fn lt(cs: []const Cell, ia: usize, ib: usize) bool { return finerLess({}, cs[ia], cs[ib]); } }.lt); + reachCut(cells, &order, tier); var scratch = std.heap.ArenaAllocator.init(gpa); defer scratch.deinit(); @@ -189,14 +211,12 @@ pub fn ownedAtTierIndexed(gpa: Allocator, cells: []const Cell, tier: u8) ![]Owne out.deinit(gpa); } - // Subtrahend pointer list, reused (cleared) each iteration. It only holds borrowed pointers - // into `covs`, so it stays tiny; `gpa` (not an arena) is deliberate below — the boolean - // intermediates MUST be freed as we go. `sa` caches every cell's coverage for the whole loop, - // so if the union/diff scratch lived there too it would never be reclaimed; a coarse cell - // unions hundreds of finer cells, and every cell's union piling up was the whole-district - // compose's memory blow-up (tens of GB). unionAll frees its running accumulator each step, so - // with `gpa` the union peaks at ~two polygons, and `uni` is freed right after the diff. Only - // the finished `owned` polygon is retained (in `gpa`, held by the partition). + // Subtrahend pointer list, reused (cleared) each iteration — it only holds borrowed + // pointers into `covs`, so it stays tiny. The boolean intermediates below deliberately + // do NOT live in `sa`: it caches every cell's coverage for the whole loop, so scratch + // parked there would never be reclaimed — a coarse cell unions hundreds of finer + // cells, and every cell's union piling up was a whole-district compose's memory + // blow-up (tens of GB). var subtr = std.ArrayList(Poly).empty; defer subtr.deinit(gpa); @@ -597,6 +617,46 @@ test "reach cap: a cell beyond its tile reach drops out of finer tiers only" { } } +test "reach cap: non-monotone reach cuts only a suffix (effective reach keeps the finer cell)" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // Pathological input: the FINER cell has the LOWER reach (a scale-less cell that + // sorted finest but banded mid-ladder, or a foreign archive). Cutting it while the + // coarser cell stays would grow the coarse face over ground the finer cell used to + // mask — so the effective reach (max of own + all coarser) must keep it, and the + // partition must match the uncapped one exactly. + const coarse_cov = try boxPoly(a, 0, 0, 100, 100); + const fine_cov = try boxPoly(a, 40, 40, 60, 60); + const capped = [_]Cell{ + .{ .cscl = 100_000, .band_floor = 9, .order = 0, .reach = 255, .cov1 = &.{coarse_cov} }, + .{ .cscl = 20_000, .band_floor = 9, .order = 0, .reach = 10, .cov1 = &.{fine_cov} }, + }; + const uncapped = [_]Cell{ + .{ .cscl = 100_000, .band_floor = 9, .order = 0, .cov1 = &.{coarse_cov} }, + .{ .cscl = 20_000, .band_floor = 9, .order = 0, .cov1 = &.{fine_cov} }, + }; + + for ([_]bool{ false, true }) |indexed| { + const got = if (indexed) try ownedAtTierIndexed(a, &capped, 13) else try ownedAtTier(a, &capped, 13); + const want = if (indexed) try ownedAtTierIndexed(a, &uncapped, 13) else try ownedAtTier(a, &uncapped, 13); + try testing.expectEqual(want.len, got.len); // both cells kept + // Same owner at a probe point inside the fine box and one outside it. + for ([_][2]i64{ .{ 50, 50 }, .{ 10, 10 } }) |p| { + var want_owner: ?usize = null; + var got_owner: ?usize = null; + for (want) |oc| if (boolean.pointInEvenOdd(oc.owned, p[0], p[1])) { + want_owner = oc.index; + }; + for (got) |oc| if (boolean.pointInEvenOdd(oc.owned, p[0], p[1])) { + got_owner = oc.index; + }; + try testing.expectEqual(want_owner, got_owner); + } + } +} + test "ownedAtTierIndexed matches ownedAtTier (owner-at-point, overlap + adjacency)" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); From 10eaf8d4b57202d52af986cfb63238b3a1298389 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 22:43:33 -0400 Subject: [PATCH 038/140] perf(pmtiles): cache deserialized leaf directories per reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader.getCompressed re-deserialized a leaf directory into the reader's never-reset arena on EVERY probe that traversed one (plus a decompressed buffer per probe for gzip-internal archives). The tile-major compositor probes each reader once per (tile, pass) across a whole district, so a single directory-heavy archive (a real z13-16 harbor cell with three 4096-entry leaves exists in the bake caches) meant unbounded arena growth — gigabytes on the wrong district, invisible on the root-only d05 corpus. Each leaf now decodes once, keyed by its offset, bounding the growth to one copy of the archive's directory; repeated probes are also cheaper. Read path only — composed output stays byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tiles/pmtiles.zig | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/tiles/pmtiles.zig b/src/tiles/pmtiles.zig index 365490f..b8953de 100644 --- a/src/tiles/pmtiles.zig +++ b/src/tiles/pmtiles.zig @@ -283,6 +283,11 @@ pub const Reader = struct { header: Header, root: []Entry, arena: std.heap.ArenaAllocator, + // Deserialized leaf directories by leaf offset. A compositor probes a reader once + // per (tile, pass); re-deserializing the same leaf into the arena on EVERY probe + // grew it without bound on directory-heavy archives, so each leaf is decoded once + // and reused. Arena-backed (freed wholesale by deinit). + leaves: std.AutoHashMapUnmanaged(u64, []Entry) = .empty, pub fn init(gpa: Allocator, bytes: []const u8) !Reader { const header = try Header.parse(bytes); @@ -314,10 +319,15 @@ pub const Reader = struct { const idx = findEntry(dir, tid) orelse return null; const e = dir[idx]; if (e.run_length == 0) { - // leaf directory pointer + // leaf directory pointer — decoded once per distinct leaf, then cached const a = r.arena.allocator(); - const raw = try maybeDecompress(a, r.bytes[@intCast(r.header.leaf_dir_offset + e.offset)..][0..e.length], r.header.internal_compression); - dir = try deserializeDir(a, raw); + const gop = try r.leaves.getOrPut(a, e.offset); + if (!gop.found_existing) { + errdefer _ = r.leaves.remove(e.offset); + const raw = try maybeDecompress(a, r.bytes[@intCast(r.header.leaf_dir_offset + e.offset)..][0..e.length], r.header.internal_compression); + gop.value_ptr.* = try deserializeDir(a, raw); + } + dir = gop.value_ptr.*; continue; } if (tid < e.tile_id + e.run_length) { @@ -732,6 +742,18 @@ test "large archive shards the root into leaf directories and still round-trips" } // An absent tile in the sharded archive still resolves to null. try std.testing.expect((try r.getTile(gpa, 15, 1, 1)) == null); + + // Repeated probes reuse the cached leaf decodes — the arena must not grow once + // every touched leaf is resident. (A compositor probes a reader once per + // (tile, pass); re-deserializing a leaf per probe was unbounded growth.) + const probes = [_]u32{ 0, 4095, 4096, 12345, N - 1 }; + for (probes) |i| _ = try r.getCompressed(15, i, 0); + const cap_before = r.arena.queryCapacity(); + for (0..100) |_| { + for (probes) |i| _ = try r.getCompressed(15, i, 0); + _ = try r.getCompressed(15, 1, 1); // absent probes walk the directory too + } + try std.testing.expectEqual(cap_before, r.arena.queryCapacity()); } test "header serialize/parse round-trip" { From e15adf106f024965a959cb8475c3d77e277a13b6 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 8 Jul 2026 22:43:33 -0400 Subject: [PATCH 039/140] fix(compose): make --from-archives ingestion deterministic + loud on partial walks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archive paths were composed in directory-enumeration order, and the ownership tie-break falls back to input order for archives carrying identical (date, name) coverage keys — two generations of one cell in a polluted cache would pick a winner by filesystem whim, silently. The collected paths are now sorted, so any such tie (and the composed bytes) is deterministic. A mid-walk I/O error also no longer silently composes a partial cell set — it warns before proceeding. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/compose.zig | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tools/compose.zig b/tools/compose.zig index 293d08f..ffd256c 100644 --- a/tools/compose.zig +++ b/tools/compose.zig @@ -81,11 +81,24 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { defer dir.close(io); var walker = dir.walk(a) catch return usageErr("cannot walk archives dir"); defer walker.deinit(); - while (walker.next(io) catch null) |entry| { + while (walker.next(io) catch |err| blk: { + // A mid-walk I/O error would silently compose a PARTIAL cell set — say so. + std.debug.print(" warn: archive walk aborted early ({s}); composing what was found\n", .{@errorName(err)}); + break :blk null; + }) |entry| { if (entry.kind != .file) continue; if (!std.mem.endsWith(u8, entry.path, ".cell.tmp") and !std.mem.endsWith(u8, entry.path, ".pmtiles")) continue; tmp_paths.append(a, std.fs.path.join(a, &.{ base_path, entry.path }) catch continue) catch {}; } + // Sort the archive paths: the ownership tie-break falls back to input order for + // archives carrying identical (date, name) keys (e.g. two generations of one + // cell in a polluted cache), and directory enumeration order is unspecified — + // sorting makes any such tie, and the composed bytes, deterministic. + std.mem.sort([]const u8, tmp_paths.items, {}, struct { + fn lt(_: void, x: []const u8, y: []const u8) bool { + return std.mem.lessThan(u8, x, y); + } + }.lt); std.debug.print("recompose: {d} pre-baked archives from {s}\n", .{ tmp_paths.items.len, base_path }); } else for (cell_paths.items) |cp| { const arc = (chart.bakeCellBytes(cp, rules_dir) catch |err| { From 107eec2a79ff7ee160b1197de3ffc6ed1f4c303f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 04:51:29 -0400 Subject: [PATCH 040/140] feat(compose): stream progress through the zoom ladder (compose_files callback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composeArchivesToFile/composeInto take an optional ComposeProgress sink and report per zoom, interpolated within a zoom by owner-face fraction and weighted by each zoom's union-bbox tile span (the deepest zoom is ~3/4 of the work). A host can render a smooth bar + ETA + "zoom N of M" across the whole 3-5 min district compose instead of one opaque blocking call. Reporting is pure observation — it reads slot/face-count and cannot perturb the composed bytes; the seam byte-identity test still passes with progress active. Surfaced through the C ABI: tile57_compose_files gains (on_progress, ctx) + a tile57_compose_progress typedef, and the Go binding's ComposeFiles takes a func(ComposeProgress) via a cgo.Handle trampoline (mirrors BakeProgress). The CLI compose + in-memory composeArchives pass null (unchanged). New test asserts the callback fires monotonically and closes at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/go/bake.go | 29 ++++++-- bindings/go/progress.go | 33 ++++++++++ include/tile57.h | 15 ++++- src/bundle.zig | 142 ++++++++++++++++++++++++++++++++++++++-- src/capi.zig | 12 +++- tools/compose.zig | 2 +- 6 files changed, 218 insertions(+), 15 deletions(-) diff --git a/bindings/go/bake.go b/bindings/go/bake.go index 16c4501..e20a967 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -51,6 +51,18 @@ static int tile57_bake_bundle_cb(const char *input, const char *out_dir, }; return tile57_bake_bundle(input, out_dir, &opts, out_cells, out_bbox); } + +// Forward declaration of the //export'd Go compose-progress callback (defined in +// progress.go), so ComposeFiles can hand libtile57 a real C function pointer. +void tile57GoComposeProgress(void *ctx, uint32_t zoom, uint32_t min_zoom, + uint32_t max_zoom, uint64_t done, uint64_t total); + +// user != NULL → re-enter Go for compose progress; NULL → no progress. +static int tile57_compose_files_cb(const char *const *paths, size_t n, const char *out_path, + uint32_t *out_cells, void *user) { + return tile57_compose_files(paths, n, out_path, out_cells, + user ? tile57GoComposeProgress : (tile57_compose_progress)0, user); +} */ import "C" @@ -237,8 +249,10 @@ func Compose(archives [][]byte) ([]byte, error) { // ComposeFiles streaming-composes the per-cell PMTiles at `paths` (each written by [BakeCell] // to disk) into one merged PMTiles at outPath. The per-cell files are mmap'd (never all // resident) and the output is streamed, so a whole district composes in bounded memory — -// prefer this over [Compose] for large cell sets. Returns the number of contributing cells. -func ComposeFiles(paths []string, outPath string) (int, error) { +// prefer this over [Compose] for large cell sets. progress, if non-nil, is called with a +// [ComposeProgress] as the compose walks the zoom ladder (Done/Total is a smooth fraction). +// Returns the number of contributing cells. +func ComposeFiles(paths []string, outPath string, progress func(ComposeProgress)) (int, error) { if len(paths) == 0 { return 0, fmt.Errorf("tile57: ComposeFiles needs at least one path: %w", ErrEmptyInput) } @@ -257,10 +271,17 @@ func ComposeFiles(paths []string, outPath string) (int, error) { cOut := C.CString(outPath) defer C.free(unsafe.Pointer(cOut)) + var user unsafe.Pointer + if progress != nil { + h := cgo.NewHandle(progress) + defer h.Delete() + user = unsafe.Pointer(&h) + } + var cells C.uint32_t - rc := C.tile57_compose_files( + rc := C.tile57_compose_files_cb( (**C.char)(unsafe.Pointer(&cpaths[0])), C.size_t(len(paths)), - cOut, &cells, + cOut, &cells, user, ) switch rc { case 1: diff --git a/bindings/go/progress.go b/bindings/go/progress.go index 583a405..2fbd6eb 100644 --- a/bindings/go/progress.go +++ b/bindings/go/progress.go @@ -26,6 +26,39 @@ type BakeProgress struct { BandName string } +// ComposeProgress is one progress report from [ComposeFiles] as the streaming compose walks the +// zoom ladder. Done/Total are opaque work weights (the deepest zoom dominates the total), so +// Done/Total is a smooth 0..1 fraction; Zoom is the level currently composing, within +// [MinZoom, MaxZoom] — enough for a host to show a bar + ETA and a "zoom N of M" line. +type ComposeProgress struct { + Zoom int + MinZoom int + MaxZoom int + Done uint64 + Total uint64 +} + +// tile57GoComposeProgress is the C-callable progress callback libtile57 invokes during +// tile57_compose_files. The host's Go progress func is carried across the seam as a cgo.Handle +// pointed to by `user`. NULL user = no progress wanted. +// +//export tile57GoComposeProgress +func tile57GoComposeProgress(user unsafe.Pointer, zoom, minZoom, maxZoom C.uint32_t, done, total C.uint64_t) { + if user == nil { + return + } + h := *(*cgo.Handle)(user) + if cb, ok := h.Value().(func(ComposeProgress)); ok && cb != nil { + cb(ComposeProgress{ + Zoom: int(zoom), + MinZoom: int(minZoom), + MaxZoom: int(maxZoom), + Done: uint64(done), + Total: uint64(total), + }) + } +} + // tile57GoBakeProgress is the C-callable progress callback libtile57 invokes // during tile57_bake_pmtiles / tile57_bake_bundle. The host's Go progress func is // carried across the seam as a cgo.Handle, pointed to by `user`. NULL user = no diff --git a/include/tile57.h b/include/tile57.h index 5c2547f..df27284 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -112,14 +112,23 @@ int tile57_compose(const uint8_t *blob, size_t blob_len, const size_t *lens, size_t n, uint8_t **out, size_t *out_len); +/* Progress callback for tile57_compose_files, invoked as the streaming compose walks the zoom + * ladder. `done`/`total` are opaque work weights (the deepest zoom dominates the total), so + * `done`/`total` is a smooth 0..1 fraction; `zoom` is the level being composed, within + * [`min_zoom`, `max_zoom`]. A host renders done/total as a bar + ETA and "zoom N of M". `ctx` is + * the opaque pointer passed to tile57_compose_files. */ +typedef void (*tile57_compose_progress)(void *ctx, uint32_t zoom, uint32_t min_zoom, + uint32_t max_zoom, uint64_t done, uint64_t total); + /* Streaming disk-to-disk composite: combine the `n` per-cell PMTiles at `paths` (each written * by tile57_bake_cell_bytes) into one merged PMTiles at `out_path`. The per-cell files are * mmap'd (never all resident) and the output is streamed, so a whole district composes in * bounded memory — prefer this over tile57_compose for large cell sets. Writes the count of - * contributing cells to *out_cells if non-NULL. Returns 1 on success, 0 if nothing composed, - * -1 on error. */ + * contributing cells to *out_cells if non-NULL. `on_progress` (NULL to skip) is called with `ctx` + * as the compose advances. Returns 1 on success, 0 if nothing composed, -1 on error. */ int tile57_compose_files(const char *const *paths, size_t n, const char *out_path, - uint32_t *out_cells); + uint32_t *out_cells, + tile57_compose_progress on_progress, void *ctx); /* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + diff --git a/src/bundle.zig b/src/bundle.zig index 1346bd7..7c31c4f 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -806,6 +806,41 @@ const Framing = struct { cells: usize, }; +// A compose progress sink. The streaming compose invokes `cb` as it walks the zoom ladder: +// `done`/`total` are union-bbox tile-count weights (each zoom weighted by the tiles it spans, so +// the deepest zoom — ~3/4 of the work — dominates the bar), interpolated within a zoom by the +// owner-face fraction, so a host renders a smooth bar + ETA and a "zoom N of M" line. Reporting is +// pure observation and cannot affect the composed bytes. `ctx` is opaque host state. +pub const ComposeProgress = struct { + cb: *const fn (ctx: ?*anyopaque, zoom: u32, min_zoom: u32, max_zoom: u32, done: u64, total: u64) callconv(.c) void, + ctx: ?*anyopaque = null, + + // Set by composeInto as it walks the zoom loop; read by composeZoom to interpolate within one. + min_zoom: u32 = 0, + max_zoom: u32 = 0, + total: u64 = 1, // guarded ≥1 so a host's done/total never divides by zero + base: u64 = 0, // weight of the zooms already fully composed + zoom: u32 = 0, + zoom_weight: u64 = 0, // this zoom's tile-span weight + + fn emit(self: *const ComposeProgress, done_in_zoom: u64) void { + self.cb(self.ctx, self.zoom, self.min_zoom, self.max_zoom, self.base + done_in_zoom, self.total); + } +}; + +// Tiles the union bbox `ubox` (E7 lon/lat) spans at zoom `z` — the per-zoom compose-work weight, +// projected exactly as composeZoom projects a face's tile cover so the weights track real work. +fn uboxTileCount(ubox: [4]i32, z: u8) u64 { + const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + const w_tl = engine.tile.lonLatToWorld(@as(f64, @floatFromInt(ubox[0])) / 1e7, @as(f64, @floatFromInt(ubox[3])) / 1e7); + const w_br = engine.tile.lonLatToWorld(@as(f64, @floatFromInt(ubox[2])) / 1e7, @as(f64, @floatFromInt(ubox[1])) / 1e7); + const tx0 = worldAxisToTile(w_tl[0], scale); + const tx1 = worldAxisToTile(w_br[0], scale); + const ty0 = worldAxisToTile(w_tl[1], scale); + const ty1 = worldAxisToTile(w_br[1], scale); + return (@as(u64, tx1 - tx0) + 1) * (@as(u64, ty1 - ty0) + 1); +} + // The shared compose core: recover each reader's embedded coverage + SCAMIN ladder, rebuild the // ownership partition (via the canonical toPlaneCells adapter), and stream every composed tile // into `sw` — one governing band per zoom (partition.mapForZoom), each owner's tiles clipped to @@ -814,7 +849,7 @@ const Framing = struct { // Returns the framing to seal the archive, or null if no cell carried coverage or no tile // composed. Readers are BORROWED — kept alive by the caller; `readers` indexing aligns with the // partition (readers without coverage are filtered out here). -fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers: []const *engine.pmtiles.Reader) !?Framing { +fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers: []const *engine.pmtiles.Reader, progress: ?*ComposeProgress) !?Framing { const geo = engine.geo; const scene = engine.scene; @@ -884,11 +919,30 @@ fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers // composeZoom's per-tile scratch. var zoom_arena = std.heap.ArenaAllocator.init(gpa); defer zoom_arena.deinit(); + // Weight each zoom by the tiles the union bbox spans there, so progress tracks where the time + // actually goes (the deepest zoom is ~3/4 of the work); composeZoom interpolates within a zoom + // by owner-face fraction. Weighting is derived only from ubox/zoom — it can't perturb output. + if (progress) |pg| { + var tw: u64 = 0; + var zz: u8 = minz; + while (zz <= loop_max) : (zz += 1) tw += uboxTileCount(ubox, zz); + pg.min_zoom = minz; + pg.max_zoom = loop_max; + pg.total = @max(1, tw); + pg.base = 0; + } var z: u8 = minz; while (z <= loop_max) : (z += 1) { _ = zoom_arena.reset(.retain_capacity); - try composeZoom(sw, gpa, zoom_arena.allocator(), &part, kept.items, z); + if (progress) |pg| { + pg.zoom = z; + pg.zoom_weight = uboxTileCount(ubox, z); + pg.emit(0); + } + try composeZoom(sw, gpa, zoom_arena.allocator(), &part, kept.items, z, progress); + if (progress) |pg| pg.base += pg.zoom_weight; } + if (progress) |pg| pg.cb(pg.ctx, loop_max, minz, loop_max, pg.total, pg.total); if (sw.num_addressed == 0) return null; // 5. Framing: VECTOR_LAYERS + union SCAMIN metadata (no single-cell coverage), bbox, center. @@ -933,7 +987,7 @@ pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[ var sw = pmtiles.StreamWriter.init(gpa); defer sw.deinit(); - const fr = (try composeInto(gpa, &sw, readers.items)) orelse return null; + const fr = (try composeInto(gpa, &sw, readers.items, null)) orelse return null; defer gpa.free(fr.meta); return try sw.finishBytes(writeOpts(fr)); } @@ -944,7 +998,7 @@ pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[ /// partition + one zoom's composed tiles + the touched-page working set — independent of the /// number of cells. Returns the count of cells that contributed, or 0 if none. This is the path /// a whole-district composite takes. -pub fn composeArchivesToFile(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, out_path: []const u8) !usize { +pub fn composeArchivesToFile(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, out_path: []const u8, progress: ?*ComposeProgress) !usize { const pmtiles = engine.pmtiles; var ra = std.heap.ArenaAllocator.init(gpa); defer ra.deinit(); @@ -995,7 +1049,7 @@ pub fn composeArchivesToFile(io: std.Io, gpa: std.mem.Allocator, paths: []const var sw = pmtiles.StreamWriter.initFile(gpa, io, data_file); defer sw.deinit(); - const fr = (try composeInto(gpa, &sw, readers.items)) orelse { + const fr = (try composeInto(gpa, &sw, readers.items, progress)) orelse { data_file.close(io); std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; return 0; @@ -1041,6 +1095,7 @@ fn composeZoom( part: *const engine.geo.partition.Partition, readers: []const *engine.pmtiles.Reader, z: u8, + progress: ?*const ComposeProgress, ) !void { const mvt = engine.mvt; const tile = engine.tile; @@ -1060,7 +1115,15 @@ fn composeZoom( var tile_scratch = std.heap.ArenaAllocator.init(gpa); defer tile_scratch.deinit(); + // Report progress at the owner-face granularity, throttled to ≤~128 crossings per zoom + // (owner-face count ≈ cell count). slot is monotonic across skipped faces, so the bar only + // advances. Interpolated between this zoom's base weight and base+zoom_weight. + const prog_stride: usize = @max(1, map.faces.len >> 7); for (map.faces, 0..) |face, slot| { + if (progress) |pg| { + if (map.faces.len > 0 and slot % prog_stride == 0) + pg.emit(pg.zoom_weight * slot / map.faces.len); + } if (face.owned.len == 0) continue; const ci = face.index; const cscl = part.cells[ci].cscl; @@ -2576,7 +2639,7 @@ test "composeArchivesToFile: streams a seam split disk-to-disk" { dir.deleteFile(io, po) catch {}; } - const nc = try composeArchivesToFile(io, gpa, &.{ pa, pb }, po); + const nc = try composeArchivesToFile(io, gpa, &.{ pa, pb }, po, null); try testing.expectEqual(@as(usize, 2), nc); const bytes = try dir.readFileAlloc(io, po, a, .unlimited); @@ -2595,3 +2658,70 @@ test "composeArchivesToFile: streams a seam split disk-to-disk" { try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(E, 4), midy)); try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(@as(i64, E) * 3, 4), midy)); } + +test "composeArchivesToFile: progress fires monotonically and reaches total" { + const gpa = testing.allocator; + const io = testing.io; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const tile = engine.tile; + + // Two cells sharing a seam tile at z13 (as above) — enough to walk the zoom ladder and emit. + const z: u8 = 13; + const scale: f64 = @floatFromInt(@as(u64, 1) << z); + const w = tile.lonLatToWorld(-76.48, 38.97); + const tx = worldAxisToTile(w[0], scale); + const ty = worldAxisToTile(w[1], scale); + const bnds = tile.tileBoundsLonLat(z, tx, ty); + const midlon = (bnds[0] + bnds[2]) / 2.0; + const dlat = (bnds[3] - bnds[1]) * 0.01; + + const arc_a = try synthCell(gpa, a, "AAAAAAAA", bnds[0], bnds[1] + dlat, midlon, bnds[3] - dlat, 20_000, z, tx, ty); + defer gpa.free(arc_a); + const arc_b = try synthCell(gpa, a, "BBBBBBBB", midlon, bnds[1] + dlat, bnds[2], bnds[3] - dlat, 20_000, z, tx, ty); + defer gpa.free(arc_b); + + const dir = std.Io.Dir.cwd(); + const pa = ".zig-cache/compose_prog_a.pmtiles.tmp"; + const pb = ".zig-cache/compose_prog_b.pmtiles.tmp"; + const po = ".zig-cache/compose_prog_out.pmtiles.tmp"; + try dir.writeFile(io, .{ .sub_path = pa, .data = arc_a }); + try dir.writeFile(io, .{ .sub_path = pb, .data = arc_b }); + defer { + dir.deleteFile(io, pa) catch {}; + dir.deleteFile(io, pb) catch {}; + dir.deleteFile(io, po) catch {}; + } + + const Rec = struct { + n: usize = 0, + last_done: u64 = 0, + max_done: u64 = 0, + total: u64 = 0, + monotonic: bool = true, + min_zoom: u32 = 999, + max_zoom: u32 = 0, + fn cb(ctx: ?*anyopaque, zoom: u32, min_zoom: u32, max_zoom: u32, done: u64, total: u64) callconv(.c) void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + self.n += 1; + if (done < self.last_done) self.monotonic = false; + self.last_done = done; + if (done > self.max_done) self.max_done = done; + self.total = total; + self.min_zoom = min_zoom; + self.max_zoom = max_zoom; + _ = zoom; + } + }; + var rec = Rec{}; + var prog = ComposeProgress{ .cb = Rec.cb, .ctx = &rec }; + + const nc = try composeArchivesToFile(io, gpa, &.{ pa, pb }, po, &prog); + try testing.expectEqual(@as(usize, 2), nc); + try testing.expect(rec.n > 0); // the callback actually fired + try testing.expect(rec.monotonic); // done never went backwards + try testing.expect(rec.total > 0); + try testing.expectEqual(rec.total, rec.max_done); // the final emit closes the bar at 100% + try testing.expect(rec.max_zoom >= rec.min_zoom); +} diff --git a/src/capi.zig b/src/capi.zig index f078e12..c60d0aa 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -151,6 +151,8 @@ export fn tile57_compose_files( n: usize, out_path: ?[*:0]const u8, out_cells: ?*u32, + on_progress: ?*const fn (ctx: ?*anyopaque, zoom: u32, min_zoom: u32, max_zoom: u32, done: u64, total: u64) callconv(.c) void, + ctx: ?*anyopaque, ) callconv(.c) c_int { const ps = paths orelse return -1; const op = spanOpt(out_path) orelse return -1; @@ -159,10 +161,18 @@ export fn tile57_compose_files( defer gpa.free(list); for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return -1; + // Optional streaming progress: a stack-lived sink the compose walks the zoom ladder through. + var prog: bundle.ComposeProgress = undefined; + var pptr: ?*bundle.ComposeProgress = null; + if (on_progress) |cb| { + prog = .{ .cb = cb, .ctx = ctx }; + pptr = &prog; + } + // The lib has no std.process.Init; stand up a threaded std.Io for the file I/O. var threaded: std.Io.Threaded = .init(gpa, .{}); defer threaded.deinit(); - const nc = bundle.composeArchivesToFile(threaded.io(), gpa, list, op) catch return -1; + const nc = bundle.composeArchivesToFile(threaded.io(), gpa, list, op, pptr) catch return -1; if (out_cells) |p| p.* = @intCast(nc); return if (nc > 0) 1 else 0; } diff --git a/tools/compose.zig b/tools/compose.zig index ffd256c..8bc6d10 100644 --- a/tools/compose.zig +++ b/tools/compose.zig @@ -116,7 +116,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // 3. Stream-compose the per-cell files into one PMTiles (mmap in, streamed out — the cell // set is never all resident). - const nc = bundle.composeArchivesToFile(io, a, tmp_paths.items, out_path) catch |err| { + const nc = bundle.composeArchivesToFile(io, a, tmp_paths.items, out_path, null) catch |err| { std.debug.print("error: compose failed ({s})\n", .{@errorName(err)}); return; }; From 95839941fd8ca6733376944e0975482021a4d1cf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 06:12:34 -0400 Subject: [PATCH 041/140] feat(partition): serialize/deserialize the ownership partition to a sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-band ownership partition is a pure function of the cell set (coverage rings, cscl, band_floor, order, reach) and its owned-face geometry is tiny (~1 MB for a district) even though building it runs the expensive owned-face booleans. Add a versioned little-endian binary encoding so the partition can be precomputed once and reused: a cold recompose skips the build, and a future runtime compositor can serve tiles from the sidecar alone. `inputKey` hashes the exact build inputs so a loader rejects a stale sidecar (→ rebuild) — the safety valve that makes an incremental recompose sound when coverage is unchanged. `pos`/`tiers` are reconstructed on load. Faces are serialized in-order so the compositor's tile iteration order (and hence the archive byte order) is preserved. Round-trip + stale/corrupt-rejection tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geo/partition.zig | 240 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/src/geo/partition.zig b/src/geo/partition.zig index 2a8dbbd..6718ad2 100644 --- a/src/geo/partition.zig +++ b/src/geo/partition.zig @@ -124,6 +124,167 @@ pub fn build(gpa: std.mem.Allocator, cells: []const plane.Cell) !Partition { return .{ .gpa = gpa, .cells = cells, .tiers = tiers, .maps = maps }; } +// =========================================================================== +// Serialization — a precomputed partition as a self-contained sidecar. +// =========================================================================== +// +// The partition is a pure function of the cell set (each cell's coverage rings, +// cscl, band_floor, order, reach). Building it runs the expensive owned-face +// booleans; the RESULT is tiny (the whole owned-face geometry is ~1 MB for a +// district). So it is worth precomputing once and reusing: a cold recompose skips +// the build, and a runtime compositor can serve tiles from the sidecar alone. +// +// Format (little-endian, length-prefixed): +// magic "T57P" | version u32 | input_key u64 | n_cells u32 | n_maps u32 +// per map: tier u8 | n_faces u32 +// per face: cell_index u32 | n_rings u32 +// per ring: n_pts u32 | pts (x i64, y i64)… +// `pos` and `tiers` are reconstructed on load (pos from faces + n_cells). +// +// `input_key` binds the geometry to the exact cells it was built from: a loader +// recomputes it from the cells it holds and rejects a stale sidecar (→ rebuild), +// which is what makes an incremental recompose safe when coverage is unchanged. + +pub const MAGIC = [4]u8{ 'T', '5', '7', 'P' }; +pub const FORMAT_VERSION: u32 = 1; + +pub const LoadError = error{ + BadMagic, + UnsupportedVersion, + StalePartition, // input_key mismatch — the cells changed; rebuild + CellCountMismatch, + Truncated, + OutOfMemory, +}; + +/// A hash of the exact inputs `build` consumes, so a stored partition can be +/// matched to the cells a loader holds. Coverage points are hashed as raw bytes; +/// both shipped targets are little-endian, and a false mismatch only forces a +/// (safe) rebuild. +pub fn inputKey(cells: []const plane.Cell) u64 { + var h = std.hash.Wyhash.init(0x5457_3537_5041_5254); // "TW57PART" + for (cells) |c| { + h.update(std.mem.asBytes(&c.cscl)); + h.update(std.mem.asBytes(&c.band_floor)); + h.update(std.mem.asBytes(&c.order)); + h.update(std.mem.asBytes(&c.reach)); + for (c.cov1) |ring| h.update(std.mem.sliceAsBytes(ring)); + h.update(&[_]u8{0xff}); // cov1/cov2 boundary marker + for (c.cov2) |ring| h.update(std.mem.sliceAsBytes(ring)); + h.update(&[_]u8{0xfe}); // cell boundary marker + } + return h.final(); +} + +fn putInt(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), comptime T: type, v: T) !void { + var tmp: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &tmp, v, .little); + try buf.appendSlice(gpa, &tmp); +} + +/// Encode `part` to a fresh `gpa`-owned byte slice (free with `gpa.free`). +pub fn serialize(gpa: std.mem.Allocator, part: *const Partition) ![]u8 { + var buf = std.ArrayList(u8).empty; + errdefer buf.deinit(gpa); + try buf.appendSlice(gpa, &MAGIC); + try putInt(gpa, &buf, u32, FORMAT_VERSION); + try putInt(gpa, &buf, u64, inputKey(part.cells)); + try putInt(gpa, &buf, u32, @intCast(part.cells.len)); + try putInt(gpa, &buf, u32, @intCast(part.maps.len)); + for (part.maps) |m| { + try putInt(gpa, &buf, u8, m.tier); + try putInt(gpa, &buf, u32, @intCast(m.faces.len)); + for (m.faces) |f| { + try putInt(gpa, &buf, u32, @intCast(f.index)); + try putInt(gpa, &buf, u32, @intCast(f.owned.len)); + for (f.owned) |ring| { + try putInt(gpa, &buf, u32, @intCast(ring.len)); + for (ring) |p| { + try putInt(gpa, &buf, i64, p.x); + try putInt(gpa, &buf, i64, p.y); + } + } + } + } + return buf.toOwnedSlice(gpa); +} + +const Cursor = struct { + bytes: []const u8, + pos: usize = 0, + fn getInt(self: *Cursor, comptime T: type) !T { + const n = @sizeOf(T); + if (self.pos + n > self.bytes.len) return error.Truncated; + const v = std.mem.readInt(T, self.bytes[self.pos..][0..n], .little); + self.pos += n; + return v; + } +}; + +// Read one face (index + rings) fully, or free its partial geometry and error. +fn readFace(gpa: std.mem.Allocator, cur: *Cursor) !plane.OwnedCell { + const index: usize = @intCast(try cur.getInt(u32)); + const n_rings = try cur.getInt(u32); + const owned = try gpa.alloc([]plane.Pt, n_rings); + var built: usize = 0; + errdefer { + for (owned[0..built]) |r| gpa.free(r); + gpa.free(owned); + } + while (built < n_rings) : (built += 1) { + const n_pts = try cur.getInt(u32); + const ring = try gpa.alloc(plane.Pt, n_pts); + errdefer gpa.free(ring); + for (ring) |*p| p.* = .{ .x = try cur.getInt(i64), .y = try cur.getInt(i64) }; + owned[built] = ring; + } + return .{ .index = index, .owned = owned }; +} + +/// Decode a sidecar produced by `serialize` into a live `Partition` that borrows +/// `cells` (kept alive by the caller, exactly as `build` does). Rejects a sidecar +/// whose `input_key` does not match `cells` (the cells changed → rebuild). +pub fn deserialize(gpa: std.mem.Allocator, bytes: []const u8, cells: []const plane.Cell) LoadError!Partition { + if (bytes.len < MAGIC.len or !std.mem.eql(u8, bytes[0..MAGIC.len], &MAGIC)) return error.BadMagic; + var cur = Cursor{ .bytes = bytes, .pos = MAGIC.len }; + if (try cur.getInt(u32) != FORMAT_VERSION) return error.UnsupportedVersion; + if (try cur.getInt(u64) != inputKey(cells)) return error.StalePartition; + if (try cur.getInt(u32) != @as(u32, @intCast(cells.len))) return error.CellCountMismatch; + const n_maps = try cur.getInt(u32); + + const tiers = try gpa.alloc(u8, n_maps); + errdefer gpa.free(tiers); + const maps = try gpa.alloc(BandMap, n_maps); + errdefer gpa.free(maps); + var built_maps: usize = 0; + errdefer for (maps[0..built_maps]) |m| { + plane.freeOwned(gpa, m.faces); + gpa.free(m.pos); + }; + + for (0..n_maps) |i| { + const tier = try cur.getInt(u8); + const n_faces = try cur.getInt(u32); + const faces = try gpa.alloc(plane.OwnedCell, n_faces); + var built_faces: usize = 0; + errdefer { + for (faces[0..built_faces]) |f| boolean.freePolygon(gpa, f.owned); + gpa.free(faces); + } + while (built_faces < n_faces) : (built_faces += 1) { + faces[built_faces] = try readFace(gpa, &cur); + } + const pos = try gpa.alloc(i32, cells.len); + @memset(pos, -1); + for (faces, 0..) |f, slot| pos[f.index] = @intCast(slot); + tiers[i] = tier; + maps[i] = .{ .tier = tier, .faces = faces, .pos = pos }; + built_maps = i + 1; + } + + return .{ .gpa = gpa, .cells = cells, .tiers = tiers, .maps = maps }; +} + // =========================================================================== // Tests // =========================================================================== @@ -182,3 +343,82 @@ test "partition band-stack: tiers descending, mapForZoom + ownerAt resolve per b try testing.expect(part.ownedFace(1, 10) == null); // harbor below its floor: owns nothing try testing.expect(part.ownedFace(99, 14) == null); // out of range } + +test "partition serialize/deserialize round-trips exactly" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const coarse = try boxPoly(a, 0, 0, 100, 100); + const harbor = try boxPoly(a, 40, 40, 60, 60); + const cells = [_]plane.Cell{ + .{ .cscl = 100_000, .band_floor = 9, .order = 0, .cov1 = &.{coarse} }, + .{ .cscl = 20_000, .band_floor = 13, .order = 0, .cov1 = &.{harbor} }, + }; + + var part = try build(testing.allocator, &cells); + defer part.deinit(); + + const bytes = try serialize(testing.allocator, &part); + defer testing.allocator.free(bytes); + + var got = try deserialize(testing.allocator, bytes, &cells); + defer got.deinit(); + + // Structural equality: tiers, per-map faces (IN ORDER — the compositor's tile + // iteration order rides on it), owned rings and their points. + try testing.expectEqualSlices(u8, part.tiers, got.tiers); + try testing.expectEqual(part.maps.len, got.maps.len); + for (part.maps, got.maps) |m0, m1| { + try testing.expectEqual(m0.tier, m1.tier); + try testing.expectEqualSlices(i32, m0.pos, m1.pos); + try testing.expectEqual(m0.faces.len, m1.faces.len); + for (m0.faces, m1.faces) |f0, f1| { + try testing.expectEqual(f0.index, f1.index); + try testing.expectEqual(f0.owned.len, f1.owned.len); + for (f0.owned, f1.owned) |r0, r1| { + try testing.expectEqualSlices(u8, std.mem.sliceAsBytes(r0), std.mem.sliceAsBytes(r1)); + } + } + } + + // Re-serializing the loaded partition yields the identical bytes. + const bytes2 = try serialize(testing.allocator, &got); + defer testing.allocator.free(bytes2); + try testing.expectEqualSlices(u8, bytes, bytes2); + + // Ownership queries agree with the freshly-built partition. + try testing.expectEqual(part.ownerAt(14, 50, 50), got.ownerAt(14, 50, 50)); + try testing.expectEqual(part.ownerAt(14, 10, 10), got.ownerAt(14, 10, 10)); + try testing.expectEqual(part.ownerAt(10, 50, 50), got.ownerAt(10, 50, 50)); +} + +test "partition sidecar rejects a stale cell set + a corrupt blob" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const coarse = try boxPoly(a, 0, 0, 100, 100); + const harbor = try boxPoly(a, 40, 40, 60, 60); + const cells = [_]plane.Cell{ + .{ .cscl = 100_000, .band_floor = 9, .order = 0, .cov1 = &.{coarse} }, + .{ .cscl = 20_000, .band_floor = 13, .order = 0, .cov1 = &.{harbor} }, + }; + var part = try build(testing.allocator, &cells); + defer part.deinit(); + const bytes = try serialize(testing.allocator, &part); + defer testing.allocator.free(bytes); + + // Different coverage → different input_key → rejected (→ caller rebuilds). + const harbor2 = try boxPoly(a, 41, 40, 60, 60); // one vertex moved + const cells2 = [_]plane.Cell{ + .{ .cscl = 100_000, .band_floor = 9, .order = 0, .cov1 = &.{coarse} }, + .{ .cscl = 20_000, .band_floor = 13, .order = 0, .cov1 = &.{harbor2} }, + }; + try testing.expectError(error.StalePartition, deserialize(testing.allocator, bytes, &cells2)); + + // A truncated blob and a bad magic are caught, not UB. + try testing.expectError(error.Truncated, deserialize(testing.allocator, bytes[0 .. bytes.len - 4], &cells)); + const bad = [_]u8{0} ** 8; + try testing.expectError(error.BadMagic, deserialize(testing.allocator, &bad, &cells)); +} From 4187bde20bd164c0f8ea7a4b19516afcd1264992 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 06:34:58 -0400 Subject: [PATCH 042/140] fix(partition): hash coverage POINTS in inputKey, not ring-slice pointers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cov1/cov2 are bags of polygons (`[]const Poly`, each `[]const []const Pt`), so the first loop level is a polygon, not a ring. sliceAsBytes on that level hashed the inner ring slices' fat pointers — heap addresses that vary per process — so the validity key was nondeterministic across runs and a freshly-saved sidecar was always rejected as stale. Descend to the actual `[]const Pt` rings and hash the coordinate bytes (plus each ring length, so a regrouping can't alias). The same-process round-trip test missed it (identical pointers); the cross-process compose save/load is what exposed it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geo/partition.zig | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/geo/partition.zig b/src/geo/partition.zig index 6718ad2..929ab24 100644 --- a/src/geo/partition.zig +++ b/src/geo/partition.zig @@ -161,6 +161,14 @@ pub const LoadError = error{ /// matched to the cells a loader holds. Coverage points are hashed as raw bytes; /// both shipped targets are little-endian, and a false mismatch only forces a /// (safe) rebuild. +fn hashPolyPoints(h: *std.hash.Wyhash, poly: plane.Poly) void { + for (poly) |ring| { + const n: u32 = @intCast(ring.len); + h.update(std.mem.asBytes(&n)); + h.update(std.mem.sliceAsBytes(ring)); // ring is []const Pt — real coordinate bytes + } +} + pub fn inputKey(cells: []const plane.Cell) u64 { var h = std.hash.Wyhash.init(0x5457_3537_5041_5254); // "TW57PART" for (cells) |c| { @@ -168,9 +176,12 @@ pub fn inputKey(cells: []const plane.Cell) u64 { h.update(std.mem.asBytes(&c.band_floor)); h.update(std.mem.asBytes(&c.order)); h.update(std.mem.asBytes(&c.reach)); - for (c.cov1) |ring| h.update(std.mem.sliceAsBytes(ring)); + // cov1/cov2 are bags of polygons (each a set of rings). Descend to the POINTS and hash + // their bytes — never the ring slices' fat pointers, which are heap addresses that vary + // per process. Ring lengths go in too so a regrouping can't alias to the same digest. + for (c.cov1) |poly| hashPolyPoints(&h, poly); h.update(&[_]u8{0xff}); // cov1/cov2 boundary marker - for (c.cov2) |ring| h.update(std.mem.sliceAsBytes(ring)); + for (c.cov2) |poly| hashPolyPoints(&h, poly); h.update(&[_]u8{0xfe}); // cell boundary marker } return h.final(); From 7493d6b852ffeb47992821c52b35a30b44d054e1 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 06:35:08 -0400 Subject: [PATCH 043/140] feat(compose): precompute the ownership partition to/from a sidecar Wire the partition serialization into the compositor. `composeInto` takes a PartOpt: if handed sidecar bytes whose input_key matches this exact cell set it loads the partition and skips the expensive owned-face build; otherwise it builds, and hands back fresh bytes to persist. A stale/corrupt/missing sidecar falls back to a build (with a warning), so it is always safe. New streaming variant composeArchivesToFileOpt(load_path, save_path) reads the sidecar before and writes it after (composeArchivesToFile stays a thin wrapper, so the C ABI is unchanged); CLI flags --save-partition / --load-partition. Point both at one file for a self-refreshing cache: build+save when coverage changes, load otherwise. Validated on NOAA d05 (848 cells): saved sidecar is byte-identical across runs (deterministic key), the load path reuses it (no rebuild), and both save- and load-path outputs are byte-identical to the rebuilt-partition reference. This is the substrate for on-demand runtime composition and incremental recompose. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bundle.zig | 70 ++++++++++++++++++++++++++++++++++++++++++++--- tools/compose.zig | 10 +++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index 7c31c4f..cebe544 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -849,7 +849,17 @@ fn uboxTileCount(ubox: [4]i32, z: u8) u64 { // Returns the framing to seal the archive, or null if no cell carried coverage or no tile // composed. Readers are BORROWED — kept alive by the caller; `readers` indexing aligns with the // partition (readers without coverage are filtered out here). -fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers: []const *engine.pmtiles.Reader, progress: ?*ComposeProgress) !?Framing { +/// How `composeInto` sources the ownership partition. Default = build it from the cells. +pub const PartOpt = struct { + /// Borrowed sidecar bytes to try; used iff valid for these cells, else the partition is built. + load: ?[]const u8 = null, + /// When composeInto BUILDS (does not load) the partition, it sets `*save_out` to a fresh + /// gpa-owned serialization for the caller to persist (free with gpa.free). Left untouched when + /// the partition is loaded from `load`. + save_out: ?*?[]u8 = null, +}; + +fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers: []const *engine.pmtiles.Reader, progress: ?*ComposeProgress, popt: PartOpt) !?Framing { const geo = engine.geo; const scene = engine.scene; @@ -899,8 +909,25 @@ fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers // transient scratch to MBs. const cells = try toPlaneCells(a, shims.items); for (cells, kept.items) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); - var part = try geo.partition.build(gpa, cells); + + // The partition is a pure function of these cells. If the caller handed us a sidecar that + // still matches (its input_key binds it to this exact cell set), load it and skip the + // expensive owned-face build; otherwise build, and hand the caller the fresh bytes to persist. + var part: geo.partition.Partition = undefined; + var loaded = false; + if (popt.load) |bytes| { + if (geo.partition.deserialize(gpa, bytes, cells)) |p| { + part = p; + loaded = true; + } else |err| { + std.debug.print(" partition sidecar unusable ({s}); rebuilding\n", .{@errorName(err)}); + } + } + if (!loaded) part = try geo.partition.build(gpa, cells); defer part.deinit(); + if (!loaded) if (popt.save_out) |so| { + so.* = try geo.partition.serialize(gpa, &part); + }; // 3. Output zoom span = union of the cells' native band windows, plus one fill-up zoom past // the deepest band (overscale; deeper coarse-only zooms → client camera + overzoom). @@ -987,7 +1014,7 @@ pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[ var sw = pmtiles.StreamWriter.init(gpa); defer sw.deinit(); - const fr = (try composeInto(gpa, &sw, readers.items, null)) orelse return null; + const fr = (try composeInto(gpa, &sw, readers.items, null, .{})) orelse return null; defer gpa.free(fr.meta); return try sw.finishBytes(writeOpts(fr)); } @@ -999,6 +1026,16 @@ pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[ /// number of cells. Returns the count of cells that contributed, or 0 if none. This is the path /// a whole-district composite takes. pub fn composeArchivesToFile(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, out_path: []const u8, progress: ?*ComposeProgress) !usize { + return composeArchivesToFileOpt(io, gpa, paths, out_path, progress, null, null); +} + +/// Like `composeArchivesToFile`, but precomputes the ownership partition to/from a sidecar: +/// * `load_path` (if non-null and present + valid for this cell set) is loaded, skipping the +/// partition build; a missing/stale/corrupt sidecar falls back to a build (with a warning). +/// * `save_path` (if non-null) is written with the built partition, so a later run can `load` it. +/// Point both at the same file for a self-refreshing cache: build+save on first run (or when the +/// coverage changed), load on every run where it still matches. +pub fn composeArchivesToFileOpt(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, out_path: []const u8, progress: ?*ComposeProgress, load_path: ?[]const u8, save_path: ?[]const u8) !usize { const pmtiles = engine.pmtiles; var ra = std.heap.ArenaAllocator.init(gpa); defer ra.deinit(); @@ -1049,13 +1086,38 @@ pub fn composeArchivesToFile(io: std.Io, gpa: std.mem.Allocator, paths: []const var sw = pmtiles.StreamWriter.initFile(gpa, io, data_file); defer sw.deinit(); - const fr = (try composeInto(gpa, &sw, readers.items, progress)) orelse { + // Read a partition sidecar if one was provided and is present (validity is verified inside + // composeInto against this exact cell set; a stale/corrupt one falls back to a build). + var load_bytes: ?[]const u8 = null; + if (load_path) |lp| load_from: { + var lf = std.Io.Dir.cwd().openFile(io, lp, .{}) catch break :load_from; + defer lf.close(io); + const st = lf.stat(io) catch break :load_from; + const n: usize = @intCast(st.size); + if (n == 0) break :load_from; + const buf = try raa.alloc(u8, n); + _ = lf.readPositionalAll(io, buf, 0) catch break :load_from; + load_bytes = buf; + } + var save_bytes: ?[]u8 = null; + defer if (save_bytes) |sb| gpa.free(sb); + + const fr = (try composeInto(gpa, &sw, readers.items, progress, .{ + .load = load_bytes, + .save_out = if (save_path != null) &save_bytes else null, + })) orelse { data_file.close(io); std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; return 0; }; defer gpa.free(fr.meta); + // Persist a freshly-built partition so a later run can load it (skipped when it was loaded). + if (save_path) |sp| if (save_bytes) |sb| { + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = sp, .data = sb }) catch |err| + std.debug.print(" warn: could not write partition sidecar {s} ({s})\n", .{ sp, @errorName(err) }); + }; + const pre = try sw.prefix(raa, writeOpts(fr)); var out_file = try std.Io.Dir.cwd().createFile(io, out_path, .{}); try out_file.writeStreamingAll(io, pre); diff --git a/tools/compose.zig b/tools/compose.zig index 8bc6d10..2b3dc6e 100644 --- a/tools/compose.zig +++ b/tools/compose.zig @@ -1,5 +1,5 @@ //! `compose -o [--rules DIR] [--keep-cells] -//! [--from-archives]` — the +//! [--from-archives] [--save-partition FILE] [--load-partition FILE]` — the //! per-cell composite bake, disk-to-disk. Bake each cell to its OWN native-scale PMTiles (with //! its M_COVR coverage embedded in the metadata), written to a temp file so only ONE cell's //! archive is ever resident; then stream them through the ownership partition into one merged @@ -23,6 +23,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var rules: ?[]const u8 = null; var keep_cells = false; // retain the per-cell temp PMTiles (a reusable cache) instead of deleting var from_archives = false; // compose a dir of pre-baked archives (skip the bake) + var save_partition: ?[]const u8 = null; // write the built ownership partition to this sidecar + var load_partition: ?[]const u8 = null; // load the ownership partition from this sidecar (skip build) var f = Flags{ .args = args }; while (f.next()) |arg| { @@ -34,6 +36,10 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { keep_cells = true; } else if (std.mem.eql(u8, arg, "--from-archives")) { from_archives = true; // is a DIR of pre-baked *.cell.tmp / *.pmtiles — skip baking + } else if (std.mem.eql(u8, arg, "--save-partition")) { + save_partition = f.val(arg) orelse return; + } else if (std.mem.eql(u8, arg, "--load-partition")) { + load_partition = f.val(arg) orelse return; } else if (std.mem.startsWith(u8, arg, "-")) { return usageErr("unknown flag"); } else if (base == null) { @@ -116,7 +122,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // 3. Stream-compose the per-cell files into one PMTiles (mmap in, streamed out — the cell // set is never all resident). - const nc = bundle.composeArchivesToFile(io, a, tmp_paths.items, out_path, null) catch |err| { + const nc = bundle.composeArchivesToFileOpt(io, a, tmp_paths.items, out_path, null, load_partition, save_partition) catch |err| { std.debug.print("error: compose failed ({s})\n", .{@errorName(err)}); return; }; From 444a93b4167e2a64ae8c8e86ee8b35343ba23321 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 07:00:34 -0400 Subject: [PATCH 044/140] =?UTF-8?q?feat(compose):=20composeTile=20?= =?UTF-8?q?=E2=80=94=20on-demand=20per-tile=20composition=20from=20a=20res?= =?UTF-8?q?ident=20partition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add composeTile(part, readers, z,x,y): compose ONE tile on demand, byte-identical to the tile the batch compositor writes. With the partition loaded once (from the sidecar), serving a tile is a classify against the owned faces plus either one mmap-slice copy (verbatim, the common case) or one decode/clip/encode (seam) — not a whole-district pass. This is the runtime compositor: it turns the 38s district batch into per-tile-ms serving behind a camera/LRU. Byte-identity is by construction: the FULL/EMPTY/SEAM classify (faceTileBBox + tileWidthE7 + tileClassifyBox) and the seam gather (composeSeamTile) are extracted from composeZoom and shared, so the batch and on-demand paths run the exact same math in the same face order. composeZoom now calls the shared helpers; the d05 848-cell batch stays byte-identical. Hermetic test proves it end-to-end: compose a fixture the batch way and, tile by tile across the whole coverage window (empties included), assert composeTile matches — covering a full-owner cell (verbatim blob) and an abutting-cell seam split (recomposed tile). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bundle.zig | 329 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 266 insertions(+), 63 deletions(-) diff --git a/src/bundle.zig b/src/bundle.zig index cebe544..9f17b9a 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -1137,6 +1137,80 @@ pub fn composeArchivesToFileOpt(io: std.Io, gpa: std.mem.Allocator, paths: []con return fr.cells; } +// The tile-index cover (nw..se) of an owner face's lon/lat bbox at zoom `scale = 1< continue, // owner owns none of this tile (buffer included): skip .empty => { // Owner owns the whole tile: copy its native blob verbatim if present @@ -1269,38 +1317,60 @@ fn composeZoom( const tx = kv.key_ptr.x; const ty = kv.key_ptr.y; _ = tile_scratch.reset(.retain_capacity); - const ta = tile_scratch.allocator(); - - var buckets: [N_COMPOSE_LAYERS]std.ArrayList(mvt.Feature) = undefined; - for (&buckets) |*b| b.* = std.ArrayList(mvt.Feature).empty; - for (kv.value_ptr.items) |slot| { - const face = map.faces[slot]; - const ci = face.index; - // The cell's tile content at (z,tx,ty): its native tile, or — one fill-up zoom - // past its band — its deepest native ancestor scaled up (overscale). - const layers = (try ownerTile(ta, readers[ci], part.cells[ci].cscl, z, tx, ty)) orelse continue; - // The owned face in THIS tile's pixel space (recomputed — caching every pending - // tile's projection is exactly the zoom-sized retention this pass removes). - const face_px = try compose.projectFace(ta, face.owned, z, tx, ty); - if (face_px.len == 0) continue; - for (layers) |layer| { - const li = layerIndex(layer.name) orelse continue; - for (layer.features) |feat| { - try compose.clipFeatureToFace(ta, &buckets[li], feat, face_px); - } - } - } + // Decode + clip + orient + encode one tile in the per-tile scratch (shared with the + // on-demand composeTile — see composeSeamTile). Recomputing each owner's projected face + // here rather than caching pass 1's is the zoom-sized retention this two-pass split removes. + const enc = (try composeSeamTile(tile_scratch.allocator(), part, map, readers, kv.value_ptr.items, z, tx, ty)) orelse continue; + try sw.add(z, tx, ty, enc); + } +} - var layers = std.ArrayList(mvt.Layer).empty; - for (&buckets, 0..) |*bucket, li| { - if (bucket.items.len == 0) continue; - const feats = try orientPolys(ta, bucket.items); - try layers.append(ta, .{ .name = scene.VECTOR_LAYERS[li], .features = feats }); +/// Compose ONE tile on demand from a resident partition + mmap'd per-cell `readers` (cell index == +/// reader index, exactly as composeInto aligns them), byte-identical to the tile the batch +/// compositor writes for (z,tx,ty). Returns the gzipped MLT the archive stores — a verbatim owner +/// blob (an mmap-slice copy, the common case) or a freshly composed seam tile — gpa-owned; null if +/// no cell owns this tile. This is the runtime compositor: with the partition loaded once, serving a +/// tile is a classify plus either one memcpy or one decode/clip/encode, not a whole-district pass. +pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Partition, readers: []const *engine.pmtiles.Reader, z: u8, tx: u32, ty: u32) !?[]u8 { + const compose = engine.scene.compose; + const map = part.mapForZoom(z) orelse return null; + const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const ta = arena.allocator(); + + // Pass-1-equivalent for this one tile: walk owners in face order (the batch's tie order), cull + // by face bbox, classify. The FIRST owner that fully owns the tile (buffer included) and has a + // native blob wins the verbatim copy — faces are a disjoint partition, so that owner is unique. + // Every other contributing owner (seam, or fully-owned-but-no-native) is collected in face order. + var slots = std.ArrayList(u32).empty; + for (map.faces, 0..) |face, slot| { + if (face.owned.len == 0) continue; + const ci = face.index; + const cscl = part.cells[ci].cscl; + const bb = faceTileBBox(face, scale); + if (tx < bb.tx0 or tx > bb.tx1 or ty < bb.ty0 or ty > bb.ty1) continue; + + var grid = try engine.geo.plane.EdgeGrid.init(ta, face.owned, tileWidthE7(z)); + defer grid.deinit(); + switch (grid.classify(tileClassifyBox(z, tx, ty))) { + .full => continue, // owns none of this tile + .empty => { // owns the whole tile: verbatim blob if present, else fall through + if (try readers[ci].getCompressed(z, tx, ty)) |blob| return try gpa.dupe(u8, blob); + }, + .seam => {}, } - if (layers.items.len == 0) continue; - const enc = try engine.mlt.encode(ta, .{ .layers = layers.items }); - try sw.add(z, tx, ty, enc); + if (!(try ownerHasTile(readers[ci], cscl, z, tx, ty))) continue; + const face_px = try compose.projectFace(ta, face.owned, z, tx, ty); + if (face_px.len == 0) continue; + try slots.append(ta, @intCast(slot)); } + if (slots.items.len == 0) return null; + + // gzip the composed MLT so the served bytes match what StreamWriter.add stores in the archive. + const enc = (try composeSeamTile(ta, part, map, readers, slots.items, z, tx, ty)) orelse return null; + return try engine.pmtiles.StreamWriter.gzipTile(gpa, enc); } // The output-layer slot for a decoded layer name (one of scene.VECTOR_LAYERS), or null to drop. @@ -2546,6 +2616,139 @@ test "composeArchives: two abutting cells split a shared tile at the seam, no do try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(@as(i64, E) * 3, 4), midy)); } +// Compose `arcs` two ways — the batch composeArchives (the byte-truth) and, tile by tile, the +// on-demand composeTile over a rebuilt partition — and assert every tile across the coverage +// window (empties included: null both ways) is byte-identical. Returns how many verbatim vs +// freshly-composed seam tiles were exercised. +const ComposeTileCounts = struct { checked: usize, verbatim: usize, seam: usize }; +fn verifyComposeTileMatchesBatch(gpa: std.mem.Allocator, arcs: []const []const u8) !ComposeTileCounts { + const pmtiles = engine.pmtiles; + const scene = engine.scene; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + + const composed = (try composeArchives(gpa, arcs)) orelse return error.TestUnexpectedResult; + defer gpa.free(composed); + var truth = try pmtiles.Reader.init(gpa, composed); + defer truth.deinit(); + + // Per-cell readers, aligned cell index == reader index exactly as composeInto arranges them. + const readers = try a.alloc(*pmtiles.Reader, arcs.len); + for (arcs, 0..) |arc, i| { + readers[i] = try a.create(pmtiles.Reader); + readers[i].* = try pmtiles.Reader.init(gpa, arc); + } + defer for (readers) |rp| rp.deinit(); + + var shims = std.ArrayList(LoadedCov).empty; + var ubox = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; + var minz: u8 = 255; + var maxz: u8 = 0; + for (readers) |rp| { + const meta = readMetaJson(a, rp) orelse return error.TestUnexpectedResult; + const cc = (try scene.coverage.decodeFromMetadata(a, meta)) orelse return error.TestUnexpectedResult; + const b = [4]f64{ + @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, + @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, + }; + ubox[0] = @min(ubox[0], b[0]); + ubox[1] = @min(ubox[1], b[1]); + ubox[2] = @max(ubox[2], b[2]); + ubox[3] = @max(ubox[3], b[3]); + const bz = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cc.cscl)); + minz = @min(minz, bz.min); + maxz = @max(maxz, bz.max); + try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = b }); + } + const cells = try toPlaneCells(a, shims.items); + for (cells, readers) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); + var part = try engine.geo.partition.build(gpa, cells); + defer part.deinit(); + + const fill_max = @min(maxz + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); + const loop_max = @max(maxz, fill_max); + + var counts = ComposeTileCounts{ .checked = 0, .verbatim = 0, .seam = 0 }; + var z: u8 = minz; + while (z <= loop_max) : (z += 1) { + const nw = lonLatToTile(ubox[0], ubox[3], z); // min lon, max lat → min tx, min ty + const se = lonLatToTile(ubox[2], ubox[1], z); + var tx = nw[0] -| 1; + while (tx <= se[0] + 1) : (tx += 1) { + var ty = nw[1] -| 1; + while (ty <= se[1] + 1) : (ty += 1) { + const want = try truth.getCompressed(z, tx, ty); // borrowed from truth's arena + const got = try composeTile(gpa, &part, readers, z, tx, ty); + defer if (got) |g| gpa.free(g); + if (want == null and got == null) continue; + if (want == null or got == null) return error.ComposeTileMismatch; + try testing.expectEqualSlices(u8, want.?, got.?); + counts.checked += 1; + // A verbatim tile equals some owner's native blob byte-for-byte; a seam tile does not. + var is_verbatim = false; + for (readers) |rp| { + if (try rp.getCompressed(z, tx, ty)) |nat| if (std.mem.eql(u8, got.?, nat)) { + is_verbatim = true; + break; + }; + } + if (is_verbatim) counts.verbatim += 1 else counts.seam += 1; + } + } + } + return counts; +} + +test "composeTile: on-demand tiles are byte-identical to the batch-composed archive" { + const gpa = testing.allocator; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const tile = engine.tile; + + // Scenario 1 — a single cell that fully owns its tile (coverage overflows the tile on every + // side, so the owner owns the tile AND its render buffer): composeTile serves the VERBATIM blob. + { + const z: u8 = 14; + const scale: f64 = @floatFromInt(@as(u64, 1) << z); + const w = tile.lonLatToWorld(-76.5, 39.0); + const tx = worldAxisToTile(w[0], scale); + const ty = worldAxisToTile(w[1], scale); + const tb = tile.tileBoundsLonLat(z, tx, ty); + const dlon = tb[2] - tb[0]; + const dlat = tb[3] - tb[1]; + const arc = try synthCell(gpa, a, "INTERIOR", tb[0] - dlon, tb[1] - dlat, tb[2] + dlon, tb[3] + dlat, 20_000, z, tx, ty); + defer gpa.free(arc); + const c = try verifyComposeTileMatchesBatch(gpa, &.{arc}); + try testing.expect(c.checked > 0); + try testing.expect(c.verbatim > 0); // full owner → verbatim passthrough + } + + // Scenario 2 — two abutting cells split one z13 tile at the mid-line: SEAM recomposition. + { + const z: u8 = 13; + const scale: f64 = @floatFromInt(@as(u64, 1) << z); + const w = tile.lonLatToWorld(-76.48, 38.97); + const tx = worldAxisToTile(w[0], scale); + const ty = worldAxisToTile(w[1], scale); + const bnds = tile.tileBoundsLonLat(z, tx, ty); + const lon0 = bnds[0]; + const lat0 = bnds[1]; + const lon1 = bnds[2]; + const lat1 = bnds[3]; + const midlon = (lon0 + lon1) / 2.0; + const dlat = (lat1 - lat0) * 0.01; + const arc_a = try synthCell(gpa, a, "AAAAAAAA", lon0, lat0 + dlat, midlon, lat1 - dlat, 20_000, z, tx, ty); + defer gpa.free(arc_a); + const arc_b = try synthCell(gpa, a, "BBBBBBBB", midlon, lat0 + dlat, lon1, lat1 - dlat, 20_000, z, tx, ty); + defer gpa.free(arc_b); + const c = try verifyComposeTileMatchesBatch(gpa, &.{ arc_a, arc_b }); + try testing.expect(c.checked > 0); + try testing.expect(c.seam > 0); // shared tile → seam recomposition + } +} + test "composeArchives: a coarse owner overscales one fill-up zoom past its native band" { const gpa = testing.allocator; var arena = std.heap.ArenaAllocator.init(gpa); From fd63cb3befed489c8cc1757a6ed4f484ead12485 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 07:13:16 -0400 Subject: [PATCH 045/140] =?UTF-8?q?feat(compose):=20ComposeSource=20+=20`c?= =?UTF-8?q?ompose-tile`=20CLI=20=E2=80=94=20resident=20on-demand=20serving?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ComposeSource: the per-cell archives held mmap'd and the ownership partition built/loaded ONCE, so serve(z,x,y) composes any tile via composeTile without a district pass. openComposeSourceFiles mmaps the archives (cell set never fully resident), keeps only coverage-carrying ones (cell index == reader index), and loads the partition sidecar when valid (else builds). `tile57 compose-tile [--load-partition F] [-o out] [--bench N]` drives it and measures serving latency. Measured on NOAA d05 (848 cells): open (mmap + load partition sidecar) 25.6 ms, then serve one tile 0.98 ms; a 528-tile block amortises to 0.65 ms/tile — vs the 38 s batch. The served tile decodes to the same 3480-byte MLT as the batch reference, and loaded- vs built-partition serves are byte-identical. This is the runtime compositor the precompute work was building toward: per-tile-ms serving behind a camera/LRU. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bundle.zig | 129 +++++++++++++++++++++++++++++++++++++++ tools/compose_tile.zig | 135 +++++++++++++++++++++++++++++++++++++++++ tools/main.zig | 4 ++ 3 files changed, 268 insertions(+) create mode 100644 tools/compose_tile.zig diff --git a/src/bundle.zig b/src/bundle.zig index 9f17b9a..3a2e66f 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -1373,6 +1373,135 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Par return try engine.pmtiles.StreamWriter.gzipTile(gpa, enc); } +/// A resident compositor: the per-cell archives held mmap'd and the ownership partition built once +/// (or loaded from a sidecar), so `serve` composes any tile without a whole-district pass. Open once, +/// serve many, deinit. Only coverage-carrying archives are kept; cell index == reader index. This is +/// the runtime backing for on-demand serving — the batch is for producing a full archive; this is for +/// a camera asking for tiles. +pub const ComposeSource = struct { + gpa: std.mem.Allocator, + arena: std.heap.ArenaAllocator, // owns the readers/maps arrays + adapted cells (borrowed by part) + maps: []const []align(std.heap.page_size_min) const u8, + readers: []const *engine.pmtiles.Reader, + part: engine.geo.partition.Partition, + minz: u8, + maxz: u8, + loop_max: u8, // deepest zoom the sources can serve (native windows + one fill-up overscale zoom) + + /// Compose one tile → gzipped MLT (gpa-owned; null if no cell owns it), byte-identical to batch. + pub fn serve(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !?[]u8 { + return composeTile(gpa, &self.part, self.readers, z, tx, ty); + } + pub fn deinit(self: *ComposeSource) void { + const gpa = self.gpa; + self.part.deinit(); + for (self.readers) |rp| rp.deinit(); + for (self.maps) |m| std.posix.munmap(m); + self.arena.deinit(); + gpa.destroy(self); + } +}; + +/// Open a resident ComposeSource over per-cell PMTiles at `paths` (mmap'd, so the cell set is never +/// fully resident). If `load_partition` is non-null and valid for this cell set the partition is +/// loaded (no build); else it is built. Returns null if no archive carries coverage. Free with +/// `ComposeSource.deinit`. +pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, load_partition: ?[]const u8) !?*ComposeSource { + const pmtiles = engine.pmtiles; + const scene = engine.scene; + const src = try gpa.create(ComposeSource); + src.* = .{ .gpa = gpa, .arena = std.heap.ArenaAllocator.init(gpa), .maps = &.{}, .readers = &.{}, .part = undefined, .minz = 0, .maxz = 0, .loop_max = 0 }; + errdefer { + src.arena.deinit(); + gpa.destroy(src); + } + const a = src.arena.allocator(); + + // mmap + open each archive; keep only those carrying coverage, so readers/maps/shims stay + // aligned (cell index == reader index) exactly as composeInto arranges them. + var readers = std.ArrayList(*pmtiles.Reader).empty; + var maps = std.ArrayList([]align(std.heap.page_size_min) const u8).empty; + var shims = std.ArrayList(LoadedCov).empty; + var built_part = false; + errdefer { + for (readers.items) |rp| rp.deinit(); + for (maps.items) |m| std.posix.munmap(m); + if (built_part) src.part.deinit(); + } + var minz: u8 = 255; + var maxz: u8 = 0; + for (paths) |path| { + var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch continue; + const st = f.stat(io) catch { + f.close(io); + continue; + }; + const len: usize = @intCast(st.size); + if (len == 0) { + f.close(io); + continue; + } + const map = std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, f.handle, 0) catch { + f.close(io); + continue; + }; + f.close(io); + const rp = a.create(pmtiles.Reader) catch { + std.posix.munmap(map); + continue; + }; + rp.* = pmtiles.Reader.init(gpa, map) catch { + std.posix.munmap(map); + continue; + }; + const meta = readMetaJson(a, rp) orelse { + rp.deinit(); + std.posix.munmap(map); + continue; + }; + const cc = (scene.coverage.decodeFromMetadata(a, meta) catch null) orelse { + rp.deinit(); + std.posix.munmap(map); + continue; + }; + const bz = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cc.cscl)); + minz = @min(minz, bz.min); + maxz = @max(maxz, bz.max); + try maps.append(a, map); + try readers.append(a, rp); + try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = .{ + @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, + @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, + } }); + } + if (readers.items.len == 0) { + src.arena.deinit(); + gpa.destroy(src); + return null; + } + + const cells = try toPlaneCells(a, shims.items); + for (cells, readers.items) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); + + var loaded = false; + if (load_partition) |bytes| { + if (engine.geo.partition.deserialize(gpa, bytes, cells)) |p| { + src.part = p; + loaded = true; + } else |err| std.debug.print(" partition sidecar unusable ({s}); building\n", .{@errorName(err)}); + } + if (!loaded) src.part = try engine.geo.partition.build(gpa, cells); + built_part = true; + + const fill_max = @min(maxz + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); + src.maps = maps.items; + src.readers = readers.items; + src.minz = minz; + src.maxz = maxz; + src.loop_max = @max(maxz, fill_max); + return src; +} + // The output-layer slot for a decoded layer name (one of scene.VECTOR_LAYERS), or null to drop. fn layerIndex(name: []const u8) ?usize { for (engine.scene.VECTOR_LAYERS, 0..) |ln, i| { diff --git a/tools/compose_tile.zig b/tools/compose_tile.zig new file mode 100644 index 0000000..5bf2c0e --- /dev/null +++ b/tools/compose_tile.zig @@ -0,0 +1,135 @@ +//! `compose-tile [--load-partition FILE] [-o out] [--bench N]` — serve one +//! composed tile ON DEMAND from a resident ownership partition + mmap'd per-cell archives (the +//! runtime-compositor path), byte-identical to the batch. `--bench N` then serves an N×N tile block +//! around (x,y) to report per-tile serving latency, amortising the one-time open. + +const std = @import("std"); +const engine = @import("engine"); +const bundle = @import("bundle"); +const common = @import("common.zig"); +const Flags = common.Flags; +const usageErr = common.usageErr; + +// Monotonic nanoseconds (std.time has no Timer in this toolchain). +fn nowNs() u64 { + var ts: std.os.linux.timespec = undefined; + _ = std.os.linux.clock_gettime(std.os.linux.CLOCK.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec)); +} + +pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { + var dir_path: ?[]const u8 = null; + var zs: ?[]const u8 = null; + var xs: ?[]const u8 = null; + var ys: ?[]const u8 = null; + var out: ?[]const u8 = null; + var load_partition: ?[]const u8 = null; + var bench: u32 = 0; + + var f = Flags{ .args = args }; + while (f.next()) |arg| { + if (std.mem.eql(u8, arg, "-o") or std.mem.eql(u8, arg, "--output")) { + out = f.val(arg) orelse return; + } else if (std.mem.eql(u8, arg, "--load-partition")) { + load_partition = f.val(arg) orelse return; + } else if (std.mem.eql(u8, arg, "--bench")) { + bench = f.int(u32, arg) orelse return; + } else if (std.mem.startsWith(u8, arg, "-")) { + return usageErr("unknown flag"); + } else if (dir_path == null) { + dir_path = arg; + } else if (zs == null) { + zs = arg; + } else if (xs == null) { + xs = arg; + } else if (ys == null) { + ys = arg; + } else return usageErr("unexpected argument"); + } + const dir = dir_path orelse return usageErr("missing "); + const z = std.fmt.parseInt(u8, zs orelse return usageErr("missing z"), 10) catch return usageErr("bad z"); + const tx = std.fmt.parseInt(u32, xs orelse return usageErr("missing x"), 10) catch return usageErr("bad x"); + const ty = std.fmt.parseInt(u32, ys orelse return usageErr("missing y"), 10) catch return usageErr("bad y"); + + // Enumerate the per-cell archives (sorted, like `compose --from-archives`). + var paths = std.ArrayList([]const u8).empty; + { + var d = std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }) catch return usageErr("cannot open archive dir"); + defer d.close(io); + var walker = d.walk(a) catch return usageErr("cannot walk archive dir"); + defer walker.deinit(); + while (walker.next(io) catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.path, ".cell.tmp") and !std.mem.endsWith(u8, entry.path, ".pmtiles")) continue; + paths.append(a, std.fs.path.join(a, &.{ dir, entry.path }) catch continue) catch {}; + } + } + std.mem.sort([]const u8, paths.items, {}, struct { + fn lt(_: void, x: []const u8, y: []const u8) bool { + return std.mem.lessThan(u8, x, y); + } + }.lt); + if (paths.items.len == 0) return usageErr("no *.cell.tmp / *.pmtiles archives found"); + + // Read the partition sidecar, if provided (a missing/stale one just rebuilds inside open). + var load_bytes: ?[]const u8 = null; + if (load_partition) |lp| load_from: { + var lf = std.Io.Dir.cwd().openFile(io, lp, .{}) catch break :load_from; + defer lf.close(io); + const st = lf.stat(io) catch break :load_from; + const n: usize = @intCast(st.size); + if (n == 0) break :load_from; + const buf = a.alloc(u8, n) catch break :load_from; + _ = lf.readPositionalAll(io, buf, 0) catch break :load_from; + load_bytes = buf; + } + + // Open the resident source (mmap archives + partition once) — the amortised cost. + const open_t0 = nowNs(); + const src = (bundle.openComposeSourceFiles(io, a, paths.items, load_bytes) catch |err| { + std.debug.print("error: open compose source failed ({s})\n", .{@errorName(err)}); + return; + }) orelse { + std.debug.print("no coverage-carrying archives in {s}\n", .{dir}); + return; + }; + defer src.deinit(); + const open_ms = @as(f64, @floatFromInt(nowNs() - open_t0)) / 1e6; + std.debug.print("opened {d} cell(s), partition {s}, in {d:.1} ms (serve z {d}..{d})\n", .{ src.readers.len, if (load_bytes != null) "loaded" else "built", open_ms, src.minz, src.loop_max }); + + // Serve the requested tile. + const serve_t0 = nowNs(); + const tile = try src.serve(a, z, tx, ty); + const serve_ms = @as(f64, @floatFromInt(nowNs() - serve_t0)) / 1e6; + if (tile) |t| { + std.debug.print("served z{d}/{d}/{d}: {d} bytes (gzipped MLT) in {d:.3} ms\n", .{ z, tx, ty, t.len, serve_ms }); + if (out) |op| std.Io.Dir.cwd().writeFile(io, .{ .sub_path = op, .data = t }) catch |err| + std.debug.print(" warn: could not write {s} ({s})\n", .{ op, @errorName(err) }); + a.free(t); + } else std.debug.print("z{d}/{d}/{d}: no cell owns this tile (null) in {d:.3} ms\n", .{ z, tx, ty, serve_ms }); + + // Optional benchmark: serve an N×N block around (tx,ty), reporting amortised per-tile latency. + if (bench > 0) { + const half = bench / 2; + var served: usize = 0; + var bytes: usize = 0; + const bench_t0 = nowNs(); + var dx: u32 = 0; + while (dx < bench) : (dx += 1) { + var dy: u32 = 0; + while (dy < bench) : (dy += 1) { + const bx = (tx + dx) -| half; + const by = (ty + dy) -| half; + const tl = src.serve(a, z, bx, by) catch continue; + if (tl) |t| { + served += 1; + bytes += t.len; + a.free(t); + } + } + } + const total_ms = @as(f64, @floatFromInt(nowNs() - bench_t0)) / 1e6; + const n: f64 = @floatFromInt(bench * bench); + std.debug.print("bench: {d}/{d} tiles owned, queried {d} in {d:.1} ms = {d:.3} ms/tile ({d} bytes total)\n", .{ served, @as(u32, bench * bench), @as(u32, bench * bench), total_ms, total_ms / n, bytes }); + } +} diff --git a/tools/main.zig b/tools/main.zig index 824259a..35155b8 100644 --- a/tools/main.zig +++ b/tools/main.zig @@ -24,6 +24,7 @@ const common = @import("common.zig"); const bake = @import("bake.zig"); const compose = @import("compose.zig"); +const compose_tile = @import("compose_tile.zig"); const assets = @import("assets.zig"); const sprite = @import("sprite.zig"); const pattern = @import("pattern.zig"); @@ -56,6 +57,9 @@ pub fn main(init: std.process.Init) !void { if (std.mem.eql(u8, sub, "compose")) { return compose.run(io, arena, args); } + if (std.mem.eql(u8, sub, "compose-tile")) { + return compose_tile.run(io, arena, args); + } if (std.mem.eql(u8, sub, "assets")) { return assets.run(io, arena, args); From 64a4a60dd5b32fa7f788806e5491e60d641a1810 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 07:33:48 -0400 Subject: [PATCH 046/140] =?UTF-8?q?feat(capi/go):=20runtime=20compositor?= =?UTF-8?q?=20ABI=20=E2=80=94=20tile57=5Fcompose=5Fopen/serve/meta/close?= =?UTF-8?q?=20+=20Go=20ComposeSource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the resident on-demand compositor across the C ABI and Go so a live host can serve tiles without a district pass. composeTile grows a `gzip` flag: true = the archive's stored (gzipped) bytes (the byte-identity test path); false = raw decompressed MLT, which is what a tile server hands its HTTP layer (it gzips on the wire). ComposeSource.serve returns raw MLT and now carries union coverage bounds for a Meta. C ABI: tile57_compose_open(paths, n, partition_path) -> opaque handle (mmap archives + load/build partition once), tile57_compose_serve(z,x,y) -> raw MLT, tile57_compose_meta_get -> zoom range + bounds, tile57_compose_close. Go binding: OpenCompose / (*ComposeSource).Serve / Meta / Close (Serve mutex-serialised — the per-reader leaf cache is not concurrency-safe yet). Go integration test (env-gated on a per-cell archive dir) opens NOAA d05 (848 cells, z0..16), serves the coverage-centre tile as raw MLT, and confirms an out-of-coverage tile is blank. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/go/compose.go | 112 ++++++++++++++++++++++++++++++++++++ bindings/go/compose_test.go | 78 +++++++++++++++++++++++++ include/tile57.h | 34 +++++++++++ src/bundle.zig | 46 +++++++++------ src/capi.zig | 98 +++++++++++++++++++++++++++++++ tools/compose_tile.zig | 2 +- 6 files changed, 352 insertions(+), 18 deletions(-) create mode 100644 bindings/go/compose.go create mode 100644 bindings/go/compose_test.go diff --git a/bindings/go/compose.go b/bindings/go/compose.go new file mode 100644 index 0000000..4c6d80e --- /dev/null +++ b/bindings/go/compose.go @@ -0,0 +1,112 @@ +//go:build cgo + +package tile57 + +/* +#include +#include "tile57.h" +*/ +import "C" + +import ( + "fmt" + "sync" + "unsafe" +) + +// ComposeSource is a resident runtime compositor: the per-cell PMTiles held mmap'd and the +// ownership partition loaded once, so Serve composes any tile on demand (byte-faithful to the +// batch ComposeFiles). It is the on-demand counterpart of ComposeFiles — for a live tile server +// rather than producing a full archive. Serve is serialised by an internal mutex (the underlying +// per-reader leaf cache is not safe for concurrent access). +type ComposeSource struct { + mu sync.Mutex + ptr *C.tile57_compose_source +} + +// ComposeMeta is a ComposeSource's served zoom range + union coverage bounds (degrees). +type ComposeMeta struct { + MinZoom, MaxZoom uint8 + Cells uint32 + West, South, East, North float64 +} + +// OpenCompose opens a resident compositor over the per-cell PMTiles at paths (each written by +// [BakeCell] / `tile57 compose --keep-cells`), mmap'd so the cell set is never fully resident. If +// partitionPath is non-empty it names a partition sidecar (from `tile57 compose --save-partition`) +// to load and skip the owned-face build; a missing/stale one falls back to building. Close it when +// done — callers must not Close while any goroutine can still call Serve. +func OpenCompose(paths []string, partitionPath string) (*ComposeSource, error) { + if len(paths) == 0 { + return nil, fmt.Errorf("tile57: OpenCompose needs at least one path: %w", ErrEmptyInput) + } + var ar cArena + defer ar.free() + // A C array of C strings — pointer-free from Go's view (cgocheck-safe). + cpaths := (**C.char)(ar.track(C.malloc(C.size_t(len(paths)) * C.size_t(unsafe.Sizeof((*C.char)(nil)))))) + pv := unsafe.Slice(cpaths, len(paths)) + for i, p := range paths { + pv[i] = ar.str(p) + } + var cpart *C.char + if partitionPath != "" { + cpart = ar.str(partitionPath) + } + ptr := C.tile57_compose_open(cpaths, C.size_t(len(paths)), cpart) + if ptr == nil { + return nil, fmt.Errorf("tile57: OpenCompose found no coverage or failed to open: %w", ErrNoCoverage) + } + return &ComposeSource{ptr: ptr}, nil +} + +// Serve composes the tile (z,x,y) on demand, returning raw (decompressed) MLT bytes, or (nil, nil) +// if no cell owns the tile (a blank tile). Thread-safe (serialised). +func (c *ComposeSource) Serve(z uint8, x, y uint32) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr == nil { + return nil, fmt.Errorf("tile57: Serve on a closed ComposeSource") + } + var out *C.uint8_t + var outLen C.size_t + rc := C.tile57_compose_serve(c.ptr, C.uint8_t(z), C.uint32_t(x), C.uint32_t(y), &out, &outLen) + switch rc { + case 1: + return tileBytes(out, outLen), nil + case 0: + return nil, nil // no cell owns this tile + default: + return nil, fmt.Errorf("tile57: compose serve failed at %d/%d/%d", z, x, y) + } +} + +// Meta returns the compositor's served zoom range + union coverage bounds. +func (c *ComposeSource) Meta() ComposeMeta { + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr == nil { + return ComposeMeta{} + } + var m C.tile57_compose_meta + C.tile57_compose_meta_get(c.ptr, &m) + return ComposeMeta{ + MinZoom: uint8(m.min_zoom), + MaxZoom: uint8(m.max_zoom), + Cells: uint32(m.cells), + West: float64(m.west), + South: float64(m.south), + East: float64(m.east), + North: float64(m.north), + } +} + +// Close releases the compositor (munmaps the archives, frees the partition). Idempotent. +func (c *ComposeSource) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr != nil { + C.tile57_compose_close(c.ptr) + c.ptr = nil + } + return nil +} diff --git a/bindings/go/compose_test.go b/bindings/go/compose_test.go new file mode 100644 index 0000000..53306a6 --- /dev/null +++ b/bindings/go/compose_test.go @@ -0,0 +1,78 @@ +//go:build cgo + +package tile57 + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// lonLatToTile lives in tile57_test.go (same package). + +// Set TILE57_COMPOSE_TESTDIR to a dir of per-cell *.cell.tmp/*.pmtiles archives (e.g. from +// `tile57 compose -o out.pmtiles --keep-cells`), optionally TILE57_COMPOSE_PARTITION to +// a sidecar (`--save-partition`). Skips when unset — no machine paths baked into the test. +func TestOpenComposeServe(t *testing.T) { + dir := os.Getenv("TILE57_COMPOSE_TESTDIR") + if dir == "" { + t.Skip("set TILE57_COMPOSE_TESTDIR to a dir of per-cell *.cell.tmp/*.pmtiles archives") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var paths []string + for _, e := range entries { + if e.IsDir() { + continue + } + if n := e.Name(); strings.HasSuffix(n, ".cell.tmp") || strings.HasSuffix(n, ".pmtiles") { + paths = append(paths, filepath.Join(dir, n)) + } + } + sort.Strings(paths) + if len(paths) == 0 { + t.Fatalf("no per-cell archives in %s", dir) + } + + src, err := OpenCompose(paths, os.Getenv("TILE57_COMPOSE_PARTITION")) + if err != nil { + t.Fatal(err) + } + defer src.Close() + + m := src.Meta() + t.Logf("compose: %d cells, z%d..%d, bounds [%.3f, %.3f, %.3f, %.3f]", + m.Cells, m.MinZoom, m.MaxZoom, m.West, m.South, m.East, m.North) + if m.Cells == 0 { + t.Fatal("no coverage-carrying cells") + } + + // Serve the tile at the coverage centre, a few zooms into the range — the centre of real + // coverage should have content, so a non-blank tile proves the serve path end-to-end. + z := m.MinZoom + 3 + if z > m.MaxZoom { + z = m.MaxZoom + } + cx, cy := lonLatToTile((m.West+m.East)/2, (m.South+m.North)/2, z) + tile, err := src.Serve(z, cx, cy) + if err != nil { + t.Fatal(err) + } + t.Logf("served z%d/%d/%d: %d bytes (raw MLT)", z, cx, cy, len(tile)) + if len(tile) == 0 { + t.Fatalf("centre tile z%d/%d/%d is blank — expected content", z, cx, cy) + } + + // A tile far outside coverage must be blank (nil), not an error. + blank, err := src.Serve(z, 0, 0) + if err != nil { + t.Fatal(err) + } + if blank != nil { + t.Fatalf("tile z%d/0/0 should be blank, got %d bytes", z, len(blank)) + } +} diff --git a/include/tile57.h b/include/tile57.h index df27284..154382a 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -130,6 +130,40 @@ int tile57_compose_files(const char *const *paths, size_t n, const char *out_pat uint32_t *out_cells, tile57_compose_progress on_progress, void *ctx); +/* Runtime compositor — the on-demand counterpart of tile57_compose_files. Rather than pass over a + * whole district to produce one archive, it holds the per-cell PMTiles mmap'd and the ownership + * partition resident, so any tile can be composed for the cost of a classify + one decode/clip or a + * decompress. Open once, serve many, close. */ +typedef struct tile57_compose_source tile57_compose_source; + +/* Coverage/zoom summary filled by tile57_compose_meta. */ +typedef struct { + uint8_t min_zoom; + uint8_t max_zoom; /* deepest zoom served (native windows + one fill-up overscale zoom) */ + uint32_t cells; /* coverage-carrying archives held */ + double west, south, east, north; /* union coverage bounds, degrees */ +} tile57_compose_meta; + +/* Open a resident compositor over the `n` per-cell PMTiles at `paths` (each from + * tile57_bake_cell_bytes, on disk), mmap'd so the cell set is never fully resident. `partition_path` + * (NULL to skip) names a partition sidecar (from `tile57 compose --save-partition`) to load and skip + * the build; a missing/stale one falls back to building. Returns an opaque handle (free with + * tile57_compose_close), or NULL on error / no coverage-carrying archive. */ +tile57_compose_source *tile57_compose_open(const char *const *paths, size_t n, + const char *partition_path); + +/* Compose the tile (z,x,y) on demand into RAW (decompressed) MLT in *out / *out_len (free with + * tile57_free) — what a live tile server hands its HTTP layer (which gzips on the wire). Returns 1 + * served, 0 if no cell owns this tile, -1 on error. Byte-faithful to the batch compositor. */ +int tile57_compose_serve(tile57_compose_source *src, uint8_t z, uint32_t x, uint32_t y, + uint8_t **out, size_t *out_len); + +/* Fill *out with the compositor's zoom range + union coverage bounds. */ +void tile57_compose_meta_get(tile57_compose_source *src, tile57_compose_meta *out); + +/* Release a compositor opened by tile57_compose_open (munmaps the archives, frees the partition). */ +void tile57_compose_close(tile57_compose_source *src); + /* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + * cscl + date/name under a "coverage" key, so the composite stitcher rebuilds the diff --git a/src/bundle.zig b/src/bundle.zig index 3a2e66f..ddc9853 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -1326,12 +1326,13 @@ fn composeZoom( } /// Compose ONE tile on demand from a resident partition + mmap'd per-cell `readers` (cell index == -/// reader index, exactly as composeInto aligns them), byte-identical to the tile the batch -/// compositor writes for (z,tx,ty). Returns the gzipped MLT the archive stores — a verbatim owner -/// blob (an mmap-slice copy, the common case) or a freshly composed seam tile — gpa-owned; null if -/// no cell owns this tile. This is the runtime compositor: with the partition loaded once, serving a -/// tile is a classify plus either one memcpy or one decode/clip/encode, not a whole-district pass. -pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Partition, readers: []const *engine.pmtiles.Reader, z: u8, tx: u32, ty: u32) !?[]u8 { +/// reader index, exactly as composeInto aligns them). `gzip` = true returns the gzipped MLT the +/// batch archive stores (byte-identical to it — a verbatim owner blob copied verbatim, or a freshly +/// composed seam tile re-gzipped); `gzip` = false returns the raw decompressed MLT (what a live tile +/// server wants — the HTTP layer gzips on the wire). gpa-owned; null if no cell owns this tile. This +/// is the runtime compositor: with the partition loaded once, serving a tile is a classify plus +/// either one memcpy/decompress or one decode/clip/encode, not a whole-district pass. +pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Partition, readers: []const *engine.pmtiles.Reader, z: u8, tx: u32, ty: u32, gzip: bool) !?[]u8 { const compose = engine.scene.compose; const map = part.mapForZoom(z) orelse return null; const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); @@ -1357,7 +1358,9 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Par switch (grid.classify(tileClassifyBox(z, tx, ty))) { .full => continue, // owns none of this tile .empty => { // owns the whole tile: verbatim blob if present, else fall through - if (try readers[ci].getCompressed(z, tx, ty)) |blob| return try gpa.dupe(u8, blob); + if (gzip) { + if (try readers[ci].getCompressed(z, tx, ty)) |blob| return try gpa.dupe(u8, blob); + } else if (try readers[ci].getTile(ta, z, tx, ty)) |raw| return try gpa.dupe(u8, raw); }, .seam => {}, } @@ -1368,9 +1371,9 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Par } if (slots.items.len == 0) return null; - // gzip the composed MLT so the served bytes match what StreamWriter.add stores in the archive. const enc = (try composeSeamTile(ta, part, map, readers, slots.items, z, tx, ty)) orelse return null; - return try engine.pmtiles.StreamWriter.gzipTile(gpa, enc); + // gzip=true → match the archive's stored (gzipped) bytes; gzip=false → hand back the raw MLT. + return if (gzip) try engine.pmtiles.StreamWriter.gzipTile(gpa, enc) else try gpa.dupe(u8, enc); } /// A resident compositor: the per-cell archives held mmap'd and the ownership partition built once @@ -1387,10 +1390,12 @@ pub const ComposeSource = struct { minz: u8, maxz: u8, loop_max: u8, // deepest zoom the sources can serve (native windows + one fill-up overscale zoom) + bounds: [4]f64, // union coverage [west, south, east, north] in degrees - /// Compose one tile → gzipped MLT (gpa-owned; null if no cell owns it), byte-identical to batch. + /// Compose one tile → raw (decompressed) MLT (gpa-owned; null if no cell owns it). This is what + /// a live tile server hands its HTTP layer, which gzips on the wire. Byte-faithful to the batch. pub fn serve(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !?[]u8 { - return composeTile(gpa, &self.part, self.readers, z, tx, ty); + return composeTile(gpa, &self.part, self.readers, z, tx, ty, false); } pub fn deinit(self: *ComposeSource) void { const gpa = self.gpa; @@ -1410,7 +1415,7 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const const pmtiles = engine.pmtiles; const scene = engine.scene; const src = try gpa.create(ComposeSource); - src.* = .{ .gpa = gpa, .arena = std.heap.ArenaAllocator.init(gpa), .maps = &.{}, .readers = &.{}, .part = undefined, .minz = 0, .maxz = 0, .loop_max = 0 }; + src.* = .{ .gpa = gpa, .arena = std.heap.ArenaAllocator.init(gpa), .maps = &.{}, .readers = &.{}, .part = undefined, .minz = 0, .maxz = 0, .loop_max = 0, .bounds = .{ 0, 0, 0, 0 } }; errdefer { src.arena.deinit(); gpa.destroy(src); @@ -1430,6 +1435,7 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const } var minz: u8 = 255; var maxz: u8 = 0; + var ubox = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; // union coverage [w, s, e, n] for (paths) |path| { var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch continue; const st = f.stat(io) catch { @@ -1467,12 +1473,17 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const const bz = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cc.cscl)); minz = @min(minz, bz.min); maxz = @max(maxz, bz.max); - try maps.append(a, map); - try readers.append(a, rp); - try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = .{ + const b = [4]f64{ @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, - } }); + }; + ubox[0] = @min(ubox[0], b[0]); + ubox[1] = @min(ubox[1], b[1]); + ubox[2] = @max(ubox[2], b[2]); + ubox[3] = @max(ubox[3], b[3]); + try maps.append(a, map); + try readers.append(a, rp); + try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = b }); } if (readers.items.len == 0) { src.arena.deinit(); @@ -1499,6 +1510,7 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const src.minz = minz; src.maxz = maxz; src.loop_max = @max(maxz, fill_max); + src.bounds = ubox; return src; } @@ -2808,7 +2820,7 @@ fn verifyComposeTileMatchesBatch(gpa: std.mem.Allocator, arcs: []const []const u var ty = nw[1] -| 1; while (ty <= se[1] + 1) : (ty += 1) { const want = try truth.getCompressed(z, tx, ty); // borrowed from truth's arena - const got = try composeTile(gpa, &part, readers, z, tx, ty); + const got = try composeTile(gpa, &part, readers, z, tx, ty, true); // gzip → archive-identical defer if (got) |g| gpa.free(g); if (want == null and got == null) continue; if (want == null or got == null) return error.ComposeTileMismatch; diff --git a/src/capi.zig b/src/capi.zig index c60d0aa..d328156 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -177,6 +177,104 @@ export fn tile57_compose_files( return if (nc > 0) 1 else 0; } +/// Coverage/zoom summary of a resident compositor, filled by tile57_compose_meta. +const CComposeMeta = extern struct { + min_zoom: u8, + max_zoom: u8, // deepest zoom that can be served (native windows + one fill-up overscale zoom) + cells: u32, // coverage-carrying archives held + west: f64, + south: f64, + east: f64, + north: f64, +}; + +/// Read a partition sidecar file into a fresh gpa-owned buffer (or error). Used only during open. +fn readSidecar(io: std.Io, path: []const u8) ![]u8 { + var f = try std.Io.Dir.cwd().openFile(io, path, .{}); + defer f.close(io); + const st = try f.stat(io); + const n: usize = @intCast(st.size); + const buf = try gpa.alloc(u8, n); + errdefer gpa.free(buf); + _ = try f.readPositionalAll(io, buf, 0); + return buf; +} + +/// Open a resident runtime compositor over the `n` per-cell PMTiles at `paths` (each from +/// tile57_bake_cell_bytes, on disk), mmap'd so the cell set is never fully resident. `partition_path` +/// (or NULL) names a partition sidecar (from `tile57 compose --save-partition`) to load and skip the +/// build; a missing/stale one falls back to building. Returns an opaque handle (free with +/// tile57_compose_close), or NULL on error / no coverage-carrying archive. See tile57.h. +export fn tile57_compose_open( + paths: ?[*]const ?[*:0]const u8, + n: usize, + partition_path: ?[*:0]const u8, +) callconv(.c) ?*bundle.ComposeSource { + const ps = paths orelse return null; + if (n == 0) return null; + const list = gpa.alloc([]const u8, n) catch return null; + defer gpa.free(list); + for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return null; + + // The lib has no std.process.Init; stand up a threaded std.Io for the open-time file I/O + // (mmap survives it, and serve/close need no io). + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var load_bytes: ?[]const u8 = null; + var owned: ?[]u8 = null; + defer if (owned) |b| gpa.free(b); + if (spanOpt(partition_path)) |pp| { + if (readSidecar(io, pp)) |b| { + owned = b; + load_bytes = b; + } else |_| {} + } + + return (bundle.openComposeSourceFiles(io, gpa, list, load_bytes) catch return null) orelse return null; +} + +/// Compose the tile (z,x,y) on demand, returning the RAW (decompressed) MLT in *out / *out_len (free +/// with tile57_free) — what a live tile server hands its HTTP layer. 1=served, 0=no cell owns this +/// tile, -1=error. Byte-faithful to the batch compositor. See tile57.h. +export fn tile57_compose_serve( + handle: ?*bundle.ComposeSource, + z: u8, + x: u32, + y: u32, + out: *[*]u8, + out_len: *usize, +) callconv(.c) c_int { + const src = handle orelse return -1; + const tile = src.serve(gpa, z, x, y) catch return -1; + if (tile) |t| { + out.* = t.ptr; + out_len.* = t.len; + return 1; + } + return 0; +} + +/// Fill `out` with the compositor's zoom range + union coverage bounds. See tile57.h. +export fn tile57_compose_meta_get(handle: ?*bundle.ComposeSource, out: *CComposeMeta) callconv(.c) void { + const src = handle orelse return; + out.* = .{ + .min_zoom = src.minz, + .max_zoom = src.loop_max, + .cells = @intCast(src.readers.len), + .west = src.bounds[0], + .south = src.bounds[1], + .east = src.bounds[2], + .north = src.bounds[3], + }; +} + +/// Release a compositor opened by tile57_compose_open (munmaps the archives, frees the partition). +export fn tile57_compose_close(handle: ?*bundle.ComposeSource) callconv(.c) void { + if (handle) |src| src.deinit(); +} + /// The metadata JSON blob of a PMTiles archive (e.g. the embedded per-cell "coverage" /// a single-cell bake carries), into *out / *out_len (free with tile57_free). 1=ok, /// 0=no metadata, -1=error. See tile57.h. diff --git a/tools/compose_tile.zig b/tools/compose_tile.zig index 5bf2c0e..8b29131 100644 --- a/tools/compose_tile.zig +++ b/tools/compose_tile.zig @@ -102,7 +102,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const tile = try src.serve(a, z, tx, ty); const serve_ms = @as(f64, @floatFromInt(nowNs() - serve_t0)) / 1e6; if (tile) |t| { - std.debug.print("served z{d}/{d}/{d}: {d} bytes (gzipped MLT) in {d:.3} ms\n", .{ z, tx, ty, t.len, serve_ms }); + std.debug.print("served z{d}/{d}/{d}: {d} bytes (raw MLT) in {d:.3} ms\n", .{ z, tx, ty, t.len, serve_ms }); if (out) |op| std.Io.Dir.cwd().writeFile(io, .{ .sub_path = op, .data = t }) catch |err| std.debug.print(" warn: could not write {s} ({s})\n", .{ op, @errorName(err) }); a.free(t); From bf1ca8922f400268cd4b0a87f2b65e6e74b2b6c1 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 07:57:13 -0400 Subject: [PATCH 047/140] feat(capi/go): tile57_compose_save_partition + Go ComposeSource.SavePartition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialize a resident compositor's ownership partition to a sidecar so a host can persist it once (after the first build) and load it on every subsequent open — turning cold-start from a ~1.3s build into a ~25ms load. ComposeSource.serialize Partition reuses the existing partition serializer. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/go/compose.go | 16 ++++++++++++++++ include/tile57.h | 4 ++++ src/bundle.zig | 5 +++++ src/capi.zig | 13 +++++++++++++ 4 files changed, 38 insertions(+) diff --git a/bindings/go/compose.go b/bindings/go/compose.go index 4c6d80e..1e5110e 100644 --- a/bindings/go/compose.go +++ b/bindings/go/compose.go @@ -100,6 +100,22 @@ func (c *ComposeSource) Meta() ComposeMeta { } } +// SavePartition serializes the resident ownership partition to path — a sidecar a later +// OpenCompose can load (as partitionPath) to skip the owned-face build. +func (c *ComposeSource) SavePartition(path string) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr == nil { + return fmt.Errorf("tile57: SavePartition on a closed ComposeSource") + } + var ar cArena + defer ar.free() + if C.tile57_compose_save_partition(c.ptr, ar.str(path)) != 1 { + return fmt.Errorf("tile57: SavePartition to %q failed", path) + } + return nil +} + // Close releases the compositor (munmaps the archives, frees the partition). Idempotent. func (c *ComposeSource) Close() error { c.mu.Lock() diff --git a/include/tile57.h b/include/tile57.h index 154382a..d5f31f1 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -161,6 +161,10 @@ int tile57_compose_serve(tile57_compose_source *src, uint8_t z, uint32_t x, uint /* Fill *out with the compositor's zoom range + union coverage bounds. */ void tile57_compose_meta_get(tile57_compose_source *src, tile57_compose_meta *out); +/* Serialize the compositor's ownership partition to the file `path` (a sidecar a later + * tile57_compose_open can load to skip the build). Returns 1 ok, -1 on error. */ +int tile57_compose_save_partition(tile57_compose_source *src, const char *path); + /* Release a compositor opened by tile57_compose_open (munmaps the archives, frees the partition). */ void tile57_compose_close(tile57_compose_source *src); diff --git a/src/bundle.zig b/src/bundle.zig index ddc9853..56dac3f 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -1397,6 +1397,11 @@ pub const ComposeSource = struct { pub fn serve(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !?[]u8 { return composeTile(gpa, &self.part, self.readers, z, tx, ty, false); } + /// Serialize the resident ownership partition to a sidecar blob (gpa-owned) a later open can + /// load to skip the owned-face build. + pub fn serializePartition(self: *ComposeSource, gpa: std.mem.Allocator) ![]u8 { + return engine.geo.partition.serialize(gpa, &self.part); + } pub fn deinit(self: *ComposeSource) void { const gpa = self.gpa; self.part.deinit(); diff --git a/src/capi.zig b/src/capi.zig index d328156..ce1f80c 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -270,6 +270,19 @@ export fn tile57_compose_meta_get(handle: ?*bundle.ComposeSource, out: *CCompose }; } +/// Serialize the compositor's ownership partition to the file `path` (a sidecar a later +/// tile57_compose_open can load to skip the build). 1=ok, -1=error. See tile57.h. +export fn tile57_compose_save_partition(handle: ?*bundle.ComposeSource, path: ?[*:0]const u8) callconv(.c) c_int { + const src = handle orelse return -1; + const p = spanOpt(path) orelse return -1; + const bytes = src.serializePartition(gpa) catch return -1; + defer gpa.free(bytes); + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + std.Io.Dir.cwd().writeFile(threaded.io(), .{ .sub_path = p, .data = bytes }) catch return -1; + return 1; +} + /// Release a compositor opened by tile57_compose_open (munmaps the archives, frees the partition). export fn tile57_compose_close(handle: ?*bundle.ComposeSource) callconv(.c) void { if (handle) |src| src.deinit(); From d4c569a6f0a873c4e7823ba3434ff59853b8cb19 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 08:36:02 -0400 Subject: [PATCH 048/140] feat(compose): report tile OWNERSHIP so a host can tell transient-empty from true-empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composeTile now returns {tile, owned}: `owned` = at least one cell's coverage face covers this tile (the ownership partition says a cell SHOULD render here). So a caller can distinguish tile==null && owned (a cell owns the ground but produced nothing — transient while its per-cell bake runs, an error once bakes are done) from tile==null && !owned (true empty ocean, safe to cache). This is what lets a live tile server avoid caching a blank that will fill in, and flag one that won't. ABI: tile57_compose_serve returns 1 served / 2 owned-but-empty / 0 not-owned / -1; Go (*ComposeSource).Serve returns (body, owned, err). Byte-identity preserved (the tile bytes are unchanged; owned is a side channel) — bundle-test + the d05 Go integration test pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/go/compose.go | 19 ++++++++++++------- bindings/go/compose_test.go | 14 ++++++++++---- include/tile57.h | 9 +++++++-- src/bundle.zig | 36 ++++++++++++++++++++++++------------ src/capi.zig | 12 +++++++----- tools/compose_tile.zig | 7 ++++--- 6 files changed, 64 insertions(+), 33 deletions(-) diff --git a/bindings/go/compose.go b/bindings/go/compose.go index 1e5110e..7a04ead 100644 --- a/bindings/go/compose.go +++ b/bindings/go/compose.go @@ -59,24 +59,29 @@ func OpenCompose(paths []string, partitionPath string) (*ComposeSource, error) { return &ComposeSource{ptr: ptr}, nil } -// Serve composes the tile (z,x,y) on demand, returning raw (decompressed) MLT bytes, or (nil, nil) -// if no cell owns the tile (a blank tile). Thread-safe (serialised). -func (c *ComposeSource) Serve(z uint8, x, y uint32) ([]byte, error) { +// Serve composes the tile (z,x,y) on demand, returning raw (decompressed) MLT bytes plus `owned` — +// whether the ownership partition says a cell SHOULD render here. body!=nil → served (owned). body +// ==nil && owned → a cell owns this ground but produced nothing (transient while its per-cell bake +// runs; an error state once bakes are done). body==nil && !owned → true empty ocean (safe to cache). +// Thread-safe (serialised). +func (c *ComposeSource) Serve(z uint8, x, y uint32) (body []byte, owned bool, err error) { c.mu.Lock() defer c.mu.Unlock() if c.ptr == nil { - return nil, fmt.Errorf("tile57: Serve on a closed ComposeSource") + return nil, false, fmt.Errorf("tile57: Serve on a closed ComposeSource") } var out *C.uint8_t var outLen C.size_t rc := C.tile57_compose_serve(c.ptr, C.uint8_t(z), C.uint32_t(x), C.uint32_t(y), &out, &outLen) switch rc { case 1: - return tileBytes(out, outLen), nil + return tileBytes(out, outLen), true, nil // served (content implies owned) + case 2: + return nil, true, nil // owned but empty case 0: - return nil, nil // no cell owns this tile + return nil, false, nil // not owned — true empty default: - return nil, fmt.Errorf("tile57: compose serve failed at %d/%d/%d", z, x, y) + return nil, false, fmt.Errorf("tile57: compose serve failed at %d/%d/%d", z, x, y) } } diff --git a/bindings/go/compose_test.go b/bindings/go/compose_test.go index 53306a6..ddd088b 100644 --- a/bindings/go/compose_test.go +++ b/bindings/go/compose_test.go @@ -58,21 +58,27 @@ func TestOpenComposeServe(t *testing.T) { z = m.MaxZoom } cx, cy := lonLatToTile((m.West+m.East)/2, (m.South+m.North)/2, z) - tile, err := src.Serve(z, cx, cy) + tile, owned, err := src.Serve(z, cx, cy) if err != nil { t.Fatal(err) } - t.Logf("served z%d/%d/%d: %d bytes (raw MLT)", z, cx, cy, len(tile)) + t.Logf("served z%d/%d/%d: %d bytes (raw MLT), owned=%v", z, cx, cy, len(tile), owned) if len(tile) == 0 { t.Fatalf("centre tile z%d/%d/%d is blank — expected content", z, cx, cy) } + if !owned { + t.Fatalf("centre tile z%d/%d/%d has content but owned=false", z, cx, cy) + } - // A tile far outside coverage must be blank (nil), not an error. - blank, err := src.Serve(z, 0, 0) + // A tile far outside coverage must be blank (nil) AND not owned (true empty ocean), not an error. + blank, blankOwned, err := src.Serve(z, 0, 0) if err != nil { t.Fatal(err) } if blank != nil { t.Fatalf("tile z%d/0/0 should be blank, got %d bytes", z, len(blank)) } + if blankOwned { + t.Fatalf("tile z%d/0/0 (far outside coverage) should be unowned", z) + } } diff --git a/include/tile57.h b/include/tile57.h index d5f31f1..c9a6e59 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -153,8 +153,13 @@ tile57_compose_source *tile57_compose_open(const char *const *paths, size_t n, const char *partition_path); /* Compose the tile (z,x,y) on demand into RAW (decompressed) MLT in *out / *out_len (free with - * tile57_free) — what a live tile server hands its HTTP layer (which gzips on the wire). Returns 1 - * served, 0 if no cell owns this tile, -1 on error. Byte-faithful to the batch compositor. */ + * tile57_free) — what a live tile server hands its HTTP layer (which gzips on the wire). Returns: + * 1 served (bytes in *out/*out_len), + * 2 OWNED but empty — a cell owns this ground per the ownership partition but produced nothing + * (transient while its per-cell bake is still running; an error state once bakes are done), + * 0 not owned — true empty ocean (no cell owns this ground; safe to cache), + * -1 error. + * Byte-faithful to the batch compositor. */ int tile57_compose_serve(tile57_compose_source *src, uint8_t z, uint32_t x, uint32_t y, uint8_t **out, size_t *out_len); diff --git a/src/bundle.zig b/src/bundle.zig index 56dac3f..2787d17 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -1332,9 +1332,9 @@ fn composeZoom( /// server wants — the HTTP layer gzips on the wire). gpa-owned; null if no cell owns this tile. This /// is the runtime compositor: with the partition loaded once, serving a tile is a classify plus /// either one memcpy/decompress or one decode/clip/encode, not a whole-district pass. -pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Partition, readers: []const *engine.pmtiles.Reader, z: u8, tx: u32, ty: u32, gzip: bool) !?[]u8 { +pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Partition, readers: []const *engine.pmtiles.Reader, z: u8, tx: u32, ty: u32, gzip: bool) !TileResult { const compose = engine.scene.compose; - const map = part.mapForZoom(z) orelse return null; + const map = part.mapForZoom(z) orelse return .{ .tile = null, .owned = false }; const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); var arena = std.heap.ArenaAllocator.init(gpa); @@ -1345,6 +1345,9 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Par // by face bbox, classify. The FIRST owner that fully owns the tile (buffer included) and has a // native blob wins the verbatim copy — faces are a disjoint partition, so that owner is unique. // Every other contributing owner (seam, or fully-owned-but-no-native) is collected in face order. + // `owned` = at least one cell's coverage face covers this tile (the partition says it SHOULD + // render here) — so a caller can tell a transient/erroneous empty from true empty ocean. + var owned = false; var slots = std.ArrayList(u32).empty; for (map.faces, 0..) |face, slot| { if (face.owned.len == 0) continue; @@ -1358,24 +1361,32 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const engine.geo.partition.Par switch (grid.classify(tileClassifyBox(z, tx, ty))) { .full => continue, // owns none of this tile .empty => { // owns the whole tile: verbatim blob if present, else fall through + owned = true; if (gzip) { - if (try readers[ci].getCompressed(z, tx, ty)) |blob| return try gpa.dupe(u8, blob); - } else if (try readers[ci].getTile(ta, z, tx, ty)) |raw| return try gpa.dupe(u8, raw); + if (try readers[ci].getCompressed(z, tx, ty)) |blob| return .{ .tile = try gpa.dupe(u8, blob), .owned = true }; + } else if (try readers[ci].getTile(ta, z, tx, ty)) |raw| return .{ .tile = try gpa.dupe(u8, raw), .owned = true }; }, - .seam => {}, + .seam => owned = true, } if (!(try ownerHasTile(readers[ci], cscl, z, tx, ty))) continue; const face_px = try compose.projectFace(ta, face.owned, z, tx, ty); if (face_px.len == 0) continue; try slots.append(ta, @intCast(slot)); } - if (slots.items.len == 0) return null; + if (slots.items.len == 0) return .{ .tile = null, .owned = owned }; - const enc = (try composeSeamTile(ta, part, map, readers, slots.items, z, tx, ty)) orelse return null; + const enc = (try composeSeamTile(ta, part, map, readers, slots.items, z, tx, ty)) orelse return .{ .tile = null, .owned = owned }; // gzip=true → match the archive's stored (gzipped) bytes; gzip=false → hand back the raw MLT. - return if (gzip) try engine.pmtiles.StreamWriter.gzipTile(gpa, enc) else try gpa.dupe(u8, enc); + const bytes = if (gzip) try engine.pmtiles.StreamWriter.gzipTile(gpa, enc) else try gpa.dupe(u8, enc); + return .{ .tile = bytes, .owned = true }; } +/// The outcome of composing one tile: its bytes (null if nothing rendered) and whether the ownership +/// partition says a cell SHOULD render here. `tile == null and owned` = expected-but-empty (a cell +/// owns the ground but produced nothing — transient during a bake, suspect once a bake is done); +/// `tile == null and !owned` = true empty (no cell owns this ground — open ocean, safe to cache). +pub const TileResult = struct { tile: ?[]u8, owned: bool }; + /// A resident compositor: the per-cell archives held mmap'd and the ownership partition built once /// (or loaded from a sidecar), so `serve` composes any tile without a whole-district pass. Open once, /// serve many, deinit. Only coverage-carrying archives are kept; cell index == reader index. This is @@ -1392,9 +1403,10 @@ pub const ComposeSource = struct { loop_max: u8, // deepest zoom the sources can serve (native windows + one fill-up overscale zoom) bounds: [4]f64, // union coverage [west, south, east, north] in degrees - /// Compose one tile → raw (decompressed) MLT (gpa-owned; null if no cell owns it). This is what - /// a live tile server hands its HTTP layer, which gzips on the wire. Byte-faithful to the batch. - pub fn serve(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !?[]u8 { + /// Compose one tile → raw (decompressed) MLT + the ownership flag (gpa-owned bytes; null when + /// nothing rendered — `owned` then says whether a cell SHOULD have). This is what a live tile + /// server hands its HTTP layer, which gzips on the wire. Byte-faithful to the batch. + pub fn serve(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !TileResult { return composeTile(gpa, &self.part, self.readers, z, tx, ty, false); } /// Serialize the resident ownership partition to a sidecar blob (gpa-owned) a later open can @@ -2825,7 +2837,7 @@ fn verifyComposeTileMatchesBatch(gpa: std.mem.Allocator, arcs: []const []const u var ty = nw[1] -| 1; while (ty <= se[1] + 1) : (ty += 1) { const want = try truth.getCompressed(z, tx, ty); // borrowed from truth's arena - const got = try composeTile(gpa, &part, readers, z, tx, ty, true); // gzip → archive-identical + const got = (try composeTile(gpa, &part, readers, z, tx, ty, true)).tile; // gzip → archive-identical defer if (got) |g| gpa.free(g); if (want == null and got == null) continue; if (want == null or got == null) return error.ComposeTileMismatch; diff --git a/src/capi.zig b/src/capi.zig index ce1f80c..a2488ef 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -236,8 +236,10 @@ export fn tile57_compose_open( } /// Compose the tile (z,x,y) on demand, returning the RAW (decompressed) MLT in *out / *out_len (free -/// with tile57_free) — what a live tile server hands its HTTP layer. 1=served, 0=no cell owns this -/// tile, -1=error. Byte-faithful to the batch compositor. See tile57.h. +/// with tile57_free) — what a live tile server hands its HTTP layer. Returns 1 = served (bytes set), +/// 2 = OWNED-but-empty (a cell owns this ground per the partition but produced nothing — transient +/// during a bake, an error state once bakes are done), 0 = not owned (true empty ocean, safe to +/// cache), -1 = error. Byte-faithful to the batch compositor. See tile57.h. export fn tile57_compose_serve( handle: ?*bundle.ComposeSource, z: u8, @@ -247,13 +249,13 @@ export fn tile57_compose_serve( out_len: *usize, ) callconv(.c) c_int { const src = handle orelse return -1; - const tile = src.serve(gpa, z, x, y) catch return -1; - if (tile) |t| { + const res = src.serve(gpa, z, x, y) catch return -1; + if (res.tile) |t| { out.* = t.ptr; out_len.* = t.len; return 1; } - return 0; + return if (res.owned) 2 else 0; } /// Fill `out` with the compositor's zoom range + union coverage bounds. See tile57.h. diff --git a/tools/compose_tile.zig b/tools/compose_tile.zig index 8b29131..6c587ac 100644 --- a/tools/compose_tile.zig +++ b/tools/compose_tile.zig @@ -99,10 +99,11 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // Serve the requested tile. const serve_t0 = nowNs(); - const tile = try src.serve(a, z, tx, ty); + const res = try src.serve(a, z, tx, ty); + const tile = res.tile; const serve_ms = @as(f64, @floatFromInt(nowNs() - serve_t0)) / 1e6; if (tile) |t| { - std.debug.print("served z{d}/{d}/{d}: {d} bytes (raw MLT) in {d:.3} ms\n", .{ z, tx, ty, t.len, serve_ms }); + std.debug.print("served z{d}/{d}/{d}: {d} bytes (raw MLT, owned={}) in {d:.3} ms\n", .{ z, tx, ty, t.len, res.owned, serve_ms }); if (out) |op| std.Io.Dir.cwd().writeFile(io, .{ .sub_path = op, .data = t }) catch |err| std.debug.print(" warn: could not write {s} ({s})\n", .{ op, @errorName(err) }); a.free(t); @@ -120,7 +121,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { while (dy < bench) : (dy += 1) { const bx = (tx + dx) -| half; const by = (ty + dy) -| half; - const tl = src.serve(a, z, bx, by) catch continue; + const tl = (src.serve(a, z, bx, by) catch continue).tile; if (tl) |t| { served += 1; bytes += t.len; From 359fa7abeac3681a3c9faa6d7a355fe4ef40778f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 09:24:41 -0400 Subject: [PATCH 049/140] =?UTF-8?q?refactor(go):=20drop=20legacy=20bake/co?= =?UTF-8?q?mpose=20bindings=20(Step=20B=20=E2=80=94=20Go=20bindings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove every Go binding for the batch/bundle tile-production ABI now that no host calls them: BakePmtiles, BakeBundle, Compose, ComposeFiles, and the cell-backed chart Source.Tile/SetTileFormat/ClearCache. Delete their dead support — the BakeOpts + Cell types, the cellInputs/cOmit cmem helpers, the whole progress.go (BakeProgress/ComposeProgress + the tile57GoBakeProgress/GoComposeProgress //export trampolines), and the three C trampolines in bake.go's cgo preamble. Kept: BakeCell, PMTilesMetadata, BakePartitionDebug (bake.go); Open/OpenChartBytes/ OpenPMTiles + Cells/Features/Info/Scamin/Meta/Close (chart metadata); the whole compose.go (OpenCompose/Serve/Meta/SavePartition/Close) + style/asset bindings. Tests trimmed to the kept surface (live compose + metadata); go vet + go test green. The C ABI exports for the removed functions still exist in the .a (unreferenced) — they come out in Step C along with the Zig impl. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/go/bake.go | 256 ------------------------------------- bindings/go/bundle_test.go | 46 ------- bindings/go/cmem.go | 42 ------ bindings/go/errors_test.go | 11 +- bindings/go/progress.go | 87 ------------- bindings/go/tile57.go | 44 ------- bindings/go/tile57_test.go | 73 ----------- 7 files changed, 1 insertion(+), 558 deletions(-) delete mode 100644 bindings/go/progress.go diff --git a/bindings/go/bake.go b/bindings/go/bake.go index e20a967..c833ea3 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -5,180 +5,14 @@ package tile57 /* #include #include "tile57.h" - -// Forward declaration of the //export'd Go progress callback (defined in -// progress.go). The trampoline lets us hand libtile57 a real C function pointer -// that re-enters Go, without taking the address of a Go func in Go code. -void tile57GoBakeProgress(void *user, uint8_t stage, size_t done, size_t total, - uint8_t band_index, uint8_t band_count, const char *band_name); - -// user != NULL → re-enter Go for progress; NULL → no progress (bake_pmtiles has no -// console default, and its trampoline no-ops on a NULL user regardless). -static int tile57_bake_pmtiles_cb(const tile57_cell *cells, size_t count, - const char *rules_dir, uint8_t minz, uint8_t maxz, - int omit_pick_attrs, uint8_t format, - void *user, uint8_t **out, size_t *out_len) { - tile57_bake_opts opts = { - .rules_dir = rules_dir, - .catalog_dir = NULL, - .created = NULL, - .minzoom = minz, - .maxzoom = maxz, - .omit_pick_attrs = omit_pick_attrs != 0, - .progress = user ? tile57GoBakeProgress : (tile57_bake_progress)0, - .progress_user = user, - .format = format, - }; - return tile57_bake_pmtiles(cells, count, &opts, out, out_len); -} - -// user != NULL → re-enter Go for progress; NULL → the lib's built-in console. -static int tile57_bake_bundle_cb(const char *input, const char *out_dir, - const char *rules_dir, const char *catalog_dir, - const char *created, uint8_t minz, uint8_t maxz, - int omit_pick_attrs, uint8_t format, - void *user, uint32_t *out_cells, double *out_bbox) { - tile57_bake_opts opts = { - .rules_dir = rules_dir, - .catalog_dir = catalog_dir, - .created = created, - .minzoom = minz, - .maxzoom = maxz, - .omit_pick_attrs = omit_pick_attrs != 0, - .progress = user ? tile57GoBakeProgress : (tile57_bake_progress)0, - .progress_user = user, - .format = format, - }; - return tile57_bake_bundle(input, out_dir, &opts, out_cells, out_bbox); -} - -// Forward declaration of the //export'd Go compose-progress callback (defined in -// progress.go), so ComposeFiles can hand libtile57 a real C function pointer. -void tile57GoComposeProgress(void *ctx, uint32_t zoom, uint32_t min_zoom, - uint32_t max_zoom, uint64_t done, uint64_t total); - -// user != NULL → re-enter Go for compose progress; NULL → no progress. -static int tile57_compose_files_cb(const char *const *paths, size_t n, const char *out_path, - uint32_t *out_cells, void *user) { - return tile57_compose_files(paths, n, out_path, out_cells, - user ? tile57GoComposeProgress : (tile57_compose_progress)0, user); -} */ import "C" import ( "fmt" - "runtime/cgo" "unsafe" ) -// BakeOpts are the shared knobs for [BakePmtiles] and [BakeBundle]. The zero value -// is the default: the catalogue embedded in libtile57, no band clamp, and the -// per-feature pick-report attributes included. CatalogDir and Created apply to -// [BakeBundle] only ([BakePmtiles] ignores them). -type BakeOpts struct { - RulesDir string // "" = embedded portrayal rules - CatalogDir string // "" = embedded S-101 catalogue (BakeBundle only) - Created string // "" = manifest "created" unset (BakeBundle only) - MinZoom uint8 // 0/0 = no band clamp - MaxZoom uint8 - OmitPickAttrs bool // true = drop the pick-report attrs (class/cell/s57) for a leaner bake - Format TileFormat // baked tile encoding; the zero value bakes the engine default (MLT) -} - -// BakePmtiles bakes an ENC_ROOT's in-memory cells into ONE zoom-banded PMTiles -// archive (write it to a file and open cheaply via [OpenPMTiles], instead of -// generating tiles live). opts.MinZoom/MaxZoom clamp the per-cell bands (0/0 = no -// clamp); opts.CatalogDir/Created are ignored here. progress, if non-nil, is called -// with a [BakeProgress] per update: stage 0 = loading/portraying cells, stage 1 = -// baking tiles (per-band done/total + band label). -func BakePmtiles(cells []Cell, opts BakeOpts, progress func(BakeProgress)) ([]byte, error) { - if len(cells) == 0 { - return nil, fmt.Errorf("tile57: no cells to bake: %w", ErrEmptyInput) - } - cRules, freeRules := cStringOrNil(opts.RulesDir) - defer freeRules() - - arena := &cArena{} - defer arena.free() - _, base := arena.cellInputs(cells) - - var user unsafe.Pointer - if progress != nil { - h := cgo.NewHandle(progress) - defer h.Delete() - user = unsafe.Pointer(&h) - } - - var out *C.uint8_t - var outLen C.size_t - rc := C.tile57_bake_pmtiles_cb(base, C.size_t(len(cells)), cRules, - C.uint8_t(opts.MinZoom), C.uint8_t(opts.MaxZoom), cOmit(opts.OmitPickAttrs), C.uint8_t(opts.Format), user, &out, &outLen) - switch rc { - case 1: - return tileBytes(out, outLen), nil - case 0: - return nil, fmt.Errorf("tile57: bake produced no tiles: %w", ErrNoCoverage) - default: - return nil, fmt.Errorf("tile57: bake failed") - } -} - -// BakeBundle bakes an on-disk input (a .000 cell or an ENC_ROOT directory) into a -// self-contained chart bundle under outDir — the canonical, drift-proof package: -// -// outDir/tiles/chart.pmtiles (PMTiles + scamin & vector_layers metadata) -// outDir/assets/colortables.json, linestyles.json, sprite-mln{,@2x}.{json,png} -// outDir/assets/style-{day,dusk,night}.json (per-scheme, SCAMIN-bucketed) -// outDir/manifest.json (schema_version, bbox, cells, styles) -// -// opts.RulesDir/CatalogDir "" use the catalogue embedded in libtile57. opts.Created -// "" leaves the manifest timestamp unset. opts.MinZoom/MaxZoom clamp the per-cell -// bands (0/0 → no clamp). progress nil uses the lib's built-in console progress. -// Returns the cell count and bbox (west,south,east,north). -// -// progress reports a [BakeProgress] per update: stage 0 = loading cells -// (Done/Total = cells); stage 1 = baking tiles, where Done/Total are PER BAND -// (reset each band) and Total is that band's planned tile count — so a UI can show -// a per-band percentage. BandIndex/BandCount/BandName give the band label -// ("approach", band 3/6). The planned total slightly over-counts the emitted tiles -// (empty tiles are skipped), matching the Go baker's planned bar. -func BakeBundle(input, outDir string, opts BakeOpts, progress func(BakeProgress)) (cellCount int, bbox [4]float64, err error) { - if input == "" || outDir == "" { - return 0, bbox, fmt.Errorf("tile57: BakeBundle needs input and out dir: %w", ErrEmptyInput) - } - cIn := C.CString(input) - defer C.free(unsafe.Pointer(cIn)) - cOut := C.CString(outDir) - defer C.free(unsafe.Pointer(cOut)) - cRules, fr := cStringOrNil(opts.RulesDir) - defer fr() - cCat, fc := cStringOrNil(opts.CatalogDir) - defer fc() - cCreated, fcr := cStringOrNil(opts.Created) - defer fcr() - - var user unsafe.Pointer - if progress != nil { - h := cgo.NewHandle(progress) - defer h.Delete() - user = unsafe.Pointer(&h) - } - - var cells C.uint32_t - var bb [4]C.double - rc := C.tile57_bake_bundle_cb(cIn, cOut, cRules, cCat, cCreated, - C.uint8_t(opts.MinZoom), C.uint8_t(opts.MaxZoom), cOmit(opts.OmitPickAttrs), C.uint8_t(opts.Format), user, &cells, &bb[0]) - switch rc { - case 1: - return int(cells), [4]float64{float64(bb[0]), float64(bb[1]), float64(bb[2]), float64(bb[3])}, nil - case 0: - return 0, bbox, fmt.Errorf("tile57: bundle bake covered nothing: %w", ErrNoCoverage) - default: - return 0, bbox, fmt.Errorf("tile57: bundle bake failed") - } -} - // BakeCell bakes ONE on-disk cell (a .000 path + its .001.. updates) to a PMTiles // archive over its NATIVE band zoom range and returns the bytes — the per-cell tile // store the composite stitcher consumes (the stitcher handles any cross-band zoom @@ -203,96 +37,6 @@ func BakeCell(path string) ([]byte, error) { return nil, fmt.Errorf("tile57: cell bake failed") } } - -// Compose combines per-cell PMTiles archives (each from [BakeCell], coverage embedded) -// into one merged PMTiles via the ownership partition: at every tile each owning cell's -// features are clipped to the ground it owns and merged, with cross-band overscale one zoom -// past each band. Native scale only otherwise — deeper coarse-only zooms are left to the -// client camera + MapLibre overzoom. Returns the composed archive bytes. -func Compose(archives [][]byte) ([]byte, error) { - if len(archives) == 0 { - return nil, fmt.Errorf("tile57: Compose needs at least one archive: %w", ErrEmptyInput) - } - // CGo-safe marshalling: concatenate the archives into one pointer-free buffer plus a - // lengths array, so C receives single pointers to memory holding no Go pointers. - lens := make([]C.size_t, len(archives)) - total := 0 - for i, a := range archives { - lens[i] = C.size_t(len(a)) - total += len(a) - } - if total == 0 { - return nil, fmt.Errorf("tile57: Compose archives are all empty: %w", ErrEmptyInput) - } - blob := make([]byte, 0, total) - for _, a := range archives { - blob = append(blob, a...) - } - - var out *C.uint8_t - var outLen C.size_t - rc := C.tile57_compose( - (*C.uint8_t)(unsafe.Pointer(&blob[0])), C.size_t(len(blob)), - (*C.size_t)(unsafe.Pointer(&lens[0])), C.size_t(len(archives)), - &out, &outLen, - ) - switch rc { - case 1: - return tileBytes(out, outLen), nil - case 0: - return nil, fmt.Errorf("tile57: compose produced no tiles: %w", ErrNoCoverage) - default: - return nil, fmt.Errorf("tile57: compose failed") - } -} - -// ComposeFiles streaming-composes the per-cell PMTiles at `paths` (each written by [BakeCell] -// to disk) into one merged PMTiles at outPath. The per-cell files are mmap'd (never all -// resident) and the output is streamed, so a whole district composes in bounded memory — -// prefer this over [Compose] for large cell sets. progress, if non-nil, is called with a -// [ComposeProgress] as the compose walks the zoom ladder (Done/Total is a smooth fraction). -// Returns the number of contributing cells. -func ComposeFiles(paths []string, outPath string, progress func(ComposeProgress)) (int, error) { - if len(paths) == 0 { - return 0, fmt.Errorf("tile57: ComposeFiles needs at least one path: %w", ErrEmptyInput) - } - if outPath == "" { - return 0, fmt.Errorf("tile57: ComposeFiles needs an output path: %w", ErrEmptyInput) - } - cpaths := make([]*C.char, len(paths)) - for i, p := range paths { - cpaths[i] = C.CString(p) - } - defer func() { - for _, cp := range cpaths { - C.free(unsafe.Pointer(cp)) - } - }() - cOut := C.CString(outPath) - defer C.free(unsafe.Pointer(cOut)) - - var user unsafe.Pointer - if progress != nil { - h := cgo.NewHandle(progress) - defer h.Delete() - user = unsafe.Pointer(&h) - } - - var cells C.uint32_t - rc := C.tile57_compose_files_cb( - (**C.char)(unsafe.Pointer(&cpaths[0])), C.size_t(len(paths)), - cOut, &cells, user, - ) - switch rc { - case 1: - return int(cells), nil - case 0: - return 0, fmt.Errorf("tile57: compose produced no tiles: %w", ErrNoCoverage) - default: - return 0, fmt.Errorf("tile57: compose files failed") - } -} - // PMTilesMetadata returns a PMTiles archive's metadata JSON blob (decompressed), or // nil if the archive carries none. A [BakeCell] archive embeds the cell's coverage // under a "coverage" key. The pmtiles bytes are read but not retained. diff --git a/bindings/go/bundle_test.go b/bindings/go/bundle_test.go index 17bc085..5603da4 100644 --- a/bindings/go/bundle_test.go +++ b/bindings/go/bundle_test.go @@ -4,7 +4,6 @@ package tile57 import ( "os" - "path/filepath" "testing" ) @@ -33,48 +32,3 @@ func TestSourceScamin(t *testing.T) { } t.Logf("SCAMIN manifest: %v", sc) } - -func TestBakeBundle(t *testing.T) { - if _, err := os.Stat(testCell); err != nil { - t.Skipf("no test cell: %v", err) - } - out := t.TempDir() - // Capture the progress reports to verify the band label detail (host §3): a - // stage-1 report must carry a band name + a sane band index/count. - var stage1Named bool - var maxBandCount int - progress := func(p BakeProgress) { - if p.BandCount > maxBandCount { - maxBandCount = p.BandCount - } - if p.Stage == 1 && p.BandName != "" && p.BandIndex < p.BandCount { - stage1Named = true - } - } - n, bbox, err := BakeBundle(testCell, out, BakeOpts{MaxZoom: 16}, progress) - if err != nil { - t.Fatal(err) - } - if n == 0 { - t.Fatal("baked 0 cells") - } - if maxBandCount == 0 { - t.Fatal("progress never reported a band count") - } - if !stage1Named { - t.Fatal("progress never reported a stage-1 band label (name + index 0 { - updPtrs := (**C.uint8_t)(a.track(C.malloc(C.size_t(m) * C.size_t(unsafe.Sizeof((*C.uint8_t)(nil)))))) - updLens := (*C.size_t)(a.track(C.malloc(C.size_t(m) * C.size_t(unsafe.Sizeof(C.size_t(0)))))) - ps := unsafe.Slice(updPtrs, m) - ls := unsafe.Slice(updLens, m) - for j, u := range c.Updates { - ps[j] = a.bytes(u) - ls[j] = C.size_t(len(u)) - } - in.updates = updPtrs - in.update_lens = updLens - in.update_count = C.size_t(m) - } - } - return inputs, base -} diff --git a/bindings/go/errors_test.go b/bindings/go/errors_test.go index 9e402e1..c76381f 100644 --- a/bindings/go/errors_test.go +++ b/bindings/go/errors_test.go @@ -11,10 +11,7 @@ import ( // host can branch on "empty input" / "covered nothing" / "source closed" rather than // scraping the message string. func TestSentinelErrors(t *testing.T) { - // ErrEmptyInput: no cells. - if _, err := BakePmtiles(nil, BakeOpts{}, nil); !errors.Is(err, ErrEmptyInput) { - t.Errorf("BakePmtiles(nil): want ErrEmptyInput, got %v", err) - } + // ErrEmptyInput: empty input. if _, err := OpenChartBytes(nil); !errors.Is(err, ErrEmptyInput) { t.Errorf("OpenChartBytes(nil): want ErrEmptyInput, got %v", err) } @@ -24,10 +21,4 @@ func TestSentinelErrors(t *testing.T) { if _, err := BuildStyle(nil, MarinerDefaults(), nil, nil, nil, 0); !errors.Is(err, ErrEmptyInput) { t.Errorf("BuildStyle(empty template): want ErrEmptyInput, got %v", err) } - - // ErrSourceClosed: Tile on a closed source. - s := &Source{} // ptr nil == closed - if _, err := s.Tile(0, 0, 0); !errors.Is(err, ErrSourceClosed) { - t.Errorf("Tile on closed source: want ErrSourceClosed, got %v", err) - } } diff --git a/bindings/go/progress.go b/bindings/go/progress.go deleted file mode 100644 index 2fbd6eb..0000000 --- a/bindings/go/progress.go +++ /dev/null @@ -1,87 +0,0 @@ -//go:build cgo - -package tile57 - -/* -#include "tile57.h" -*/ -import "C" - -import ( - "runtime/cgo" - "unsafe" -) - -// BakeProgress is one progress report from [BakePmtiles] / [BakeBundle]. -type BakeProgress struct { - Stage uint8 // 0 = loading/portraying cells, 1 = baking tiles - Done int // cells loaded (stage 0) or tiles baked in this band (stage 1) - Total int // total cells (stage 0) or the band's planned tile count (stage 1; 0 = unknown) - // BandIndex/BandCount locate the current band among the bands that actually bake - // (0-based index; count = how many bands have cells), so a UI can show "band i/n". - BandIndex int - BandCount int - // BandName is the navigational-purpose name of the current band ("berthing", - // "harbor", "approach", "coastal", "general", "overview"), or "" if not band-specific. - BandName string -} - -// ComposeProgress is one progress report from [ComposeFiles] as the streaming compose walks the -// zoom ladder. Done/Total are opaque work weights (the deepest zoom dominates the total), so -// Done/Total is a smooth 0..1 fraction; Zoom is the level currently composing, within -// [MinZoom, MaxZoom] — enough for a host to show a bar + ETA and a "zoom N of M" line. -type ComposeProgress struct { - Zoom int - MinZoom int - MaxZoom int - Done uint64 - Total uint64 -} - -// tile57GoComposeProgress is the C-callable progress callback libtile57 invokes during -// tile57_compose_files. The host's Go progress func is carried across the seam as a cgo.Handle -// pointed to by `user`. NULL user = no progress wanted. -// -//export tile57GoComposeProgress -func tile57GoComposeProgress(user unsafe.Pointer, zoom, minZoom, maxZoom C.uint32_t, done, total C.uint64_t) { - if user == nil { - return - } - h := *(*cgo.Handle)(user) - if cb, ok := h.Value().(func(ComposeProgress)); ok && cb != nil { - cb(ComposeProgress{ - Zoom: int(zoom), - MinZoom: int(minZoom), - MaxZoom: int(maxZoom), - Done: uint64(done), - Total: uint64(total), - }) - } -} - -// tile57GoBakeProgress is the C-callable progress callback libtile57 invokes -// during tile57_bake_pmtiles / tile57_bake_bundle. The host's Go progress func is -// carried across the seam as a cgo.Handle, pointed to by `user`. NULL user = no -// progress wanted. -// -//export tile57GoBakeProgress -func tile57GoBakeProgress(user unsafe.Pointer, stage C.uint8_t, done, total C.size_t, bandIndex, bandCount C.uint8_t, bandName *C.char) { - if user == nil { - return - } - h := *(*cgo.Handle)(user) - if cb, ok := h.Value().(func(BakeProgress)); ok && cb != nil { - name := "" - if bandName != nil { - name = C.GoString(bandName) - } - cb(BakeProgress{ - Stage: uint8(stage), - Done: int(done), - Total: int(total), - BandIndex: int(bandIndex), - BandCount: int(bandCount), - BandName: name, - }) - } -} diff --git a/bindings/go/tile57.go b/bindings/go/tile57.go index 424967c..a6edd3e 100644 --- a/bindings/go/tile57.go +++ b/bindings/go/tile57.go @@ -164,41 +164,6 @@ func (s *Source) Info() ChartInfo { } } -// SetTileFormat selects the encoding for LIVE-generated tiles on a cell-backed -// source (FormatDefault = the engine default, MLT). Cell-backed sources open -// generating MVT, so an MLT-capable host opts in here. No-op for a baked PMTiles -// source (its stored encoding is fixed). Changing the format drops the tile cache. -func (s *Source) SetTileFormat(f TileFormat) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr != nil { - C.tile57_chart_set_tile_format(s.ptr, C.uint8_t(f)) - } -} - -// Tile fetches tile (z, x, y) as decompressed vector-tile bytes in the source's -// tile encoding (see [ChartInfo.TileType] / [Meta.TileType]; stored bytes verbatim -// for a PMTiles source, the live generation format for a cell source). (nil, nil) -// means a blank tile (valid but empty) — the TileSource "no tile here" convention. -func (s *Source) Tile(z uint8, x, y uint32) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return nil, ErrSourceClosed - } - var out *C.uint8_t - var outLen C.size_t - st := C.tile57_chart_tile(s.ptr, C.uint8_t(z), C.uint32_t(x), C.uint32_t(y), &out, &outLen) - switch st { - case C.TILE57_TILE_OK: - return tileBytes(out, outLen), nil - case C.TILE57_TILE_EMPTY: - return nil, nil - default: - return nil, fmt.Errorf("tile57: tile %d/%d/%d generation error (status %d)", z, x, y, int(st)) - } -} - // Meta reports the source's display metadata (zoom range, bounds, SCAMIN manifest). // tile57 serves decompressed MVT, so Gzipped is always false. Bounds fall back to the // world extent when libtile57 reports them as degenerate/near-global. The SCAMIN @@ -253,15 +218,6 @@ func (s *Source) scaminLocked() []uint32 { return s.scamin } -// ClearCache drops the in-memory tile cache to bound memory in long-running hosts. -func (s *Source) ClearCache() { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr != nil { - C.tile57_chart_clear_cache(s.ptr) - } -} - // Close releases the source and all cached tiles. It is idempotent. Per the ABI's // lifetime rule, callers must not Close while any goroutine can still call Tile; // the server Closes a set only after deregistering it. diff --git a/bindings/go/tile57_test.go b/bindings/go/tile57_test.go index 21e1901..d2f9936 100644 --- a/bindings/go/tile57_test.go +++ b/bindings/go/tile57_test.go @@ -3,7 +3,6 @@ package tile57 import ( - "bytes" "math" "os" "testing" @@ -31,46 +30,6 @@ func TestColortablesDefault(t *testing.T) { // Pick-report attributes (class / s57 / cell, S-52 §10.8) ride on BAKED tiles by default // (pick_attrs on). Cell-backed charts are metadata-only — bake first, serve the archive — // so bake the fixture to PMTiles and scan its tiles for the pick-report keys. -func TestPickAttrs(t *testing.T) { - data, err := os.ReadFile(testCell) - if err != nil { - t.Skipf("no test cell: %v", err) - } - pm, err := BakePmtiles([]Cell{{Base: data}}, BakeOpts{MaxZoom: 24}, nil) - if err != nil { - t.Fatalf("BakePmtiles: %v", err) - } - tmp := t.TempDir() + "/chart.pmtiles" - if err := os.WriteFile(tmp, pm, 0o644); err != nil { - t.Fatal(err) - } - src, err := OpenPMTiles(tmp) - if err != nil { - t.Fatalf("OpenPMTiles: %v", err) - } - defer src.Close() - - // Scan the tile grid covering the chart bounds until a tile carries the pick-report - // property keys (present on every feature when pick attrs are on). - info := src.Info() - for z := info.MinZoom; z <= info.MaxZoom && z <= 16; z++ { - x0, y0 := lonLatToTile(info.West, info.North, z) - x1, y1 := lonLatToTile(info.East, info.South, z) - for x := x0; x <= x1; x++ { - for y := y0; y <= y1; y++ { - body, err := src.Tile(z, x, y) - if err != nil { - t.Fatalf("Tile %d/%d/%d: %v", z, x, y, err) - } - if bytes.Contains(body, []byte("class")) && bytes.Contains(body, []byte("s57")) { - return // pick-report attributes present — done - } - } - } - } - t.Fatal("baked tiles carry no pick-report attributes (class/s57) — expected them by default") -} - // A cell-backed chart (OpenChartBytes) is METADATA-ONLY: it exposes bounds/scale for a // header scan but never generates tiles on demand — the chart-api contract is "bake first, // serve the archive". Assert the metadata is present and that Tile() is refused. @@ -92,10 +51,6 @@ func TestOpenCellMetadata(t *testing.T) { t.Logf("zoom %d..%d, bounds W=%.4f S=%.4f E=%.4f N=%.4f", info.MinZoom, info.MaxZoom, info.West, info.South, info.East, info.North) - // Cell-backed tiles are refused by design — bake first (BakeCell / BakePmtiles). - if _, err := src.Tile(info.MinZoom, 0, 0); err == nil { - t.Fatal("cell-backed Tile() should be refused (metadata-only; bake first)") - } } // TestOpenPath opens a directory as a STREAMING chart (the engine enumerates cell @@ -118,10 +73,6 @@ func TestOpenPath(t *testing.T) { t.Logf("streamed: zoom %d..%d bounds W=%.4f S=%.4f E=%.4f N=%.4f", info.MinZoom, info.MaxZoom, info.West, info.South, info.East, info.North) - // Streaming cell-backed tiles are refused by design — bake first. - if _, err := src.Tile(info.MinZoom, 0, 0); err == nil { - t.Fatal("streaming cell-backed Tile() should be refused (metadata-only; bake first)") - } } // TestOpenPMTilesAndInfo bakes the fixture to a PMTiles file, opens it via the path, @@ -141,30 +92,6 @@ func TestOpenPMTilesAndInfo(t *testing.T) { } rc.Close() - // Bake to a PMTiles file, then open it by path. - pm, err := BakePmtiles([]Cell{{Base: data}}, BakeOpts{MaxZoom: 24}, nil) - if err != nil { - t.Fatalf("BakePmtiles: %v", err) - } - tmp := t.TempDir() + "/chart.pmtiles" - if err := os.WriteFile(tmp, pm, 0o644); err != nil { - t.Fatal(err) - } - src, err := OpenPMTiles(tmp) - if err != nil { - t.Fatalf("OpenPMTiles: %v", err) - } - defer src.Close() - - info := src.Info() - if info.MaxZoom < info.MinZoom || !info.HasBounds { - t.Fatalf("pmtiles chart info looks unset: %+v", info) - } - t.Logf("pmtiles: zoom %d..%d bounds W=%.4f E=%.4f anchor=%v", info.MinZoom, info.MaxZoom, info.West, info.East, info.HasAnchor) - tx, ty := lonLatToTile((info.West+info.East)/2, (info.South+info.North)/2, info.MaxZoom) - if _, err := src.Tile(info.MaxZoom, tx, ty); err != nil { - t.Fatalf("Tile: %v", err) - } } // lonLatToTile maps a lon/lat to its XYZ web-Mercator tile at zoom z. From 7f329555806cb1ccdcef49687c273fd654cb8f42 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 09:41:11 -0400 Subject: [PATCH 050/140] =?UTF-8?q?refactor(capi):=20drop=20the=20legacy?= =?UTF-8?q?=20tile-production=20C=20ABI=20(Step=20C=20=E2=80=94=20ABI=20+?= =?UTF-8?q?=20header)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the 8 batch/bundle + dead chart-tile exports and their dead support from capi.zig + tile57.h: tile57_compose, tile57_compose_files, tile57_bake_pmtiles, tile57_bake_bundle, tile57_charts_open, tile57_chart_tile, tile57_chart_set_tile_ format, tile57_chart_clear_cache — plus the now-orphaned CellInput/toCellInputs/ CBakeOpts/bakeOptsOr/bakeFormat/BakeProgress/generator helpers (capi.zig, 252 lines) and the tile57_cell / tile57_bake_opts / tile57_bake_progress / tile57_ compose_progress / tile57_tile_status types + their decls (tile57.h, 153 lines). The tile-production ABI is now exactly: tile57_bake_cell_bytes → tile57_compose_ open/serve/meta_get/save_partition/close. Kept intact: chart open + metadata, the render-surface API (render_view/pdf/view_cb/surface_cb — the general "make a surface, render anywhere" primitive), style/assets, catalog, partition-debug, warmup, free. Rewrote the header's top-of-file overview to describe the one tile path. Lib links, removed symbols absent from libtile57.a, Go bindings build. The Zig impl of the removed batch functions (bundle.composeInto/composeZoom/bakeBundle/bakeRoot, chart.tile) is now unreferenced and comes out in Step D. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/tile57.h | 181 ++++------------------------------ src/capi.zig | 252 ----------------------------------------------- 2 files changed, 17 insertions(+), 416 deletions(-) diff --git a/include/tile57.h b/include/tile57.h index c9a6e59..6e46c01 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -8,18 +8,24 @@ * by any matching renderer — maplibre-gl >= 5.12 decodes both natively (vector * source `encoding` option) — but the ABI itself is renderer-agnostic. * - * Lifetime: a tile57_chart must OUTLIVE every renderer/adapter still holding it. - * In the MapLibre hosts the chart is captured by a long-lived FileSource and - * is intentionally never closed before process exit (closing first would be a - * use-after-free during Map teardown). Call tile57_chart_close only once nothing - * can still call tile57_chart_tile on it. + * Tiles are made ONE way: bake each ENC cell to its own PMTiles + * (tile57_bake_cell_bytes), then compose them ON DEMAND through the ownership + * partition (tile57_compose_open / tile57_compose_serve). A tile57_chart is a + * SEPARATE handle used for metadata (open + get_info / cells / features / coverage + * / query) and for rendering a view to pixels / PDF / a callback surface. * - * Threading: a tile57_chart is NOT internally synchronized. Do not call into the - * same chart from multiple threads concurrently (the tile cache is mutated by - * tile57_chart_tile without a lock). Distinct charts are independent. + * Lifetime: a tile57_chart / tile57_compose_source must OUTLIVE every renderer or + * adapter still holding it. In the MapLibre hosts the handle is captured by a + * long-lived source and intentionally never closed before process exit (closing + * first would be a use-after-free during teardown). Call tile57_chart_close / + * tile57_compose_close only once nothing can still call into it. * - * Memory: tile57_chart_tile allocates *out; release it with tile57_free, passing the - * same length. All pointers are POD across the seam. + * Threading: neither handle is internally synchronized — do not call into the SAME + * handle from multiple threads concurrently (caches are mutated without a lock). + * Distinct handles are independent. + * + * Memory: calls that return bytes allocate *out; release it with tile57_free, + * passing the same length. All pointers are POD across the seam. * * The S-101 portrayal self-test / bring-up entry points live in a separate * header, tile57_diag.h (developer tooling, not part of the embedding API). @@ -44,25 +50,6 @@ const char *tile57_version(void); /* Opaque chart handle. */ typedef struct tile57_chart tile57_chart; -/* One ENC cell for tile57_bake_pmtiles: the base .000 bytes plus its - * sequential update files (.001, .002, … in order). `updates`/`update_lens` are - * parallel arrays of length `update_count`; pass update_count = 0 (and NULL - * arrays) for a base-only cell. All bytes are copied. - * - * `name` is the source cell name (e.g. "US4MD81M"), emitted as the `cell` pick- - * report property on every feature from this cell (see the pick attributes below). - * NULL/"" = omit it. The field is appended last, so a host that zero-inits the - * struct (or was compiled before this field existed) gets the NULL (no-badge) - * behaviour and stays ABI-compatible for the original fields. */ -typedef struct { - const uint8_t *base; - size_t base_len; - const uint8_t *const *updates; - const size_t *update_lens; - size_t update_count; - const char *name; -} tile57_cell; - /* ---- pick-report attributes --------------------------------------------- * * Every emitted MVT feature carries the per-feature cursor-pick / inspector @@ -102,34 +89,6 @@ tile57_chart *tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t * -1=error. */ int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); -/* Combine N per-cell PMTiles (each from tile57_bake_cell_bytes, coverage embedded) into - * one merged PMTiles via the ownership partition: at every tile each owning cell's features - * are clipped to the ground it owns and merged, with cross-band overscale one zoom past each - * band. The archives are passed CONCATENATED in `blob` (blob_len bytes total), split by the - * `n` lengths in `lens` (their sum must equal blob_len). Returns 1 with the composed archive - * in *out / *out_len (free with tile57_free), 0 if nothing composed, -1 on error. */ -int tile57_compose(const uint8_t *blob, size_t blob_len, - const size_t *lens, size_t n, - uint8_t **out, size_t *out_len); - -/* Progress callback for tile57_compose_files, invoked as the streaming compose walks the zoom - * ladder. `done`/`total` are opaque work weights (the deepest zoom dominates the total), so - * `done`/`total` is a smooth 0..1 fraction; `zoom` is the level being composed, within - * [`min_zoom`, `max_zoom`]. A host renders done/total as a bar + ETA and "zoom N of M". `ctx` is - * the opaque pointer passed to tile57_compose_files. */ -typedef void (*tile57_compose_progress)(void *ctx, uint32_t zoom, uint32_t min_zoom, - uint32_t max_zoom, uint64_t done, uint64_t total); - -/* Streaming disk-to-disk composite: combine the `n` per-cell PMTiles at `paths` (each written - * by tile57_bake_cell_bytes) into one merged PMTiles at `out_path`. The per-cell files are - * mmap'd (never all resident) and the output is streamed, so a whole district composes in - * bounded memory — prefer this over tile57_compose for large cell sets. Writes the count of - * contributing cells to *out_cells if non-NULL. `on_progress` (NULL to skip) is called with `ctx` - * as the compose advances. Returns 1 on success, 0 if nothing composed, -1 on error. */ -int tile57_compose_files(const char *const *paths, size_t n, const char *out_path, - uint32_t *out_cells, - tile57_compose_progress on_progress, void *ctx); - /* Runtime compositor — the on-demand counterpart of tile57_compose_files. Rather than pass over a * whole district to produce one archive, it holds the per-cell PMTiles mmap'd and the ownership * partition resident, so any tile can be composed for the cost of a classify + one decode/clip or a @@ -154,7 +113,7 @@ tile57_compose_source *tile57_compose_open(const char *const *paths, size_t n, /* Compose the tile (z,x,y) on demand into RAW (decompressed) MLT in *out / *out_len (free with * tile57_free) — what a live tile server hands its HTTP layer (which gzips on the wire). Returns: - * 1 served (bytes in *out/*out_len), + * 1 served (bytes in *out / *out_len), * 2 OWNED but empty — a cell owns this ground per the ownership partition but produced nothing * (transient while its per-cell bake is still running; an error state once bakes are done), * 0 not owned — true empty ocean (no cell owns this ground; safe to cache), @@ -180,11 +139,6 @@ void tile57_compose_close(tile57_compose_source *src); int tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, uint8_t **out, size_t *out_len); -/* Open a whole ENC_ROOT directory (or a single cell) as a lazily-baked streaming - * chart: enumerates cells + peeks each bbox/scale, reads bytes on demand (working - * set only). For tile fetch / bake, NOT live render_surface_cb. NULL/failure -> NULL. */ -tile57_chart *tile57_charts_open(const char *path); - /* Populate the process-global read-only registries (the S-100 feature catalogue and * the complex-linestyle table) on the calling thread. Both are idempotent lazy-init * and thereafter read-only. Call this ONCE on your main thread before opening or @@ -222,78 +176,6 @@ typedef struct { } tile57_chart_info; void tile57_chart_get_info(tile57_chart *chart, tile57_chart_info *out); -/* Progress callback for tile57_bake_pmtiles / tile57_bake_bundle. stage 0 = - * loading/portraying cells, stage 1 = baking tiles. Stage 0 done/total count cells. - * Stage 1 done/total count tiles: for tile57_bake_bundle they are PER BAND (reset - * each band) with total = that band's planned tile count, so a host can draw a - * per-band percentage (the count is a planned estimate from cell bboxes — the actual - * emitted total is a little lower as empty tiles are skipped, like the Go baker's - * planned bar). total 0 means "unknown" (the tile57_bake_pmtiles path with no planned - * count). - * - * band_index / band_count locate the current band among the bands that actually - * bake (0-based index; band_count = how many bands have cells), so the host can - * label "band /". band_name is the band's - * navigational-purpose name ("berthing","harbor","approach","coastal","general", - * "overview") as a static NUL-terminated string owned by the library (valid for the - * call; copy if retained), or NULL during a non-band-specific report. Together they - * let the host show e.g. "Generating approach tiles (band 3/6): 84/427 (20%)". */ -typedef void (*tile57_bake_progress)(void *user, uint8_t stage, size_t done, size_t total, - uint8_t band_index, uint8_t band_count, - const char *band_name); - -/* Shared bake options for tile57_bake_pmtiles / tile57_bake_bundle. Pass NULL for - * all defaults (embedded rules/catalogue, no band clamp, pick attrs included, no - * progress). `catalog_dir` and `created` apply to tile57_bake_bundle only — - * tile57_bake_pmtiles ignores them. */ -typedef struct { - const char *rules_dir; /* NULL = embedded portrayal rules */ - const char *catalog_dir; /* NULL = embedded S-101 catalogue (bundle only) */ - const char *created; /* NULL = manifest "created" unset (bundle only) */ - uint8_t minzoom, maxzoom; /* 0/0 = no band clamp */ - bool omit_pick_attrs; - tile57_bake_progress progress; - void *progress_user; /* NULL = default/none */ - uint8_t format; /* baked tile encoding: 0 = default (mlt), - * TILE57_TILE_TYPE_MVT, TILE57_TILE_TYPE_MLT. - * Honored by tile57_bake_pmtiles AND - * tile57_bake_bundle. Appended last, so a - * zero-initialised struct bakes the default. */ -} tile57_bake_opts; - -/* Bake an ENC_ROOT's in-memory cells into ONE PMTiles archive, zoom-banded per cell - * by compilation scale, so the result opens cheaply (tile57_chart_open_pmtiles) - * instead of holding every cell live. `opts` may be NULL for all defaults; it reads - * rules_dir / minzoom / maxzoom / omit_pick_attrs / progress / progress_user (the - * catalog_dir and created fields are ignored — they apply to tile57_bake_bundle). - * minzoom/maxzoom of 0/0 leave the per-cell bands unclamped. On success returns 1 - * with the archive in *out and *out_len (free with tile57_free); 0 if nothing - * covered; -1 on error. Like the live open, this parses + portrays every cell, so - * peak memory tracks the ENC_ROOT size; run it once and cache the archive. */ -int tile57_bake_pmtiles(const tile57_cell *cells, size_t count, - const tile57_bake_opts *opts, - uint8_t **out, size_t *out_len); - -/* Bake a single cell.000 OR a whole ENC_ROOT directory (`input`, an on-disk path) - * into a self-contained chart bundle written under `out_dir` — the SAME package the - * `tile57 bake … -o out_dir/` CLI emits: - * out_dir/tiles/chart.pmtiles (PMTiles with scamin + vector_layers metadata) - * out_dir/assets/colortables.json, linestyles.json, sprite-mln{,@2x}.{json,png} - * out_dir/assets/style-{day,dusk,night}.json (per-scheme, SCAMIN-bucketed) - * out_dir/manifest.json (schema_version, bbox, cells, styles) - * The host registers chart.pmtiles and serves style-*.json verbatim. `opts` may be - * NULL for all defaults, else it reads every field: rules_dir/catalog_dir NULL or "" - * use the catalogue embedded in the library (no on-disk catalogue needed), a - * non-empty path overrides from disk; created NULL/"" leaves the manifest "created" - * unset, else stamps it (e.g. an ISO8601 string); minzoom/maxzoom of 0/0 leave the - * per-cell bands unclamped; progress NULL uses a built-in console progress. - * `out_cell_count` and `out_bbox` (filled west,south,east,north) are optional — pass - * NULL to skip. Returns 1 on success, 0 if nothing was covered (no geometry), -1 on - * error. */ -int tile57_bake_bundle(const char *input, const char *out_dir, - const tile57_bake_opts *opts, - uint32_t *out_cell_count, double *out_bbox); - /* Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) into a * single PMTiles at out_path: the composited ownership faces (which cell renders which * ground at each band), one polygon per owning cell tagged with the properties @@ -343,31 +225,6 @@ void tile57_chart_close(tile57_chart *chart); * tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ int tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len); -/* Result of tile57_chart_tile. */ -typedef enum { - TILE57_TILE_OK = 1, /* *out / *out_len set (free with tile57_free) */ - TILE57_TILE_EMPTY = 0, /* tile is valid but has no features */ - TILE57_TILE_ERROR = -1, /* generation / decode failure */ -} tile57_tile_status; - -/* Fetch tile (z, x, y) as decompressed vector-tile bytes, VERBATIM in the chart's - * tile encoding — discoverable via tile57_chart_info.tile_type: a PMTiles-backed - * chart returns the stored bytes as baked (MLT by default, MVT for a --format mvt - * bake); a cell-backed chart generates in its live format (see - * tile57_chart_set_tile_format). There is NO transcode layer — hosts hint the - * encoding to the renderer instead (maplibre-gl >= 5.12 decodes MLT natively via - * the vector source `encoding` option). Results are cached per chart, so - * re-requesting a tile (as renderers do) is cheap and deterministic. */ -tile57_tile_status tile57_chart_tile(tile57_chart *chart, uint8_t z, uint32_t x, uint32_t y, - uint8_t **out, size_t *out_len); - -/* Select the encoding for LIVE-generated tiles on a cell-backed chart: 0 = engine - * default (mlt), TILE57_TILE_TYPE_MVT, TILE57_TILE_TYPE_MLT. Cell-backed charts - * OPEN generating MVT (so existing MVT-only embedders are unaffected); a host - * whose renderer decodes MLT opts in here. No-op for a baked PMTiles chart (its - * stored encoding is fixed). Changing the format drops the tile cache. */ -void tile57_chart_set_tile_format(tile57_chart *chart, uint8_t format); - /* Render a VIEW of the chart — centre + fractional zoom + pixel size — to a * PNG through the native S-52 pixel path: the mariner settings evaluate LIVE * (real safety contour + category/SCAMIN/text-group gates + day/dusk/night @@ -566,10 +423,6 @@ int tile57_chart_features(tile57_chart *chart, const char *classes, * colortables, atlases, …), passing the same length. The universal free. */ void tile57_free(void *ptr, size_t len); -/* Drop the in-memory tile cache to bound memory in long-running hosts. Safe to - * call any time; subsequent tile57_chart_tile calls simply regenerate/decode. */ -void tile57_chart_clear_cache(tile57_chart *chart); - /* ---- chart-style generation --------------------------------------------- * * tile57 ships tile generation AND style generation together: tile57_build_style diff --git a/src/capi.zig b/src/capi.zig index a2488ef..b5d29c0 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -37,37 +37,6 @@ export fn tile57_version() callconv(.c) [*:0]const u8 { return version_string; } -// One ENC cell's bytes for the C ABI. Mirrors tile57_cell in tile57.h. -const CellInput = extern struct { - base: [*]const u8, - base_len: usize, - updates: ?[*]const [*]const u8, - update_lens: ?[*]const usize, - update_count: usize, - // Source cell name (NUL-terminated, e.g. "US4MD81M") for the pick report's - // "source cell" badge. NULL/"" = omitted. Appended after the original fields so - // a host that zero-inits the struct gets the no-name (NULL) behaviour. - name: ?[*:0]const u8 = null, -}; - -// Convert the C CellInput[] into a Zig chart.CellInput[] (slices into the host's -// borrowed buffers). Allocates the slice-of-slices into `a`; the engine copies -// what it keeps, so the caller frees the conversion arrays after the call. -fn toCellInputs(a: std.mem.Allocator, c_cells: []const CellInput) ?[]chart.CellInput { - const out = a.alloc(chart.CellInput, c_cells.len) catch return null; - for (c_cells, 0..) |cc, i| { - var ups: []const []const u8 = &.{}; - if (cc.updates != null and cc.update_lens != null and cc.update_count > 0) { - const arr = a.alloc([]const u8, cc.update_count) catch return null; - var k: usize = 0; - while (k < cc.update_count) : (k += 1) arr[k] = cc.updates.?[k][0..cc.update_lens.?[k]]; - ups = arr; - } - out[i] = .{ .base = cc.base[0..cc.base_len], .updates = ups, .name = spanOpt(cc.name) orelse "" }; - } - return out; -} - /// Open ONE S-57 cell (a .000 file, with its .001.. update chain auto-read from the /// same directory) — or a whole ENC_ROOT directory — via the STREAMING path-open: cell /// metadata (name/scale/M_COVR) is enumerated up front and tiles are baked lazily per @@ -108,75 +77,6 @@ export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, out: *[*]u8, out_len: *us return 0; } -/// Combine N per-cell PMTiles archives (each from tile57_bake_cell_bytes, coverage -/// embedded) into one merged PMTiles via the ownership partition. The archives are passed -/// CONCATENATED in `blob` (total `blob_len` bytes), split by the `n` `lens` -/// (sum(lens) == blob_len). Returns 1 with the composed archive in *out / *out_len (free -/// with tile57_free), 0 if nothing composed, -1 on error. See tile57.h. -export fn tile57_compose( - blob: ?[*]const u8, - blob_len: usize, - lens: ?[*]const usize, - n: usize, - out: *[*]u8, - out_len: *usize, -) callconv(.c) c_int { - const b = blob orelse return -1; - const ls = lens orelse return -1; - if (n == 0) return 0; - const archives = gpa.alloc([]const u8, n) catch return -1; - defer gpa.free(archives); - var off: usize = 0; - for (0..n) |i| { - if (off + ls[i] > blob_len) return -1; // lengths overrun the blob - archives[i] = b[off .. off + ls[i]]; - off += ls[i]; - } - const composed = bundle.composeArchives(gpa, archives) catch return -1; - if (composed) |c| { - out.* = c.ptr; - out_len.* = c.len; - return 1; - } - return 0; -} - -/// Streaming disk-to-disk composite: combine the `n` per-cell PMTiles at `paths` (each from -/// tile57_bake_cell_bytes, written to disk) into one merged PMTiles at `out_path`. The per-cell -/// files are mmap'd (never all resident) and the output is streamed, so a whole district -/// composes in bounded memory. Writes the count of contributing cells to *out_cells if -/// non-null. Returns 1 on success, 0 if nothing composed, -1 on error. See tile57.h. -export fn tile57_compose_files( - paths: ?[*]const ?[*:0]const u8, - n: usize, - out_path: ?[*:0]const u8, - out_cells: ?*u32, - on_progress: ?*const fn (ctx: ?*anyopaque, zoom: u32, min_zoom: u32, max_zoom: u32, done: u64, total: u64) callconv(.c) void, - ctx: ?*anyopaque, -) callconv(.c) c_int { - const ps = paths orelse return -1; - const op = spanOpt(out_path) orelse return -1; - if (n == 0) return 0; - const list = gpa.alloc([]const u8, n) catch return -1; - defer gpa.free(list); - for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return -1; - - // Optional streaming progress: a stack-lived sink the compose walks the zoom ladder through. - var prog: bundle.ComposeProgress = undefined; - var pptr: ?*bundle.ComposeProgress = null; - if (on_progress) |cb| { - prog = .{ .cb = cb, .ctx = ctx }; - pptr = &prog; - } - - // The lib has no std.process.Init; stand up a threaded std.Io for the file I/O. - var threaded: std.Io.Threaded = .init(gpa, .{}); - defer threaded.deinit(); - const nc = bundle.composeArchivesToFile(threaded.io(), gpa, list, op, pptr) catch return -1; - if (out_cells) |p| p.* = @intCast(nc); - return if (nc > 0) 1 else 0; -} - /// Coverage/zoom summary of a resident compositor, filled by tile57_compose_meta. const CComposeMeta = extern struct { min_zoom: u8, @@ -304,13 +204,6 @@ export fn tile57_pmtiles_metadata(pmtiles_ptr: ?[*]const u8, len: usize, out: *[ return 0; } -/// Open a whole ENC_ROOT directory (or a single cell) as a lazily-baked chart — the -/// `.cells` backend, for tile fetch / bake, not live render_surface_cb. See tile57.h. -export fn tile57_charts_open(path: ?[*:0]const u8) callconv(.c) ?*Chart { - const p = spanOpt(path) orelse return null; - return Chart.openPath(p, null, true) catch null; -} - /// Populate the process-global read-only registries (S-100 catalogue + linestyles) on /// the calling thread. Call ONCE on the main thread before opening/baking cells from /// worker threads, so concurrent bake/render is race-free. See tile57.h. @@ -436,116 +329,6 @@ export fn tile57_chart_coverage(handle: ?*Chart, cb: ?*const CCoverageCb) callco return 0; } -// Progress callback for tile57_bake_pmtiles / tile57_bake_bundle (matches the header -// typedef + chart.Progress + bake_enc.Progress). -const BakeProgress = ?*const fn (user: ?*anyopaque, stage: u8, done: usize, total: usize, band_index: u8, band_count: u8, band_name: ?[*:0]const u8) callconv(.c) void; - -// Shared bake options. Mirrors tile57_bake_opts in tile57.h. catalog_dir/created -// are read only by tile57_bake_bundle. -const CBakeOpts = extern struct { - rules_dir: ?[*:0]const u8, - catalog_dir: ?[*:0]const u8, - created: ?[*:0]const u8, - minzoom: u8, - maxzoom: u8, - omit_pick_attrs: bool, - progress: BakeProgress, - progress_user: ?*anyopaque, - // Baked tile encoding: 0 = engine default (MLT), TILE57_TILE_TYPE_MVT, - // TILE57_TILE_TYPE_MLT. Appended for ABI-append-safety (a zero-initialised - // struct bakes the default). - format: u8, -}; - -// The passed opts or all-defaults (matching NULL opts = every field at its default). -fn bakeOptsOr(opts: ?*const CBakeOpts) CBakeOpts { - return if (opts) |p| p.* else .{ - .rules_dir = null, - .catalog_dir = null, - .created = null, - .minzoom = 0, - .maxzoom = 0, - .omit_pick_attrs = false, - .progress = null, - .progress_user = null, - .format = 0, - }; -} - -// tile57_bake_opts.format -> engine TileFormat (0 = default = MLT). -fn bakeFormat(v: u8) @import("scene").TileFormat { - return if (v == TILE_TYPE_MVT) .mvt else .mlt; -} - -/// Bake an ENC_ROOT into ONE PMTiles archive. See tile57.h. 1=ok, 0=empty, -1=error. -export fn tile57_bake_pmtiles( - cells_ptr: [*]const CellInput, - count: usize, - opts: ?*const CBakeOpts, - out: *[*]u8, - out_len: *usize, -) callconv(.c) c_int { - const o = bakeOptsOr(opts); - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const cells = toCellInputs(arena.allocator(), cells_ptr[0..count]) orelse return -1; - const archive = chart.bakeArchive(cells, spanOpt(o.rules_dir), o.minzoom, o.maxzoom, bakeFormat(o.format), !o.omit_pick_attrs, o.progress, o.progress_user, null) catch return -1; - if (archive) |a| { - out.* = a.ptr; - out_len.* = a.len; - return 1; - } - return 0; -} - -// Manifest generator string for bake_bundle (matches the CLI's "tile57 0.1.0"). -const generator = "tile57 " ++ version_string; - -/// Bake a single cell.000 OR a whole ENC_ROOT directory (on-disk paths) into a -/// self-contained chart bundle written under out_dir — the SAME package the -/// `tile57 bake … -o out/` CLI emits (tiles/chart.pmtiles with scamin+vector_layers -/// metadata + assets/{colortables,linestyles}.json + sprite-mln + per-scheme -/// style-{day,dusk,night}.json + manifest.json). rules_dir/catalog_dir NULL or "" -/// use the catalogue embedded in the library. created NULL/"" leaves the manifest -/// "created" unset (else an ISO8601 stamp). progress may be NULL (the built-in -/// console progress is used). out_cell_count / out_bbox (w,s,e,n) are optional. -/// 1=ok, 0=nothing covered (no geometry), -1=error. See tile57.h. -export fn tile57_bake_bundle( - input: [*:0]const u8, - out_dir: [*:0]const u8, - opts: ?*const CBakeOpts, - out_cell_count: ?*u32, - out_bbox: ?*[4]f64, -) callconv(.c) c_int { - const o = bakeOptsOr(opts); - // The bundle pipeline does filesystem I/O (read ENC, write the bundle dir); the - // lib has no std.process.Init, so stand up a threaded std.Io for the call. - var threaded: std.Io.Threaded = .init(gpa, .{}); - defer threaded.deinit(); - const io = threaded.io(); - // Scratch arena: bakeBundle's allocations (paths, manifest, styles, cell names) - // are all consumed by the on-disk writes, so they're freed when this returns. - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const res = bundle.bakeBundle(io, arena.allocator(), .{ - .input = std.mem.span(input), - .out_dir = std.mem.span(out_dir), - .rules_dir = spanOpt(o.rules_dir) orelse "", - .catalog_dir = spanOpt(o.catalog_dir) orelse "", - .generator = generator, - .created = spanOpt(o.created) orelse "", - .minzoom = o.minzoom, - .maxzoom = o.maxzoom, - .format = bakeFormat(o.format), - .pick_attrs = !o.omit_pick_attrs, - .progress = o.progress, - .progress_user = o.progress_user, - }) catch |err| return if (err == error.NoGeometry) 0 else -1; - if (out_cell_count) |p| p.* = @intCast(res.cell_count); - if (out_bbox) |p| p.* = res.bounds; - return 1; -} - /// Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) into a /// single PMTiles at out_path: the composited faces (which cell renders which ground /// at each band), tagged cell/cscl/band/tier/oi/color, NO portrayed content — for a @@ -593,27 +376,6 @@ export fn tile57_chart_scamin(handle: ?*Chart, out: *[*]i32, out_len: *usize) ca return 1; } -/// Fetch tile (z,x,y) as decompressed vector-tile bytes in the chart's tile -/// encoding (chart_info.tile_type: stored type for a PMTiles backend, the live -/// generation format for a cell backend). 1=OK + out/out_len, 0=empty, -1=error. -export fn tile57_chart_tile( - handle: ?*Chart, - z: u8, - x: u32, - y: u32, - out: *[*]u8, - out_len: *usize, -) callconv(.c) c_int { - const s = handle orelse return -1; - const r = s.tile(z, x, y) catch return -1; - if (r) |bytes| { - out.* = bytes.ptr; - out_len.* = bytes.len; - return 1; - } - return 0; -} - /// Render a VIEW of the chart (centre + fractional zoom + pixel size) to PNG /// through the native S-52 pixel path: the mariner settings evaluate LIVE /// (real safety contour, category/SCAMIN/text-group gates, palette), symbols @@ -830,20 +592,6 @@ export fn tile57_free(ptr: ?*anyopaque, len: usize) callconv(.c) void { chart.freeBytes(@as([*]u8, @ptrCast(p))[0..len]); } -/// Drop the in-memory tile cache (bounds memory in long-running hosts). -export fn tile57_chart_clear_cache(handle: ?*Chart) callconv(.c) void { - if (handle) |s| s.clearCache(); -} - -/// Select the encoding for LIVE-generated tiles on a cell-backed chart -/// (0 = engine default (MLT), TILE57_TILE_TYPE_MVT, TILE57_TILE_TYPE_MLT). -/// No-op for a baked PMTiles chart — its stored encoding is fixed. Changing the -/// format drops the tile cache so served coordinates regenerate. The result is -/// reported by chart_info.tile_type. See tile57.h. -export fn tile57_chart_set_tile_format(handle: ?*Chart, fmt: u8) callconv(.c) void { - if (handle) |s| s.setTileFormat(bakeFormat(fmt)); -} - // ---- portrayal asset generation (in-memory; mirrors tile57.h) -------------- // // Generate the S-101 portrayal assets from the library's embedded catalogue (or an From c439abedac4848227357cf932fd058ba495fda7f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 10:24:29 -0400 Subject: [PATCH 051/140] =?UTF-8?q?refactor(engine):=20drop=20the=20legacy?= =?UTF-8?q?=20batch=20bake/compose=20impl=20(Step=20D=20=E2=80=94=20Zig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make individual per-cell bake + live compose the ONLY tile-production path. bundle.zig: delete the batch machinery — bakeBundle, bakeRoot, composeArchives / composeArchivesToFile[Opt], composeInto, composeZoom + the orphaned bake infra (LoadWork/CarryState/BakeSink/CellPortray/…) + the batch tests (~2170 lines). Keep the runtime compositor (composeTile / ComposeSource / openComposeSourceFiles), the shared clip/seam/classify helpers, and bakePartitionDebug (still reachable via the C ABI). readParseCell is KEPT — loadCells depends on it (grep-gate caught it). chart.zig: drop Chart.tile / tileKey / setTileFormat / clearCache (the on-demand tile-cache path — its C ABI wrappers went in Step C). Keep the cache / tile_format / pick_attrs fields the render path reads. CLI: the `bake` subcommand now produces the live-composite structure directly — /tiles/.pmtiles + partition.tpart (bakeCellBytes + openComposeSourceFiles + serializePartition), the same layout chartplotter-go bakes. The old batch-bundle `bake` is gone; `compose-tile` still serves tiles on demand. C ABI (capi.zig / include/tile57.h) and the Go bindings are unchanged. Gates: 5-tile byte-identity matches the frozen baseline; zig build test / bundle-test / compose-test green; render-to-PNG smoke (renderView) ok; the new bake CLI serves a real cell end-to-end; chartplotter-go relinks clean (CGO). Co-Authored-By: Claude Opus 4.8 (1M context) --- build.zig | 10 +- src/bundle.zig | 2189 +-------------------------------------------- src/chart.zig | 87 +- src/tile57.zig | 8 +- tools/bake.zig | 191 ++-- tools/compose.zig | 134 --- tools/main.zig | 12 +- 7 files changed, 136 insertions(+), 2495 deletions(-) delete mode 100644 tools/compose.zig diff --git a/build.zig b/build.zig index 4bc96a7..b10ab9b 100644 --- a/build.zig +++ b/build.zig @@ -306,10 +306,10 @@ pub fn build(b: *std.Build) void { catalog_embed.addImport("areafills_registry", embedDir(b, "areafills_registry", PORTRAYAL_CATALOG ++ "/AreaFills", ".xml")); catalog_embed.addImport("colorprofile_registry", colorprofile_registry); - // The chart-bundle pipeline as a library module (bundle.bakeBundle): tiles + - // assets + manifest. Target-agnostic + libc (the sprite atlas builder), so the - // CLI baker AND libtile57.a (the C ABI, tile57_bake_bundle) build the SAME bundle - // over the shared singleton packages. See src/bundle.zig. + // The chart-bundle module: S-101 portrayal asset emission + the per-cell composite + // (ownership partition + on-demand compositor). Target-agnostic + libc (the sprite + // atlas builder), so the CLI baker AND libtile57.a share the SAME emitters over the + // shared singleton packages. See src/bundle.zig. const bundle_mod = b.createModule(.{ .root_source_file = b.path("src/bundle.zig"), .link_libc = true, @@ -577,7 +577,7 @@ pub fn build(b: *std.Build) void { _ = addPkgTest(b, compose_step, "src/scene/compose.zig", target, optimize, &compose_deps); _ = addPkgTest(b, test_step, "src/scene/compose.zig", target, optimize, &compose_deps); - // The chart-bundle module hosts the per-cell composite driver (composeArchives). Its full + // The chart-bundle module hosts the per-cell composite (composeTile / ComposeSource). Its full // dep set (engine + assets/sprite/catalog) needs libc, so create the test module directly // rather than via addPkgTest (which omits link_libc). const bundle_test_mod = b.createModule(.{ diff --git a/src/bundle.zig b/src/bundle.zig index 2787d17..a794da4 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -2,9 +2,10 @@ //! emission (colortables / linestyles / sprite / patterns / sprite-mln) built from //! the embedded catalogue (or an on-disk PortrayalCatalog dir). Moved out of the //! `tile57` CLI so the CLI is a thin wrapper and any consumer (the C ABI, a Go/JS -//! binding, another UI) can build the same bundle. The tile-bake half (bakeRoot) + -//! the full `bakeBundle` orchestration land here next; this file currently holds the -//! asset emitters. Pure-Zig + libc (the sprite atlas builder needs nanosvg/stb). +//! binding, another UI) can build the same assets. Alongside the asset emitters it +//! holds the per-cell composite: the ownership partition + the on-demand tile +//! compositor (composeTile / ComposeSource) that serves a live map from per-cell +//! archives. Pure-Zig + libc (the sprite atlas builder needs nanosvg/stb). //! //! Each reader takes a `catalog_dir`: "" = the embedded catalogue (catalog module's //! @embedFile'd registries), else `/{Symbols,LineStyles,AreaFills, @@ -241,201 +242,6 @@ pub fn emitSpriteMln(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8, return atlas; } -// ======================================================================== -// bakeBundle — the full chart-bundle orchestration (tiles + assets + manifest). -// Moved out of the `tile57 bake` CLI so any consumer (the C ABI, a Go/JS binding) -// produces the SAME self-contained bundle the CLI emits. The CLI's `runBake` is now -// a thin wrapper: parse args -> bundle.bakeBundle(opts) -> print summary. -// ======================================================================== - -// Wall-clock seconds (libc time(); std.time is behind std.Io in 0.16). For bake -// progress timing only — coarse is fine. -extern fn time(t: ?*c_long) callconv(.c) c_long; -fn nowSec() i64 { - return @intCast(time(null)); -} - -// Total parse+portray calls across the bake (atomic; workers run in parallel). -// Compared against the cell count per band to surface excessive re-parsing. -var g_parses = std.atomic.Value(usize).init(0); - -/// Inputs for `bakeBundle`. Paths are resolved by the caller: an empty `rules_dir` -/// / `catalog_dir` selects the catalogue embedded in the binary (no on-disk copy). -pub const BakeOpts = struct { - input: []const u8, // a single cell.000 OR a whole ENC_ROOT directory - out_dir: []const u8, // bundle output directory (created if absent) - rules_dir: []const u8 = "", // "" = embedded S-101 portrayal rules - catalog_dir: []const u8 = "", // "" = embedded PortrayalCatalog assets - generator: []const u8 = "", // manifest generator string (e.g. "tile57 0.1.0") - created: []const u8 = "", // manifest created stamp (ISO8601; "" = unset) - minzoom: u8 = 8, - maxzoom: u8 = 16, - lru: usize = 256, // lazy-bake tuning: parsed-GEOMETRY cells held resident. Coarse - // riders span many super-tiles, so 64 thrashed re-parses on district bakes; - // 256 holds a row of super-tiles' working set. TILE57_LRU_BUDGET overrides. - super_dz: u8 = 3, // lazy-bake tuning: spatial super-tile depth - format: engine.scene.TileFormat = .mlt, - // Cross-pack best-available: a peer pack's ENC_ROOT (same provider) read for - // M_COVR coverage only, so THIS pack's coarser cells defer where the peer is - // finer — stops an overview pack painting over the peer's sectors. "" = none. - existing_input: []const u8 = "", - // Emit per-feature pick-report attrs (s57/cell) in the baked tiles. Defaults ON; - // a lean bake sets it false to drop the bulky s57 payload. - pick_attrs: bool = true, - // Progress callback (stage 0 = loading cells, 1 = baking tiles). null = the - // built-in console progress (stderr), matching the CLI; a host passes its own. - progress: engine.bake_enc.Progress = null, - progress_user: ?*anyopaque = null, -}; - -/// What `bakeBundle` produced: the baked cell count + the union bbox [w,s,e,n]. -pub const BakeResult = struct { cell_count: usize, bounds: [4]f64 }; - -/// Bake a single cell or a whole ENC_ROOT into a self-contained chart bundle under -/// `opts.out_dir`: tiles/chart.pmtiles + assets/{colortables,linestyles}.json + -/// sprite-mln + style-{day,dusk,night}.json + manifest.json (pins schema_version, -/// couples tiles to portrayal). Asset-emit failures degrade gracefully (a warning + -/// a thinner bundle); a structural failure (no geometry, write error) is returned. -pub fn bakeBundle(io: std.Io, a: std.mem.Allocator, opts: BakeOpts) !BakeResult { - // 1. tiles -> /tiles/chart.pmtiles. Input may be a single cell.000 or a - // whole ENC_ROOT directory (band-streamed multi-cell bake, streamed to disk). - const tiles_dir = try std.fs.path.join(a, &.{ opts.out_dir, "tiles" }); - try std.Io.Dir.cwd().createDirPath(io, tiles_dir); - const tiles_path = try std.fs.path.join(a, &.{ tiles_dir, "chart.pmtiles" }); - const rb = try bakeRoot(io, a, opts.input, tiles_path, opts.rules_dir, opts.minzoom, opts.maxzoom, opts.lru, opts.super_dz, true, opts.format, opts.pick_attrs, opts.existing_input, opts.progress, opts.progress_user); - const bounds = rb.bounds; - const cells = rb.cells; - const snd_stacks = rb.sounds; // sounding glyph stacks for sprite-mln - - // 2. assets -> /assets/colortables.json + style-{day,dusk,night}.json - const assets_dir = try std.fs.path.join(a, &.{ opts.out_dir, "assets" }); - try std.Io.Dir.cwd().createDirPath(io, assets_dir); - const ct_path = try std.fs.path.join(a, &.{ assets_dir, "colortables.json" }); - const ls_path = try std.fs.path.join(a, &.{ assets_dir, "linestyles.json" }); - _ = emitLinestyles(io, a, opts.catalog_dir, ls_path) catch |err| - std.debug.print("warning: linestyles emit failed ({s})\n", .{@errorName(err)}); - // The bundle ships ONLY the MapLibre-ready sprite (sprite-mln): it already - // contains the pivot-centred symbols + ctr:/pat: variants, so the raw - // sprite.json/patterns.json (the web/oracle format) would be redundant here — - // they stay available via the standalone `tile57 sprite`/`pattern` commands. - // If sprite-mln emits, the styles get a `sprite` URL so symbols + patterns - // render; snd_stacks (collected during the bake) gives it the sounding composites. - const sprite_ok = if (emitSpriteMln(io, a, opts.catalog_dir, DEFAULT_CSS, assets_dir, "sprite-mln", snd_stacks)) |_| true else |err| blk: { - std.debug.print("warning: sprite-mln emit failed ({s})\n", .{@errorName(err)}); - break :blk false; - }; - var styles: ?assets.Manifest.Styles = null; - if (emitColorTables(io, a, opts.catalog_dir, ct_path)) |ct| { - // One style.json per palette, resolving colour tokens from the colortables. - // sprite enables the symbol/pattern layers; glyphs (text) await SDF glyphs. - var ok = true; - // SCAMIN bucket minzooms are latitude-dependent; fix them at the archive center. - const scamin_lat = (bounds[1] + bounds[3]) / 2.0; - for ([_][]const u8{ "day", "dusk", "night" }) |sc| { - const sj = assets.styleJson(a, .{ - .scheme = sc, - .colortables_json = ct, - // The bundle style must hint the tiles' encoding so maplibre-gl - // (>=5.12) picks its MLT decoder for an MLT bake; MVT emits nothing. - .encoding = if (opts.format == .mlt) "mlt" else null, - .sprite = if (sprite_ok) "sprite-mln" else null, - .minzoom = opts.minzoom, - .scamin = rb.scamin, - .scamin_lat = scamin_lat, - }) catch { - ok = false; - break; - }; - const name = try std.fmt.allocPrint(a, "style-{s}.json", .{sc}); - const sp = try std.fs.path.join(a, &.{ assets_dir, name }); - try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = sp, .data = sj }); - } - if (ok) styles = .{ - .day = "assets/style-day.json", - .dusk = "assets/style-dusk.json", - .night = "assets/style-night.json", - }; - } else |err| { - std.debug.print("warning: assets emit failed ({s}); bundle has tiles + manifest only\n", .{@errorName(err)}); - } - - // 3. manifest.json — pins schema_version, couples tiles <-> portrayal. - const b = bounds; - const manifest = try assets.manifestJson(a, .{ - .generator = opts.generator, - .created = opts.created, - .tiles_rel = "tiles/chart.pmtiles", - .colortables_rel = "assets/colortables.json", - .minzoom = opts.minzoom, - .maxzoom = opts.maxzoom, - .bbox = b, - .anchor = .{ (b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0 }, - .cells = cells, - .styles = styles, - }); - const manifest_path = try std.fs.path.join(a, &.{ opts.out_dir, "manifest.json" }); - try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = manifest_path, .data = manifest }); - - return .{ .cell_count = cells.len, .bounds = bounds }; -} - -// ---- bake-root (whole ENC_ROOT / single cell -> one banded PMTiles) ----- - -// True if `path` is a directory (an ENC_ROOT) rather than a single cell.000 file. -fn isDir(io: std.Io, path: []const u8) bool { - var d = std.Io.Dir.cwd().openDir(io, path, .{}) catch return false; - d.close(io); - return true; -} - -// Live bake-root progress context (file-level so the C-ABI Progress callback can -// read the current band + super-tile position alongside the running tile count). -const ProgressCtx = struct { band: []const u8 = "?", st: usize = 0, st_total: usize = 0, band_start: i64 = 0 }; -var g_prog: ProgressCtx = .{}; -var g_bake_start: i64 = 0; - -/// Console progress for bake-root: stage 0 = loading cells, 1 = baking tiles. -/// Shows the band label (name + i/n), super-tile position, the per-band tile bar -/// (done/total %, host §3) and throughput. `done`/`total` for stage 1 are per-band -/// (total 0 = unknown). `band_index`/`band_count`/`band_name` are the host §3 band -/// label; band_name (when given) overrides g_prog.band so it's always consistent. -fn cliProgress(ctx: ?*anyopaque, stage: u8, done: usize, total: usize, band_index: u8, band_count: u8, band_name: ?[*:0]const u8) callconv(.c) void { - _ = ctx; - const name: []const u8 = if (band_name) |p| std.mem.span(p) else g_prog.band; - const elapsed = @as(f64, @floatFromInt(@max(nowSec() - g_bake_start, 1))); - if (stage == 0) { - std.debug.print("\r {s}: loading {d} cells ", .{ name, done }); - } else { - const band_secs = @as(f64, @floatFromInt(@max(nowSec() - g_prog.band_start, 1))); - const rate = @as(f64, @floatFromInt(done)) / band_secs; - if (total > 0) { - const pct = @as(f64, @floatFromInt(done)) * 100.0 / @as(f64, @floatFromInt(total)); - std.debug.print("\r {s} (band {d}/{d}): super-tile {d}/{d} {d}/{d} tiles ({d:.0}%) ({d:.0}/s, {d:.0}s) ", .{ name, band_index + 1, band_count, g_prog.st, g_prog.st_total, done, total, pct, rate, elapsed }); - } else { - std.debug.print("\r {s} (band {d}/{d}): super-tile {d}/{d} {d} tiles ({d:.0}/s, {d:.0}s) ", .{ name, band_index + 1, band_count, g_prog.st, g_prog.st_total, done, rate, elapsed }); - } - } -} - -// One ENC_ROOT base cell: its `.000` path (updates are .001…, found on load) -// and the cheap peek bbox [w,s,e,n], used to assign it to spatial super-tiles -// without a full parse. `light` (patched in from the scamin pre-pass, which -// parses every cell once) is the conservative attr-based sector-figure reach -// (scene.scanLightReachAttrs): the super-tile index + planned-tile estimate -// widen the cell's span by it so light legs/arcs crossing tile / super-tile -// boundaries stay addressed (and the cell is loaded for the neighbouring -// super-tiles its figures reach into). -const CellEntry = struct { path: []const u8, bbox: [4]f64, light: engine.scene.LightReach = .{} }; - -// ---- Light-reach pre-pass ----------------------------------------------- -// Before any band pass, every cell is read+parsed once (parallel, transient) -// and scanned for its attr-based sector-figure reach -// (scene.scanLightReachAttrs): the super-tile index + planned-tile spans widen -// each cell's span by it so light legs/arcs crossing tile / super-tile -// boundaries stay addressed. (The scamin_pts overlay dedup that used to ride -// this pass is gone — SCAMIN points go through the coverage-clipped composite -// like every other feature.) - // Read + parse one cell (base .000 + sequential updates) from `dir`; null on // any failure. The parsed cell allocates from smp_allocator (thread-safe). fn readParseCell(io: std.Io, dir: std.Io.Dir, bpath: []const u8) ?engine.s57.Cell { @@ -516,16 +322,6 @@ fn loadCells(io: std.Io, a: std.mem.Allocator, path: []const u8) []LoadedCov { return out.toOwnedSlice(a) catch &.{}; } -/// Cross-pack best-available context (BakeOpts.existing_input / bake --existing): -/// peer packs' M_COVR + cscl for suppression only (never emitted) — the ContextCell -/// projection of loadCells. -fn loadContextCoverage(io: std.Io, a: std.mem.Allocator, path: []const u8) []engine.bake_enc.ContextCell { - const cells = loadCells(io, a, path); - const out = a.alloc(engine.bake_enc.ContextCell, cells.len) catch return &.{}; - for (cells, 0..) |c, i| out[i] = .{ .coverage = c.coverage, .bounds = c.bounds, .cscl = c.cscl }; - return out; -} - // A cyclic palette so adjacent ownership faces get distinct colours in the debug // view; a face carries its colour as a `color` property for a trivial data-driven style. const DEBUG_PALETTE = [_][]const u8{ @@ -783,7 +579,7 @@ fn worldAxisToTile(w: f64, scale: f64) u32 { } // =========================================================================== -// Per-cell composite — Step 3: composeArchives +// Per-cell composite — the on-demand tile compositor // =========================================================================== // // Combine N per-cell PMTiles (each native-band-scale, its M_COVR coverage embedded in the @@ -797,348 +593,8 @@ fn worldAxisToTile(w: f64, scale: f64) u32 { const N_COMPOSE_LAYERS = engine.scene.VECTOR_LAYERS.len; -// The archive framing composeInto computes for the caller to seal the PMTiles: metadata JSON -// (gpa-owned; caller frees), union bbox (E7), a viewer center zoom, and the cell count. -const Framing = struct { - meta: []const u8, - ubox: [4]i32, - center_zoom: u8, - cells: usize, -}; - -// A compose progress sink. The streaming compose invokes `cb` as it walks the zoom ladder: -// `done`/`total` are union-bbox tile-count weights (each zoom weighted by the tiles it spans, so -// the deepest zoom — ~3/4 of the work — dominates the bar), interpolated within a zoom by the -// owner-face fraction, so a host renders a smooth bar + ETA and a "zoom N of M" line. Reporting is -// pure observation and cannot affect the composed bytes. `ctx` is opaque host state. -pub const ComposeProgress = struct { - cb: *const fn (ctx: ?*anyopaque, zoom: u32, min_zoom: u32, max_zoom: u32, done: u64, total: u64) callconv(.c) void, - ctx: ?*anyopaque = null, - - // Set by composeInto as it walks the zoom loop; read by composeZoom to interpolate within one. - min_zoom: u32 = 0, - max_zoom: u32 = 0, - total: u64 = 1, // guarded ≥1 so a host's done/total never divides by zero - base: u64 = 0, // weight of the zooms already fully composed - zoom: u32 = 0, - zoom_weight: u64 = 0, // this zoom's tile-span weight - - fn emit(self: *const ComposeProgress, done_in_zoom: u64) void { - self.cb(self.ctx, self.zoom, self.min_zoom, self.max_zoom, self.base + done_in_zoom, self.total); - } -}; - -// Tiles the union bbox `ubox` (E7 lon/lat) spans at zoom `z` — the per-zoom compose-work weight, -// projected exactly as composeZoom projects a face's tile cover so the weights track real work. -fn uboxTileCount(ubox: [4]i32, z: u8) u64 { - const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); - const w_tl = engine.tile.lonLatToWorld(@as(f64, @floatFromInt(ubox[0])) / 1e7, @as(f64, @floatFromInt(ubox[3])) / 1e7); - const w_br = engine.tile.lonLatToWorld(@as(f64, @floatFromInt(ubox[2])) / 1e7, @as(f64, @floatFromInt(ubox[1])) / 1e7); - const tx0 = worldAxisToTile(w_tl[0], scale); - const tx1 = worldAxisToTile(w_br[0], scale); - const ty0 = worldAxisToTile(w_tl[1], scale); - const ty1 = worldAxisToTile(w_br[1], scale); - return (@as(u64, tx1 - tx0) + 1) * (@as(u64, ty1 - ty0) + 1); -} - -// The shared compose core: recover each reader's embedded coverage + SCAMIN ladder, rebuild the -// ownership partition (via the canonical toPlaneCells adapter), and stream every composed tile -// into `sw` — one governing band per zoom (partition.mapForZoom), each owner's tiles clipped to -// its owned face and merged per layer, with cross-band overscale one zoom past each band. `sw` -// may be in-memory (composeArchives) or file-backed (composeArchivesToFile); this is agnostic. -// Returns the framing to seal the archive, or null if no cell carried coverage or no tile -// composed. Readers are BORROWED — kept alive by the caller; `readers` indexing aligns with the -// partition (readers without coverage are filtered out here). -/// How `composeInto` sources the ownership partition. Default = build it from the cells. -pub const PartOpt = struct { - /// Borrowed sidecar bytes to try; used iff valid for these cells, else the partition is built. - load: ?[]const u8 = null, - /// When composeInto BUILDS (does not load) the partition, it sets `*save_out` to a fresh - /// gpa-owned serialization for the caller to persist (free with gpa.free). Left untouched when - /// the partition is loaded from `load`. - save_out: ?*?[]u8 = null, -}; - -fn composeInto(gpa: std.mem.Allocator, sw: *engine.pmtiles.StreamWriter, readers: []const *engine.pmtiles.Reader, progress: ?*ComposeProgress, popt: PartOpt) !?Framing { - const geo = engine.geo; - const scene = engine.scene; - - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - - // 1. Recover embedded coverage + SCAMIN from each reader; keep only those carrying coverage, - // aligned so cell index == kept-reader index for composeZoom. - var kept = std.ArrayList(*engine.pmtiles.Reader).empty; - var shims = std.ArrayList(LoadedCov).empty; - var scamins = std.AutoHashMap(u32, void).init(a); - var ubox = [4]i32{ std.math.maxInt(i32), std.math.maxInt(i32), std.math.minInt(i32), std.math.minInt(i32) }; - for (readers) |rp| { - const meta = readMetaJson(a, rp) orelse continue; - const cc = (scene.coverage.decodeFromMetadata(a, meta) catch null) orelse continue; - for (parseScamin(a, meta)) |s| scamins.put(s, {}) catch {}; - ubox[0] = @min(ubox[0], cc.bbox[0]); - ubox[1] = @min(ubox[1], cc.bbox[1]); - ubox[2] = @max(ubox[2], cc.bbox[2]); - ubox[3] = @max(ubox[3], cc.bbox[3]); - try kept.append(a, rp); - try shims.append(a, .{ - .name = cc.name, - .date = cc.date, - .cscl = cc.cscl, - .coverage = cc.cov1, - .bounds = .{ - @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, - @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, - @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, - @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, - }, - }); - } - if (shims.items.len == 0) return null; - - // 2. Adapt to plane.Cell (band floor via bandOf + the DSID ordersBeforeKeys tie-break) and - // materialise the per-band ownership partition. `part` borrows `cells` (arena-backed). - // Each cell's partition participation is capped at its tile REACH — the deepest zoom - // ownerTile can serve for it: the band ladder (native window + fill-up overscale), with - // the archive header's max_zoom folded in so a foreign archive carrying tiles past its - // band window is never cut while it can still emit. Beyond reach the cell owns ground it - // can never draw, so finer tiers skip it instead of paying for its owned-face boolean — - // the output is identical (the builders cut only reach-suffixes of the ownership walk, - // see plane.Cell.reach), and the whole-district partition build drops from GBs of - // transient scratch to MBs. - const cells = try toPlaneCells(a, shims.items); - for (cells, kept.items) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); - - // The partition is a pure function of these cells. If the caller handed us a sidecar that - // still matches (its input_key binds it to this exact cell set), load it and skip the - // expensive owned-face build; otherwise build, and hand the caller the fresh bytes to persist. - var part: geo.partition.Partition = undefined; - var loaded = false; - if (popt.load) |bytes| { - if (geo.partition.deserialize(gpa, bytes, cells)) |p| { - part = p; - loaded = true; - } else |err| { - std.debug.print(" partition sidecar unusable ({s}); rebuilding\n", .{@errorName(err)}); - } - } - if (!loaded) part = try geo.partition.build(gpa, cells); - defer part.deinit(); - if (!loaded) if (popt.save_out) |so| { - so.* = try geo.partition.serialize(gpa, &part); - }; - - // 3. Output zoom span = union of the cells' native band windows, plus one fill-up zoom past - // the deepest band (overscale; deeper coarse-only zooms → client camera + overzoom). - var minz: u8 = 255; - var maxz: u8 = 0; - for (shims.items) |lc| { - const zr = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(lc.cscl)); - minz = @min(minz, zr.min); - maxz = @max(maxz, zr.max); - } - const fill_max = @min(maxz + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); - const loop_max = @max(maxz, fill_max); - - // 4. Stream one zoom at a time. The zoom arena holds only the discovery state (classifier - // grids + per-tile owner lists); the composed tiles themselves live one at a time in - // composeZoom's per-tile scratch. - var zoom_arena = std.heap.ArenaAllocator.init(gpa); - defer zoom_arena.deinit(); - // Weight each zoom by the tiles the union bbox spans there, so progress tracks where the time - // actually goes (the deepest zoom is ~3/4 of the work); composeZoom interpolates within a zoom - // by owner-face fraction. Weighting is derived only from ubox/zoom — it can't perturb output. - if (progress) |pg| { - var tw: u64 = 0; - var zz: u8 = minz; - while (zz <= loop_max) : (zz += 1) tw += uboxTileCount(ubox, zz); - pg.min_zoom = minz; - pg.max_zoom = loop_max; - pg.total = @max(1, tw); - pg.base = 0; - } - var z: u8 = minz; - while (z <= loop_max) : (z += 1) { - _ = zoom_arena.reset(.retain_capacity); - if (progress) |pg| { - pg.zoom = z; - pg.zoom_weight = uboxTileCount(ubox, z); - pg.emit(0); - } - try composeZoom(sw, gpa, zoom_arena.allocator(), &part, kept.items, z, progress); - if (progress) |pg| pg.base += pg.zoom_weight; - } - if (progress) |pg| pg.cb(pg.ctx, loop_max, minz, loop_max, pg.total, pg.total); - if (sw.num_addressed == 0) return null; - - // 5. Framing: VECTOR_LAYERS + union SCAMIN metadata (no single-cell coverage), bbox, center. - const scamin_list = try scaminSorted(a, &scamins); - const meta = try scene.metadataJson(gpa, scamin_list, null); // gpa-owned: survives `arena` - const span_deg = @max(0.01, @as(f64, @floatFromInt(ubox[2] - ubox[0])) / 1e7); - const cz: u8 = @intFromFloat(std.math.clamp(std.math.log2(720.0 / span_deg), @as(f64, @floatFromInt(minz)), @as(f64, @floatFromInt(maxz)))); - return .{ .meta = meta, .ubox = ubox, .center_zoom = cz, .cells = shims.items.len }; -} - -fn writeOpts(fr: Framing) engine.pmtiles.WriteOptions { - return .{ - .metadata_json = fr.meta, - .tile_type = .mlt, - .min_lon_e7 = fr.ubox[0], - .min_lat_e7 = fr.ubox[1], - .max_lon_e7 = fr.ubox[2], - .max_lat_e7 = fr.ubox[3], - .center_zoom = fr.center_zoom, - }; -} - -/// Compose per-cell PMTiles archives (BORROWED — kept alive by the caller) into one merged -/// PMTiles, returned as bytes (`gpa`-owned; free with `gpa.free` / `tile57_free`), or null if -/// nothing composed. The partition is rebuilt from each archive's embedded coverage (no `.000` -/// re-parse). IN-MEMORY: holds all archives + the composed output resident — for whole districts -/// use `composeArchivesToFile` (mmap + streamed output). -pub fn composeArchives(gpa: std.mem.Allocator, archives: []const []const u8) !?[]u8 { - const pmtiles = engine.pmtiles; - var ra = std.heap.ArenaAllocator.init(gpa); - defer ra.deinit(); - const raa = ra.allocator(); - - var readers = std.ArrayList(*pmtiles.Reader).empty; - defer for (readers.items) |rp| rp.deinit(); - for (archives) |arc| { - const rp = raa.create(pmtiles.Reader) catch continue; - rp.* = pmtiles.Reader.init(gpa, arc) catch continue; - readers.append(raa, rp) catch rp.deinit(); - } - if (readers.items.len == 0) return null; - - var sw = pmtiles.StreamWriter.init(gpa); - defer sw.deinit(); - const fr = (try composeInto(gpa, &sw, readers.items, null, .{})) orelse return null; - defer gpa.free(fr.meta); - return try sw.finishBytes(writeOpts(fr)); -} - -/// Streaming disk-to-disk compose: mmap each per-cell PMTiles at `paths` (so the OS pages tiles -/// on demand — the whole cell set is never resident) and stream the merged archive to `out_path` -/// (tile data to a temp file, then header/directories/metadata prepended). Peak memory is the -/// partition + one zoom's composed tiles + the touched-page working set — independent of the -/// number of cells. Returns the count of cells that contributed, or 0 if none. This is the path -/// a whole-district composite takes. -pub fn composeArchivesToFile(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, out_path: []const u8, progress: ?*ComposeProgress) !usize { - return composeArchivesToFileOpt(io, gpa, paths, out_path, progress, null, null); -} - -/// Like `composeArchivesToFile`, but precomputes the ownership partition to/from a sidecar: -/// * `load_path` (if non-null and present + valid for this cell set) is loaded, skipping the -/// partition build; a missing/stale/corrupt sidecar falls back to a build (with a warning). -/// * `save_path` (if non-null) is written with the built partition, so a later run can `load` it. -/// Point both at the same file for a self-refreshing cache: build+save on first run (or when the -/// coverage changed), load on every run where it still matches. -pub fn composeArchivesToFileOpt(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, out_path: []const u8, progress: ?*ComposeProgress, load_path: ?[]const u8, save_path: ?[]const u8) !usize { - const pmtiles = engine.pmtiles; - var ra = std.heap.ArenaAllocator.init(gpa); - defer ra.deinit(); - const raa = ra.allocator(); - - // mmap each cell (read-only, private). Close the fd right after mapping — the mapping - // survives it, and keeping hundreds of fds open would hit the process limit. - var readers = std.ArrayList(*pmtiles.Reader).empty; - var maps = std.ArrayList([]align(std.heap.page_size_min) const u8).empty; - defer { - for (readers.items) |rp| rp.deinit(); - for (maps.items) |m| std.posix.munmap(m); - } - for (paths) |path| { - var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch continue; - const st = f.stat(io) catch { - f.close(io); - continue; - }; - const len: usize = @intCast(st.size); - if (len == 0) { - f.close(io); - continue; - } - const map = std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, f.handle, 0) catch { - f.close(io); - continue; - }; - f.close(io); - maps.append(raa, map) catch { - std.posix.munmap(map); - continue; - }; - const rp = raa.create(pmtiles.Reader) catch continue; - rp.* = pmtiles.Reader.init(gpa, map) catch continue; - readers.append(raa, rp) catch rp.deinit(); - } - if (readers.items.len == 0) return 0; - - // Stream the composed tile data to a temp file; the header/dirs/metadata prefix is assembled - // last and written ahead of it (mirrors the streaming root bake). - const data_tmp = try std.fmt.allocPrint(raa, "{s}.data.tmp", .{out_path}); - var data_file = try std.Io.Dir.cwd().createFile(io, data_tmp, .{ .read = true }); - errdefer { - data_file.close(io); - std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; - } - var sw = pmtiles.StreamWriter.initFile(gpa, io, data_file); - defer sw.deinit(); - - // Read a partition sidecar if one was provided and is present (validity is verified inside - // composeInto against this exact cell set; a stale/corrupt one falls back to a build). - var load_bytes: ?[]const u8 = null; - if (load_path) |lp| load_from: { - var lf = std.Io.Dir.cwd().openFile(io, lp, .{}) catch break :load_from; - defer lf.close(io); - const st = lf.stat(io) catch break :load_from; - const n: usize = @intCast(st.size); - if (n == 0) break :load_from; - const buf = try raa.alloc(u8, n); - _ = lf.readPositionalAll(io, buf, 0) catch break :load_from; - load_bytes = buf; - } - var save_bytes: ?[]u8 = null; - defer if (save_bytes) |sb| gpa.free(sb); - - const fr = (try composeInto(gpa, &sw, readers.items, progress, .{ - .load = load_bytes, - .save_out = if (save_path != null) &save_bytes else null, - })) orelse { - data_file.close(io); - std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; - return 0; - }; - defer gpa.free(fr.meta); - - // Persist a freshly-built partition so a later run can load it (skipped when it was loaded). - if (save_path) |sp| if (save_bytes) |sb| { - std.Io.Dir.cwd().writeFile(io, .{ .sub_path = sp, .data = sb }) catch |err| - std.debug.print(" warn: could not write partition sidecar {s} ({s})\n", .{ sp, @errorName(err) }); - }; - - const pre = try sw.prefix(raa, writeOpts(fr)); - var out_file = try std.Io.Dir.cwd().createFile(io, out_path, .{}); - try out_file.writeStreamingAll(io, pre); - { - const chunk = try raa.alloc(u8, 1 << 20); // 1 MiB - var pos: u64 = 0; - while (pos < sw.data_len) { - const want: usize = @intCast(@min(@as(u64, chunk.len), sw.data_len - pos)); - _ = try data_file.readPositionalAll(io, chunk[0..want], pos); - try out_file.writeStreamingAll(io, chunk[0..want]); - pos += want; - } - } - out_file.close(io); - data_file.close(io); - std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; - return fr.cells; -} - -// The tile-index cover (nw..se) of an owner face's lon/lat bbox at zoom `scale = 1<> 7); - for (map.faces, 0..) |face, slot| { - if (progress) |pg| { - if (map.faces.len > 0 and slot % prog_stride == 0) - pg.emit(pg.zoom_weight * slot / map.faces.len); - } - if (face.owned.len == 0) continue; - const ci = face.index; - const cscl = part.cells[ci].cscl; - - // Tile cover of this owner's face bbox (lon/lat → world → tile index), nw..se. - const bb = faceTileBBox(face, scale); - - // FULL/EMPTY/SEAM classifier over this owner's owned face (integer lon/lat). A tile the - // owner covers ENTIRELY (its buffer too, no seam crossing) is copied VERBATIM from the - // per-cell bake — byte-identical, no decode/clip/re-encode; only seam tiles run the - // geometry path. Bucket ≈ one tile wide. (classify's `covered` is the owned face, so its - // labels are inverted from their names: center-inside → `.empty` = fully owned.) - var grid = try engine.geo.plane.EdgeGrid.init(sa, face.owned, tileWidthE7(z)); - defer grid.deinit(); - - var tx = bb.tx0; - while (tx <= bb.tx1) : (tx += 1) { - var ty = bb.ty0; - while (ty <= bb.ty1) : (ty += 1) { - // Classify (z,tx,ty) against the owned face, the box expanded by the render - // BUFFER so a passthrough only fires when the owner owns the tile AND its buffer. - switch (grid.classify(tileClassifyBox(z, tx, ty))) { - .full => continue, // owner owns none of this tile (buffer included): skip - .empty => { - // Owner owns the whole tile: copy its native blob verbatim if present - // (byte-identical), else fall through to the overscale/clip path. - if (try readers[ci].getCompressed(z, tx, ty)) |blob| { - try sw.addCompressed(z, tx, ty, blob); - continue; - } - }, - .seam => {}, // real geometry: decode + clip below - } - - // Record this owner iff the compose pass would keep it: reachable content - // (a native blob, or — in the fill-up window — an overscale ancestor) AND a - // non-empty owned face in this tile's pixel space. No decode yet; pass 2 - // pays that one tile at a time. - if (!(try ownerHasTile(readers[ci], cscl, z, tx, ty))) continue; - _ = tile_scratch.reset(.retain_capacity); - const face_px = try compose.projectFace(tile_scratch.allocator(), face.owned, z, tx, ty); - if (face_px.len == 0) continue; - - const gop = try pending.getOrPut(.{ .x = tx, .y = ty }); - if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(u32).empty; - try gop.value_ptr.append(sa, @intCast(slot)); - } - } - } - - // Pass 2 — compose each pending tile: decode every contributing owner's tile (native or - // overscaled ancestor), clip its features to the owner's projected face, then per-layer - // concat (VECTOR_LAYERS order), polygons re-oriented (boolean intersect output has - // unspecified winding), MLT-encoded — matching the per-cell input — and streamed out. - // Everything lives in the per-tile scratch, freed before the next tile. - var it = pending.iterator(); - while (it.next()) |kv| { - const tx = kv.key_ptr.x; - const ty = kv.key_ptr.y; - _ = tile_scratch.reset(.retain_capacity); - // Decode + clip + orient + encode one tile in the per-tile scratch (shared with the - // on-demand composeTile — see composeSeamTile). Recomputing each owner's projected face - // here rather than caching pass 1's is the zoom-sized retention this two-pass split removes. - const enc = (try composeSeamTile(tile_scratch.allocator(), part, map, readers, kv.value_ptr.items, z, tx, ty)) orelse continue; - try sw.add(z, tx, ty, enc); - } -} - /// Compose ONE tile on demand from a resident partition + mmap'd per-cell `readers` (cell index == -/// reader index, exactly as composeInto aligns them). `gzip` = true returns the gzipped MLT the +/// reader index, exactly as openComposeSourceFiles aligns them). `gzip` = true returns the gzipped MLT the /// batch archive stores (byte-identical to it — a verbatim owner blob copied verbatim, or a freshly /// composed seam tile re-gzipped); `gzip` = false returns the raw decompressed MLT (what a live tile /// server wants — the HTTP layer gzips on the wire). gpa-owned; null if no cell owns this tile. This @@ -1440,7 +782,7 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const const a = src.arena.allocator(); // mmap + open each archive; keep only those carrying coverage, so readers/maps/shims stay - // aligned (cell index == reader index) exactly as composeInto arranges them. + // aligned (cell index == reader index) for composeTile. var readers = std.ArrayList(*pmtiles.Reader).empty; var maps = std.ArrayList([]align(std.heap.page_size_min) const u8).empty; var shims = std.ArrayList(LoadedCov).empty; @@ -1635,1516 +977,3 @@ fn parseScamin(a: std.mem.Allocator, meta: []const u8) []const u32 { const v = std.json.parseFromSliceLeaky(Dto, a, meta, .{ .ignore_unknown_fields = true }) catch return &.{}; return v.scamin; } - -// The distinct SCAMIN denominators, ascending — the composed metadata's ladder. -fn scaminSorted(a: std.mem.Allocator, set: *std.AutoHashMap(u32, void)) ![]const u32 { - const out = try a.alloc(u32, set.count()); - var it = set.keyIterator(); - var i: usize = 0; - while (it.next()) |k| : (i += 1) out[i] = k.*; - std.mem.sort(u32, out, {}, comptime std.sort.asc(u32)); - return out; -} - -const LightScanWork = struct { - entries: []const CellEntry, - dir: std.Io.Dir, - io: std.Io, - lights: []engine.scene.LightReach, - - fn run(uptr: *anyopaque, i: usize, scratch: std.mem.Allocator) void { - _ = scratch; - const c: *LightScanWork = @ptrCast(@alignCast(uptr)); - var cell = readParseCell(c.io, c.dir, c.entries[i].path) orelse return; - defer cell.deinit(); - c.lights[i] = engine.scene.scanLightReachAttrs(&cell); - } -}; - -// Scan every root cell's attr-based sector-figure reach in parallel (one -// transient parse per cell). `light_out` is `entries`-aligned; best-effort. -fn scanEntryLights(io: std.Io, dir: std.Io.Dir, entries: []const CellEntry, light_out: []engine.scene.LightReach) void { - if (entries.len == 0 or light_out.len < entries.len) return; - var lw = LightScanWork{ .entries = entries, .dir = dir, .io = io, .lights = light_out }; - engine.bake_enc.parallelFor(std.heap.smp_allocator, entries.len, &lw, LightScanWork.run); -} - -// A cell's portrayal: the per-feature S-101 instruction streams (+ display -// variants), computed ONCE by the Lua engine and kept resident for the whole band -// in `arena`. Compact relative to geometry, and the expensive thing to produce — -// so it's never recomputed, even when the cell's geometry is evicted + re-parsed. -const CellPortray = struct { - arena: *std.heap.ArenaAllocator, // sticky: owns the streams below - base: ?[]const ?[]const u8, - plain: ?[]const ?[]const u8, - simplified: ?[]const ?[]const u8, - bounds: [4]f64, - // EXACT sector-figure reach from the portrayal streams - // (scene.collectLightReach) — drives the Backend's buildTileMap reach ring - // and the LIGHTS feature-cull margin. Resident with the streams so a - // geometry re-parse doesn't recompute it. - light: engine.scene.LightReach = .{}, -}; - -// A cell's parsed geometry (the heavy part): the S-57 model + assembled geo cache. -// EVICTABLE under LRU pressure and re-parsed on demand (cheap — no re-portrayal). -const CellGeom = struct { - cell: engine.s57.Cell, // cell.arena owns the geometry - geo: ?engine.scene.GeoParts, - geo_world: ?engine.scene.GeoWorld = null, // precomputed world coords (in geo_arena) - geo_arena: ?*std.heap.ArenaAllocator, - feat_bbox: []const ?[4]f64 = &.{}, // per-feature bbox for the per-tile cull (in geo_arena) - coverage: []const []const []const engine.s57.LonLat = &.{}, // M_COVR (cell.arena) for quilting - scamins: []const u32 = &.{}, // distinct SCAMIN denoms (cell.arena) — the handoff ladder -}; - -// A deferred band's cells riding into the next-coarser pass (the band handoff): -// entries + resident portrayal + surviving geometry, index-aligned. The consuming -// pass appends them behind its own cells (bakeBand own_len) so the deferred floor -// tiles bake with BOTH bands' cells — WITHOUT re-portraying (the expensive step) — -// then frees them at its end. Peak memory: two adjacent bands, as specced. -const CarryState = struct { - entries: []const CellEntry, - portray: []?CellPortray, - geom: []?CellGeom, - gave_up: []bool, - - // Fresh all-null state for entries whose own pass never loaded them (a fully - // deferred zoom clamp): the consuming pass loads + portrays them itself. - fn initEmpty(gpa: std.mem.Allocator, entries: []const CellEntry) !CarryState { - const po = try gpa.alloc(?CellPortray, entries.len); - @memset(po, null); - const ge = try gpa.alloc(?CellGeom, entries.len); - @memset(ge, null); - const gu = try gpa.alloc(bool, entries.len); - @memset(gu, false); - return .{ .entries = entries, .portray = po, .geom = ge, .gave_up = gu }; - } - - // Free the surviving cells + portrayal arenas + the slices themselves. - fn deinit(self: *const CarryState, gpa: std.mem.Allocator) void { - for (self.geom) |*g| if (g.*) |*gg| { - gg.cell.deinit(); - if (gg.geo_arena) |ga| { - ga.deinit(); - gpa.destroy(ga); - } - }; - // Portrayal arenas are owned by the bake-lifetime pcache; free only the slice. - gpa.free(self.portray); - gpa.free(self.geom); - gpa.free(self.gave_up); - } -}; - -// Copy per-feature instruction streams into `a` (the sticky portrayal arena) so -// they outlive the transient portrayal arena. nulls (features with no stream) stay. -fn copyStreams(a: std.mem.Allocator, src: []const ?[]const u8) ![]const ?[]const u8 { - const out = try a.alloc(?[]const u8, src.len); - for (src, 0..) |s, i| out[i] = if (s) |bytes| try a.dupe(u8, bytes) else null; - return out; -} - -// Parallel cell loader. Each index READS its cell's .000 (+ sequential updates) -// from disk, parses it (a fresh per-thread Lua state; page allocator is -// thread-safe), builds its geo cache (evictable), and — only the first time the -// cell is loaded (portray[ci] == null) — runs the S-101 portrayal and copies the -// streams into a sticky arena. Reading in the worker (std.Io.Dir.readFileAlloc is -// thread-safe: each call opens its own handle, no shared mutable state) makes a -// super-tile's cell reads parallel, so a cold first-bake isn't disk-read-serial. -// Re-loads after a geometry eviction re-parse only, reusing the resident -// portrayal. Distinct ci per j ⇒ race-free. -const LoadWork = struct { - cells: []const u32, // cell indices into `entries` - entries: []const CellEntry, // cell file path; bytes read in-worker (parallel I/O) - dir: std.Io.Dir, - io: std.Io, - portray: []?CellPortray, // resident; produced only when null - geom: []?CellGeom, // (re)built every load - rules_dir: []const u8, - gpa: std.mem.Allocator, - - fn run(uptr: *anyopaque, j: usize, scratch: std.mem.Allocator) void { - _ = scratch; // parsed cell + portrayal are persistent; kept in their own arenas - const c: *LoadWork = @ptrCast(@alignCast(uptr)); - const ci = c.cells[j]; - const bpath = c.entries[ci].path; - const base = c.dir.readFileAlloc(c.io, bpath, c.gpa, .unlimited) catch return; - defer c.gpa.free(base); - if (base.len == 0) return; - // Read sequential updates (.001…) until the first missing one. - var ups = std.ArrayList([]const u8).empty; - defer { - for (ups.items) |ub| c.gpa.free(ub); - ups.deinit(c.gpa); - } - const stem = bpath[0 .. bpath.len - 4]; - var u: u32 = 1; - while (u <= 999) : (u += 1) { - const upn = std.fmt.allocPrint(c.gpa, "{s}.{d:0>3}", .{ stem, u }) catch break; - defer c.gpa.free(upn); - const ub = c.dir.readFileAlloc(c.io, upn, c.gpa, .unlimited) catch break; - ups.append(c.gpa, ub) catch { - c.gpa.free(ub); - break; - }; - } - _ = g_parses.fetchAdd(1, .monotonic); // count parses (re-parse diagnostics) - var cell = engine.s57.parseCellWithUpdates(c.gpa, base, ups.items) catch return; - cell.name = std.fs.path.stem(std.fs.path.basename(bpath)); // pick-report source-cell badge (slice into the bake-lived entry path) - const b = cell.bounds() orelse { - cell.deinit(); - return; - }; - var geo: ?engine.scene.GeoParts = null; - var geo_world: ?engine.scene.GeoWorld = null; - var geo_arena: ?*std.heap.ArenaAllocator = null; - var feat_bbox: []const ?[4]f64 = &.{}; - if (c.gpa.create(std.heap.ArenaAllocator)) |ga| { - ga.* = std.heap.ArenaAllocator.init(c.gpa); - // Assemble line/area geometry once + its world coords, so every tile - // reprojects cheaply (no per-point tan/log) — the bake's biggest cost. - if (engine.scene.buildGeoCache(ga.allocator(), &cell)) |g| { - geo = g; - geo_world = engine.scene.buildGeoWorld(ga.allocator(), g) catch null; - } else |_| {} - feat_bbox = engine.scene.buildFeatBBox(ga.allocator(), &cell, geo) catch &.{}; - geo_arena = ga; - } else |_| {} - const coverage = cell.mcovrCoverage(cell.arena.allocator()); // before the move (cell.arena-owned) - // Distinct SCAMIN denominators (the band-handoff quantization ladder) — - // cheap feature scan, rebuilt with the cell on every re-parse. - const scamins = engine.bake_enc.collectScamins(cell.arena.allocator(), &cell) catch &.{}; - c.geom[ci] = .{ .cell = cell, .geo = geo, .geo_world = geo_world, .geo_arena = geo_arena, .feat_bbox = feat_bbox, .coverage = coverage, .scamins = scamins }; - - // Label-point cache: built from the (reused or fresh) portrayal — only features - // whose base stream draws a Text/centred-symbol consult labelPoint, so cache just - // those (see scene.buildLabelCache). Set on the STORED cell so the per-tile emit - // reuses it instead of re-running the dominant polylabel search every tile. - if (c.portray[ci]) |pc| { // already portrayed — reuse it (the speed win) - if (c.geom[ci]) |*g| if (g.geo_arena) |ga| { - g.cell.label_cache = engine.scene.buildLabelCache(ga.allocator(), &g.cell, g.geo, pc.base) catch null; - }; - return; - } - const sticky = c.gpa.create(std.heap.ArenaAllocator) catch return; - sticky.* = std.heap.ArenaAllocator.init(c.gpa); - const sa = sticky.allocator(); - var tmp = std.heap.ArenaAllocator.init(c.gpa); - defer tmp.deinit(); - if (engine.portray.portrayCellVariants(tmp.allocator(), &cell, c.rules_dir)) |cp| { - c.portray[ci] = .{ - .arena = sticky, - .base = copyStreams(sa, cp.base) catch null, - .plain = if (cp.plain) |p| (copyStreams(sa, p) catch null) else null, - .simplified = if (cp.simplified) |p| (copyStreams(sa, p) catch null) else null, - .bounds = b, - .light = engine.scene.collectLightReach(&cell, cp.base), - }; - } else |_| { - // Portrayal failed: a resident entry with no streams (classify() fallback); - // marks the cell portrayed so it isn't retried. - c.portray[ci] = .{ .arena = sticky, .base = null, .plain = null, .simplified = null, .bounds = b }; - } - if (c.geom[ci]) |*g| if (g.geo_arena) |ga| { - g.cell.label_cache = engine.scene.buildLabelCache(ga.allocator(), &g.cell, g.geo, c.portray[ci].?.base) catch null; - }; - } -}; - -// A whole-ENC_ROOT bake result: the union bbox of the cells, their stems (for the -// bundle manifest), the distinct sounding glyph stacks collected while baking (for -// sprite-mln), and the distinct SCAMIN denominators (for the client bucket manifest). -// The archive itself is streamed straight to `out_path`. -const RootBake = struct { bounds: [4]f64, cells: []const []const u8, sounds: []const []const u8, scamin: []const u32 = &.{} }; - -// Per-tile sink for the streaming bake: feed each tile into the PMTiles -// StreamWriter (gzip+dedup, no raw-tile retention) and, in the same pass, collect -// its soundings-layer glyph stacks (so sprite-mln gets composites without a -// re-read). A per-tile scratch arena keeps the MVT decode from accumulating. -const BakeSink = struct { - sw: *engine.pmtiles.StreamWriter, - sounds: *std.StringHashMap(void), - scamin: *std.AutoHashMap(u32, void), // distinct SCAMIN denominators -> archive manifest - a: std.mem.Allocator, // persistent (sound-stack strings; returned to caller) - gpa: std.mem.Allocator, // scratch backing - collect_sounds: bool, // bundle wants sprite-mln sounding composites; plain bake doesn't - format: engine.scene.TileFormat, // baked tile encoding — selects the decode codec below - - // `comp` is the already-gzipped tile (compressed in the gen worker), so the - // serial path here is just dedup + write — fast. Sounding-stack + SCAMIN-manifest - // collection (only for the bundle) gunzips + decodes with the codec matching the - // bake format (mlt.decode returns the same DecodedLayer shape as mvt.decode); - // plain bake-root skips it. - fn run(ctx: ?*anyopaque, z: u8, x: u32, y: u32, comp: []const u8) anyerror!void { - const self: *BakeSink = @ptrCast(@alignCast(ctx.?)); - if (self.collect_sounds) collect: { - var scratch = std.heap.ArenaAllocator.init(self.gpa); - defer scratch.deinit(); - const raw = engine.gzip.decompress(scratch.allocator(), comp) catch break :collect; - const layers = switch (self.format) { - .mvt => engine.mvt.decode(scratch.allocator(), raw) catch break :collect, - .mlt => engine.mlt.decode(scratch.allocator(), raw) catch break :collect, - }; - for (layers) |L| { - const is_snd = std.mem.eql(u8, L.name, "soundings"); - for (L.features) |feat| for (feat.properties) |p| { - // Distinct SCAMIN denominators across EVERY layer -> the client's - // scamin ladder (filter-gate crossings). oscl (overscale) tags - // join it identically: the AP(OVERSC01) gate must flip at a - // client crossing (specs/overscale.md). - if (std.mem.eql(u8, p.key, "scamin") or std.mem.eql(u8, p.key, "oscl")) { - switch (p.value) { - .int => |iv| if (iv > 0) self.scamin.put(@intCast(iv), {}) catch {}, - else => {}, - } - continue; - } - // Soundings glyph stacks for the sprite-mln composite atlas. Both - // unit variants must be collected: the SAME atlas serves the metres - // (sym_s/sym_g) and the feet (sym_s_ft/sym_g_ft) styles — the client - // swaps the icon-image field at runtime, so a feet composite the - // metres pass never referenced (e.g. a deep wreck/obstruction whose - // feet digits compose differently) would otherwise be missing and the - // sounding render blank in feet mode. - if (!is_snd) continue; - const k = p.key; - const is_sound_glyph = std.mem.eql(u8, k, "sym_s") or std.mem.eql(u8, k, "sym_g") or - std.mem.eql(u8, k, "sym_s_ft") or std.mem.eql(u8, k, "sym_g_ft") or - std.mem.eql(u8, k, "symbol_names"); - if (!is_sound_glyph) continue; - switch (p.value) { - .string => |sv| if (std.mem.indexOfScalar(u8, sv, ',') != null and !self.sounds.contains(sv)) { - self.sounds.put(self.a.dupe(u8, sv) catch return, {}) catch {}; - }, - else => {}, - } - }; - } - } - try self.sw.addCompressed(z, x, y, comp); - } -}; - -// Parse the named environment variable as a usize, returning `dflt` when it is -// unset or unparseable — the bake-tuning host toggle (TILE57_SUPER_DZ / -// TILE57_LRU_BUDGET). Reads the process environment the host launched with; no -// ABI/flag surface needed. The engine is linked into the host, so this sees the -// host's env. -fn envUsizeOr(name: [:0]const u8, dflt: usize) usize { - const v = std.c.getenv(name.ptr) orelse return dflt; - const s = std.mem.sliceTo(v, 0); - return std.fmt.parseInt(usize, std.mem.trim(u8, s, " \t\r\n"), 10) catch dflt; -} - -// Bake an ENC_ROOT directory (or a single cell.000) into one band-streamed PMTiles -// archive written straight to `out_path` (the data section streams through a -// StreamWriter — only the compressed data + small directory are held, not the raw -// tiles). Returns the union bounds + cell stems + sounding stacks + SCAMIN denoms. -// error.NoGeometry if no cell parses. -fn bakeRoot(io: std.Io, a: std.mem.Allocator, root_path: []const u8, out_path: []const u8, rules_dir: []const u8, minzoom: u8, maxzoom: u8, lru_budget_arg: usize, super_dz_arg: u8, collect_sounds: bool, format: engine.scene.TileFormat, pick_attrs: bool, existing_path: []const u8, progress: engine.bake_enc.Progress, progress_user: ?*anyopaque) !RootBake { - // Bake-tuning env overrides (host toggle, no ABI/flag change): TILE57_SUPER_DZ / - // TILE57_LRU_BUDGET replace the caller's defaults for the lazy super-tile bake — - // super_dz = how coarse a "super-tile" the band is loaded/generated in (bigger = - // fewer, larger super-tiles = more cells resident at once), lru_budget = the cap - // on parsed+portrayed cells kept between super-tiles. Read once per bake. - const lru_budget = envUsizeOr("TILE57_LRU_BUDGET", lru_budget_arg); - const super_dz: u8 = @intCast(@min(envUsizeOr("TILE57_SUPER_DZ", super_dz_arg), @as(usize, 16))); - // Overscale fill-up depth (crisp zooms past each band's window; 0-2). Cost - // ~4x that band's uncovered footprint per zoom; blank water never returns - // at 0 (camera cap + one-level stretch) — this dial buys crisp depth. - const fillup_dz: u8 = @intCast(@min(envUsizeOr("TILE57_FILLUP_DZ", engine.bake_enc.FILLUP_DZ), @as(usize, 2))); - // Progress sink: the caller's callback, else the built-in console writer (the CLI - // path, byte-identical to before). Both only touch stderr — never the tile bytes. - const prog: engine.bake_enc.Progress = progress orelse cliProgress; - // smp_allocator (Zig's fast thread-safe general-purpose allocator), not - // page_allocator: the bake makes many small, short-lived allocations across - // worker threads (per-tile gzip results, cell reads, index lists). page_allocator - // rounds every allocation up to a page and mmaps it, churning page faults; - // smp_allocator pools and reuses freed blocks. - const gpa = std.heap.smp_allocator; - // Bake-lifetime portrayal cache, keyed by cell path: a cell can appear in - // SEVERAL passes (its own band, a coarse-rider slot in the next-finer pass, - // a carry slot in the next-coarser) and portrayal is ~80% of the bake — so - // it runs ONCE per cell per bake, whichever pass loads it first. The cache - // owns every portrayal arena (pass-end no longer frees them; CarryState - // frees only its slices) and releases them all when the bake returns. - var pcache = std.StringHashMap(CellPortray).init(gpa); - defer { - var pit = pcache.valueIterator(); - while (pit.next()) |pc| { - pc.arena.deinit(); - gpa.destroy(pc.arena); - } - pcache.deinit(); - } - // Input may be a whole ENC_ROOT directory OR a single cell `.000` file; either - // way the lazy super-tile bake below streams to disk through the SAME path. For - // a single file the "dir" is its parent (the worker reads the cell + its `.001…` - // updates from there) and pass 1 is just that one cell. - const single_file = !isDir(io, root_path); - const dir_path = if (single_file) (std.fs.path.dirname(root_path) orelse ".") else root_path; - var dir = try std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }); - defer dir.close(io); - - // Pass 1: learn each cell's band + bbox. An exchange-set catalogue (CATALOG.031) - // gives every cell's coverage bbox in ONE file (used verbatim), but NOT its - // compilation scale — so the band still comes from a per-cell CSCL peek. The Go - // reference always bands by BandForScale(CompilationScale) (bake.go:592,710), - // never the NOAA name digit: a US2/general-named cell at 1:240000-1:500000 bands - // COASTAL by scale, a US5/harbor-named cell at 1:40000 bands APPROACH — the name - // digit is only a coverage-purpose hint and straddles the bandOf thresholds. - // Fallback (no catalogue): walk the dir and peek each cell (bbox + CSCL). All - // three input paths band identically by bandOf(cscl). - const Bands = engine.bake_enc; - var band_cells: [Bands.bands_fine_to_coarse.len]std.ArrayList(CellEntry) = undefined; - for (&band_cells) |*bc| bc.* = std.ArrayList(CellEntry).empty; - var cell_names = std.ArrayList([]const u8).empty; - var ubox: [4]f64 = .{ 1e9, 1e9, -1e9, -1e9 }; // [w,s,e,n] - var total_cells: usize = 0; - - const via_catalog = if (single_file) sf: { - // Single cell: one entry, band + bbox from a cheap peek (no dir scan). - const base = std.fs.path.basename(root_path); - const bytes = dir.readFileAlloc(io, base, gpa, .unlimited) catch return error.NoGeometry; - defer gpa.free(bytes); - const m = engine.s57.peekMeta(gpa, bytes) orelse return error.NoGeometry; - const bb = m.bounds orelse return error.NoGeometry; - ubox = bb; - total_cells = 1; - try cell_names.append(a, try a.dupe(u8, std.fs.path.stem(base))); - try band_cells[@intFromEnum(Bands.bandOf(m.cscl))].append(a, .{ .path = try a.dupe(u8, base), .bbox = bb }); - break :sf false; - } else cat: { - const cbytes = dir.readFileAlloc(io, "CATALOG.031", gpa, .unlimited) catch break :cat false; - defer gpa.free(cbytes); - const entries = engine.s57.parseCatalog(a, cbytes) orelse break :cat false; - var n_cells: usize = 0; - for (entries) |e| { - if (!e.is_cell) continue; - n_cells += 1; - try cell_names.append(a, e.stem); - total_cells += 1; - const bb = e.bbox orelse continue; - ubox[0] = @min(ubox[0], bb[0]); - ubox[1] = @min(ubox[1], bb[1]); - ubox[2] = @max(ubox[2], bb[2]); - ubox[3] = @max(ubox[3], bb[3]); - // Band by the parsed CSCL (Go BandForScale), NOT the name digit: peek the - // cell for its compilation scale. The catalogue supplies the bbox but no - // scale, so this one cheap read per cell is what keeps the band correct - // (an unreadable cell -> bandOf(0) = approach, the same default the - // dir-walk + single-file paths use). Identical banding to those paths. - const band = bfs: { - const cb = dir.readFileAlloc(io, e.path, gpa, .unlimited) catch break :bfs Bands.bandOf(0); - defer gpa.free(cb); - const m = engine.s57.peekMeta(gpa, cb); - break :bfs Bands.bandOf(if (m) |mm| mm.cscl else 0); - }; - try band_cells[@intFromEnum(band)].append(a, .{ .path = e.path, .bbox = bb }); - } - break :cat n_cells > 0; - }; - - if (!single_file and !via_catalog) { - // De-dup by cell stem: the recursive walk can find the SAME cell under two - // subfolders (a provider ENC_ROOT holds one district subfolder per download, and - // adjacent NOAA districts share boundary cells). Baking a stem twice double-draws - // its features into the overlapping tiles, so the first occurrence wins (a shared - // boundary cell is the same edition across districts). - var seen = std.StringHashMap(void).init(a); - defer seen.deinit(); - var walker = try dir.walk(a); - defer walker.deinit(); - while (try walker.next(io)) |entry| { - if (entry.kind != .file) continue; - if (!std.mem.endsWith(u8, entry.path, ".000")) continue; - if (seen.contains(std.fs.path.stem(std.fs.path.basename(entry.path)))) continue; - const path = try a.dupe(u8, entry.path); - const stem = try a.dupe(u8, std.fs.path.stem(std.fs.path.basename(path))); - try seen.put(stem, {}); - const bytes = dir.readFileAlloc(io, path, gpa, .unlimited) catch continue; - const meta = engine.s57.peekMeta(gpa, bytes); - gpa.free(bytes); - try cell_names.append(a, stem); - total_cells += 1; - // A cell with no peek-bbox has no geometry to bake — skip it (it would - // produce no tiles). The peek bbox is a superset of the parsed bounds, - // so a cell is assigned to every super-tile its real geometry touches. - const m = meta orelse continue; - const bb = m.bounds orelse continue; - ubox[0] = @min(ubox[0], bb[0]); - ubox[1] = @min(ubox[1], bb[1]); - ubox[2] = @max(ubox[2], bb[2]); - ubox[3] = @max(ubox[3], bb[3]); - try band_cells[@intFromEnum(Bands.bandOf(m.cscl))].append(a, .{ .path = path, .bbox = bb }); - } - } - if (total_cells == 0) return error.NoGeometry; - std.debug.print("baking {d} cells from {s} ({s}, rules: {s})\n", .{ total_cells, root_path, if (via_catalog) "via CATALOG.031" else "scanned", if (rules_dir.len == 0) "embedded" else rules_dir }); - - engine.catalogue.warmUp(); // warm the shared catalogue before parallel portrayal - registerLinestyles(gpa, embeddedLinestyles(a) catch &.{}); // complex-line tessellation table - engine.portray.setQuiet(true); // many threads -> suppress the per-cell stderr - - // Stream the gzipped tiles straight to a temp data file (concatenated into - // out_path at the end) so the whole compressed archive never lives in RAM — - // only the directory + dedup map do. Peak memory is then one band's cells. - const data_tmp = try std.fmt.allocPrint(a, "{s}.data.tmp", .{out_path}); - var data_file = try std.Io.Dir.cwd().createFile(io, data_tmp, .{ .read = true }); - errdefer { - data_file.close(io); - std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; - } - var sw = engine.pmtiles.StreamWriter.initFile(gpa, io, data_file); - defer sw.deinit(); - var sounds = std.StringHashMap(void).init(a); - var scamin = std.AutoHashMap(u32, void).init(gpa); - defer scamin.deinit(); - // Sounding-stack + SCAMIN-manifest collection decodes each tile with the codec - // matching the bake format, so MLT bakes get the same sprite-mln sounding - // composites + SCAMIN manifest an MVT bake does. - var sink = BakeSink{ .sw = &sw, .sounds = &sounds, .scamin = &scamin, .a = a, .gpa = gpa, .collect_sounds = collect_sounds, .format = format }; - var baker = Bands.Baker.init(gpa, minzoom, maxzoom, .{ .ctx = &sink, .func = BakeSink.run }); - baker.fillup_dz = fillup_dz; - baker.format = format; - baker.pick_attrs = pick_attrs; - defer baker.deinit(); - - // Light-reach pre-pass: every cell parsed once (parallel, transient) for its - // attr-based sector-figure reach — the super-tile index + planned-tile spans - // widen each cell's span by it. - const prepass_start = nowSec(); - var all_entries = std.ArrayList(CellEntry).empty; - defer all_entries.deinit(gpa); - for (band_cells) |bc| all_entries.appendSlice(gpa, bc.items) catch break; - const entry_lights: []engine.scene.LightReach = a.alloc(engine.scene.LightReach, all_entries.items.len) catch &.{}; - @memset(entry_lights, .{}); - scanEntryLights(io, dir, all_entries.items, entry_lights); - // Patch each cell's reach back into its band entry (band_cells order == the - // all_entries concatenation order). - { - var k: usize = 0; - for (&band_cells) |*bc| for (bc.items) |*e| { - if (k < entry_lights.len) e.light = entry_lights[k]; - k += 1; - }; - } - // Cross-pack best-available: a peer pack's coverage (bake --existing) suppresses - // this pack's coarser cells where the peer is finer — stops an overview pack - // painting over the peer's sectors. Empty (no --existing) = a plain bake. - baker.context = loadContextCoverage(io, a, existing_path); - if (baker.context.len > 0) - std.debug.print(" cross-pack context: {d} peer coverage cell(s) from {s}\n", .{ baker.context.len, existing_path }); - std.debug.print(" light-reach pre-pass: {d} cell(s) scanned ({d}s)\n", .{ all_entries.items.len, nowSec() - prepass_start }); - - // Pass 2: bake each band finest → coarsest (best-band dedup via baker.emitted). - // Within a band, walk spatial "super-tiles" (one tile at zoom zs = zlo - SUPER_DZ): - // load ONLY the cells overlapping each super-tile (parse + portray in parallel), - // generate that super-tile's tiles (clipped, parallel), and keep parsed cells in - // a small LRU so neighbouring super-tiles reuse them (cells span a few super-tiles - // and are parsed once). Peak memory is the busiest super-tile's cells + the LRU - // budget — not the whole band. Coarse bands have few (huge) cells and a tiny grid, - // so they fall back to loading them together. - std.debug.print(" (lazy bake: lru={d} cells, super-dz={d})\n", .{ lru_budget, super_dz }); - g_bake_start = nowSec(); - // The coarsest populated band gets .extend_min (fill down to minzoom — the - // live tileRefs coarsest-band fallback); every other populated band defers its - // floor into the next-coarser pass (.defer_down, the band handoff). - var coarsest_pop: ?Bands.Band = null; - for (Bands.bands_fine_to_coarse) |band| { - if (band_cells[@intFromEnum(band)].items.len > 0) coarsest_pop = band; - } - // Band label (host §3): count the passes that will actually bake — a band with - // no cells still runs when the next-finer band deferred its floor tiles into it. - var band_count: u8 = 0; - { - var carry_n: usize = 0; - for (Bands.bands_fine_to_coarse) |band| { - const own_n = band_cells[@intFromEnum(band)].items.len; - const floor: Bands.FloorMode = if (coarsest_pop == band) .extend_min else .defer_down; - if (Bands.passHasWork(band, minzoom, maxzoom, own_n, carry_n, floor)) band_count += 1; - carry_n = if (own_n > 0 and floor == .defer_down and Bands.floorDeferred(band, minzoom, maxzoom)) own_n else 0; - } - } - baker.band_count = band_count; - var carry: ?CarryState = null; // the finer band's cells riding into this pass - var band_ord: u8 = 0; - var done_cells: usize = 0; - for (Bands.bands_fine_to_coarse) |band| { - const entries = band_cells[@intFromEnum(band)].items; - const carry_entries: []const CellEntry = if (carry) |c| c.entries else &.{}; - const floor: Bands.FloorMode = if (coarsest_pop == band) .extend_min else .defer_down; - // Whether this band's own floor rides on into the NEXT pass (its cells must - // then outlive this one). .extend_min never defers — nothing coarser runs. - const deferred = entries.len > 0 and floor == .defer_down and Bands.floorDeferred(band, minzoom, maxzoom); - if (!Bands.passHasWork(band, minzoom, maxzoom, entries.len, carry_entries.len, floor)) { - // Nothing bakes here. Drop a consumed-less carry; when this band still - // defers (its whole clamped range IS the deferred floor), hand its - // entries on with fresh state — the next pass loads them itself. - if (carry) |*c| c.deinit(gpa); - carry = if (deferred) try CarryState.initEmpty(gpa, entries) else null; - done_cells += entries.len; - continue; - } - const parses0 = g_parses.load(.monotonic); - const band_start = nowSec(); - baker.band_index = band_ord; - band_ord += 1; - - // Effective zoom span (deferral/extension applied) — drives the super-tile - // zoom zs. A carry-only pass bakes just the deferred floor (this band's max). - const zr = Bands.bandZooms(band); - var zlo = @max(minzoom, zr.min); - const zhi = @min(maxzoom, zr.max); - switch (floor) { - .defer_down => if (zlo == zr.min and zlo <= zhi) { - zlo += 1; - }, - .extend_min => zlo = minzoom, - } - const z_first: u8 = if (entries.len > 0 and zlo <= zhi) zlo else zr.max; - const zs: u8 = if (z_first >= super_dz) z_first - super_dz else 0; - - // Combined pass cells: own first (indices < n_own), then the finer band's - // carry, then the COARSE RIDERS — every strictly-coarser band's cells, - // joining this pass's tiles as extra contributors (Baker.rider_start) so a - // finer-band tile at a cell-bbox edge carries the coarser neighbour's - // content (the composite clips them to the ground finer coverage leaves). - // Riders never enumerate tiles; they load/evict through the same - // super-tile LRU as everything else. Every state array below is - // index-aligned with this slice. - var rider_entries = std.ArrayList(CellEntry).empty; - defer rider_entries.deinit(gpa); - { - // NEXT-COARSER POPULATED band only. All-coarser riders made the giant - // general/overview cells overlap EVERY super-tile of a fine pass — - // thousands of LRU-thrashing re-parses ("painfully slow" district - // bakes) — while the edge-hole they'd fill (a tile where NO nearer - // band covers either) is vanishingly rare: harbor cells sit inside - // approach coverage, approach inside coastal, and each pass's own - // holes are filled by ITS immediate coarser neighbour in ITS pass. - var past_self = false; - for (Bands.bands_fine_to_coarse) |b2| { - if (b2 == band) { - past_self = true; - continue; - } - if (!past_self) continue; - const items = band_cells[@intFromEnum(b2)].items; - if (items.len == 0) continue; - rider_entries.appendSlice(gpa, items) catch {}; - break; - } - } - const n_own = entries.len; - const n_nonrider = n_own + carry_entries.len; - const n_all = n_nonrider + rider_entries.items.len; - const pass_entries = try a.alloc(CellEntry, n_all); - @memcpy(pass_entries[0..n_own], entries); - @memcpy(pass_entries[n_own..n_nonrider], carry_entries); - @memcpy(pass_entries[n_nonrider..], rider_entries.items); - - // Inverted index: super-tile (sx,sy at zs) → the cell indices overlapping it. - var stmap = std.AutoHashMap(u64, std.ArrayList(u32)).init(gpa); - defer { - var vit = stmap.valueIterator(); - while (vit.next()) |v| v.deinit(gpa); - stmap.deinit(); - } - for (pass_entries[0..n_nonrider], 0..) |e, i| { - const nw = lonLatToTile(e.bbox[0], e.bbox[3], zs); - const se = lonLatToTile(e.bbox[2], e.bbox[1], zs); - var sy = nw[1]; - while (sy <= se[1]) : (sy += 1) { - var sx = nw[0]; - while (sx <= se[0]) : (sx += 1) { - const k = (@as(u64, sy) << 32) | sx; // row-major (spatial locality) - const gop = try stmap.getOrPut(k); - if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(u32).empty; - try gop.value_ptr.append(gpa, @intCast(i)); - } - } - // Sector-figure reach: ALSO assign the cell to the super-tiles its - // light legs/arcs reach into (buildTileMap addresses those tiles via - // its reach ring, but only for cells loaded in that super-tile's - // pass). Margin in zs-tiles: the ground legs scale with 2^z so their - // zs-tile count is zoom-independent; the display-mm figures span at - // most one tile at the pass's coarsest zoom < one zs-tile; +1 covers - // the z-tile -> super-tile rounding. Raw-span members are skipped. - const lb = e.light.bbox orelse continue; - const m: u32 = 1 + @as(u32, @intFromFloat(@ceil(engine.scene.lightReachTiles(e.light.range_m, zs, (lb[1] + lb[3]) * 0.5)))); - const max_idx: u32 = @intCast((@as(u64, 1) << @intCast(zs)) - 1); - const lnw = lonLatToTile(lb[0], lb[3], zs); - const lse = lonLatToTile(lb[2], lb[1], zs); - const rxlo = lnw[0] -| m; - const rxhi = @min(lse[0] +| m, max_idx); - var ry = lnw[1] -| m; - const ryhi = @min(lse[1] +| m, max_idx); - while (ry <= ryhi) : (ry += 1) { - var rx = rxlo; - while (rx <= rxhi) : (rx += 1) { - if (rx >= nw[0] and rx <= se[0] and ry >= nw[1] and ry <= se[1]) continue; // raw span has it - const k = (@as(u64, ry) << 32) | rx; - const gop = try stmap.getOrPut(k); - if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(u32).empty; - try gop.value_ptr.append(gpa, @intCast(i)); - } - } - } - // Riders join only super-tiles that ALREADY exist (they never create one). - for (pass_entries[n_nonrider..], n_nonrider..) |e, i| { - const nw = lonLatToTile(e.bbox[0], e.bbox[3], zs); - const se = lonLatToTile(e.bbox[2], e.bbox[1], zs); - var sy = nw[1]; - while (sy <= se[1]) : (sy += 1) { - var sx = nw[0]; - while (sx <= se[0]) : (sx += 1) { - const k = (@as(u64, sy) << 32) | sx; - if (stmap.getPtr(k)) |lst| try lst.append(gpa, @intCast(i)); - } - } - } - - const stkeys = try gpa.alloc(u64, stmap.count()); - defer gpa.free(stkeys); - { - var kit = stmap.keyIterator(); - var j: usize = 0; - while (kit.next()) |kp| : (j += 1) stkeys[j] = kp.*; - } - std.mem.sort(u64, stkeys, {}, struct { - fn lt(_: void, x: u64, y: u64) bool { - return x < y; - } - }.lt); - g_prog.band = @tagName(band); - g_prog.st = 0; - g_prog.st_total = stkeys.len; - g_prog.band_start = nowSec(); - - // Per-band tile total for the progress bar (host §3): sum the planned tiles - // over the band's super-tiles (a planned estimate from the cheap peek bboxes, - // dedup'd against finer bands via the baker's `emitted` set, which is complete - // here since bands bake finest→coarsest). Set it on the baker so bakeBand - // reports done/total as a per-band percentage instead of "total 0 = unknown". - // A super-tile's index list is ascending, so its own cells precede its carry - // cells — the own_len split plannedTiles/bakeBand key on. - baker.band_base = baker.count; - var band_total: usize = 0; - for (stkeys) |stk| { - const cells = stmap.get(stk).?.items; - var spans = std.ArrayList(Bands.CellSpan).empty; - defer spans.deinit(gpa); - var st_own: usize = 0; - for (cells) |ci| { - if (ci >= n_nonrider) continue; // riders don't enumerate tiles - const pe = pass_entries[ci]; - spans.append(gpa, .{ .bounds = pe.bbox, .light_bbox = pe.light.bbox, .light_range_m = pe.light.range_m }) catch continue; - if (ci < n_own) st_own += 1; - } - band_total += baker.plannedTiles(band, spans.items, st_own, floor, .{ .zs = zs, .sx = @intCast(stk & 0xFFFFFFFF), .sy = @intCast(stk >> 32) }); - } - baker.band_total = band_total; - std.debug.print("\n band {s}: {d}+{d} cells (+{d} coarse riders), {d} super-tiles (z{d}-{d}), ~{d} tiles — baking…\n", .{ @tagName(band), entries.len, carry_entries.len, rider_entries.items.len, stkeys.len, @min(z_first, zhi), zhi, band_total }); - - // Per-pass caches (index-aligned with `pass_entries`): portrayal is RESIDENT - // (computed once — the expensive Lua step — and cheap to keep); geometry is - // the heavy part, held in an LRU and re-parsed (NOT re-portrayed) on demand. - // The carry block adopts the finer band's surviving state wholesale. - const portray = try gpa.alloc(?CellPortray, n_all); - defer gpa.free(portray); - @memset(portray, null); - const geom = try gpa.alloc(?CellGeom, n_all); - defer gpa.free(geom); - @memset(geom, null); - const used = try gpa.alloc(u64, n_all); // LRU tick per cell (geometry) - defer gpa.free(used); - @memset(used, 0); - const gave_up = try gpa.alloc(bool, n_all); // parse failed: don't retry - defer gpa.free(gave_up); - @memset(gave_up, false); - var n_geom: usize = 0; // loaded-geometry count (the LRU target) - if (carry) |c| { - @memcpy(portray[n_own..n_nonrider], c.portray); - @memcpy(geom[n_own..n_nonrider], c.geom); - @memcpy(gave_up[n_own..n_nonrider], c.gave_up); - gpa.free(c.portray); - gpa.free(c.geom); - gpa.free(c.gave_up); - carry = null; // consumed — the combined arrays own the state now - for (geom[n_own..]) |g| { - if (g != null) n_geom += 1; - } - } - var tick: u64 = 0; - - for (stkeys, 0..) |stk, st_i| { - g_prog.st = st_i + 1; - const cells = stmap.get(stk).?.items; - const sx: u32 = @intCast(stk & 0xFFFFFFFF); - const sy: u32 = @intCast(stk >> 32); - - // (Re)load this super-tile's cells whose geometry isn't resident — each - // worker reads its own cell's bytes + parses (+ portrays once) in - // parallel, so cold reads aren't serialised. A cell already portrayed is - // only re-parsed, never re-portrayed. - var miss = std.ArrayList(u32).empty; - defer miss.deinit(gpa); - for (cells) |ci| if (geom[ci] == null and !gave_up[ci]) { - // Portrayal computed by ANY earlier pass (rider/carry/own) is - // reused — the worker then only re-parses geometry. - if (portray[ci] == null) { - if (pcache.get(pass_entries[ci].path)) |pc| portray[ci] = pc; - } - miss.append(gpa, ci) catch {}; - }; - if (miss.items.len > 0) { - var lw = LoadWork{ .cells = miss.items, .entries = pass_entries, .dir = dir, .io = io, .portray = portray, .geom = geom, .rules_dir = rules_dir, .gpa = gpa }; - Bands.parallelFor(gpa, miss.items.len, &lw, LoadWork.run); - for (miss.items) |ci| { - if (geom[ci] != null) n_geom += 1 else gave_up[ci] = true; - if (portray[ci]) |pc| { - const gop = pcache.getOrPut(pass_entries[ci].path) catch continue; - if (!gop.found_existing) gop.value_ptr.* = pc; - } - } - } - tick += 1; - for (cells) |ci| used[ci] = tick; - - // Generate this super-tile's tiles from its loaded cells (clipped, parallel): - // pair each cell's geometry with its resident portrayal. `cells` ascends, so - // own cells land before carry cells; st_own is bakeBand's own_len split. - var subset = std.ArrayList(Bands.Backend).empty; - defer subset.deinit(gpa); - var st_own: usize = 0; - var st_nonrider: usize = 0; - for (cells) |ci| if (geom[ci]) |g| { - const p = portray[ci]; - subset.append(gpa, .{ - .cell = g.cell, - .portrayal = if (p) |pp| pp.base else null, - .portrayal_plain = if (p) |pp| pp.plain else null, - .portrayal_simplified = if (p) |pp| pp.simplified else null, - .geo = g.geo, - .geo_world = g.geo_world, - .feat_bbox = g.feat_bbox, - .bounds = if (p) |pp| pp.bounds else pass_entries[ci].bbox, - .cscl = g.cell.params.cscl, - .coverage = g.coverage, - .scamins = g.scamins, - .light_bbox = if (p) |pp| pp.light.bbox else pass_entries[ci].light.bbox, - .light_range_m = if (p) |pp| pp.light.range_m else pass_entries[ci].light.range_m, - }) catch continue; - if (ci < n_own) st_own += 1; - if (ci < n_nonrider) st_nonrider += 1; - }; - baker.rider_start = st_nonrider; - baker.bakeBand(band, subset.items, st_own, floor, .{ .zs = zs, .sx = sx, .sy = sy }, prog, progress_user) catch {}; - - // Evict least-recently-used GEOMETRY beyond the budget (never this - // super-tile's, which carry the current tick). Portrayal stays resident. - while (n_geom > lru_budget) { - var victim: ?usize = null; - var best: u64 = std.math.maxInt(u64); - for (geom, 0..) |g, ci| { - if (g != null and used[ci] < best) { - best = used[ci]; - victim = ci; - } - } - const v = victim orelse break; - if (used[v] == tick) break; // only the current super-tile's cells remain - if (geom[v]) |*g| { - g.cell.deinit(); - if (g.geo_arena) |p| { - p.deinit(); - gpa.destroy(p); - } - } - geom[v] = null; - n_geom -= 1; - } - } - - // Fill-down: an area charted only by bands FINER than the globally-coarsest - // populated band (e.g. a bay with just coastal/approach cells while the - // pack's overview cells lie elsewhere) gets no tiles below this band's - // window — the district-pack "empty z6–z8 hole". extend_min only reaches - // the ONE coarsest band; here each defer_down band fills its below-window - // zooms where no strictly-coarser band covers (bake_enc.bakeFillDown, - // mirroring the live tileRefs fallbackBand). Only cells not blanketed by a - // coarser band participate, so the extra load stays bounded. - if (floor == .defer_down and n_own > 0 and Bands.fillDownZooms(band, minzoom, maxzoom) != null) { - var coarser = std.ArrayList(Bands.CoarserBox).empty; - defer coarser.deinit(gpa); - for (Bands.bands_fine_to_coarse) |cb| { - if (@intFromEnum(cb) <= @intFromEnum(band)) continue; // strictly coarser - const mz = Bands.bandZooms(cb).max; - for (band_cells[@intFromEnum(cb)].items) |ce| coarser.append(gpa, .{ .bbox = ce.bbox, .max_z = mz }) catch {}; - } - var fd = std.ArrayList(u32).empty; // own cells with area a coarser band leaves uncovered - defer fd.deinit(gpa); - for (0..n_own) |ci| { - if (!Bands.coveredByCoarser(pass_entries[ci].bbox, coarser.items)) fd.append(gpa, @intCast(ci)) catch {}; - } - if (fd.items.len > 0) { - // (Re)load the uncovered cells' geometry (portrayal is resident) — - // fill-down tiles are broad low-zoom, so no super-tile clip applies. - var miss = std.ArrayList(u32).empty; - defer miss.deinit(gpa); - for (fd.items) |ci| if (geom[ci] == null and !gave_up[ci]) { - if (portray[ci] == null) { - if (pcache.get(pass_entries[ci].path)) |pc| portray[ci] = pc; - } - miss.append(gpa, ci) catch {}; - }; - if (miss.items.len > 0) { - var lw = LoadWork{ .cells = miss.items, .entries = pass_entries, .dir = dir, .io = io, .portray = portray, .geom = geom, .rules_dir = rules_dir, .gpa = gpa }; - Bands.parallelFor(gpa, miss.items.len, &lw, LoadWork.run); - for (miss.items) |ci| { - if (geom[ci] != null) n_geom += 1 else gave_up[ci] = true; - if (portray[ci]) |pc| { - const gop = pcache.getOrPut(pass_entries[ci].path) catch continue; - if (!gop.found_existing) gop.value_ptr.* = pc; - } - } - } - var subset = std.ArrayList(Bands.Backend).empty; - defer subset.deinit(gpa); - for (fd.items) |ci| if (geom[ci]) |g| { - const p = portray[ci]; - subset.append(gpa, .{ - .cell = g.cell, - .portrayal = if (p) |pp| pp.base else null, - .portrayal_plain = if (p) |pp| pp.plain else null, - .portrayal_simplified = if (p) |pp| pp.simplified else null, - .geo = g.geo, - .geo_world = g.geo_world, - .feat_bbox = g.feat_bbox, - .bounds = if (p) |pp| pp.bounds else pass_entries[ci].bbox, - .cscl = g.cell.params.cscl, - .coverage = g.coverage, - .scamins = g.scamins, - .light_bbox = if (p) |pp| pp.light.bbox else pass_entries[ci].light.bbox, - .light_range_m = if (p) |pp| pp.light.range_m else pass_entries[ci].light.range_m, - }) catch continue; - }; - baker.bakeFillDown(band, subset.items, coarser.items, prog, progress_user) catch {}; - } - } - - // The carry block's ride ends here: free the finer band's remaining - // geometry + portrayal. The own block either becomes the NEXT pass's carry - // (deferred — its floor tiles bake there) or is freed with it. - for (geom[n_own..], n_own..) |g, ci| if (g != null) { - if (geom[ci]) |*gg| { - gg.cell.deinit(); - if (gg.geo_arena) |p| { - p.deinit(); - gpa.destroy(p); - } - } - }; - // Portrayal arenas are owned by the bake-lifetime pcache now — nothing - // to free per pass (geometry above still is). - if (deferred) { - const po = try gpa.alloc(?CellPortray, n_own); - @memcpy(po, portray[0..n_own]); - const ge = try gpa.alloc(?CellGeom, n_own); - @memcpy(ge, geom[0..n_own]); - const gu = try gpa.alloc(bool, n_own); - @memcpy(gu, gave_up[0..n_own]); - carry = .{ .entries = entries, .portray = po, .geom = ge, .gave_up = gu }; - } else { - for (geom[0..n_own], 0..) |g, ci| if (g != null) { - if (geom[ci]) |*gg| { - gg.cell.deinit(); - if (gg.geo_arena) |p| { - p.deinit(); - gpa.destroy(p); - } - } - }; - // Portrayal arenas are owned by the bake-lifetime pcache (freed once - // when the bake returns) — do NOT free them here. These own cells' - // portrayal is registered in pcache at load, so freeing it per pass - // double-frees it at pcache teardown (the coarsest band is always - // non-deferred, so this else branch runs every bake). - } - const parses = g_parses.load(.monotonic) - parses0; - const band_secs = nowSec() - band_start; - std.debug.print( - "\r band {s} done: {d}+{d} cells, {d} super-tiles, {d} tiles total, {d} parses ({d:.2}x), {d}s\n", - .{ @tagName(band), entries.len, carry_entries.len, stkeys.len, baker.count, parses, @as(f64, @floatFromInt(parses)) / @as(f64, @floatFromInt(@max(n_all, 1))), band_secs }, - ); - done_cells += entries.len; - if (prog) |cb| cb(progress_user, 0, done_cells, total_cells, band_ord - 1, band_count, @tagName(band).ptr); - } - if (carry) |*c| c.deinit(gpa); // overview never defers, but stay leak-proof - - // Assemble out_path = prefix (header + directory + metadata) ++ the data - // section streamed to data_tmp. The prefix is small; the data is copied in - // bounded chunks (positional reads, no whole-archive buffer), then the temp - // file is removed. - // PMTiles metadata: vector_layers (TileJSON) + the distinct SCAMIN denominators - // (when collected) so the client builds its per-SCAMIN bucket layers at load. - var scamin_vals = std.ArrayList(u32).empty; // returned in RootBake (lives in `a`) - { - var it = scamin.keyIterator(); - while (it.next()) |k| try scamin_vals.append(a, k.*); - std.mem.sort(u32, scamin_vals.items, {}, std.sort.asc(u32)); - } - const meta = try engine.scene.metadataJson(a, scamin_vals.items, null); // multi-cell root bake: no single cell's coverage - const opts = engine.pmtiles.WriteOptions{ - .metadata_json = meta, - .min_lon_e7 = toE7(ubox[0]), - .min_lat_e7 = toE7(ubox[1]), - .max_lon_e7 = toE7(ubox[2]), - .max_lat_e7 = toE7(ubox[3]), - .tile_type = if (format == .mlt) .mlt else .mvt, - }; - const pre = try sw.prefix(a, opts); - var file = try std.Io.Dir.cwd().createFile(io, out_path, .{}); - try file.writeStreamingAll(io, pre); - { - const chunk = try a.alloc(u8, 1 << 20); // 1 MiB - defer a.free(chunk); - var pos: u64 = 0; - while (pos < sw.data_len) { - const want: usize = @intCast(@min(@as(u64, chunk.len), sw.data_len - pos)); - _ = try data_file.readPositionalAll(io, chunk[0..want], pos); - try file.writeStreamingAll(io, chunk[0..want]); - pos += want; - } - } - file.close(io); - data_file.close(io); - std.Io.Dir.cwd().deleteFile(io, data_tmp) catch {}; - - var stacks = std.ArrayList([]const u8).empty; - var it = sounds.keyIterator(); - while (it.next()) |k| try stacks.append(a, k.*); - return .{ .bounds = ubox, .cells = cell_names.items, .sounds = stacks.items, .scamin = scamin_vals.items }; -} - -/// lon/lat (deg) -> web-mercator tile (x, y) at zoom z, clamped to the valid range. -fn lonLatToTile(lon: f64, lat: f64, z: u8) [2]u32 { - const w = engine.tile.lonLatToWorld(lon, lat); // normalised [0,1], y down - const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); - const max_idx: f64 = scale - 1.0; - const fx = std.math.clamp(@floor(w[0] * scale), 0.0, max_idx); - const fy = std.math.clamp(@floor(w[1] * scale), 0.0, max_idx); - return .{ @intFromFloat(fx), @intFromFloat(fy) }; -} - -/// Degrees -> PMTiles E7 fixed-point. -fn toE7(v: f64) i32 { - return @intFromFloat(@round(v * 1e7)); -} - -// =========================================================================== -// Tests — composeArchives (per-cell composite Step 3) -// =========================================================================== - -const testing = std.testing; - -// Build a synthetic per-cell PMTiles: coverage = one M_COVR box (lon0..lon1 × lat0..lat1, -// degrees), plus ONE MLT tile at (z,tx,ty) whose "areas" layer is a single polygon covering -// the whole tile box — so composing clips it down to the ground the cell owns. Bytes are -// gpa-owned (free with gpa.free); scratch lives in arena `a`. -fn synthCell(gpa: std.mem.Allocator, a: std.mem.Allocator, name: []const u8, lon0: f64, lat0: f64, lon1: f64, lat1: f64, cscl: i32, z: u8, tx: u32, ty: u32) ![]u8 { - const s57 = engine.s57; - const scene = engine.scene; - const mvt = engine.mvt; - const tile = engine.tile; - const pmtiles = engine.pmtiles; - - const ring = try a.alloc(s57.LonLat, 4); - ring[0] = .{ .lon_e7 = toE7(lon0), .lat_e7 = toE7(lat0) }; - ring[1] = .{ .lon_e7 = toE7(lon1), .lat_e7 = toE7(lat0) }; - ring[2] = .{ .lon_e7 = toE7(lon1), .lat_e7 = toE7(lat1) }; - ring[3] = .{ .lon_e7 = toE7(lon0), .lat_e7 = toE7(lat1) }; - const feat = try a.dupe([]const s57.LonLat, &.{ring}); - const cov1 = try a.dupe([]const []const s57.LonLat, &.{feat}); - const cc = scene.coverage.CellCoverage{ - .name = name, - .date = "20200101", - .cscl = cscl, - .band = @intFromEnum(engine.bake_enc.bandOf(cscl)), - .bbox = .{ toE7(lon0), toE7(lat0), toE7(lon1), toE7(lat1) }, - .cov1 = cov1, - }; - const cov_json = try scene.coverage.encodeJson(a, cc); - const meta = try scene.metadataJson(a, &.{}, cov_json); - - const box = try a.alloc(mvt.Point, 4); - box[0] = .{ .x = 0, .y = 0 }; - box[1] = .{ .x = tile.EXTENT, .y = 0 }; - box[2] = .{ .x = tile.EXTENT, .y = tile.EXTENT }; - box[3] = .{ .x = 0, .y = tile.EXTENT }; - const parts = try a.dupe([]const mvt.Point, &.{box}); - const feats = try a.dupe(mvt.Feature, &.{.{ .geom_type = .polygon, .parts = parts }}); - const layers = try a.dupe(mvt.Layer, &.{.{ .name = "areas", .features = feats }}); - const tilebytes = try engine.mlt.encode(a, .{ .layers = layers }); - - var sw = pmtiles.StreamWriter.init(gpa); - defer sw.deinit(); - try sw.add(z, tx, ty, tilebytes); - return sw.finishBytes(.{ - .metadata_json = meta, - .tile_type = .mlt, - .min_lon_e7 = cc.bbox[0], - .min_lat_e7 = cc.bbox[1], - .max_lon_e7 = cc.bbox[2], - .max_lat_e7 = cc.bbox[3], - }); -} - -// How many of `feats` (decoded polygons) contain (x,y) under the even-odd rule. -fn countContains(a: std.mem.Allocator, feats: []engine.mvt.DecodedFeature, x: i64, y: i64) usize { - const boolean = engine.geo.boolean; - var count: usize = 0; - for (feats) |f| { - if (f.geom_type != .polygon) continue; - var rings = std.ArrayList([]const boolean.Pt).empty; - for (f.parts) |part| { - const w = a.alloc(boolean.Pt, part.len) catch unreachable; - for (part, 0..) |p, i| w[i] = .{ .x = p.x, .y = p.y }; - rings.append(a, w) catch unreachable; - } - if (boolean.pointInEvenOdd(rings.items, x, y)) count += 1; - } - return count; -} - -test "composeArchives: two abutting cells split a shared tile at the seam, no double-draw" { - const gpa = testing.allocator; - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - const tile = engine.tile; - - // A harbor-band location; find the z13 tile containing it, then split that tile's ground - // between two cells at the vertical mid-line. - const clon = -76.48; - const clat = 38.97; - const z: u8 = 13; - const scale: f64 = @floatFromInt(@as(u64, 1) << z); - const w = tile.lonLatToWorld(clon, clat); - const tx = worldAxisToTile(w[0], scale); - const ty = worldAxisToTile(w[1], scale); - const bnds = tile.tileBoundsLonLat(z, tx, ty); // [min_lon, min_lat, max_lon, max_lat] - const lon0 = bnds[0]; - const lat0 = bnds[1]; - const lon1 = bnds[2]; - const lat1 = bnds[3]; - const midlon = (lon0 + lon1) / 2.0; - const dlat = (lat1 - lat0) * 0.01; // keep each coverage bbox inside this one tile row - - const cscl: i32 = 20_000; // harbor band (z13-16); shared cscl → same governing band - const arc_a = try synthCell(gpa, a, "AAAAAAAA", lon0, lat0 + dlat, midlon, lat1 - dlat, cscl, z, tx, ty); - defer gpa.free(arc_a); - const arc_b = try synthCell(gpa, a, "BBBBBBBB", midlon, lat0 + dlat, lon1, lat1 - dlat, cscl, z, tx, ty); - defer gpa.free(arc_b); - - const composed = (try composeArchives(gpa, &.{ arc_a, arc_b })) orelse return error.TestUnexpectedResult; - defer gpa.free(composed); - - var r = try engine.pmtiles.Reader.init(gpa, composed); - defer r.deinit(); - // Composed archive is MLT, native harbor band only → the z13 tile exists. - try testing.expectEqual(engine.pmtiles.TileType.mlt, r.header.tile_type); - const raw = (try r.getTile(a, z, tx, ty)) orelse return error.TestUnexpectedResult; - const layers = try engine.mlt.decode(a, raw); - - var areas: ?engine.mvt.DecodedLayer = null; - for (layers) |l| { - if (std.mem.eql(u8, l.name, "areas")) areas = l; - } - const al = areas orelse return error.TestUnexpectedResult; - try testing.expect(al.features.len >= 2); // A's left half + B's right half, clipped at the seam - - const E = tile.EXTENT; - const midy: i64 = @divTrunc(E, 2); - // Left quarter owned by exactly one cell; right quarter by exactly one; neither by both - // (disjoint partition — the seam split the tile, it did not double-draw it). - try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(E, 4), midy)); - try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(@as(i64, E) * 3, 4), midy)); -} - -// Compose `arcs` two ways — the batch composeArchives (the byte-truth) and, tile by tile, the -// on-demand composeTile over a rebuilt partition — and assert every tile across the coverage -// window (empties included: null both ways) is byte-identical. Returns how many verbatim vs -// freshly-composed seam tiles were exercised. -const ComposeTileCounts = struct { checked: usize, verbatim: usize, seam: usize }; -fn verifyComposeTileMatchesBatch(gpa: std.mem.Allocator, arcs: []const []const u8) !ComposeTileCounts { - const pmtiles = engine.pmtiles; - const scene = engine.scene; - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - - const composed = (try composeArchives(gpa, arcs)) orelse return error.TestUnexpectedResult; - defer gpa.free(composed); - var truth = try pmtiles.Reader.init(gpa, composed); - defer truth.deinit(); - - // Per-cell readers, aligned cell index == reader index exactly as composeInto arranges them. - const readers = try a.alloc(*pmtiles.Reader, arcs.len); - for (arcs, 0..) |arc, i| { - readers[i] = try a.create(pmtiles.Reader); - readers[i].* = try pmtiles.Reader.init(gpa, arc); - } - defer for (readers) |rp| rp.deinit(); - - var shims = std.ArrayList(LoadedCov).empty; - var ubox = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; - var minz: u8 = 255; - var maxz: u8 = 0; - for (readers) |rp| { - const meta = readMetaJson(a, rp) orelse return error.TestUnexpectedResult; - const cc = (try scene.coverage.decodeFromMetadata(a, meta)) orelse return error.TestUnexpectedResult; - const b = [4]f64{ - @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, - @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, - }; - ubox[0] = @min(ubox[0], b[0]); - ubox[1] = @min(ubox[1], b[1]); - ubox[2] = @max(ubox[2], b[2]); - ubox[3] = @max(ubox[3], b[3]); - const bz = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cc.cscl)); - minz = @min(minz, bz.min); - maxz = @max(maxz, bz.max); - try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = b }); - } - const cells = try toPlaneCells(a, shims.items); - for (cells, readers) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); - var part = try engine.geo.partition.build(gpa, cells); - defer part.deinit(); - - const fill_max = @min(maxz + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); - const loop_max = @max(maxz, fill_max); - - var counts = ComposeTileCounts{ .checked = 0, .verbatim = 0, .seam = 0 }; - var z: u8 = minz; - while (z <= loop_max) : (z += 1) { - const nw = lonLatToTile(ubox[0], ubox[3], z); // min lon, max lat → min tx, min ty - const se = lonLatToTile(ubox[2], ubox[1], z); - var tx = nw[0] -| 1; - while (tx <= se[0] + 1) : (tx += 1) { - var ty = nw[1] -| 1; - while (ty <= se[1] + 1) : (ty += 1) { - const want = try truth.getCompressed(z, tx, ty); // borrowed from truth's arena - const got = (try composeTile(gpa, &part, readers, z, tx, ty, true)).tile; // gzip → archive-identical - defer if (got) |g| gpa.free(g); - if (want == null and got == null) continue; - if (want == null or got == null) return error.ComposeTileMismatch; - try testing.expectEqualSlices(u8, want.?, got.?); - counts.checked += 1; - // A verbatim tile equals some owner's native blob byte-for-byte; a seam tile does not. - var is_verbatim = false; - for (readers) |rp| { - if (try rp.getCompressed(z, tx, ty)) |nat| if (std.mem.eql(u8, got.?, nat)) { - is_verbatim = true; - break; - }; - } - if (is_verbatim) counts.verbatim += 1 else counts.seam += 1; - } - } - } - return counts; -} - -test "composeTile: on-demand tiles are byte-identical to the batch-composed archive" { - const gpa = testing.allocator; - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - const tile = engine.tile; - - // Scenario 1 — a single cell that fully owns its tile (coverage overflows the tile on every - // side, so the owner owns the tile AND its render buffer): composeTile serves the VERBATIM blob. - { - const z: u8 = 14; - const scale: f64 = @floatFromInt(@as(u64, 1) << z); - const w = tile.lonLatToWorld(-76.5, 39.0); - const tx = worldAxisToTile(w[0], scale); - const ty = worldAxisToTile(w[1], scale); - const tb = tile.tileBoundsLonLat(z, tx, ty); - const dlon = tb[2] - tb[0]; - const dlat = tb[3] - tb[1]; - const arc = try synthCell(gpa, a, "INTERIOR", tb[0] - dlon, tb[1] - dlat, tb[2] + dlon, tb[3] + dlat, 20_000, z, tx, ty); - defer gpa.free(arc); - const c = try verifyComposeTileMatchesBatch(gpa, &.{arc}); - try testing.expect(c.checked > 0); - try testing.expect(c.verbatim > 0); // full owner → verbatim passthrough - } - - // Scenario 2 — two abutting cells split one z13 tile at the mid-line: SEAM recomposition. - { - const z: u8 = 13; - const scale: f64 = @floatFromInt(@as(u64, 1) << z); - const w = tile.lonLatToWorld(-76.48, 38.97); - const tx = worldAxisToTile(w[0], scale); - const ty = worldAxisToTile(w[1], scale); - const bnds = tile.tileBoundsLonLat(z, tx, ty); - const lon0 = bnds[0]; - const lat0 = bnds[1]; - const lon1 = bnds[2]; - const lat1 = bnds[3]; - const midlon = (lon0 + lon1) / 2.0; - const dlat = (lat1 - lat0) * 0.01; - const arc_a = try synthCell(gpa, a, "AAAAAAAA", lon0, lat0 + dlat, midlon, lat1 - dlat, 20_000, z, tx, ty); - defer gpa.free(arc_a); - const arc_b = try synthCell(gpa, a, "BBBBBBBB", midlon, lat0 + dlat, lon1, lat1 - dlat, 20_000, z, tx, ty); - defer gpa.free(arc_b); - const c = try verifyComposeTileMatchesBatch(gpa, &.{ arc_a, arc_b }); - try testing.expect(c.checked > 0); - try testing.expect(c.seam > 0); // shared tile → seam recomposition - } -} - -test "composeArchives: a coarse owner overscales one fill-up zoom past its native band" { - const gpa = testing.allocator; - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - const tile = engine.tile; - - // A coastal cell (cscl 300000 → coastal, native z9-11) with a single tile at its native - // MAX z11. Composing it alone must still yield a z12 tile (one fill-up zoom past z11), - // overscaled from the z11 tile — the cross-band-down gap the compositor fills. - const z: u8 = 11; - const scale: f64 = @floatFromInt(@as(u64, 1) << z); - const w = tile.lonLatToWorld(-76.3, 38.5); - const tx = worldAxisToTile(w[0], scale); - const ty = worldAxisToTile(w[1], scale); - const b = tile.tileBoundsLonLat(z, tx, ty); - const arc = try synthCell(gpa, a, "COAST001", b[0], b[1], b[2], b[3], 300_000, z, tx, ty); - defer gpa.free(arc); - - const composed = (try composeArchives(gpa, &.{arc})) orelse return error.TestUnexpectedResult; - defer gpa.free(composed); - - var r = try engine.pmtiles.Reader.init(gpa, composed); - defer r.deinit(); - // Native z11 present, and its NW z12 child materialised by overscale. - try testing.expect((try r.getTile(a, z, tx, ty)) != null); - const child = (try r.getTile(a, z + 1, tx * 2, ty * 2)) orelse return error.TestUnexpectedResult; - const layers = try engine.mlt.decode(a, child); - var areas: ?engine.mvt.DecodedLayer = null; - for (layers) |l| { - if (std.mem.eql(u8, l.name, "areas")) areas = l; - } - const al = areas orelse return error.TestUnexpectedResult; - try testing.expect(al.features.len >= 1); - // The overscaled coastal fill covers the child tile interior. - try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(tile.EXTENT, 2), @divTrunc(tile.EXTENT, 2))); -} - -test "ownerHasTile mirrors ownerTile's null/non-null across the native + fill-up window" { - const gpa = testing.allocator; - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - const tile = engine.tile; - - // A coastal cell (native z9-11, fill-up to z12) with a single tile at its native max. - const z: u8 = 11; - const scale: f64 = @floatFromInt(@as(u64, 1) << z); - const w = tile.lonLatToWorld(-76.3, 38.5); - const tx = worldAxisToTile(w[0], scale); - const ty = worldAxisToTile(w[1], scale); - const b = tile.tileBoundsLonLat(z, tx, ty); - const cscl: i32 = 300_000; - const arc = try synthCell(gpa, a, "COAST001", b[0], b[1], b[2], b[3], cscl, z, tx, ty); - defer gpa.free(arc); - - var r = try engine.pmtiles.Reader.init(gpa, arc); - defer r.deinit(); - - // The tile-major compositor records a tile in its discovery pass iff the compose - // pass's decode would contribute; ONE disagreement changes the discovery map's - // insertion sequence and with it the whole zoom's output byte order. Pin the pair - // across every window boundary. - const cases = [_]struct { z: u8, x: u32, y: u32 }{ - .{ .z = z, .x = tx, .y = ty }, // native hit - .{ .z = z, .x = tx + 5, .y = ty }, // native miss - .{ .z = z + 1, .x = tx * 2, .y = ty * 2 }, // fill-up: NW child of the ancestor - .{ .z = z + 1, .x = tx * 2 + 1, .y = ty * 2 + 1 }, // fill-up: SE child, same ancestor - .{ .z = z + 1, .x = (tx + 5) * 2, .y = ty * 2 }, // fill-up: no ancestor - .{ .z = z + 2, .x = tx * 4, .y = ty * 4 }, // past FILLUP_DZ: never reachable - .{ .z = z - 1, .x = tx / 2, .y = ty / 2 }, // below the baked window (no fill-down) - }; - for (cases) |c| { - const decoded = try ownerTile(a, &r, cscl, c.z, c.x, c.y); - try testing.expectEqual(decoded != null, try ownerHasTile(&r, cscl, c.z, c.x, c.y)); - } - // The sweep exercises both outcomes on both sides of the fill-up boundary. - try testing.expect(try ownerHasTile(&r, cscl, z + 1, tx * 2, ty * 2)); - try testing.expect(!try ownerHasTile(&r, cscl, z + 2, tx * 4, ty * 4)); -} - -test "composeArchives: an interior tile is copied byte-identical from the per-cell bake" { - const gpa = testing.allocator; - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - const tile = engine.tile; - - // A single harbor cell whose coverage spans a 3×3 tile block, with a native tile at the - // centre (z14). The centre tile + its buffer is fully interior to the owned face (a lone - // cell owns everything), so the compositor must copy it VERBATIM — byte-identical to the - // per-cell bake, no decode/clip/re-encode. - const z: u8 = 14; - const scale: f64 = @floatFromInt(@as(u64, 1) << z); - const w = tile.lonLatToWorld(-76.5, 39.0); - const tx = worldAxisToTile(w[0], scale); - const ty = worldAxisToTile(w[1], scale); - const tb = tile.tileBoundsLonLat(z, tx, ty); - const dlon = tb[2] - tb[0]; - const dlat = tb[3] - tb[1]; - const arc = try synthCell(gpa, a, "INTERIOR", tb[0] - dlon, tb[1] - dlat, tb[2] + dlon, tb[3] + dlat, 20_000, z, tx, ty); - defer gpa.free(arc); - - const composed = (try composeArchives(gpa, &.{arc})) orelse return error.TestUnexpectedResult; - defer gpa.free(composed); - - var rc = try engine.pmtiles.Reader.init(gpa, composed); - defer rc.deinit(); - var rp = try engine.pmtiles.Reader.init(gpa, arc); - defer rp.deinit(); - const got = (try rc.getCompressed(z, tx, ty)) orelse return error.TestUnexpectedResult; - const want = (try rp.getCompressed(z, tx, ty)) orelse return error.TestUnexpectedResult; - try testing.expectEqualSlices(u8, want, got); // verbatim passthrough -} - -test "composeArchivesToFile: streams a seam split disk-to-disk" { - const gpa = testing.allocator; - const io = testing.io; - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - const tile = engine.tile; - - // Same split as the in-memory seam test, but the per-cell archives go to disk, are mmap'd, - // and the composite streams back to a file. - const z: u8 = 13; - const scale: f64 = @floatFromInt(@as(u64, 1) << z); - const w = tile.lonLatToWorld(-76.48, 38.97); - const tx = worldAxisToTile(w[0], scale); - const ty = worldAxisToTile(w[1], scale); - const bnds = tile.tileBoundsLonLat(z, tx, ty); - const lon0 = bnds[0]; - const lat0 = bnds[1]; - const lon1 = bnds[2]; - const lat1 = bnds[3]; - const midlon = (lon0 + lon1) / 2.0; - const dlat = (lat1 - lat0) * 0.01; - - const arc_a = try synthCell(gpa, a, "AAAAAAAA", lon0, lat0 + dlat, midlon, lat1 - dlat, 20_000, z, tx, ty); - defer gpa.free(arc_a); - const arc_b = try synthCell(gpa, a, "BBBBBBBB", midlon, lat0 + dlat, lon1, lat1 - dlat, 20_000, z, tx, ty); - defer gpa.free(arc_b); - - const dir = std.Io.Dir.cwd(); - const pa = ".zig-cache/compose_stream_a.pmtiles.tmp"; - const pb = ".zig-cache/compose_stream_b.pmtiles.tmp"; - const po = ".zig-cache/compose_stream_out.pmtiles.tmp"; - try dir.writeFile(io, .{ .sub_path = pa, .data = arc_a }); - try dir.writeFile(io, .{ .sub_path = pb, .data = arc_b }); - defer { - dir.deleteFile(io, pa) catch {}; - dir.deleteFile(io, pb) catch {}; - dir.deleteFile(io, po) catch {}; - } - - const nc = try composeArchivesToFile(io, gpa, &.{ pa, pb }, po, null); - try testing.expectEqual(@as(usize, 2), nc); - - const bytes = try dir.readFileAlloc(io, po, a, .unlimited); - var r = try engine.pmtiles.Reader.init(gpa, bytes); - defer r.deinit(); - try testing.expectEqual(engine.pmtiles.TileType.mlt, r.header.tile_type); - const raw = (try r.getTile(a, z, tx, ty)) orelse return error.TestUnexpectedResult; - const layers = try engine.mlt.decode(a, raw); - var areas: ?engine.mvt.DecodedLayer = null; - for (layers) |l| { - if (std.mem.eql(u8, l.name, "areas")) areas = l; - } - const al = areas orelse return error.TestUnexpectedResult; - const E = tile.EXTENT; - const midy: i64 = @divTrunc(E, 2); - try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(E, 4), midy)); - try testing.expectEqual(@as(usize, 1), countContains(a, al.features, @divTrunc(@as(i64, E) * 3, 4), midy)); -} - -test "composeArchivesToFile: progress fires monotonically and reaches total" { - const gpa = testing.allocator; - const io = testing.io; - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - const tile = engine.tile; - - // Two cells sharing a seam tile at z13 (as above) — enough to walk the zoom ladder and emit. - const z: u8 = 13; - const scale: f64 = @floatFromInt(@as(u64, 1) << z); - const w = tile.lonLatToWorld(-76.48, 38.97); - const tx = worldAxisToTile(w[0], scale); - const ty = worldAxisToTile(w[1], scale); - const bnds = tile.tileBoundsLonLat(z, tx, ty); - const midlon = (bnds[0] + bnds[2]) / 2.0; - const dlat = (bnds[3] - bnds[1]) * 0.01; - - const arc_a = try synthCell(gpa, a, "AAAAAAAA", bnds[0], bnds[1] + dlat, midlon, bnds[3] - dlat, 20_000, z, tx, ty); - defer gpa.free(arc_a); - const arc_b = try synthCell(gpa, a, "BBBBBBBB", midlon, bnds[1] + dlat, bnds[2], bnds[3] - dlat, 20_000, z, tx, ty); - defer gpa.free(arc_b); - - const dir = std.Io.Dir.cwd(); - const pa = ".zig-cache/compose_prog_a.pmtiles.tmp"; - const pb = ".zig-cache/compose_prog_b.pmtiles.tmp"; - const po = ".zig-cache/compose_prog_out.pmtiles.tmp"; - try dir.writeFile(io, .{ .sub_path = pa, .data = arc_a }); - try dir.writeFile(io, .{ .sub_path = pb, .data = arc_b }); - defer { - dir.deleteFile(io, pa) catch {}; - dir.deleteFile(io, pb) catch {}; - dir.deleteFile(io, po) catch {}; - } - - const Rec = struct { - n: usize = 0, - last_done: u64 = 0, - max_done: u64 = 0, - total: u64 = 0, - monotonic: bool = true, - min_zoom: u32 = 999, - max_zoom: u32 = 0, - fn cb(ctx: ?*anyopaque, zoom: u32, min_zoom: u32, max_zoom: u32, done: u64, total: u64) callconv(.c) void { - const self: *@This() = @ptrCast(@alignCast(ctx.?)); - self.n += 1; - if (done < self.last_done) self.monotonic = false; - self.last_done = done; - if (done > self.max_done) self.max_done = done; - self.total = total; - self.min_zoom = min_zoom; - self.max_zoom = max_zoom; - _ = zoom; - } - }; - var rec = Rec{}; - var prog = ComposeProgress{ .cb = Rec.cb, .ctx = &rec }; - - const nc = try composeArchivesToFile(io, gpa, &.{ pa, pb }, po, &prog); - try testing.expectEqual(@as(usize, 2), nc); - try testing.expect(rec.n > 0); // the callback actually fired - try testing.expect(rec.monotonic); // done never went backwards - try testing.expect(rec.total > 0); - try testing.expectEqual(rec.total, rec.max_done); // the final emit closes the bar at 100% - try testing.expect(rec.max_zoom >= rec.min_zoom); -} diff --git a/src/chart.zig b/src/chart.zig index 6172e2f..3c54e55 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -8,13 +8,13 @@ //! //! This is the single source of truth; the C ABI (capi.zig / include/tile57.h) //! is a thin shim over these types. The engine uses a single thread-safe -//! general-purpose allocator internally — `tile`/`bakeArchive` return bytes owned -//! by it; free them with `freeBytes`. +//! general-purpose allocator internally — the render / bake / JSON entry points +//! return bytes owned by it; free them with `freeBytes`. //! -//! Threading: a Chart is NOT internally synchronized — don't call `tile` on the -//! same Chart from multiple threads concurrently (the tile cache + LRU mutate -//! without a lock). Distinct charts are independent. `openCells`/`bakeArchive` -//! parallelize internally over cores. +//! Threading: a Chart is NOT internally synchronized — don't call its render / +//! query methods on the same Chart from multiple threads concurrently. Distinct +//! charts are independent. `openCells`/`bakeArchive` parallelize internally over +//! cores. const std = @import("std"); const pmtiles = @import("tiles").pmtiles; @@ -89,7 +89,7 @@ pub const CellBytes = extern struct { /// host holds only the working set's bytes — not the whole ENC_ROOT. pub const CellReadFn = *const fn (user: ?*anyopaque, index: usize, out: *CellBytes) callconv(.c) bool; -/// Free bytes returned by `Chart.tile` / `bakeArchive` (page-allocator owned). +/// Free bytes returned by the render / bake / JSON entry points (page-allocator owned). pub fn freeBytes(bytes: []u8) void { gpa.free(bytes); } @@ -405,10 +405,6 @@ fn lazyFreeCell(lc: *LazyCell) void { if (lc.name.len > 0) gpa.free(@constCast(lc.name)); } -fn tileKey(z: u8, x: u32, y: u32) u64 { - return (@as(u64, z) << 48) | (@as(u64, x) << 24) | @as(u64, y); -} - // Resolve the S-101 rules dir: explicit arg, else TILE57_S101_RULES, else "" — // which uses the rules embedded in the binary (the Lua searcher in lua_shim.c), // so no on-disk catalogue is required. A non-empty path overrides the embedded @@ -713,11 +709,9 @@ pub const Chart = struct { // Defaults ON; the C ABI open can turn it off for lean tiles. (No effect on a // PMTiles/reader backend — those tiles are already baked.) pick_attrs: bool = true, - // Encoding for LIVE-generated tiles (cell backends). Defaults to MVT so - // existing chart-handle consumers (MVT-only renderers) are unaffected; a host - // whose renderer decodes MLT opts in via setTileFormat. A PMTiles/reader - // backend ignores it — stored tiles serve verbatim in their baked encoding - // (see tileType). + // The tile encoding reported for a cell backend (via tileType); fixed at MVT. + // A PMTiles/reader backend ignores it — stored tiles serve verbatim in their + // baked encoding (see tileType). tile_format: scene.TileFormat = .mvt, // A live cell baked to an in-memory PMTiles (openCellBaked) is a .reader for // fast render, but still carries the source cell's real M_COVR coverage and @@ -826,7 +820,6 @@ pub const Chart = struct { /// bytes on demand for the working set (freed on LRU eviction) — the caller hands /// over only a path and the engine holds only what tiles need. The chart owns the /// retained Io + Dir for its lifetime (freed in deinit). Errors if no cell parses. - /// TODO(chart-api): unify the enumeration with the baker's bakeRoot (parity-gated). pub fn openPath(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart { const threaded = try gpa.create(std.Io.Threaded); errdefer gpa.destroy(threaded); @@ -935,9 +928,9 @@ pub const Chart = struct { }; } - /// The encoding `tile` returns: a PMTiles backend reports its archive's stored - /// tile type (tiles serve verbatim); a cell backend reports its live - /// generation format (`tile_format`). Non-vector archive types (png/…) are + /// The tile encoding this chart's tiles carry: a PMTiles backend reports its + /// archive's stored tile type (tiles serve verbatim); a cell backend reports its + /// live generation format (`tile_format`). Non-vector archive types (png/…) are /// reported as-is. pub fn tileType(self: *Chart) pmtiles.TileType { return switch (self.backend) { @@ -949,19 +942,6 @@ pub const Chart = struct { }; } - /// Select the encoding for LIVE-generated tiles (cell backends; no-op for a - /// baked PMTiles backend — its stored encoding is fixed). Drops the tile - /// cache so already-served coordinates regenerate in the new format. - pub fn setTileFormat(self: *Chart, fmt: scene.TileFormat) void { - switch (self.backend) { - .reader => return, - .cell, .cells => {}, - } - if (self.tile_format == fmt) return; - self.tile_format = fmt; - self.clearCache(); - } - /// Min/max zoom served (PMTiles: archive range; cell: 0..18). pub fn zoomRange(self: *Chart) struct { min: u8, max: u8 } { return switch (self.backend) { @@ -1069,36 +1049,6 @@ pub const Chart = struct { } } - /// Fetch tile (z,x,y) as decompressed vector-tile bytes in the chart's tile - /// encoding (`tileType`): a PMTiles backend serves the stored bytes verbatim - /// (MVT or MLT, whatever was baked); a cell backend generates in - /// `tile_format`. Returns null for an empty / absent tile, else - /// page-allocator-owned bytes (free with `freeBytes`). Cached per source, so - /// re-requests are cheap and deterministic. - pub fn tile(self: *Chart, z: u8, x: u32, y: u32) !?[]u8 { - const key = tileKey(z, x, y); - if (self.cache.get(key)) |cached| { - if (cached.len == 0) return null; - return try gpa.dupe(u8, cached); - } - // Baked tiles are the ONLY tile path: cell-backed charts exist for - // metadata/inspection (cellsJson/featuresJson/scamin/renderView on a - // single cell), never on-demand tile generation — bake first, serve - // the archive. - const bytes: []u8 = switch (self.backend) { - .reader => |*r| (r.getTile(gpa, z, x, y) catch return error.TileGen) orelse try gpa.alloc(u8, 0), - .cell, .cells => return error.TileGen, - }; - if (self.cache.count() >= self.cache_max) { - var cit = self.cache.valueIterator(); - while (cit.next()) |v| gpa.free(v.*); - self.cache.clearRetainingCapacity(); - } - self.cache.put(key, bytes) catch {}; // best-effort; cache owns `bytes` on success - if (bytes.len == 0) return null; - return try gpa.dupe(u8, bytes); - } - /// Render a VIEW of the chart — centre + fractional zoom + pixel size — /// through the native S-52 pixel path: real portrayal, vector symbols, /// labels + declutter over the whole canvas. Returns PNG bytes (gpa-owned; @@ -1503,13 +1453,6 @@ pub const Chart = struct { return try gpa.dupe(u8, out.items); } - /// Drop the in-memory tile cache (bounds memory in long-running hosts). - pub fn clearCache(self: *Chart) void { - var it = self.cache.valueIterator(); - while (it.next()) |v| gpa.free(v.*); - self.cache.clearRetainingCapacity(); - } - /// The distinct SCAMIN denominators present in the source, ascending. The host /// publishes these as the live SCAMIN manifest so its style builds one native /// per-value bucket layer per denominator (host-canonical-backend.md §2). A baked @@ -1935,7 +1878,7 @@ const BakeWork = struct { portrayal_plain = cp.plain; portrayal_simplified = cp.simplified; } else |_| {} - // Build the geometry cache for EVERY cell (as bakeRoot does — unconditional). + // Build the geometry cache for EVERY cell, unconditionally. // `build_geo` (cacheGeoForBand) gated it to the finer bands, but coarse cells are // exactly the ones that hurt without it: the geo cache both cheapens per-tile // reprojection AND lets buildLabelCache assemble each feature's parts to populate @@ -1944,7 +1887,7 @@ const BakeWork = struct { geo = scene.buildGeoCache(p.allocator(), &cell) catch null; // Per-feature label-point (polylabel) cache — tile-invariant, so compute it ONCE // per cell (only for Text/centred-symbol features) instead of re-running the search - // for every tile a feature touches. Mirrors bakeRoot; arena outlives via c.arenas. + // for every tile a feature touches; the arena outlives the call via c.arenas. cell.label_cache = scene.buildLabelCache(p.allocator(), &cell, geo, portrayal) catch null; } // M_COVR coverage + scale for per-cell quilting (allocate into the cell's own diff --git a/src/tile57.zig b/src/tile57.zig index 5fa2bb4..08826e3 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -23,10 +23,10 @@ const std = @import("std"); /// Library version (matches build.zig.zon and tile57_version()). pub const version = "0.1.0"; -// ---- high-level engine: open a Chart, fetch tiles, bake, build a style ---- +// ---- high-level engine: open a Chart, render, bake, build a style ---- const chart = @import("chart.zig"); -/// An embeddable chart tile source: `Chart.openBytes` / `Chart.openCells`, -/// then `.tile(z,x,y)`. See chart.zig. +/// An embeddable chart source: `Chart.openBytes` / `Chart.openCells`, then +/// render / query / bake. See chart.zig. pub const Chart = chart.Chart; pub const Format = chart.Format; pub const CellInput = chart.CellInput; @@ -38,7 +38,7 @@ pub const CellBytes = chart.CellBytes; pub const CellReadFn = chart.CellReadFn; /// Bake an ENC_ROOT into one band-streamed PMTiles archive. pub const bakeArchive = chart.bakeArchive; -/// Free bytes returned by `Chart.tile` / `bakeArchive`. +/// Free bytes returned by the render / bake entry points. pub const freeBytes = chart.freeBytes; /// MapLibre style generation from a template + mariner S-52 display settings. diff --git a/tools/bake.zig b/tools/bake.zig index e357819..008870f 100644 --- a/tools/bake.zig +++ b/tools/bake.zig @@ -1,67 +1,38 @@ +//! `bake -o [--rules DIR] [--from-archives]` — produce a +//! LIVE-composite structure on disk. Bake each cell to its OWN native-scale PMTiles under +//! `/tiles/` (with its M_COVR coverage embedded in the metadata), then open a resident +//! compositor over them and write the ownership partition to `/partition.tpart`. There is +//! NO merged archive: a runtime compositor (`ComposeSource` / the `compose-tile` command / the C +//! ABI `tile57_compose_*`) serves any tile ON DEMAND from this structure, so the per-cell bakes stay +//! dumb + cacheable and the partition holds all cross-cell ownership. Native scale only — deeper +//! coarse zooms are left to the client camera + MapLibre overzoom. +//! +//! `--from-archives`: `` is ALREADY a directory of per-cell archives (*.pmtiles / *.cell.tmp); +//! skip the bake and only (re)build the partition sidecar into `/partition.tpart` over them +//! — the fast re-partition loop over a tiles dir. + const std = @import("std"); -const engine = @import("engine"); -const assets = @import("assets"); -const bundle = @import("bundle"); // chart-bundle pipeline (asset emitters etc.) — the lib owns it +const chart = @import("chart"); // per-cell bake (bakeCellBytes) + freeBytes +const bundle = @import("bundle"); // openComposeSourceFiles + serializePartition (the resident compositor) const common = @import("common.zig"); const Flags = common.Flags; const usageErr = common.usageErr; const resolveRulesDir = common.resolveRulesDir; -const resolveCatalogDir = common.resolveCatalogDir; -const VERSION = common.VERSION; -const DEFAULT_MINZOOM = common.DEFAULT_MINZOOM; -const DEFAULT_MAXZOOM = common.DEFAULT_MAXZOOM; -const DEFAULT_LRU_BUDGET = common.DEFAULT_LRU_BUDGET; -const DEFAULT_SUPER_DZ = common.DEFAULT_SUPER_DZ; -/// `bake -o [--rules DIR] [--catalog DIR] -/// [--minzoom N] [--maxzoom N] [--lru N] [--superdz N] [--created ISO8601]` — -/// THE bake command. A single cell or a whole ENC_ROOT, streamed through the same -/// lazy banded bake into a self-contained chart bundle: tiles/chart.pmtiles + -/// assets/colortables.json + sprite-mln + style-{day,dusk,night}.json + -/// manifest.json (pins schema_version, couples tiles to portrayal). pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var base: ?[]const u8 = null; var out: ?[]const u8 = null; var rules: ?[]const u8 = null; - var catalog: ?[]const u8 = null; - var created: []const u8 = ""; - var minzoom: u8 = DEFAULT_MINZOOM; - var maxzoom: u8 = DEFAULT_MAXZOOM; - var lru: usize = DEFAULT_LRU_BUDGET; // lazy-bake tuning: parsed cells held resident - var super_dz: u8 = DEFAULT_SUPER_DZ; // lazy-bake tuning: spatial super-tile depth - var format: engine.scene.TileFormat = .mlt; // tile encoding: mlt (default) or mvt - var existing: []const u8 = ""; // cross-pack best-available: a peer pack's ENC_ROOT - var partition_debug = false; // emit the ownership-partition debug PMTiles instead of a bundle - var part_band: i8 = -1; // partition-debug: <0 = band governing each zoom; 0..5 = one band's map + var from_archives = false; // is a dir of pre-baked archives — skip baking, only build the partition var f = Flags{ .args = args }; while (f.next()) |arg| { if (std.mem.eql(u8, arg, "-o") or std.mem.eql(u8, arg, "--output")) { out = f.val(arg) orelse return; - } else if (std.mem.eql(u8, arg, "--existing")) { - existing = f.val(arg) orelse return; } else if (std.mem.eql(u8, arg, "--rules")) { rules = f.val(arg) orelse return; - } else if (std.mem.eql(u8, arg, "--catalog")) { - catalog = f.val(arg) orelse return; - } else if (std.mem.eql(u8, arg, "--created")) { - created = f.val(arg) orelse return; - } else if (std.mem.eql(u8, arg, "--minzoom")) { - minzoom = f.int(u8, arg) orelse return; - } else if (std.mem.eql(u8, arg, "--maxzoom")) { - maxzoom = f.int(u8, arg) orelse return; - } else if (std.mem.eql(u8, arg, "--lru")) { - lru = f.int(usize, arg) orelse return; - } else if (std.mem.eql(u8, arg, "--superdz")) { - super_dz = f.int(u8, arg) orelse return; - } else if (std.mem.eql(u8, arg, "--format")) { - const v = f.val(arg) orelse return; - format = if (std.mem.eql(u8, v, "mlt")) .mlt else if (std.mem.eql(u8, v, "mvt")) .mvt else return usageErr("--format must be mvt or mlt"); - } else if (std.mem.eql(u8, arg, "--partition-debug")) { - partition_debug = true; - } else if (std.mem.eql(u8, arg, "--band")) { - const v = f.val(arg) orelse return; - part_band = bandArg(v) orelse return usageErr("--band must be a rank 0..5 or a name (berthing..overview)"); + } else if (std.mem.eql(u8, arg, "--from-archives")) { + from_archives = true; } else if (std.mem.startsWith(u8, arg, "-")) { return usageErr("unknown flag"); } else if (base == null) { @@ -70,59 +41,95 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { return usageErr("unexpected argument (cell updates are auto-discovered next to the .000)"); } } - const base_path = base orelse return usageErr("missing input"); const out_dir = out orelse return usageErr("missing -o/--output "); - if (minzoom > maxzoom) return usageErr("--minzoom must be <= --maxzoom"); - if (maxzoom > 24) return usageErr("--maxzoom too large (max 24)"); - if (lru < 1) return usageErr("--lru must be >= 1"); + const rules_dir = resolveRulesDir(rules); - // Debug: emit the ownership partition (which cell owns which ground per band) as a - // single PMTiles with no portrayed content, for eyeballing the composite quilt. - if (partition_debug) { - // Full overview→approach range by default (z13+ shows harbor detail but the - // face-tile count grows ~4^z, so raise --maxzoom on demand). - const dbg_minz: u8 = if (minzoom == DEFAULT_MINZOOM) 0 else minzoom; - const dbg_maxz: u8 = if (maxzoom == DEFAULT_MAXZOOM) 12 else maxzoom; - const nc = bundle.bakePartitionDebug(io, a, base_path, out_dir, dbg_minz, dbg_maxz, part_band) catch |err| { - std.debug.print("error: partition-debug bake of {s} failed ({s})\n", .{ base_path, @errorName(err) }); - return; - }; - std.debug.print("partition-debug: {d} cell(s) -> {s} (z{d}-{d}, band {d}, layer \"partition\": cell/cscl/band/tier/oi/color)\n", .{ nc, out_dir, dbg_minz, dbg_maxz, part_band }); - return; + // The per-cell archive paths that back the compositor. + var archive_paths = std.ArrayList([]const u8).empty; + + if (from_archives) { + // is a directory of pre-baked *.pmtiles / *.cell.tmp — read them in place. + var dir = std.Io.Dir.cwd().openDir(io, base_path, .{ .iterate = true }) catch return usageErr("cannot open archives dir"); + defer dir.close(io); + var walker = dir.walk(a) catch return usageErr("cannot walk archives dir"); + defer walker.deinit(); + while (walker.next(io) catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.path, ".cell.tmp") and !std.mem.endsWith(u8, entry.path, ".pmtiles")) continue; + archive_paths.append(a, std.fs.path.join(a, &.{ base_path, entry.path }) catch continue) catch {}; + } + if (archive_paths.items.len == 0) return usageErr("no *.pmtiles / *.cell.tmp archives found"); + std.debug.print("re-partition: {d} pre-baked archives from {s}\n", .{ archive_paths.items.len, base_path }); + } else { + // Bake each cell (dedup by stem — a boundary cell shared by two districts bakes once) + // to its own /tiles/.pmtiles. + const tiles_dir = try std.fs.path.join(a, &.{ out_dir, "tiles" }); + try std.Io.Dir.cwd().createDirPath(io, tiles_dir); + + var cell_paths = std.ArrayList([]const u8).empty; + if (std.mem.endsWith(u8, base_path, ".000")) { + try cell_paths.append(a, base_path); + } else { + var dir = std.Io.Dir.cwd().openDir(io, base_path, .{ .iterate = true }) catch return usageErr("cannot open ENC_ROOT"); + defer dir.close(io); + var seen = std.StringHashMap(void).init(a); + var walker = dir.walk(a) catch return usageErr("cannot walk ENC_ROOT"); + defer walker.deinit(); + while (walker.next(io) catch null) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; + const stem = std.fs.path.stem(std.fs.path.basename(entry.path)); + if (seen.contains(stem)) continue; + seen.put(a.dupe(u8, stem) catch continue, {}) catch {}; + cell_paths.append(a, std.fs.path.join(a, &.{ base_path, entry.path }) catch continue) catch {}; + } + } + if (cell_paths.items.len == 0) return usageErr("no .000 cells found"); + + for (cell_paths.items) |cp| { + const arc = (chart.bakeCellBytes(cp, rules_dir) catch |err| { + std.debug.print(" warn: bake of {s} failed ({s}); skipping\n", .{ cp, @errorName(err) }); + continue; + }) orelse continue; // no M_COVR coverage — not a composable cell + defer chart.freeBytes(arc); + const stem = std.fs.path.stem(std.fs.path.basename(cp)); + const name = std.fmt.allocPrint(a, "{s}.pmtiles", .{stem}) catch continue; + const arc_path = std.fs.path.join(a, &.{ tiles_dir, name }) catch continue; + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = arc_path, .data = arc }) catch |err| { + std.debug.print(" warn: could not write {s} ({s})\n", .{ arc_path, @errorName(err) }); + continue; + }; + archive_paths.append(a, arc_path) catch {}; + if (archive_paths.items.len % 25 == 0) std.debug.print(" baked {d}/{d} cells…\n", .{ archive_paths.items.len, cell_paths.items.len }); + } + if (archive_paths.items.len == 0) return usageErr("no cells baked (no .000 with M_COVR found)"); } - // The whole tiles + assets + manifest pipeline lives in the `bundle` lib module - // (bundle.bakeBundle) so any consumer (the C ABI, a Go/JS binding) emits the same - // package; the CLI just resolves args -> options and prints the summary. - const res = bundle.bakeBundle(io, a, .{ - .input = base_path, - .out_dir = out_dir, - .rules_dir = resolveRulesDir(rules), - .catalog_dir = resolveCatalogDir(catalog), - .generator = VERSION, - .created = created, - .minzoom = minzoom, - .maxzoom = maxzoom, - .lru = lru, - .super_dz = super_dz, - .format = format, - .existing_input = existing, - }) catch |err| { - std.debug.print("error: cannot bake {s} ({s})\n", .{ base_path, @errorName(err) }); + // Sort the archive paths so the ownership tie-break (which falls back to input order for + // archives carrying identical (date, name) keys) and the partition it produces are deterministic. + std.mem.sort([]const u8, archive_paths.items, {}, struct { + fn lt(_: void, x: []const u8, y: []const u8) bool { + return std.mem.lessThan(u8, x, y); + } + }.lt); + + // Open the resident compositor over the per-cell archives (mmap'd) and serialize its ownership + // partition to /partition.tpart — the sidecar a runtime open loads to skip the build. + const src = (bundle.openComposeSourceFiles(io, a, archive_paths.items, null) catch |err| { + std.debug.print("error: open compose source failed ({s})\n", .{@errorName(err)}); + return; + }) orelse return usageErr("no coverage-carrying archives (nothing to compose)"); + defer src.deinit(); + + const part_bytes = src.serializePartition(a) catch |err| { + std.debug.print("error: partition serialization failed ({s})\n", .{@errorName(err)}); return; }; + const part_path = try std.fs.path.join(a, &.{ out_dir, "partition.tpart" }); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = part_path, .data = part_bytes }); std.debug.print( - "bundled {d} cell(s) -> {s}/\n tiles/chart.pmtiles + assets/colortables.json + sprite-mln + style-{{day,dusk,night}}.json + manifest.json (schema {s})\n", - .{ res.cell_count, out_dir, assets.SCHEMA_VERSION }, + "live structure -> {s}/\n {d} per-cell archive(s){s} + partition.tpart (serve z {d}..{d})\n", + .{ out_dir, src.readers.len, if (from_archives) " (in place)" else " under tiles/", src.minz, src.loop_max }, ); } - -// Parse a --band value: a rank 0..5, or a navigational-purpose name (finest→coarsest). -fn bandArg(v: []const u8) ?i8 { - const names = [_][]const u8{ "berthing", "harbor", "approach", "coastal", "general", "overview" }; - for (names, 0..) |nm, i| if (std.mem.eql(u8, v, nm)) return @intCast(i); - const n = std.fmt.parseInt(i8, v, 10) catch return null; - return if (n < 0 or n > 5) null else n; -} diff --git a/tools/compose.zig b/tools/compose.zig deleted file mode 100644 index 2b3dc6e..0000000 --- a/tools/compose.zig +++ /dev/null @@ -1,134 +0,0 @@ -//! `compose -o [--rules DIR] [--keep-cells] -//! [--from-archives] [--save-partition FILE] [--load-partition FILE]` — the -//! per-cell composite bake, disk-to-disk. Bake each cell to its OWN native-scale PMTiles (with -//! its M_COVR coverage embedded in the metadata), written to a temp file so only ONE cell's -//! archive is ever resident; then stream them through the ownership partition into one merged -//! PMTiles (bundle.composeArchivesToFile mmaps the per-cell files, so the whole cell set is -//! never loaded into memory at once). This is the two-stage model that retires the streaming -//! in-bake cross-cell combiner. Native scale only for now — cross-band overscale one zoom past -//! each band; deeper coarse-only zooms are left to the client camera + MapLibre overzoom. - -const std = @import("std"); -const engine = @import("engine"); -const chart = @import("chart"); // per-cell bake (bakeCellBytes) + freeBytes -const bundle = @import("bundle"); // composeArchivesToFile (the partition-driven compositor) -const common = @import("common.zig"); -const Flags = common.Flags; -const usageErr = common.usageErr; -const resolveRulesDir = common.resolveRulesDir; - -pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { - var base: ?[]const u8 = null; - var out: ?[]const u8 = null; - var rules: ?[]const u8 = null; - var keep_cells = false; // retain the per-cell temp PMTiles (a reusable cache) instead of deleting - var from_archives = false; // compose a dir of pre-baked archives (skip the bake) - var save_partition: ?[]const u8 = null; // write the built ownership partition to this sidecar - var load_partition: ?[]const u8 = null; // load the ownership partition from this sidecar (skip build) - - var f = Flags{ .args = args }; - while (f.next()) |arg| { - if (std.mem.eql(u8, arg, "-o") or std.mem.eql(u8, arg, "--output")) { - out = f.val(arg) orelse return; - } else if (std.mem.eql(u8, arg, "--rules")) { - rules = f.val(arg) orelse return; - } else if (std.mem.eql(u8, arg, "--keep-cells")) { - keep_cells = true; - } else if (std.mem.eql(u8, arg, "--from-archives")) { - from_archives = true; // is a DIR of pre-baked *.cell.tmp / *.pmtiles — skip baking - } else if (std.mem.eql(u8, arg, "--save-partition")) { - save_partition = f.val(arg) orelse return; - } else if (std.mem.eql(u8, arg, "--load-partition")) { - load_partition = f.val(arg) orelse return; - } else if (std.mem.startsWith(u8, arg, "-")) { - return usageErr("unknown flag"); - } else if (base == null) { - base = arg; - } else { - return usageErr("unexpected argument (cell updates are auto-discovered next to the .000)"); - } - } - const base_path = base orelse return usageErr("missing input"); - const out_path = out orelse return usageErr("missing -o/--output "); - const rules_dir = resolveRulesDir(rules); - - // 1. Enumerate the cell .000 paths (dedup by stem — a boundary cell shared by two districts - // bakes once). - var cell_paths = std.ArrayList([]const u8).empty; - if (std.mem.endsWith(u8, base_path, ".000")) { - try cell_paths.append(a, base_path); - } else { - var dir = std.Io.Dir.cwd().openDir(io, base_path, .{ .iterate = true }) catch return usageErr("cannot open ENC_ROOT"); - defer dir.close(io); - var seen = std.StringHashMap(void).init(a); - var walker = dir.walk(a) catch return usageErr("cannot walk ENC_ROOT"); - defer walker.deinit(); - while (walker.next(io) catch null) |entry| { - if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; - const stem = std.fs.path.stem(std.fs.path.basename(entry.path)); - if (seen.contains(stem)) continue; - seen.put(a.dupe(u8, stem) catch continue, {}) catch {}; - cell_paths.append(a, std.fs.path.join(a, &.{ base_path, entry.path }) catch continue) catch {}; - } - } - if (!from_archives and cell_paths.items.len == 0) return usageErr("no .000 cells found"); - - // 2. Bake each cell to its own temp PMTiles file (one cell resident at a time — the bytes - // are freed as soon as they are written). - var tmp_paths = std.ArrayList([]const u8).empty; - defer if (!keep_cells and !from_archives) for (tmp_paths.items) |p| { - std.Io.Dir.cwd().deleteFile(io, p) catch {}; - }; - if (from_archives) { - // is a directory of pre-baked *.cell.tmp / *.pmtiles — compose them directly. - // The fast recompose loop over a --keep-cells cache: the bake (~minutes for a - // district) is skipped, only the partition + compose (~seconds to minutes) reruns. - var dir = std.Io.Dir.cwd().openDir(io, base_path, .{ .iterate = true }) catch return usageErr("cannot open archives dir"); - defer dir.close(io); - var walker = dir.walk(a) catch return usageErr("cannot walk archives dir"); - defer walker.deinit(); - while (walker.next(io) catch |err| blk: { - // A mid-walk I/O error would silently compose a PARTIAL cell set — say so. - std.debug.print(" warn: archive walk aborted early ({s}); composing what was found\n", .{@errorName(err)}); - break :blk null; - }) |entry| { - if (entry.kind != .file) continue; - if (!std.mem.endsWith(u8, entry.path, ".cell.tmp") and !std.mem.endsWith(u8, entry.path, ".pmtiles")) continue; - tmp_paths.append(a, std.fs.path.join(a, &.{ base_path, entry.path }) catch continue) catch {}; - } - // Sort the archive paths: the ownership tie-break falls back to input order for - // archives carrying identical (date, name) keys (e.g. two generations of one - // cell in a polluted cache), and directory enumeration order is unspecified — - // sorting makes any such tie, and the composed bytes, deterministic. - std.mem.sort([]const u8, tmp_paths.items, {}, struct { - fn lt(_: void, x: []const u8, y: []const u8) bool { - return std.mem.lessThan(u8, x, y); - } - }.lt); - std.debug.print("recompose: {d} pre-baked archives from {s}\n", .{ tmp_paths.items.len, base_path }); - } else for (cell_paths.items) |cp| { - const arc = (chart.bakeCellBytes(cp, rules_dir) catch |err| { - std.debug.print(" warn: bake of {s} failed ({s}); skipping\n", .{ cp, @errorName(err) }); - continue; - }) orelse continue; - defer chart.freeBytes(arc); - const stem = std.fs.path.stem(std.fs.path.basename(cp)); - const tmp = std.fmt.allocPrint(a, "{s}.{s}.cell.tmp", .{ out_path, stem }) catch continue; - std.Io.Dir.cwd().writeFile(io, .{ .sub_path = tmp, .data = arc }) catch continue; - tmp_paths.append(a, tmp) catch {}; - if (tmp_paths.items.len % 25 == 0) std.debug.print(" baked {d}/{d} cells…\n", .{ tmp_paths.items.len, cell_paths.items.len }); - } - if (tmp_paths.items.len == 0) return usageErr("no cells baked (no .000 with M_COVR found)"); - - // 3. Stream-compose the per-cell files into one PMTiles (mmap in, streamed out — the cell - // set is never all resident). - const nc = bundle.composeArchivesToFileOpt(io, a, tmp_paths.items, out_path, null, load_partition, save_partition) catch |err| { - std.debug.print("error: compose failed ({s})\n", .{@errorName(err)}); - return; - }; - if (nc == 0) { - std.debug.print("compose produced no tiles\n", .{}); - return; - } - std.debug.print("composed {d} cell(s) -> {s}{s}\n", .{ nc, out_path, if (keep_cells) " (per-cell temp files kept)" else "" }); -} diff --git a/tools/main.zig b/tools/main.zig index 35155b8..da8d14a 100644 --- a/tools/main.zig +++ b/tools/main.zig @@ -1,10 +1,10 @@ //! tile57 — the offline S-57 -> PMTiles baker / inspector CLI. //! //! Subcommands: -//! bake -o [--rules DIR] [--minzoom N] [--maxzoom N] [update.001 ...] -//! Decode an S-57 base cell (applying any update files), portray it, and -//! pre-bake every web-mercator MVT tile covering the cell's bounds across -//! the requested zoom range into a clustered PMTiles archive. +//! bake -o [--rules DIR] [--from-archives] +//! Bake each cell to /tiles/.pmtiles and write the ownership +//! partition to /partition.tpart — the live-composite structure a +//! runtime compositor serves tiles from on demand. //! inspect [z x y] //! Parse a PMTiles archive (header + directory) and, if z/x/y is given, //! read+gunzip+decode that tile and list its MVT layers. @@ -23,7 +23,6 @@ const std = @import("std"); const common = @import("common.zig"); const bake = @import("bake.zig"); -const compose = @import("compose.zig"); const compose_tile = @import("compose_tile.zig"); const assets = @import("assets.zig"); const sprite = @import("sprite.zig"); @@ -54,9 +53,6 @@ pub fn main(init: std.process.Init) !void { if (std.mem.eql(u8, sub, "bake")) { return bake.run(io, arena, args); } - if (std.mem.eql(u8, sub, "compose")) { - return compose.run(io, arena, args); - } if (std.mem.eql(u8, sub, "compose-tile")) { return compose_tile.run(io, arena, args); } From d1196293534c5f24d405b9d4c199024e7304f499 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 10:29:46 -0400 Subject: [PATCH 052/140] docs(cli): point `tile57 bake` usage at the live-composite structure The runtime usage string still described the old batch-bundle bake (chart.pmtiles + assets + manifest, --minzoom/--maxzoom/--lru/--superdz). Rewrite it for the repurposed `bake` (per-cell tiles/ + partition.tpart) and document `compose-tile`. Drop the now-unused DEFAULT_MINZOOM/MAXZOOM/ LRU_BUDGET/SUPER_DZ consts (their only consumer was the deleted batch bake). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/common.zig | 43 ++++++++++++++----------------------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/tools/common.zig b/tools/common.zig index f7fb0d3..1789ac1 100644 --- a/tools/common.zig +++ b/tools/common.zig @@ -8,15 +8,6 @@ const bundle = @import("bundle"); // chart-bundle pipeline (asset emitters etc.) pub const VERSION = "tile57 0.1.0"; -pub const DEFAULT_MINZOOM: u8 = 8; -pub const DEFAULT_MAXZOOM: u8 = 16; - -// Lazy-baker tuning (bake-root): LRU budget = parsed cells kept loaded across -// super-tiles; super-tile depth = how far below a band's min zoom the spatial -// batch tile sits. Overridable via --lru / --superdz for tuning. -pub const DEFAULT_LRU_BUDGET: usize = 256; -pub const DEFAULT_SUPER_DZ: u8 = 3; - // Env access lives in the Lua C shim (Zig 0.16 gates env behind std.Io); // returns the S-101 rules dir from TILE57_S101_RULES or null. Mirrors capi.zig. extern fn tg_env_rules() callconv(.c) ?[*:0]const u8; @@ -151,26 +142,20 @@ pub fn printUsage() void { \\{s} — offline S-57 -> PMTiles baker / inspector \\ \\usage: - \\ tile57 bake -o [options] - \\ Bake a single S-57 cell OR a whole ENC_ROOT (every .000 + - \\ its auto-discovered updates, zoom-banded per cell by compilation - \\ scale) into a self-contained chart bundle, streamed to disk: - \\ /tiles/chart.pmtiles + assets/colortables.json + sprite-mln + - \\ style-{{day,dusk,night}}.json + manifest.json (pins schema_version, - \\ couples tiles to portrayal). - \\ -o, --output DIR output bundle directory (required) + \\ tile57 bake -o [--rules DIR] [--from-archives] + \\ Produce a live-composite structure: bake each cell (a single .000 + + \\ its auto-discovered updates, OR every .000 in an ENC_ROOT, at + \\ native band scale) to its own /tiles/.pmtiles with M_COVR + \\ coverage embedded, then write the ownership partition to + \\ /partition.tpart. A runtime compositor (compose-tile / the C ABI) + \\ serves any tile ON DEMAND from this structure — there is no merged archive. + \\ -o, --output DIR output directory (required) \\ --rules DIR S-101 portrayal rules directory (default: embedded) - \\ --catalog DIR PortrayalCatalog (default: parent of --rules) - \\ --minzoom N lowest zoom to bake (default {d}) - \\ --maxzoom N highest zoom to bake (default {d}) - \\ --created ISO8601 stamp the manifest (no wall clock in-process) - \\ --lru N parsed cells held resident (lazy-bake tuning; trade - \\ memory for fewer re-parses; default {d}) - \\ --superdz N spatial super-tile depth below a band's min zoom - \\ (lazy-bake tuning; default {d}) - \\ --format mlt|mvt tile encoding (default mlt = MapLibre Tile; - \\ mvt = Mapbox Vector Tile, kept for consumers - \\ without an MLT decoder) + \\ --from-archives is already a dir of per-cell archives; skip + \\ baking, only (re)build partition.tpart over them + \\ tile57 compose-tile [--load-partition FILE] [-o out] [--bench N] + \\ Serve ONE composed tile on demand from a live-composite structure + \\ (the runtime compositor); --bench N times an NxN block around (x,y). \\ tile57 assets -o \\ Emit just the portrayal assets (colortables.json today) for a \\ catalogue, independent of any cell. @@ -208,5 +193,5 @@ pub fn printUsage() void { \\ tile57 version \\ tile57 help \\ - , .{ VERSION, DEFAULT_MINZOOM, DEFAULT_MAXZOOM, DEFAULT_LRU_BUDGET, DEFAULT_SUPER_DZ }); + , .{VERSION}); } From d991c7650b7afd8a37a6baa9da3dd61058179c80 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 10:41:58 -0400 Subject: [PATCH 053/140] docs(capi): regroup tile57.h into 7 sections + drop stale removed-fn refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize the public C ABI header into the clean sections it grew out of: ①version ②chart open+metadata ③cell baking ④live composing ⑤render surface ⑥style+assets ⑦util/catalog/debug. Same 37 functions / 23 types (verified by symbol-set diff + a cc -fsyntax-only check) — comments and order only. Sweep the comment refs to functions removed in the live-compose cleanup (Steps B/C): tile57_chart_tile, tile57_chart_set_tile_format, tile57_chart_clear_cache, tile57_bake_pmtiles, tile57_bake_bundle, tile57_compose_files. The file banner no longer claims the chart "serves tiles by (z,x,y)" (that was the removed tile57_chart_tile path) — tiles are now bake-cell + compose-on-demand, and the chart is the metadata/render handle. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/tile57.h | 483 ++++++++++++++++++++++++++--------------------- 1 file changed, 271 insertions(+), 212 deletions(-) diff --git a/include/tile57.h b/include/tile57.h index 6e46c01..8de9078 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -1,18 +1,30 @@ /* tile57.h — public C ABI for libtile57. * - * libtile57 is an embeddable nautical-chart tile engine. You open a chart - * from a path or in-memory bytes (a PMTiles archive or a raw S-57 ENC cell) and it - * serves decompressed vector tiles by (z, x, y) — MapLibre Tiles (MLT, the default - * bake format) or Mapbox Vector Tiles (MVT), per tile57_chart_info.tile_type. The - * bytes are produced by the Zig tile generator (the engine/ sources) and consumed - * by any matching renderer — maplibre-gl >= 5.12 decodes both natively (vector - * source `encoding` option) — but the ABI itself is renderer-agnostic. + * libtile57 is an embeddable nautical-chart engine. It turns IHO S-57 ENC cells + * into vector tiles + a matching S-52 style, and renders finished charts to + * pixels / PDF / a callback surface. * * Tiles are made ONE way: bake each ENC cell to its own PMTiles * (tile57_bake_cell_bytes), then compose them ON DEMAND through the ownership - * partition (tile57_compose_open / tile57_compose_serve). A tile57_chart is a - * SEPARATE handle used for metadata (open + get_info / cells / features / coverage - * / query) and for rendering a view to pixels / PDF / a callback surface. + * partition (tile57_compose_open / tile57_compose_serve). The composed bytes are + * decompressed vector tiles — MapLibre Tiles (MLT, the default) or Mapbox Vector + * Tiles (MVT) — consumed by any matching renderer (maplibre-gl >= 5.12 decodes + * both natively via the vector source `encoding` option); the ABI is + * renderer-agnostic. + * + * A tile57_chart is a SEPARATE handle used for metadata (open + get_info / cells / + * features / coverage / query / scamin) and for rendering a view to pixels / PDF / + * a callback surface. It does not itself serve (z, x, y) tiles — that is the + * compositor's job. + * + * The sections below: + * 1. Version + * 2. Chart: open + metadata + * 3. Cell baking (the per-cell tiles the compositor stitches) + * 4. Live composing (the runtime compositor) + * 5. Render surface (PNG / PDF / callback canvas + world-space surface) + * 6. Style + portrayal assets + * 7. Util / catalogue / debug * * Lifetime: a tile57_chart / tile57_compose_source must OUTLIVE every renderer or * adapter still holding it. In the MapLibre hosts the handle is captured by a @@ -41,27 +53,28 @@ extern "C" { #endif +/* ======================================================================== * + * 1. Version + * ======================================================================== */ + /* Library version. tile57_version() returns the string form, e.g. "0.1.0". */ #define TILE57_VERSION_MAJOR 0 #define TILE57_VERSION_MINOR 1 #define TILE57_VERSION_PATCH 0 const char *tile57_version(void); +/* ======================================================================== * + * 2. Chart: open + metadata + * + * A tile57_chart is the metadata + render handle: open a cell (or a baked + * PMTiles) and read its bounds / scale / coverage / features, query the feature + * under a point, or render a view (section 5). Tile production is separate — + * see sections 3 (bake) and 4 (compose). + * ======================================================================== */ + /* Opaque chart handle. */ typedef struct tile57_chart tile57_chart; -/* ---- pick-report attributes --------------------------------------------- - * - * Every emitted MVT feature carries the per-feature cursor-pick / inspector - * properties used by the S-52 §10.8 pick report: `class` (object-class acronym), - * `cell` (source cell name, from tile57_cell.name / the bundle's file stem), - * and `s57` (a JSON object string of the feature's full S-57 attribute set, - * acronym -> value). These default ON. The `s57` blob is the bulk of a feature's - * size, so a lean deployment that doesn't need pick/inspect can drop ALL three by - * passing omit_pick_attrs != 0 to the open/bake call below. omit_pick_attrs == 0 - * (the zero-initialised default) keeps them — so existing callers that pass 0 get - * the pick report for free. */ - /* Open ONE S-57 cell (a .000 file, with its .001.. update chain auto-read from the * same directory) by BAKING it to an in-memory PMTiles and serving the fast reader * render path (tile57_chart_render_surface_cb), with the cell's real M_COVR coverage @@ -80,22 +93,142 @@ tile57_chart *tile57_chart_open_header(const char *path); * (progressive load). Renders via the fast reader path. NULL/failure -> NULL. */ tile57_chart *tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t maxzoom); +/* Open one in-memory ENC cell (base .000 bytes) as a resident chart. Bytes are copied. + * NULL/failure -> NULL. */ +tile57_chart *tile57_chart_open_bytes(const uint8_t *base, size_t len); + +/* Open a baked PMTiles bundle from a file path. NULL/failure -> NULL. */ +tile57_chart *tile57_chart_open_pmtiles(const char *path); + +/* Vector-tile encodings the engine produces (reported in tile57_chart_info.tile_type; + * the compositor serves MLT). */ +typedef enum { + TILE57_TILE_TYPE_MVT = 1, /* Mapbox Vector Tile */ + TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the default) */ +} tile57_tile_type; + +/* Fixed chart metadata — folds zoom_range/bands/bounds/anchor into one + * getter. Bounds/anchor validity are flagged (false -> those fields are 0). + * tile_type is the vector-tile encoding for this chart's tiles (a + * tile57_tile_type): a PMTiles-backed chart reports its archive's stored type; a + * cell-backed chart reports the engine's default bake encoding. A host passes it + * to tile57_style_template so the renderer decodes the tiles correctly. */ +typedef struct { + uint8_t min_zoom, max_zoom; + uint32_t bands; /* bitmask of navigational bands present */ + bool has_bounds; double west, south, east, north; + bool has_anchor; double anchor_lat, anchor_lon, anchor_zoom; + uint8_t tile_type; /* tile57_tile_type */ + int32_t native_scale; /* compilation scale (1:N) for a live cell; 0 = unknown + * (PMTiles: derive the scale from the zoom band instead) */ +} tile57_chart_info; +void tile57_chart_get_info(tile57_chart *chart, tile57_chart_info *out); + +/* The distinct SCAMIN denominators present in the chart (the live SCAMIN manifest), + * ascending — the host publishes these so its style builds one native fractional- + * minzoom bucket layer per value (so features honour their 1:N min-display-scale at + * zero per-zoom cost). A PMTiles chart reads them from the archive metadata; a cell + * / ENC_ROOT chart scans every cell's features (parsed without portrayal; streamed + * cells are read transiently). On success returns 1 with *out pointing at *out_len + * int32 values; 0 if there are none; -1 on error. Free *out with + * tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ +int tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len); + +/* The chart's per-cell metadata as a JSON array, one object per cell: + * [{"name":"US5MD1MC","scale":12000,"edition":"13","update":"3", + * "issueDate":"20240105","agency":550,"bbox":[west,south,east,north]}, ...] + * `name` is the DSNM stem; `scale` is DSPM CSCL; edition/update/issueDate/ + * agency are DSID EDTN/UPDN/ISDT/AGEN after the cell's update chain is + * applied; `bbox` is the cell's geographic extent, omitted when none parses. + * Returns 1 with *out / *out_len holding the JSON (free with tile57_free); + * 0 if the chart has no cells (e.g. a PMTiles chart — its bundle manifest + * carries the cell inventory); -1 on error. */ +int tile57_chart_cells(tile57_chart *chart, uint8_t **out, size_t *out_len); + +/* The chart's features for the given object classes (comma-separated + * acronyms, e.g. "DEPARE,DRGARE") as a GeoJSON FeatureCollection: geometry + * in lon/lat (Polygon rings largest-first, MultiPoint with depths for + * soundings, LineString/Point as encoded), properties = {"class":"DEPARE", + * ...the feature's full S-57 acronym->value attribute map}. Parsed without + * portrayal; a whole-ENC_ROOT query walks every cell — the caller owns that + * cost. Returns 1 with *out / *out_len holding the JSON (free with + * tile57_free); 0 if no features matched; -1 on error. */ +int tile57_chart_features(tile57_chart *chart, const char *classes, + uint8_t **out, size_t *out_len); + +/* The chart's M_COVR(CATCOV=1) data-coverage polygons — the real coverage a host + * reports so a quilt fills gaps to coarser cells (vs. the bounding box). ring() is + * called once per polygon with its exterior ring as `npts` interleaved lon,lat + * doubles (valid only during the call). Only the live-cell backend (an opened + * .000) carries this; a baked PMTiles returns 0 with no calls. 0 ok, -1 bad args. */ +typedef struct { + void *ctx; + void (*ring)(void *ctx, const double *lonlat, size_t npts); +} tile57_coverage_cb; +int tile57_chart_coverage(tile57_chart *chart, const tile57_coverage_cb *cb); + +/* Cursor object-query (S-52 pick): feature() is invoked once per feature the + * point (lon,lat) falls in — area point-in-polygon, line/point within a small + * radius — with the S-57 object-class acronym, the attribute JSON (acronym->value), + * and the source cell name. Pointers are valid only for the duration of the call. */ +typedef struct { + void *ctx; + void (*feature)(void *ctx, const char *cls, size_t cls_len, + const char *s57, size_t s57_len, + const char *cell, size_t cell_len); +} tile57_query_cb; +/* `zoom` is the current view's web-mercator zoom: the query uses the tile at that + * zoom, so it reports the features actually DISPLAYED there (SCAMIN-bucketed) and + * the pick tolerance tracks on-screen distance. Returns 0 ok, -1 bad args. */ +int tile57_chart_query(tile57_chart *chart, double lon, double lat, double zoom, + const tile57_query_cb *cb); + +/* Release a chart and all cached tiles. Must not be called while any + * renderer/adapter may still render from it (see the lifetime note above). */ +void tile57_chart_close(tile57_chart *chart); + +/* ======================================================================== * + * 3. Cell baking + * + * The composite model bakes each ENC cell to its own PMTiles at that cell's + * compilation scale; the runtime compositor (section 4) stitches them on demand. + * ======================================================================== */ + +/* Pick-report attributes. Every emitted vector-tile feature carries the per-feature + * cursor-pick / inspector properties used by the S-52 §10.8 pick report: `class` + * (object-class acronym), `cell` (source cell name, the file stem), and `s57` (a + * JSON object string of the feature's full S-57 attribute set, acronym -> value). + * These are baked into the per-cell tiles and are what tile57_chart_query and a + * host inspector read back. */ + /* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over its * NATIVE band zoom range and nothing else — the composite model bakes each cell at its - * own compilation scale; the stitcher combines them and handles any cross-band zoom. + * own compilation scale; the compositor combines them and handles any cross-band zoom. * Returned in *out / *out_len (free with tile57_free). For a host to persist a per-cell - * tile cache to disk — then reopen with tile57_chart_open_pmtiles. The metadata embeds - * the cell's coverage (read via tile57_pmtiles_metadata). 1=ok, 0=nothing baked, + * tile cache to disk — then feed the archives to tile57_compose_open. The metadata + * embeds the cell's coverage (read via tile57_pmtiles_metadata). 1=ok, 0=nothing baked, * -1=error. */ int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); -/* Runtime compositor — the on-demand counterpart of tile57_compose_files. Rather than pass over a - * whole district to produce one archive, it holds the per-cell PMTiles mmap'd and the ownership - * partition resident, so any tile can be composed for the cost of a classify + one decode/clip or a - * decompress. Open once, serve many, close. */ +/* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len + * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + + * cscl + date/name under a "coverage" key, so the composite stitcher rebuilds the + * ownership partition without re-parsing the .000. 1=ok, 0=no metadata, -1=error. */ +int tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, + uint8_t **out, size_t *out_len); + +/* ======================================================================== * + * 4. Live composing (the runtime compositor) + * + * Holds the per-cell PMTiles (section 3) mmap'd and the ownership partition + * resident, so any tile is composed on demand for the cost of a classify + one + * decode/clip or a decompress. Open once, serve many, close. + * ======================================================================== */ + +/* Opaque runtime-compositor handle. */ typedef struct tile57_compose_source tile57_compose_source; -/* Coverage/zoom summary filled by tile57_compose_meta. */ +/* Coverage/zoom summary filled by tile57_compose_meta_get. */ typedef struct { uint8_t min_zoom; uint8_t max_zoom; /* deepest zoom served (native windows + one fill-up overscale zoom) */ @@ -104,10 +237,12 @@ typedef struct { } tile57_compose_meta; /* Open a resident compositor over the `n` per-cell PMTiles at `paths` (each from - * tile57_bake_cell_bytes, on disk), mmap'd so the cell set is never fully resident. `partition_path` - * (NULL to skip) names a partition sidecar (from `tile57 compose --save-partition`) to load and skip - * the build; a missing/stale one falls back to building. Returns an opaque handle (free with - * tile57_compose_close), or NULL on error / no coverage-carrying archive. */ + * tile57_bake_cell_bytes, on disk), mmap'd so the cell set is never fully resident. + * `partition_path` (NULL to skip) names a partition sidecar — written by + * tile57_compose_save_partition (the `tile57 bake` CLI emits one as partition.tpart) — + * to load and skip the build; a missing/stale one falls back to building. Returns an + * opaque handle (free with tile57_compose_close), or NULL on error / no + * coverage-carrying archive. */ tile57_compose_source *tile57_compose_open(const char *const *paths, size_t n, const char *partition_path); @@ -117,8 +252,7 @@ tile57_compose_source *tile57_compose_open(const char *const *paths, size_t n, * 2 OWNED but empty — a cell owns this ground per the ownership partition but produced nothing * (transient while its per-cell bake is still running; an error state once bakes are done), * 0 not owned — true empty ocean (no cell owns this ground; safe to cache), - * -1 error. - * Byte-faithful to the batch compositor. */ + * -1 error. */ int tile57_compose_serve(tile57_compose_source *src, uint8_t z, uint32_t x, uint32_t y, uint8_t **out, size_t *out_len); @@ -132,98 +266,16 @@ int tile57_compose_save_partition(tile57_compose_source *src, const char *path); /* Release a compositor opened by tile57_compose_open (munmaps the archives, frees the partition). */ void tile57_compose_close(tile57_compose_source *src); -/* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len - * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + - * cscl + date/name under a "coverage" key, so the composite stitcher rebuilds the - * ownership partition without re-parsing the .000. 1=ok, 0=no metadata, -1=error. */ -int tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, - uint8_t **out, size_t *out_len); - -/* Populate the process-global read-only registries (the S-100 feature catalogue and - * the complex-linestyle table) on the calling thread. Both are idempotent lazy-init - * and thereafter read-only. Call this ONCE on your main thread before opening or - * baking cells from worker threads, so those globals are fully populated first and - * concurrent bake/render is race-free (the allocator is thread-safe and the portrayal - * context is thread-local). Cheap and safe to call more than once. */ -void tile57_warmup(void); - -/* Open one in-memory ENC cell (base .000 bytes) as a resident chart. Bytes are copied. - * NULL/failure -> NULL. */ -tile57_chart *tile57_chart_open_bytes(const uint8_t *base, size_t len); - -/* Open a baked PMTiles bundle from a file path. NULL/failure -> NULL. */ -tile57_chart *tile57_chart_open_pmtiles(const char *path); - -/* Tile encodings served by tile57_chart_tile / produced by the bakes. */ -typedef enum { - TILE57_TILE_TYPE_MVT = 1, /* Mapbox Vector Tile */ - TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the default bake format) */ -} tile57_tile_type; - -/* Fixed chart metadata — folds zoom_range/bands/bounds/anchor into one - * getter. Bounds/anchor validity are flagged (false -> those fields are 0). - * tile_type is the encoding tile57_chart_tile returns (a tile57_tile_type): a - * PMTiles-backed chart reports its archive's stored type; a cell-backed chart - * reports its live generation format (see tile57_chart_set_tile_format). */ -typedef struct { - uint8_t min_zoom, max_zoom; - uint32_t bands; /* bitmask of navigational bands present */ - bool has_bounds; double west, south, east, north; - bool has_anchor; double anchor_lat, anchor_lon, anchor_zoom; - uint8_t tile_type; /* tile57_tile_type */ - int32_t native_scale; /* compilation scale (1:N) for a live cell; 0 = unknown - * (PMTiles: derive the scale from the zoom band instead) */ -} tile57_chart_info; -void tile57_chart_get_info(tile57_chart *chart, tile57_chart_info *out); - -/* Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) into a - * single PMTiles at out_path: the composited ownership faces (which cell renders which - * ground at each band), one polygon per owning cell tagged with the properties - * cell/cscl/band/tier/oi/color, and NO portrayed chart content — for building a - * partition-debug UI. band < 0 emits the band GOVERNING each zoom (the natural view); - * 0..5 (berthing..overview) emits one band's own map at every zoom. minzoom/maxzoom - * bound the tiles (harbor-level detail needs maxzoom >= 13; coarser bands are much - * cheaper). out_cell_count is optional. Returns 1=ok, 0=nothing covered, -1=error. */ -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); - -/* All portrayal assets in memory (the same files bake_bundle writes to disk), from the - * library's embedded catalogue (catalog_dir NULL/"") or an on-disk one. Pairs with - * tile57_bake_pmtiles + tile57_build_style for a full in-memory bundle. Returns 1 with - * *out filled (free with tile57_assets_free), 0 on error. */ -typedef struct { - uint8_t *colortables; size_t colortables_len; - uint8_t *linestyles; size_t linestyles_len; - uint8_t *sprite_json; size_t sprite_json_len; uint8_t *sprite_png; size_t sprite_png_len; - uint8_t *pattern_json; size_t pattern_json_len; uint8_t *pattern_png; size_t pattern_png_len; -} tile57_assets; -int tile57_bake_assets(const char *catalog_dir, tile57_assets *out); -/* Like tile57_bake_assets but sprite_json/sprite_png carry the MapLibre sprite-mln - * atlas (pivot-centred cells + {name:{x,y,width,height,pixelRatio}} JSON); other - * fields are NULL. Free with tile57_assets_free. 1=ok, 0=error. */ -int tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out); -/* SDF glyph atlas for GPU text: sprite_png is the RGBA signed-distance-field atlas - * of the label font; sprite_json is {"em_px","pad","glyphs":{codepoint:[u0,v0,u1, - * v1,off_x,off_y,w,h,advance]}} with the quad geometry in EM units (multiply by the - * text pixel size). A host draws each glyph as a textured quad sampling the SDF. - * Only sprite_* filled. Free with tile57_assets_free. 1=ok, 0=error. */ -int tile57_bake_glyph_sdf(tile57_assets *out); -void tile57_assets_free(tile57_assets *out); - -/* Release a chart and all cached tiles. Must not be called while any renderer - * may still call tile57_chart_tile on it (see lifetime note above). */ -void tile57_chart_close(tile57_chart *chart); +/* ======================================================================== * + * 5. Render surface + * + * A general render-surface primitive: portray a VIEW of a chart (centre + + * fractional zoom + pixel size) once and emit it as a finished PNG, a vector PDF, + * resolved pixel-space draw calls, or a world-space semantically-tagged stream. + * Make a surface, render it anywhere — a PNG file, a GPU chart app, a PDF printer. + * ======================================================================== */ -/* The distinct SCAMIN denominators present in the chart (the live SCAMIN manifest), - * ascending — the host publishes these so its style builds one native fractional- - * minzoom bucket layer per value (so features honour their 1:N min-display-scale at - * zero per-zoom cost). A PMTiles chart reads them from the archive metadata; a cell - * / ENC_ROOT chart scans every cell's features (parsed without portrayal; streamed - * cells are read transiently). On success returns 1 with *out pointing at *out_len - * int32 values; 0 if there are none; -1 on error. Free *out with - * tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ -int tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len); +struct tile57_mariner; /* fwd; defined in section 6 (style) */ /* Render a VIEW of the chart — centre + fractional zoom + pixel size — to a * PNG through the native S-52 pixel path: the mariner settings evaluate LIVE @@ -234,7 +286,6 @@ int tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len); * m->size_scale. Returns 0 with *out and *out_len set (free with tile57_free); * -1 bad handle, -2 render failure, -3 unsupported source (a baked PMTiles * chart carries no portrayal to render from). */ -struct tile57_mariner; /* fwd; defined in the chart-style section below */ int tile57_chart_render_view(tile57_chart *chart, double lon, double lat, double zoom, uint32_t width, uint32_t height, const struct tile57_mariner *m, @@ -251,7 +302,7 @@ int tile57_chart_render_pdf(tile57_chart *chart, double lon, double lat, double /* ---- callback Canvas: tile57_chart_render_view's GPU/vector twin ---------- * Run the SAME view portrayal as tile57_chart_render_view, but paint every * resolved, flattened primitive through a table of C function pointers instead - * of rasterising to PNG. The embedder (e.g. a GPU chart plugin) feeds these to + * of rasterising to PNG. The embedder (e.g. a GPU chart app) feeds these to * its own renderer. Geometry is emitted in canvas PIXEL space (y down), in * final paint order; colours are fully resolved for the active palette. */ typedef struct { float x, y; } tile57_point; /* canvas pixels */ @@ -296,7 +347,7 @@ int tile57_chart_render_view_cb(tile57_chart *chart, double lon, double lat, dou * feature's S-57 class and SCAMIN. A GPU host applies its own view transform, * pins symbols/text at the anchor, and culls by SCAMIN per frame — so pan and * zoom re-portray NOTHING. Works for a baked bundle (tile replay) or a live - * cell (full S-52 portrayal). See render_surface.md. */ + * cell (full S-52 portrayal). */ typedef struct { double x, y; } tile57_world_point; /* web-mercator [0,1], y down */ typedef struct { float x, y; } tile57_local_point; /* anchor-relative reference px */ @@ -357,82 +408,49 @@ int tile57_chart_render_surface_cb(tile57_chart *chart, double lon, double lat, const struct tile57_mariner *m, const tile57_surface_cb *surface); -/* Cursor object-query (S-52 pick): feature() is invoked once per feature the - * point (lon,lat) falls in — area point-in-polygon, line/point within a small - * radius — with the S-57 object-class acronym, the attribute JSON (acronym->value), - * and the source cell name. Pointers are valid only for the duration of the call. */ -typedef struct { - void *ctx; - void (*feature)(void *ctx, const char *cls, size_t cls_len, - const char *s57, size_t s57_len, - const char *cell, size_t cell_len); -} tile57_query_cb; -/* `zoom` is the current view's web-mercator zoom: the query uses the tile at that - * zoom, so it reports the features actually DISPLAYED there (SCAMIN-bucketed) and - * the pick tolerance tracks on-screen distance. Returns 0 ok, -1 bad args. */ -int tile57_chart_query(tile57_chart *chart, double lon, double lat, double zoom, - const tile57_query_cb *cb); - -/* The chart's M_COVR(CATCOV=1) data-coverage polygons — the real coverage a host - * reports so a quilt fills gaps to coarser cells (vs. the bounding box). ring() is - * called once per polygon with its exterior ring as `npts` interleaved lon,lat - * doubles (valid only during the call). Only the live-cell backend (an opened - * .000) carries this; a baked PMTiles returns 0 with no calls. 0 ok, -1 bad args. */ -typedef struct { - void *ctx; - void (*ring)(void *ctx, const double *lonlat, size_t npts); -} tile57_coverage_cb; -int tile57_chart_coverage(tile57_chart *chart, const tile57_coverage_cb *cb); - -/* The chart's per-cell metadata as a JSON array, one object per cell: - * [{"name":"US5MD1MC","scale":12000,"edition":"13","update":"3", - * "issueDate":"20240105","agency":550,"bbox":[west,south,east,north]}, ...] - * `name` is the DSNM stem; `scale` is DSPM CSCL; edition/update/issueDate/ - * agency are DSID EDTN/UPDN/ISDT/AGEN after the cell's update chain is - * applied; `bbox` is the cell's geographic extent, omitted when none parses. - * Returns 1 with *out / *out_len holding the JSON (free with tile57_free); - * 0 if the chart has no cells (e.g. a PMTiles chart — its bundle manifest - * carries the cell inventory); -1 on error. */ -int tile57_chart_cells(tile57_chart *chart, uint8_t **out, size_t *out_len); - -/* Decode a CATALOG.031 exchange-set catalogue (raw bytes) into a JSON array - * of its CATD entries: - * [{"file":"US5MD1MC/US5MD1MC.000","longName":"Annapolis Harbor", - * "impl":"BIN","bbox":[west,south,east,north]}, ...] - * `file` is the recorded path with separators normalised to '/'; `longName` - * is LFIL (the human chart title; empty when absent); `impl` is BIN/ASC/TXT; - * `bbox` is omitted when SLAT/WLON/NLAT/ELON are not all present (aux files). - * Not chart-scoped: the catalogue describes an exchange set, not an open - * chart. Returns 1 with *out / *out_len holding the JSON (free with - * tile57_free); 0 if the file holds no CATD records; -1 on parse error. */ -int tile57_catalog_entries(const uint8_t *catalog_031, size_t len, - uint8_t **out, size_t *out_len); +/* ======================================================================== * + * 6. Style + portrayal assets + * + * tile57 ships tile generation AND style generation together. tile57_build_style + * turns a MapLibre style template + the mariner's S-52 display options + the S-52 + * colortables into a concrete style JSON, client-side; tile57_bake_assets produces + * the colour tables, line styles, and sprite / pattern / glyph atlases the style + * references. + * ======================================================================== */ -/* The chart's features for the given object classes (comma-separated - * acronyms, e.g. "DEPARE,DRGARE") as a GeoJSON FeatureCollection: geometry - * in lon/lat (Polygon rings largest-first, MultiPoint with depths for - * soundings, LineString/Point as encoded), properties = {"class":"DEPARE", - * ...the feature's full S-57 acronym->value attribute map}. Parsed without - * portrayal; a whole-ENC_ROOT query walks every cell — the caller owns that - * cost. Returns 1 with *out / *out_len holding the JSON (free with - * tile57_free); 0 if no features matched; -1 on error. */ -int tile57_chart_features(tile57_chart *chart, const char *classes, - uint8_t **out, size_t *out_len); +/* ---- portrayal assets ---------------------------------------------------- */ -/* Free ANY buffer the engine returned (tiles, style JSON, the scamin array, - * colortables, atlases, …), passing the same length. The universal free. */ -void tile57_free(void *ptr, size_t len); +/* All portrayal assets in memory, from the library's embedded catalogue + * (catalog_dir NULL/"") or an on-disk one. Pairs with tile57_build_style + the + * composed tiles for a complete renderable chart. Returns 1 with *out filled (free + * with tile57_assets_free), 0 on error. */ +typedef struct { + uint8_t *colortables; size_t colortables_len; + uint8_t *linestyles; size_t linestyles_len; + uint8_t *sprite_json; size_t sprite_json_len; uint8_t *sprite_png; size_t sprite_png_len; + uint8_t *pattern_json; size_t pattern_json_len; uint8_t *pattern_png; size_t pattern_png_len; +} tile57_assets; +int tile57_bake_assets(const char *catalog_dir, tile57_assets *out); +/* Like tile57_bake_assets but sprite_json/sprite_png carry the MapLibre sprite-mln + * atlas (pivot-centred cells + {name:{x,y,width,height,pixelRatio}} JSON); other + * fields are NULL. Free with tile57_assets_free. 1=ok, 0=error. */ +int tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out); +/* SDF glyph atlas for GPU text: sprite_png is the RGBA signed-distance-field atlas + * of the label font; sprite_json is {"em_px","pad","glyphs":{codepoint:[u0,v0,u1, + * v1,off_x,off_y,w,h,advance]}} with the quad geometry in EM units (multiply by the + * text pixel size). A host draws each glyph as a textured quad sampling the SDF. + * Only sprite_* filled. Free with tile57_assets_free. 1=ok, 0=error. */ +int tile57_bake_glyph_sdf(tile57_assets *out); +void tile57_assets_free(tile57_assets *out); /* ---- chart-style generation --------------------------------------------- * - * tile57 ships tile generation AND style generation together: tile57_build_style - * turns a MapLibre style template + the mariner's S-52 display options + the S-52 - * colortables into a concrete style JSON, client-side. It patches the mariner- - * driven parts of the template (depth shading, sounding/danger symbol swaps, - * contour-label units, the per-scheme recolour) and AND-s the display filters - * (category, band, boundary/point style, date validity, text groups, …) onto - * every source:"chart" layer. The template + colortables are produced by the - * engine's asset generator; the host fills tile57_mariner from its UI. */ + * tile57_build_style patches the mariner-driven parts of the template (depth + * shading, sounding/danger symbol swaps, contour-label units, the per-scheme + * recolour) and AND-s the display filters (category, band, boundary/point style, + * date validity, text groups, …) onto every source:"chart" layer. The template + + * colortables are produced by the engine's asset generator; the host fills + * tile57_mariner from its UI. */ typedef enum { TILE57_SCHEME_DAY=0, TILE57_SCHEME_DUSK=1, TILE57_SCHEME_NIGHT=2 } tile57_scheme; typedef enum { TILE57_DEPTH_METERS=0, TILE57_DEPTH_FEET=1 } tile57_depth_unit; @@ -461,10 +479,10 @@ typedef struct tile57_mariner { * pointee must outlive the tile57_build_style call. NULL/len 0 -> * every viewing group shown. tile57_mariner_defaults sets NULL/0. */ uint32_t viewing_groups_off_len; - bool scamin_filter_gate; /* scamin-layers.md: gate SCAMIN with a live client-driven - * filter instead of per-value bucket layers — one *_scamin layer - * per render-type (no minzoom buckets). The client rewrites the - * SCAMIN clause on boundary crossings. NOT an S-52 setting. + bool scamin_filter_gate; /* gate SCAMIN with a live client-driven filter instead of + * per-value bucket layers — one *_scamin layer per render-type + * (no minzoom buckets). The client rewrites the SCAMIN clause on + * boundary crossings. NOT an S-52 setting. * tile57_mariner_defaults sets false (per-value buckets). */ bool show_overscale; /* S-52 §10.1.10 overscale indication: the AP(OVERSC01) * vertical-line hatch over regions displayed finer than their @@ -478,9 +496,9 @@ typedef struct tile57_mariner { * scamin: the distinct SCAMIN denominators present in the source (e.g. from * tile57_chart_scamin / the TileJSON). When non-NULL with scamin_count>0 the * `_scamin` source-layers are split into one per-value bucket layer with a native - * fractional minzoom = scaminDisplayZoom(value, scamin_lat) — the SAME gating the - * offline bundle style emits. NULL / count 0 -> the `_scamin` layers stay a single - * ungated layer (features render, but SCAMIN does not gate by value). + * fractional minzoom = scaminDisplayZoom(value, scamin_lat). NULL / count 0 -> the + * `_scamin` layers stay a single ungated layer (features render, but SCAMIN does + * not gate by value). * scamin_lat: representative latitude (degrees) for the bucket minzooms (the SCAMIN * display cutoff is latitude-dependent); use the source's center latitude. * On success returns 1 with the style JSON in *out / *out_len (free with @@ -536,10 +554,10 @@ int tile57_colortables_default(uint8_t **out, size_t *out_len); * a source's minzoom, so an inflated floor blanks every lower zoom). * maxzoom: 0 -> engine default. * tile_encoding: the chart source's tile encoding (a tile57_tile_type, from - * chart_info.tile_type / the archive header). TILE57_TILE_TYPE_MLT - * emits "encoding":"mlt" on the source so maplibre-gl >= 5.12 decodes - * MLT natively; 0 / TILE57_TILE_TYPE_MVT emits nothing (the MapLibre - * default). The hint survives tile57_build_style / tile57_style_diff. + * chart_info.tile_type). TILE57_TILE_TYPE_MLT emits "encoding":"mlt" on + * the source so maplibre-gl >= 5.12 decodes MLT natively; 0 / + * TILE57_TILE_TYPE_MVT emits nothing (the MapLibre default). The hint + * survives tile57_build_style / tile57_style_diff. * Returns 1 with out/out_len set (free with tile57_free), 0 on error. */ int tile57_style_template(tile57_scheme scheme, const char *source_tiles, const char *sprite, const char *glyphs, @@ -551,6 +569,47 @@ int tile57_style_template(tile57_scheme scheme, const char *source_tiles, * them). date_view is set to "" (today). */ void tile57_mariner_defaults(tile57_mariner *m); +/* ======================================================================== * + * 7. Util / catalogue / debug + * ======================================================================== */ + +/* Populate the process-global read-only registries (the S-100 feature catalogue and + * the complex-linestyle table) on the calling thread. Both are idempotent lazy-init + * and thereafter read-only. Call this ONCE on your main thread before opening or + * baking cells from worker threads, so those globals are fully populated first and + * concurrent bake/render is race-free (the allocator is thread-safe and the portrayal + * context is thread-local). Cheap and safe to call more than once. */ +void tile57_warmup(void); + +/* Free ANY buffer the engine returned (tiles, style JSON, the scamin array, + * colortables, atlases, …), passing the same length. The universal free. */ +void tile57_free(void *ptr, size_t len); + +/* Decode a CATALOG.031 exchange-set catalogue (raw bytes) into a JSON array + * of its CATD entries: + * [{"file":"US5MD1MC/US5MD1MC.000","longName":"Annapolis Harbor", + * "impl":"BIN","bbox":[west,south,east,north]}, ...] + * `file` is the recorded path with separators normalised to '/'; `longName` + * is LFIL (the human chart title; empty when absent); `impl` is BIN/ASC/TXT; + * `bbox` is omitted when SLAT/WLON/NLAT/ELON are not all present (aux files). + * Not chart-scoped: the catalogue describes an exchange set, not an open + * chart. Returns 1 with *out / *out_len holding the JSON (free with + * tile57_free); 0 if the file holds no CATD records; -1 on parse error. */ +int tile57_catalog_entries(const uint8_t *catalog_031, size_t len, + uint8_t **out, size_t *out_len); + +/* Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) into a + * single PMTiles at out_path: the composited ownership faces (which cell renders which + * ground at each band), one polygon per owning cell tagged with the properties + * cell/cscl/band/tier/oi/color, and NO portrayed chart content — for building a + * partition-debug UI. band < 0 emits the band GOVERNING each zoom (the natural view); + * 0..5 (berthing..overview) emits one band's own map at every zoom. minzoom/maxzoom + * bound the tiles (harbor-level detail needs maxzoom >= 13; coarser bands are much + * cheaper). out_cell_count is optional. Returns 1=ok, 0=nothing covered, -1=error. */ +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); + #ifdef __cplusplus } #endif From 76df8bb7bdb1aa0781774ef7e4df53f8ea15d1d7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 10:47:18 -0400 Subject: [PATCH 054/140] docs(site): rewrite c-api.md for the bake-cell + compose tile model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C API page still taught the removed model — open a chart and pull tiles via tile57_chart_tile, plus tile57_bake_pmtiles / tile57_bake_bundle / tile57_cell. Rewrite it around the two live handles: tile57_chart (metadata + render) and tile57_compose_source (bake each cell to its own PMTiles, then compose on demand by z/x/y through the ownership partition). Add the compose + cell-bake section, drop the tile-fetch / batch-bake / bundle sections, and mirror the header's section order. Also documents tile57_bake_glyph_sdf and render_view_cb. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/docs/c-api.md | 277 ++++++++++++++++++++++++++------------------- 1 file changed, 159 insertions(+), 118 deletions(-) diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 949191b..b26f580 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -8,21 +8,31 @@ sidebar_position: 5 `libtile57.a` exposes the whole engine behind a thin C ABI — [`include/tile57.h`](../../include/tile57.h), prefix `tile57_`. It is a shim over -the [Zig API](./zig-api.md); the two stay in lock-step. Open a **chart**, serve -vector tiles by `(z, x, y)` — MapLibre Tiles (MLT, the default bake format) or -Mapbox Vector Tiles (MVT) — render finished PNG/PDF views, and (offline) bake -archives + bundles, build a MapLibre style, and generate portrayal assets. +the [Zig API](./zig-api.md); the two stay in lock-step. + +Two handles cover the surface: + +- A **`tile57_chart`** is the metadata + render handle. Open a cell (or a baked + PMTiles) and read its bounds, scale, coverage, cells, and features; query the + feature under a point; or render a finished PNG / PDF / callback surface. +- A **`tile57_compose_source`** is the runtime **compositor**. Tiles are made one + way: bake each ENC cell to its own PMTiles, then compose them on demand by + `(z, x, y)` through the ownership partition. The composed bytes are MapLibre + Tiles (MLT, the default) or Mapbox Vector Tiles (MVT). + +The header is organized into seven sections — version, chart open + metadata, +cell baking, live composing, render surface, style + assets, and util/catalogue/ +debug — mirrored below. :::warning Lifetime + threading -A `tile57_chart` is **not** internally synchronized — use one thread per chart. -It must also outlive every consumer still holding it: if a long-lived renderer -captures the chart, close it only once nothing can still call -`tile57_chart_tile`. `tile57_chart_tile` allocates `*out`; free it with -`tile57_free` (same length). Input bytes are copied, so the caller may free them -right after the call. +Neither handle is internally synchronized — use one thread per handle. Each must +also outlive every consumer still holding it: if a long-lived renderer or +compositor captures it, close it only once nothing can still call into it. Calls +that return bytes allocate `*out`; free it with `tile57_free` (same length). +Input bytes are copied, so the caller may free them right after the call. ::: -## Open a chart + fetch tiles +## Open a chart + read metadata ```c #include "tile57.h" @@ -32,12 +42,21 @@ const char *tile57_version(void); /* "0.1.0" */ /* Opaque chart handle. */ typedef struct tile57_chart tile57_chart; -/* Open an on-disk ENC_ROOT directory (or a single .000 file) as a streaming - * chart: cells are enumerated + peeked at open, then their bytes are read on - * demand (working set only). Rules are the library's embedded catalogue. - * NULL on failure. */ +/* Open an on-disk ENC_ROOT directory (or a single .000 file, with its .001.. + * update chain). The cell is baked to an in-memory PMTiles and served through the + * reader render path, with real M_COVR coverage + compilation scale attached. + * Rules are the library's embedded catalogue. NULL on failure. */ tile57_chart *tile57_chart_open(const char *path); +/* Open a cell for METADATA ONLY — bbox, native_scale, M_COVR coverage — via a + * cheap parse with no tile bake (a chart-database / header scan). Do NOT render + * this handle. NULL on failure. */ +tile57_chart *tile57_chart_open_header(const char *path); + +/* Open a cell baking only [minzoom, maxzoom] — a narrow native band fast for + * first paint, then re-open the full range in the background (progressive load). */ +tile57_chart *tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t maxzoom); + /* Open one in-memory ENC cell (base .000 bytes) as a resident chart. Bytes are * copied. NULL on failure. */ tile57_chart *tile57_chart_open_bytes(const uint8_t *base, size_t len); @@ -45,62 +64,118 @@ tile57_chart *tile57_chart_open_bytes(const uint8_t *base, size_t len); /* Open a baked PMTiles bundle from a file path. NULL on failure. */ tile57_chart *tile57_chart_open_pmtiles(const char *path); -/* Tile encodings served by tile57_chart_tile / produced by the bakes. */ +/* Vector-tile encodings the engine produces (reported in chart_info.tile_type; + * the compositor serves MLT). */ typedef enum { TILE57_TILE_TYPE_MVT = 1, /* Mapbox Vector Tile */ - TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the default bake format) */ + TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the default) */ } tile57_tile_type; /* Fixed chart metadata, for a host that frames its own camera. Bounds/anchor * validity are flagged (false -> those fields are 0). tile_type is the encoding - * tile57_chart_tile returns: a PMTiles-backed chart reports its archive's stored - * type; a cell-backed chart reports its live generation format. */ + * for this chart's tiles (PMTiles: the archive's stored type; a cell: the engine + * default). native_scale is the compilation scale 1:N (0 if unknown). */ typedef struct { uint8_t min_zoom, max_zoom; uint32_t bands; /* bitmask: bit r = band rank r present */ bool has_bounds; double west, south, east, north; bool has_anchor; double anchor_lat, anchor_lon, anchor_zoom; uint8_t tile_type; /* tile57_tile_type */ + int32_t native_scale; } tile57_chart_info; void tile57_chart_get_info(tile57_chart *chart, tile57_chart_info *out); -typedef enum { - TILE57_TILE_OK = 1, /* *out / *out_len set; free with tile57_free */ - TILE57_TILE_EMPTY = 0, /* valid tile, no features */ - TILE57_TILE_ERROR = -1, -} tile57_tile_status; - -/* Fetch tile (z, x, y) as decompressed vector-tile bytes, VERBATIM in the - * chart's tile encoding (chart_info.tile_type — there is no transcode layer; - * hosts hint the encoding to the renderer instead, e.g. the MapLibre vector - * source `encoding` option). Cached per chart. */ -tile57_tile_status tile57_chart_tile(tile57_chart *chart, uint8_t z, uint32_t x, uint32_t y, - uint8_t **out, size_t *out_len); - -/* Select the encoding for LIVE-generated tiles on a cell-backed chart: 0 = - * engine default (mlt), TILE57_TILE_TYPE_MVT, TILE57_TILE_TYPE_MLT. Cell-backed - * charts OPEN generating MVT (existing MVT-only embedders are unaffected); a - * host whose renderer decodes MLT opts in here. No-op for a baked PMTiles chart. - * Changing the format drops the tile cache. */ -void tile57_chart_set_tile_format(tile57_chart *chart, uint8_t format); - -/* Free ANY buffer the engine returned (tiles, style JSON, the scamin array, - * colortables, …), passing the same length. The universal free. */ -void tile57_free(void *ptr, size_t len); - -void tile57_chart_clear_cache(tile57_chart *chart); -void tile57_chart_close(tile57_chart *chart); - /* The distinct SCAMIN denominators present in the chart (ascending). On success * returns 1 with *out pointing at *out_len int32 values, 0 if none, -1 on error. * Free with tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ int tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len); + +/* Release a chart and all cached tiles. */ +void tile57_chart_close(tile57_chart *chart); ``` An ENC_ROOT cell is a base `.000` plus its sequential `.001`, `.002` … update files; `tile57_chart_open` walks the directory (`CATALOG.031`, else a `*.000` scan), applies each cell's updates, and overlays the cells by scale band. +## Bake cells, then compose tiles + +Tile production is a two-step composite model. First bake each ENC cell to its +own PMTiles at that cell's compilation scale; the per-cell archive embeds the +cell's M_COVR coverage in its metadata. Then open a **compositor** over the +archives and serve any `(z, x, y)` tile on demand — the compositor stitches the +overlapping cells through an ownership partition, handling cross-band zoom. + +```c +/* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over its + * native band zoom range. Returned in *out/*out_len (free with tile57_free) — + * persist the per-cell archive, then feed it to tile57_compose_open. The metadata + * embeds the cell's coverage (read via tile57_pmtiles_metadata). 1 = ok, 0 = + * nothing baked, -1 = error. */ +int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); + +/* Read a PMTiles archive's metadata JSON blob (decompressed) into *out/*out_len. + * A single-cell bake embeds that cell's coverage + cscl + date/name under a + * "coverage" key, so the compositor rebuilds the partition without re-parsing the + * .000. 1 = ok, 0 = no metadata, -1 = error. */ +int tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, + uint8_t **out, size_t *out_len); +``` + +Every baked feature carries the pick-report properties `class` (object-class +acronym), `cell` (source cell stem), and `s57` (the full S-57 attribute set as a +JSON object) — what `tile57_chart_query` and a host inspector read back. + +The compositor holds the per-cell archives mmap'd and the ownership partition +resident, so a tile costs a classify plus one decode/clip or a decompress. Open +once, serve many, close. + +```c +/* Opaque runtime-compositor handle. */ +typedef struct tile57_compose_source tile57_compose_source; + +/* Coverage/zoom summary filled by tile57_compose_meta_get. */ +typedef struct { + uint8_t min_zoom; + uint8_t max_zoom; /* deepest zoom served (native + one overscale zoom) */ + uint32_t cells; /* coverage-carrying archives held */ + double west, south, east, north; /* union coverage bounds, degrees */ +} tile57_compose_meta; + +/* Open a resident compositor over the `n` per-cell PMTiles at `paths` (each from + * tile57_bake_cell_bytes, on disk), mmap'd so the cell set is never fully + * resident. partition_path (NULL to skip) names a sidecar — written by + * tile57_compose_save_partition (the `tile57 bake` CLI emits partition.tpart) — to + * load and skip the build; a missing/stale one falls back to building. NULL on + * error / no coverage-carrying archive. */ +tile57_compose_source *tile57_compose_open(const char *const *paths, size_t n, + const char *partition_path); + +/* Compose tile (z,x,y) on demand into RAW (decompressed) MLT in *out/*out_len — what + * a live tile server hands its HTTP layer (which gzips on the wire). Returns: + * 1 served (bytes in *out/*out_len), + * 2 OWNED but empty — a cell owns this ground but produced nothing (transient + * while its bake is still running; an error state once bakes are done), + * 0 not owned — true empty ocean (safe to cache), + * -1 error. */ +int tile57_compose_serve(tile57_compose_source *src, uint8_t z, uint32_t x, uint32_t y, + uint8_t **out, size_t *out_len); + +/* Fill *out with the compositor's zoom range + union coverage bounds. */ +void tile57_compose_meta_get(tile57_compose_source *src, tile57_compose_meta *out); + +/* Serialize the ownership partition to `path` (a sidecar a later + * tile57_compose_open loads to skip the build). 1 = ok, -1 = error. */ +int tile57_compose_save_partition(tile57_compose_source *src, const char *path); + +/* Release a compositor (munmaps the archives, frees the partition). */ +void tile57_compose_close(tile57_compose_source *src); +``` + +The `tile57 bake -o out/` CLI produces this structure +directly: `out/tiles/.pmtiles` per cell plus `out/partition.tpart`. A host +opens the compositor over `out/tiles/*.pmtiles` with that sidecar. + ## Render a finished view (PNG / PDF) The [native S-52 rendering engine](./rendering.md) draws a view of the chart — @@ -166,6 +241,11 @@ typedef struct { tile57_world_point anchor, float rot_deg, float half_w_px, float half_h_px); void (*draw_pattern)(void *ctx, const tile57_feature *f, const char *name, size_t name_len, const tile57_world_rings *rings); + /* Optional. Text as a UTF-8 string for a host SDF glyph atlas (tile57_bake_glyph_sdf), + * instead of tessellated outlines. */ + void (*draw_text_str)(void *ctx, const tile57_feature *f, tile57_world_point anchor, + float ox_px, float oy_px, const char *text, size_t text_len, + float size_px, tile57_rgba color, tile57_rgba halo); } tile57_surface_cb; /* Portray the view once and drive the callbacks. 0 ok, -1 bad handle, @@ -184,6 +264,11 @@ those two fields NULL, the same features arrive as vector outlines instead. tile57 also declutters overlapping text for you before it makes the calls (symbols and soundings always draw, per S-52), so you don't repeat that work. +There is a pixel-space twin, `tile57_chart_render_view_cb` with a `tile57_canvas_cb` +vtable, that emits the SAME portrayal as resolved paint-order draw calls in canvas +pixels — for a host that wants the engine's own paint pipeline without the PNG +encode. + ## Inspect a chart: cells, features, catalogues ```c @@ -240,78 +325,17 @@ int tile57_chart_query(tile57_chart *chart, double lon, double lat, double zoom, const tile57_query_cb *cb); ``` -The class and cell come through for any chart. The attribute JSON is filled in -only when the chart was baked with pick attributes — `tile57_bake_bundle` / -`tile57_bake_pmtiles` include them by default (set `omit_pick_attrs` to leave them -out for leaner tiles). Without them, `s57` is an empty string. - -## Bake an ENC_ROOT to PMTiles - -Bake in-memory cells into one PMTiles archive, zoom-banded per cell by -compilation scale, so the result opens cheaply (`tile57_chart_open_pmtiles`) -instead of holding every cell live. One cell = a base `.000` plus its sequential -update files. - -```c -/* One ENC cell for tile57_bake_pmtiles. `name` (the source cell stem, e.g. - * "US4MD81M") is emitted as the `cell` pick-report property; NULL/"" omits it. */ -typedef struct { - const uint8_t *base; size_t base_len; - const uint8_t *const *updates; const size_t *update_lens; size_t update_count; - const char *name; -} tile57_cell; - -/* Progress callback. stage 0 = loading/portraying cells, stage 1 = baking tiles. - * band_index/band_count/band_name locate the current navigational band. */ -typedef void (*tile57_bake_progress)(void *user, uint8_t stage, size_t done, size_t total, - uint8_t band_index, uint8_t band_count, - const char *band_name); - -/* Shared bake options. Pass NULL for all defaults (embedded rules/catalogue, no - * band clamp, pick attrs included, no progress). catalog_dir/created apply to - * tile57_bake_bundle only. */ -typedef struct { - const char *rules_dir; /* NULL = embedded portrayal rules */ - const char *catalog_dir; /* NULL = embedded S-101 catalogue (bundle only) */ - const char *created; /* NULL = manifest "created" unset (bundle only) */ - uint8_t minzoom, maxzoom; /* 0/0 = no band clamp */ - bool omit_pick_attrs; - tile57_bake_progress progress; - void *progress_user; - uint8_t format; /* baked tile encoding: 0 = default (mlt), - * TILE57_TILE_TYPE_MVT, TILE57_TILE_TYPE_MLT. - * Honored by both bake calls. */ -} tile57_bake_opts; - -/* 1 with the archive in *out/*out_len (free with tile57_free), 0 if nothing - * covered, -1 on error. */ -int tile57_bake_pmtiles(const tile57_cell *cells, size_t count, - const tile57_bake_opts *opts, - uint8_t **out, size_t *out_len); -``` - -## Bake a chart bundle - -`tile57_bake_bundle` bakes a single cell `.000` **or** a whole ENC_ROOT directory -(`input`, an on-disk path) into a self-contained chart bundle under `out_dir` — -the same package the `tile57 bake … -o out/` CLI emits: `tiles/chart.pmtiles`, -`assets/{colortables,linestyles}.json` + sprite/pattern atlases, per-scheme -`assets/style-{day,dusk,night}.json`, and `manifest.json`. `out_cell_count` / -`out_bbox` (w,s,e,n) are optional. Returns 1 on success, 0 if nothing was covered, --1 on error. - -```c -int tile57_bake_bundle(const char *input, const char *out_dir, - const tile57_bake_opts *opts, - uint32_t *out_cell_count, double *out_bbox); -``` +The class and cell come through for any chart; the attribute JSON is filled in +from the `s57` pick property baked into the tiles (empty if a chart was baked +without pick attributes). ## Generate portrayal assets -`tile57_bake_assets` produces all portrayal assets in memory (the same files -`tile57_bake_bundle` writes to disk) from the library's embedded catalogue -(`catalog_dir` NULL/"") or an on-disk `PortrayalCatalog`. Every non-NULL buffer is -owned by the library; release the whole struct with `tile57_assets_free`. +`tile57_bake_assets` produces all portrayal assets in memory — colour tables, +line styles, and the sprite / area-fill pattern atlases — from the library's +embedded catalogue (`catalog_dir` NULL/"") or an on-disk `PortrayalCatalog`. +Every non-NULL buffer is owned by the library; release the whole struct with +`tile57_assets_free`. ```c typedef struct { @@ -331,10 +355,13 @@ into one PNG, each cell centered on its symbol's pivot, plus a JSON index of `{name: {x, y, width, height, pixelRatio}}`. A GPU host loads this atlas once and draws point symbols and area patterns as textured quads by name — the atlas the [host-surface `draw_sprite`/`draw_pattern` callbacks](#render-to-a-host-surface-vector-callbacks) -hand back. Free it with `tile57_assets_free` as above. +hand back. `tile57_bake_glyph_sdf` is its text counterpart: an RGBA +signed-distance-field atlas of the label font, for a host that draws text as SDF +quads. Free either with `tile57_assets_free` as above. ```c int tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out); /* 1 = ok, 0 = error */ +int tile57_bake_glyph_sdf(tile57_assets *out); /* 1 = ok, 0 = error */ ``` ## Build a MapLibre style @@ -342,7 +369,7 @@ int tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out); /* 1 `tile57_build_style` turns a MapLibre style template + the mariner's S-52 display options + the S-52 colortables into a concrete style JSON, client-side. The template + colortables come from the built-in `tile57_style_template` / -`tile57_colortables_default` (or the bundle's assets); the host fills +`tile57_colortables_default` (or the generated assets); the host fills `tile57_mariner` from its UI. ```c @@ -350,7 +377,7 @@ typedef enum { TILE57_SCHEME_DAY=0, TILE57_SCHEME_DUSK=1, TILE57_SCHEME_NIGHT=2 typedef enum { TILE57_DEPTH_METERS=0, TILE57_DEPTH_FEET=1 } tile57_depth_unit; typedef enum { TILE57_BOUNDARY_SYMBOLIZED=0, TILE57_BOUNDARY_PLAIN=1 } tile57_boundary_style; -typedef struct { +typedef struct tile57_mariner { tile57_scheme scheme; double shallow_contour, safety_contour, deep_contour, safety_depth; bool four_shade_water; @@ -420,6 +447,20 @@ int tile57_style_template(tile57_scheme scheme, const char *source_tiles, uint8_t **out, size_t *out_len); ``` +## Util: warmup + free + +```c +/* Populate the process-global read-only registries (feature catalogue + + * complex-linestyle table) on the calling thread. Call ONCE on your main thread + * before opening or baking cells from worker threads, so concurrent bake/render is + * race-free. Idempotent. */ +void tile57_warmup(void); + +/* Free ANY buffer the engine returned (tiles, style JSON, the scamin array, + * colortables, …), passing the same length. The universal free. */ +void tile57_free(void *ptr, size_t len); +``` + ## Diagnostics header [`include/tile57_diag.h`](../../include/tile57_diag.h) (`tile57_diag_*`) exposes From 5010165b12f5093108ca761141e59da5ba5deb3e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 10:50:48 -0400 Subject: [PATCH 055/140] docs(readme): refresh Zig/C/CLI examples for the bake+compose model The C snippet pulled a tile via the removed tile57_chart_tile; the Zig snipped via the removed Chart.tile; and the CLI comments described `bake` emitting a self-contained bundle. Show the real path instead: `tile57 bake` writes per-cell tiles/.pmtiles + partition.tpart, and a host serves tiles by (z,x,y) with tile57_compose_open / tile57_compose_serve. Also drop the CHANGELOG.md link. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 50 ++++++++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5632372..d225287 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,10 @@ It is **high-performance and low-memory** by design: under an LRU bound. A **streaming** open reads a cell's bytes on demand (and frees them on eviction), so a host holds only the working set — not the whole catalogue. -- **Band-streamed bakes.** Baking an ENC_ROOT to one PMTiles archive streams - band-by-band (finest → coarsest, best-band dedup), so peak memory tracks the - largest single band. +- **Per-cell bakes.** Each ENC cell bakes to its own PMTiles at its compilation + scale, so a bake holds one cell at a time; the runtime compositor stitches the + cells by `(z, x, y)` on demand. (An offline whole-ENC_ROOT archive bake streams + band-by-band, so its peak memory tracks the largest single band.) - **Pure-Zig core.** The foundational format/encode packages have no libc; only the Lua portrayal + sprite rasterizer pull in C. @@ -114,32 +115,37 @@ const tile57 = @import("tile57"); var chart = try tile57.Chart.openPath("ENC_ROOT/", null, true); defer chart.deinit(); -if (try chart.tile(z, x, y)) |mvt| { // decompressed MVT bytes, or null if empty - defer tile57.freeBytes(mvt); - // … hand to your renderer … -} +const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null +// … render a view (chart.renderView), query features, or bake an archive … ``` -`Chart.openPath` streams cells on demand (working-set only); `Chart.openBytes` opens a -single in-memory cell. See [the Zig API docs](docs/docs/zig-api.md). +`Chart` renders views, queries features, and reads metadata; `tile57.bakeArchive` +bakes an ENC_ROOT to one band-streamed PMTiles archive offline. The runtime tile +compositor (bake per cell, then compose by `(z, x, y)`) is exposed through the +[C ABI](include/tile57.h). See [the Zig API docs](docs/docs/zig-api.md). ## Use it from C -The same engine behind a thin C ABI ([`include/tile57.h`](include/tile57.h)): +The same engine behind a thin C ABI ([`include/tile57.h`](include/tile57.h)). +Tiles are made one way — bake each cell to its own PMTiles, then compose on +demand — the structure `tile57 bake ENC_ROOT -o out/` writes: ```c -// Open an on-disk ENC_ROOT directory (or a single .000 file). -tile57_chart *chart = tile57_chart_open("ENC_ROOT/"); -uint8_t *mvt; size_t n; -if (tile57_chart_tile(chart, z, x, y, &mvt, &n) == TILE57_TILE_OK) { - /* … render mvt … */ - tile57_free(mvt, n); +// out/ holds tiles/.pmtiles (one per cell) + partition.tpart +const char *paths[] = { "out/tiles/US5MD1MC.pmtiles" }; +tile57_compose_source *src = tile57_compose_open(paths, 1, "out/partition.tpart"); + +uint8_t *tile; size_t n; +if (tile57_compose_serve(src, z, x, y, &tile, &n) == 1) { /* decompressed MLT */ + /* … hand the tile to your renderer … */ + tile57_free(tile, n); } -tile57_chart_close(chart); +tile57_compose_close(src); ``` -`libtile57.a` also exposes the ENC_ROOT bake, the MapLibre style builder, and the -asset/atlas generators. See [the C API docs](docs/docs/c-api.md). +A separate `tile57_chart` handle renders finished PNG/PDF views and answers +metadata + object queries; `libtile57.a` also exposes the MapLibre style builder +and the asset/atlas generators. See [the C API docs](docs/docs/c-api.md). ## The `tile57` CLI @@ -147,8 +153,8 @@ The offline tool bakes charts and emits portrayal assets: ```sh zig build # builds zig-out/bin/tile57 -tile57 bake CELL.000 -o out/ # one cell -> bundle (tiles + style + assets + manifest) -tile57 bake ENC_ROOT -o out/ # whole catalogue, band-streamed -> same bundle +tile57 bake CELL.000 -o out/ # one cell -> out/tiles/.pmtiles + partition.tpart +tile57 bake ENC_ROOT -o out/ # whole catalogue -> per-cell tiles/ + partition.tpart tile57 assets -o assets/ # colortables + linestyles + sprite + patterns tile57 png ENC_ROOT --view -76.48,38.974,15 --size 1600x1200 -o chart.png tile57 pdf ENC_ROOT --view -76.48,38.974,15 --size 1600x1200 -o chart.pdf @@ -172,7 +178,7 @@ Docs source lives in [`docs/`](docs/): [intro](docs/docs/intro.md), [getting started](docs/docs/getting-started.md), the [Zig API](docs/docs/zig-api.md), the [C API](docs/docs/c-api.md), the [architecture](docs/docs/architecture.md), and the -[tile schema](docs/docs/tile-schema.md). See also [`CHANGELOG.md`](CHANGELOG.md). +[tile schema](docs/docs/tile-schema.md). ## License From fa627c7e6e1b169187daca2248e791bd7ec57483 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 10:50:48 -0400 Subject: [PATCH 056/140] docs: drop CHANGELOG.md Not well kept and superseded by the commit history. Its last reference (a link in README) went with the preceding docs refresh. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 375 --------------------------------------------------- 1 file changed, 375 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7753891..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,375 +0,0 @@ -# Changelog - -All notable changes to chartplotter-native. The format loosely follows -[Keep a Changelog](https://keepachangelog.com/); the project is pre-1.0 and the -C ABI is not yet frozen. - -## [Unreleased] - -### Added — C ABI: host-surface rendering, object query, sprite atlas -- **`tile57_chart_render_surface_cb`** + the **`tile57_surface_cb`** vtable: hand - the portrayed scene to a host as world-space draw calls (area/line geometry in - web-mercator, point symbols/text as a world anchor plus a reference-px outline, - each call tagged with its SCAMIN) so a GPU host tessellates once and pans/zooms - by transforming the vertices. Includes optional `draw_sprite` / `draw_pattern` - callbacks that name atlas entries to draw point symbols and area patterns as - textured quads. See [docs/c-api.md](docs/docs/c-api.md). -- **`tile57_chart_query`** + **`tile57_query_cb`**: the S-52 cursor pick — report - every feature under a lon/lat (area point-in-polygon; line/point within a small - radius) with its class, S-57 attribute JSON, and source cell. -- **`tile57_bake_sprite_mln`**: bake just the MapLibre sprite-mln atlas (pivot- - centered symbol cells + a name-to-rect index) for a GPU host to load. -- **Fix**: the reader (baked PMTiles) replay now copies the baked `s57` / `cell` - tile properties into `FeatureMeta`, so a baked chart's object query returns - attributes, not just the class. - -### Added — S-52 overscale indication (AP(OVERSC01), specs/overscale.md) -- **Baked overscale hatch geometry**: every cell contributing to a tile - (including band-handoff carried cells) now emits its M_COVR (CATCOV=1) - data-coverage polygon, clipped per tile, into the `area_patterns` source - layer as pattern `OVERSC01`, tagged with a new int prop `oscl` = the cell's - compilation scale quantized UP the scamin ladder (same crossing alignment as - the `smax` handoff). Area fills carry the same `oscl` tag. -- **The occlusion sandwich** (style builder): the base `areas` fill layer - splits into `fill-areas#oscl` (fills of overscaled cells) UNDER the new - `overscale` layer (fill-pattern `pat:OVERSC01`, source-layer - `area_patterns`) UNDER `fill-areas` (at-scale fills) — a finer cell's opaque - DEPARE/LNDARE occlude a coarser cell's hatch, so the hatch survives only on - coarse-only patches, with zero polygon boolean ops. The generic pattern - layers exclude `OVERSC01`. -- **The oscl gate clause** (client-injected, exact shape pinned by test): - `[">", ["coalesce",["get","oscl"], 0], DENOM]` on the hatch + under-hatch - fills, and its `["!", …]` negation on the at-scale fills. DENOM is the SAME - injected literal as the scamin/smax clauses (filter-gate mode), a - zoom-derived denominator in bucket/fallback modes, and the 1e12 show-all - placeholder at boot (hatch hidden, all fills at-scale, until the client - injects the live denom). -- **`show_overscale` mariner toggle** (default true; S-52 §10.1.10): drives the - `overscale` layer's `layout.visibility` only, so a toggle is a single - `setLayoutProperty` style-diff op. C ABI: `tile57_mariner.show_overscale` - (appended; `tile57_mariner_defaults` sets true); bindings/go: - `Mariner.ShowOverscale`. -- **Pixel/ascii surfaces**: `resolve.osclVisible` mirrors the style gate, so - render_view/pdf/ascii draw the hatch consistently (via the normal AP path); - hidden under `ignore_scamin` and when `show_overscale` is off. -- **Manifest ladder**: emitted `oscl` values join the archive/live `scamin` - ladder (bundle prop scan + per-cell quantized-cscl fold), so the client's - discrete crossing machinery fires exactly at every emitted gate value. - -### Changed — MLT is the default tile format -- **MLT (MapLibre Tiles) is now the DEFAULT bake/storage tile format**; - MVT stays available explicitly (`--format mvt`, `tile57_bake_opts.format = - TILE57_TILE_TYPE_MVT`). There is NO transcode layer anywhere: the wire format - follows the storage format, and maplibre-gl ≥ 5.12 decodes MLT natively via - the vector-source `encoding` option. -- **Bake parity gap closed**: the bundle's post-bake collection (sprite-mln - sounding composites + the SCAMIN manifest feeding the client's ladder) now - decodes baked tiles with the codec matching the bake format, so an MLT bake - produces byte-identical sprite-mln output and the same `scamin` metadata as - an MVT bake of the same input. -- C ABI: `tile57_bake_opts` gained `format` (0 = default = MLT; honored by - `tile57_bake_pmtiles` AND `tile57_bake_bundle`); `tile57_chart_info` gained - `tile_type` (the encoding `tile57_chart_tile` returns — the stored type for a - PMTiles chart, the live generation format for a cell chart); new - `tile57_chart_set_tile_format` selects the live-generation encoding on a - cell-backed chart (cell charts still OPEN generating MVT so existing MVT-only - embedders are unaffected); `tile57_style_template` gained `tile_encoding` - (emits `"encoding":"mlt"` on the chart source; the hint survives - `tile57_build_style` / `tile57_style_diff`); the stale "decompressed MVT - bytes" doc on `tile57_chart_tile` now states tiles serve verbatim in the - chart's tile encoding. Bundle styles (`style-{day,dusk,night}.json`) carry - `"encoding":"mlt"` on their `pmtiles://` source for MLT bundles. -- bindings/go: `BakeOpts.Format`, `ChartInfo.TileType` / `Meta.TileType`, - `Source.SetTileFormat`, `TileFormat` (+ `Encoding()`/`EncodingFormat`), and an - `encoding TileFormat` parameter on `StyleTemplate`/`Style`. -- `tile57 inspect` now decodes MLT tiles (same layer/feature dump as MVT) - instead of hex-dumping them. - -### Fixed — scamin-aware band handoff (the band-floor blank window) -- **Zooming through a band boundary no longer blanks the chart** while the finer - band's SCAMIN-gated bulk is still hidden. Each band's floor-zoom tiles now bake - in the next-coarser band's pass with BOTH bands' cells alive; where a tile's - display window opens coarser than the covering finer cell's compilation scale, - the coarser band's features are carried down tagged with a new integer tile - property **`smax`** (the handoff denominator, quantized up onto the archive's - real SCAMIN ladder) instead of being best-band suppressed. Styles gate it with - `["<", ["coalesce",["get","smax"],0], DENOM]` beside the scamin clause (the - same injected literal in filter-gate mode; a zoom-derived denominator on the - bucket/fallback paths), so the copy hands off at the exact crossing where the - finer content activates — no hole, no double-draw. The live cells backend - applies the same rule per request (`bake_enc.carryGate`, shared). -- **Bundle bakes no longer lose every zoom below the coarsest populated band's - floor.** The coarsest band with cells now extends down to the archive minzoom - (mirroring the live path's coarsest-band fallback), so a pack without overview - cells still gets low-zoom tiles instead of blank basemap. -- Bake callers hold at most **two adjacent bands'** cells at once (the deferred - band rides into the next pass; portrayal is reused, never recomputed). - `Baker.bakeBand`/`plannedTiles` grew an own/carry split (`own_len`) and a - `FloorMode` (`defer_down`/`extend_min`). - -### Changed — chart-centric C ABI (breaking, pre-1.0) -- **The tile-source C ABI (`include/tile57.h`) was reworked into a chart-centric - surface** (see `docs/docs/c-api.md`); the internal Zig type `Source` in `src/source.zig` - is likewise renamed **`Chart`** in `src/chart.zig`, so the engine and the ABI use - the same word. The opaque handle is `tile57_chart` and the open/serve entry points - are prefixed `tile57_chart_*`. - - **Open** splits the old single `tile57_source_open(data, len, format, rules_dir)` - (and its `TILE57_FORMAT_*` enum) into three explicit functions: - `tile57_chart_open(path)` (an on-disk ENC_ROOT directory or a single `.000`, - streamed on demand), `tile57_chart_open_bytes(base, len)` (one in-memory S-57 - cell), and `tile57_chart_open_pmtiles(path)` (a baked PMTiles bundle). The - many-cells / streaming C openers (`tile57_source_open_cells{,_streaming}`) and - the resolved-format getter `tile57_source_format` are gone. - - **Metadata** — the four `tile57_source_{zoom_range,bands,bounds,anchor}` getters - fold into one `tile57_chart_get_info(chart, &info)` filling a `tile57_chart_info` - struct; a new `tile57_chart_scamin` returns the live SCAMIN manifest. - - **Tiles / lifetime** — `tile57_tile_get` → `tile57_chart_tile`, - `tile57_source_clear_cache` → `tile57_chart_clear_cache`, `tile57_source_close` - → `tile57_chart_close`. The per-kind frees collapse into one universal - `tile57_free(ptr, len)` (replacing `tile57_tile_free`). -- **Bake surface.** `tile57_bake_cells` → `tile57_bake_pmtiles`, now taking a - `tile57_cell` array (renamed from `tile57_cell_input`) plus a `tile57_bake_opts` - options struct (rules/catalog dir, zoom clamp, pick attrs, progress); the - `tile57_bake_progress` callback gains band index/count/name. New - `tile57_bake_bundle(input, out_dir, opts, …)` writes the full on-disk bundle the - `tile57 bake … -o out/` CLI emits. -- **Portrayal assets folded.** The four generators (`tile57_colortables`, - `tile57_linestyles`, `tile57_sprite_atlas`, `tile57_pattern_atlas`) and the - `tile57_named_bytes` blob type are replaced by one `tile57_bake_assets(catalog_dir, - &assets)` filling a `tile57_assets` struct (colortables + linestyles + sprite + - pattern buffers), freed with `tile57_assets_free`. `tile57_colortables_default` / - `tile57_style_template` still build a style with no on-disk catalogue, and - `tile57_build_style` / `tile57_style_diff` take the SCAMIN manifest. - -### Changed — repository split into the tile57 engine + the Qt demo -- **chartplotter-native is now the standalone tile57 engine.** The Zig engine - moved from `engine/` to the repository root, so a top-level **`zig build`** - produces `zig-out/bin/tile57` (CLI) + `zig-out/lib/libtile57.a` (C ABI). A plain - `zig build` now defaults to **ReleaseFast** (a Debug bake is ~2.6x slower); - use `-Doptimize=Debug` for development. -- **Removed the C++ render layer.** The headless renderer (`chartplotter-render` / - `libchartplotter`), its mbgl `FileSource` adapter, the vendored - `maplibre-native` submodule, `include/chartplotter.h`, the root CMake build, and - the PNG-render helper scripts are gone. The repo is now pure Zig - tile/style/asset generation behind the C ABI (`include/tile57.h` + - `tile57_diag.h`). -- **The Qt6 viewer moved to a standalone repo, `tile57-demo`**, which consumes the - tile57 engine as a git submodule (built via `zig build`) and QMapLibre. - -### Changed — interactive window is now Qt6 (QMapLibre) -- **Replaced the GLFW / macOS-Metal MapLibre Native window with a Qt6 viewer**, - `chartplotter-qt` (`app/qt`), built on the QMapLibre widget (maplibre-native-qt, - vendored as a submodule + built by `scripts/build-qmaplibre.sh`). It loads a baked - chart bundle's `style.json` (PMTiles + portrayal assets) into a - `QMapLibre::MapWidget` — no mbgl internals, no custom FileSource. -- **Removed** the GLFW + Metal/native-host window code (`app/chartplotter_main.cpp`, - `chart_native_host`, `metal_backend`, `chart_renderer_frontend`), the - `MLN_WITH_GLFW` CMake path + the `chartplotter` exe, the desktop/macos-desktop - presets, and the `chartplotter_view_*` C API. `libchartplotter` is now the headless - renderer (`chartplotter_render_png` + `chartplotter-render`); ChartTileSource / - enc_root and the offline baker are unchanged. - -### Changed — engine reshaped into foundational packages -- **`iso8211`, `s57`, `s100` are now standalone Zig packages** (`engine/src/{iso8211, - s57,s100}/`), mirroring the Go oracle's `pkg/iso8211`, `pkg/s57`, `pkg/s100` one - for one. They are pure (no libc/Lua) and target-agnostic, so the same modules - compile into both the unit tests (glibc) and the static-musl baker. Pure refactor - — behavior identical; the C ABI (`tile57_*`) is unchanged. - -### Added — offline chart-bundle baker -- **`tile57 bundle -o `** emits a self-contained, - relocatable **chart bundle**: `tiles/chart.pmtiles` + `assets/colortables.json` + - `assets/style-{day,dusk,night}.json` + `manifest.json`. The tiles carry S-52 colour - *tokens* (palette-independent); the assets carry the RGB and the style layers. Both - halves come from the same S-101 catalogue and are stamped with a `schema_version` - (`tile57/1`) so a renderer can refuse a mismatched bundle. -- **New `assets` module** (mirrors the Go oracle's `internal/engine/assets.EmitS101`) - and **`tile57 assets -o `** emit the portrayal - assets independent of a cell. First artifact: `colortables.json` (token → hex per - day/dusk/night palette, parsed from `ColorProfiles/colorProfile.xml`) — **byte- - identical to the Go oracle's output**. -- **MapLibre `style.json` generation** (`assets/style.zig`, a port of - ported from the web `s52-style.mjs`/`chart-style.mjs`): `tile57 style` - emits one style per palette, and `bundle` writes the three styles + references them - in `manifest.portrayal.styles`, so a bundle is **directly renderable**. - -### Removed -- **The legacy Python style generator `style/build_style.py`** (and the - transitional `scripts/check-style-parity.sh`). `engine/src/assets/style.zig` is - now the sole style generator — verified full-file identical to `build_style.py` - (27 layers × 3 palettes) before removal — and `scripts/gen-style.sh` drives - `tile57 style`. Line styles, sprite/pattern atlases (SVG raster), and - glyphs (SDF) — to light up the symbol/text layers — are next. - -### Added — on-demand ENC_ROOT + offline baker -- **Lazy on-demand tile generation (the new default for an ENC_ROOT).** Pointing a - host at an ENC_ROOT used to parse + portray *every* cell at open and hold them all - (≈34 GB for the ~7400-cell NOAA catalogue — a blue, never-finishing window). Now - `tile57_source_open_cells` builds a cheap spatial index (per cell: compilation- - scale band + bbox, via `s57.peekMeta`, in parallel) and parses + portrays only - the cells a *requested tile* needs, choosing the best-available scale band per - tile and keeping recent cells in an LRU. The full catalogue opens in seconds and - renders a view by touching ~a dozen cells (≈5 GB transient at open, far less - steady-state). Coverage gaps at a zoom are filled by overzooming the coarsest - band present, so tiles aren't blank. -- **No band-boundary tile gaps + scale-clamped navigation.** A tile now overlays - *all* overlapping cells whose band range includes the zoom (coarse→fine, finer on - top), so a finer cell that covers only part of a tile no longer blanks the coarser - fill (the earlier best-band single-pick left holes); a zoom in a coverage gap - overzooms the coarsest band. The window clamps navigation to the chart scale range - ~1:10,000,000 .. 1:4,000 (z≈5.8..17.1) and the vector source minzoom drops to 5 - (`gen-style.sh`) so overview scales actually draw. LRU budget raised to 256 cells - for wide views. -- **Initial-view framing.** When no explicit camera is given, the host fits the - data bounds if that lands at a usable zoom (a single cell / a region); for a - continental ENC_ROOT — whose bounds would fit at ~z2, below the style's source - minzoom, i.e. blank — it instead opens on a representative harbour cell near the - data median (`tile57_source_anchor`), robust to scattered IHO test cells. Fixes - "the window comes up but I see nothing." `chartplotter-render` with zoom `0` - exercises the same path. -- **Offline streaming banded baker** (`engine/src/bake_enc.zig`): for precomputing - a shareable PMTiles archive. Groups cells into navigational bands by CSCL (Band, - matching the Go reference), bakes finest → coarsest with best-band dedup, holding - one band at a time. Portrayal + per-tile MVT generation run across all cores - (`parallelFor`; thread-local Lua context, warmed catalogue, quiet flag); per-cell - geometry is assembled once (`buildGeoCache`) and reused. CLI `tile57 - bake-root -o out.pmtiles`; C ABI `tile57_bake_cells`. The app can use - it on open via `CHARTPLOTTER_BAKE=1` (cached under `$XDG_CACHE_HOME/chartplotter`, - `CHARTPLOTTER_BAKE_MIN/MAXZOOM` to set the range) for smooth-everywhere offline - use; the default is lazy. - -### Added — offline baker + portrayal/style refinements -- **Spatial / geometry Host binding**: `HostGetSpatial` now serves real point - geometry (`#P` points, `#M` multipoints, `#S` surfaces) to the S-101 rules via - `_HostFeaturePoints` (backed by per-feature point geometry). A `#P` point must be - a real Point or the framework's `GetSpatial` recurses forever — this was the - Obstruction/Wreck "C stack overflow". With it + derived depths + the - orientation/clearance complex attrs below, the test cells (US4MD81M, US5MD1MC) - now portray with **zero rule errors**. -- **Orientation + clearance complex attributes** synthesized from S-57 simple - attrs (ORIENT, VERCCL/VERCLR/VERCOP, HORCLR) so NavigationLine / RecommendedTrack - / SpanOpening read `feature..` instead of crashing on a nil - complex (Go sync: the `clearances` map + orientation alias). -- **`tile57` CLI** (`engine/tools/bake.zig`): pre-bake a cell to a - PMTiles archive — `tile57 bake -o out.pmtiles - [--rules DIR] [--minzoom N --maxzoom N] [updates…]` — over the cell's bounds, - plus `inspect`, `cell`, and `version`. The precache path that mirrors - chartplotter-go's `bake`. -- **Full S-101 portrayal in the baker.** `tile57` now runs the same - embedded-Lua S-101 rule engine as the live library (not the `classify()` - fallback), so baked tiles carry the full S-101 layer set - (areas/area_patterns/lines/point_symbols/soundings/text + `*_scamin` declutter - buckets). The baker's engine module (`src/bake_root.zig`) adds `portray.zig` + - the C/Lua sources on top of the pure `root.zig`; the unit-test build stays pure - Zig. On a glibc Linux host the baker links against Zig's own static musl (the - self-hosted ELF linker rejects a modern glibc `crt1.o`'s `.sframe` - relocations). Portrayal failure (e.g. rules dir not found) falls back to - `classify()` as before. Default rules dir resolves like the C++ host - (`--rules`, else `TILE57_S101_RULES`, else the vendored catalogue). -- **Honest portrayal diagnostics.** A feature whose S-101 class has no rule file - (e.g. SweptArea/SWPARE — an IHO catalogue gap) is now counted as *unportrayed* - rather than an *error*, matching the Go reference's silent suppression; the - per-error log prints the primitive's name (`Point`/`Curve`/`Surface`) instead - of a useless `table: 0x…` address. (Shared `csrc/lua_shim.c` driver, so the - live path benefits too.) -- **Native S-52 fallback for SweptArea (SWPARE).** The S-101 catalogue ships no - SweptArea rule (an IHO gap), so the Lua engine emits nothing for it. The MVT - layer now draws the Go reference's `sweptAreaBuild` fallback directly: a dashed - `CHGRD` boundary on each ring, the `SWPARE51` swept-depth bracket at the area's - representative point, and a `swept to ` label. (`engine/src/s57_mvt.zig`, - shared by the baker and the live path.) -- **Native S-52 fallback for NEWOBJ.** NEWOBJ-derived features (e.g. - VirtualAISAidToNavigation) whose S-101 rule doesn't portray the encoded geometry - — wrong primitive, unofficial stub — now draw the Go reference's `newObjectBuild` - placeholder, a dashed `CHMGF` (magenta) outline on the line/area geometry, rather - than being dropped. A genuine rule error on any *other* class is still suppressed - (no output), matching the Go reference. (`engine/src/s57_mvt.zig`.) -- **SCAMIN decluttering**: the live path now routes features carrying SCAMIN - (attr 133) into `*_scamin` MVT buckets, and the generated style gives those layers - a per-feature `minzoom` derived from the SCAMIN 1:N denominator, so minor - features drop out below their scale (replacing the M1 "both shown" stub). -- **S-52 draw priority**: `draw_prio` (from the S-101 instruction stream) is now - emitted on features and used as the area fill-sort-key (Go sync `3ca4d5f`). -- **Drying-line contour**: 0 m `VALDCO` is emitted on DEPCNT lines + a - line-centre contour-label style layer (Go sync `f86b750`). -- **M_QUAL data quality**: `zoneOfConfidence` is synthesized from CATZOC (via a - generalized complex-attribute binding) so `QualityOfBathymetricData` portrays - (Go sync `49e9cd9`). -- QUAPOS quality-of-position is parsed + aggregated per feature (Go sync - `1b04ebb`); the approximate-position dashed-line *application* is still to come. -- Derived depth attrs (`defaultClearanceDepth`/`surroundingDepth`, Go sync - `a9c8afd`) are computed + supplied to under/awash dangers; combined with the - spatial binding above this clears the danger-rule crashes. Blank S-57 attributes - are now treated as absent (they were served as `""`, building a malformed - `ScaledDecimal{Value=nil}` that crashed the depth comparison). - -### Fixed -- **Guaranteed attribute bindings no longer clobber the catalogue's multiplicity.** - `inTheWater`/`orientationValue`/`topmark` were bound unconditionally (Upper=1) - *after* the catalogue bindings, overwriting a feature type's real multiplicity — - e.g. RadioCallingInPoint's array-valued `orientationValue` (Upper=2), so the rule - crashed indexing `feature.orientationValue[1]`. Now added only when the catalogue - doesn't already bind them (matches Go's `withGuaranteed`). Clears the - RadioCallingInPoint errors on real NOAA cells; the directional RDOCAL symbols and - labels now portray. (`engine/csrc/lua_shim.c`.) - -### Changed — two-library architecture + C APIs (breaking, pre-1.0) -- The project is now two C libraries with distinct roles: - - **`libtile57`** — the Zig S-57 tile generator (was `libtilegen.a` / `tg_*`). - Prefix `tile57_*`, headers `include/tile57.h` + `include/tile57_diag.h`. The - sources live under `engine/` (Zig module `engine`). Env var - `TILE57_S101_RULES`. - - **`libchartplotter`** — the embeddable chart **widget** that opens a window - and renders (it wraps MapLibre Native + libtile57). Prefix `chartplotter_*`, - header `include/chartplotter.h`. (`libchartplotter` previously named the tile - generator; that role is now `libtile57`.) -- Redesigned the tile-source C ABI: one opener - `tile57_source_open(data, len, format, rules_dir)` with a `TILE57_FORMAT_AUTO` - sniff (replacing two openers + the duplicated try/fallback) plus - `tile57_source_open_cells` for an ENC_ROOT; `tile57_source_format/zoom_range/ - bounds/clear_cache`, a named `tile57_tile_status`, `tile57_version`, and the - S-101 rules dir as an argument (env `TILE57_S101_RULES` is only a NULL - fallback). Lifetime + threading contracts documented in the header. -- App binaries: `chart-glfw-zig` → **`chartplotter`** (window), `chartshot-zig` - → **`chartplotter-render`** (headless PNG); both are now thin mains over - `libchartplotter`. C++ FileSource adapter `ZigTileSource` → **`ChartTileSource`**. - -### Removed -- The dead `CHART_ASYNC` worker path in the tile-source adapter (an artifact of - the flicker investigation; the flicker is fixed at its source, so the worker - was unused and made the threading contract ambiguous). -- Dead `#import ` in `app/metal_backend.mm` (left over - from the reverted `CATransaction`/`presentsWithTransaction` attempts). - -### Fixed -- **macOS interactive flicker** (the headline fix): `ChartTileSource` now issues - conditional tile requests (stable `etag` + `notModified`), so MapLibre reuses - its already-parsed tiles instead of re-parsing + re-uploading them 15-60×/sec. -- Low idle CPU in the interactive window via on-demand rendering (default; - `CHART_CONTINUOUS=1` opts back into present-every-frame). -- macOS Metal `nextDrawable` nil-guard + `setAllowsNextDrawableTimeout(false)` - to stop whole-screen blanks on fast pan; async present matching MapLibre's - macOS SDK. -- macOS link warning: the Zig archive is now built for the same deployment - target as the C++ host (`CMAKE_OSX_DEPLOYMENT_TARGET`, default 14.3). - -### Added -- **`libchartplotter` chart-widget library**: `chartplotter_view_open/run/close` - (open an interactive window) + `chartplotter_render_png` (headless) + the - `chartplotter_view_options` (size/title/style/rules/camera). The MapLibre glue - the two hosts used to duplicate now lives once in the library. -- **ENC_ROOT support**: point a host at a directory to load every base cell plus - its sequential `.001…` update files, overlaid. New - `tile57_source_open_cells` C API + a `cells` backend - (`s57_mvt.generateTileMulti`); the host walks the directory (`app/enc_root.hpp`). -- **S-57 update application** (`parseCellWithUpdates`): record-level merge by FOID - / (RCNM,RCID) with RUIN insert/delete/modify and the SGCC/FSPC control fields. -- M3 interactive pan/zoom window (`chartplotter`) serving live Zig tiles. -- Live S-57 → MVT portrayal now emits soundings (SNDFRM04 glyphs), area fill - patterns, and feature names (synthesized from OBJNAM). -- Area labels placed at the true centroid when inside (Go sync `30db686`). -- In-process tile cache in the generator (memoizes generated/decoded tiles). -- A **Docusaurus documentation site** under `docs/` (Introduction, Installation, - Getting Started, C API, Architecture, Tile Schema, Known Limitations), - deployed to GitHub Pages; this changelog; `specs/go-sync.md` Go-port plan. -- MIT `LICENSE`. From e87d912901ed0e5b78ba4e99f2fc983548cf1eaf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 10:53:45 -0400 Subject: [PATCH 057/140] docs(site): refresh getting-started + zig-api for bake+compose The public Zig Chart no longer serves (z,x,y) tiles (Chart.tile / setTileFormat / clearCache are gone), and `tile57 bake` no longer emits a self-contained bundle. Rewrite getting-started around bake -> per-cell tiles/ + partition.tpart -> serve via tile57_compose_open/serve, with the Zig Chart used for render + inspect. In zig-api, drop the tile/setTileFormat/clearCache methods, add renderSurfaceView/queryPoint/coverage/nativeScale, and note tiles come from the C-ABI compositor. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/docs/getting-started.md | 110 +++++++++++++++++++---------------- docs/docs/zig-api.md | 23 +++++--- 2 files changed, 73 insertions(+), 60 deletions(-) diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index e37043e..9e677d8 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -6,8 +6,8 @@ sidebar_position: 3 # Getting Started -This guide bakes a chart with the `tile57` CLI and fetches a tile from the -engine, from both Zig and C. It assumes you have finished +This guide bakes a chart with the `tile57` CLI, then serves a composed tile and +uses the engine from Zig and C. It assumes you have finished [Installation](./installation.md) (Zig 0.16, submodules, `zig build`). ## 1. Bake a chart with the CLI @@ -19,12 +19,15 @@ the portrayal assets a renderer needs: zig build # builds zig-out/bin/tile57 T=zig-out/bin/tile57 -# One command: a single cell OR a whole ENC_ROOT (band-streamed) -> -# a self-contained bundle (tiles/chart.pmtiles + style + assets + manifest) +# Bake a single cell OR a whole ENC_ROOT to the live-composite structure: +# per-cell tiles/.pmtiles (native band scale, M_COVR embedded) + partition.tpart $T bake CELL.000 -o out/ $T bake /path/to/ENC_ROOT -o out/ -# Just the portrayal assets (from the embedded catalogue — no path needed) +# Serve one composed tile on demand from that structure (the runtime compositor) +$T compose-tile out/tiles 15 9371 12534 --load-partition out/partition.tpart -o tile.mlt + +# The portrayal assets a renderer needs (from the embedded catalogue — no path needed) $T assets -o assets/ # colortables + linestyles + sprite + patterns $T sprite-mln -o assets/ # the MapLibre sprite sheet # (pass a /path/to/PortrayalCatalog to use an on-disk catalogue instead) @@ -34,14 +37,15 @@ $T png CELL.000 --view -76.48,38.974,15 --size 1600x1200 -o chart.png $T pdf CELL.000 --view -76.48,38.974,15 --size 1600x1200 -o chart.pdf # Inspect / summarise -$T inspect out/tiles/chart.pmtiles # zoom range + tile counts -$T cell CELL.000 # summarise an S-57 cell +$T inspect out/tiles/US5MD1MC.pmtiles # zoom range + tile counts for one cell +$T cell CELL.000 # summarise an S-57 cell $T version ``` -Run `tile57 help` for usage. The full subcommand list: `bake`, `assets`, -`sprite`, `pattern`, `sprite-mln`, `style`, `png`, `pdf`, `ascii`, `cells`, -`catalog`, `features`, `inspect`, `cell`, `objlcount`, `version`, `help`. +Run `tile57 help` for usage. The full subcommand list: `bake`, `compose-tile`, +`assets`, `sprite`, `pattern`, `sprite-mln`, `style`, `png`, `pdf`, `ascii`, +`cells`, `catalog`, `features`, `inspect`, `cell`, `objlcount`, `version`, +`help`. :::info Tiles are MLT by default Bakes encode [MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) @@ -58,66 +62,70 @@ real S-52 pixels inline on kitty-graphics terminals like Ghostty or Kitty): $T ascii CELL.000 --view -76.48,38.974,13 --ansi --tui ``` -A **bundle** is a relocatable directory in which the tiles and the portrayal that -renders them travel together: +`tile57 bake` writes a **live-composite structure** — per-cell tiles plus an +ownership partition — that a runtime compositor serves tiles from on demand: ``` out/ - manifest.json pins schema_version, couples the two halves - tiles/chart.pmtiles the DATA half — S-52 colour tokens, palette-independent - assets/colortables.json token -> hex per day/dusk/night (the only RGB) - assets/style-{day,dusk,night}.json MapLibre style layers, colours pre-resolved + tiles/US5MD1MC.pmtiles one PMTiles per cell, baked at its compilation scale + tiles/US4MD81M.pmtiles (M_COVR coverage embedded in each archive's metadata) + partition.tpart the ownership partition: which cell renders which ground ``` -## 2. Fetch a tile from Zig +There is no merged archive: any `(z, x, y)` tile is composed from the overlapping +cells on demand, so a re-bake of one cell doesn't rewrite a whole district. The +portrayal assets travel separately (`tile57 assets` / `style`); the tiles carry +S-52 colour **tokens**, never RGB, so one set of tiles renders in any palette. -Add tile57 as a dependency and `@import("tile57")`: +## 2. Serve a tile from C -```zig -const tile57 = @import("tile57"); +The engine sits behind a thin C ABI ([`include/tile57.h`](../../include/tile57.h)). +Open a compositor over the bake output and serve tiles by `(z, x, y)`: -// Open a chart: a PMTiles archive, or a raw S-57 cell portrayed live. -var chart = try tile57.Chart.openBytes(cell_bytes, .auto, null); -defer chart.deinit(); +```c +#include "tile57.h" -if (try chart.tile(z, x, y)) |bytes| { // decompressed tile bytes (or null if empty) - defer tile57.freeBytes(bytes); - // … hand the tile to your renderer … +/* Open a compositor over the `tile57 bake` output (list every cell archive). */ +const char *paths[] = { "out/tiles/US5MD1MC.pmtiles" }; +tile57_compose_source *src = tile57_compose_open(paths, 1, "out/partition.tpart"); + +uint8_t *tile; size_t n; +switch (tile57_compose_serve(src, z, x, y, &tile, &n)) { + case 1: /* … render the decompressed MLT tile … */ tile57_free(tile, n); break; + case 2: /* owned but empty — a cell's bake is still in flight */ break; + case 0: /* not owned — open ocean; cache as blank */ break; + default: /* -1 error */ break; } +tile57_compose_close(src); ``` -Tiles come back in the chart's tile encoding — a baked PMTiles archive serves -its stored format (MLT by default), a live cell-backed chart opens generating -MVT and opts in to MLT via `setTileFormat`; `tileType()` tells you which. +Link against `libtile57.a`. The `partition.tpart` sidecar (NULL to skip) lets the +compositor load the ownership partition instead of rebuilding it. To render a +finished PNG/PDF, read metadata, or query the feature under a point, open a +`tile57_chart` instead — `tile57_chart_open` (an on-disk ENC_ROOT or a `.000`), +`tile57_chart_open_bytes` (one in-memory cell), or `tile57_chart_open_pmtiles` +(a baked archive). See the [C API](./c-api.md). -`Chart.openPath` / `Chart.openCellsStreaming` open a whole ENC_ROOT; -`tile57.bakeArchive` bakes one to a PMTiles archive; `tile57.assets`, -`tile57.sprite`, and `tile57.style.build` generate the style + assets. See the -[Zig API](./zig-api.md). +## 3. Use the engine from Zig -## 3. Fetch a tile from C - -The same engine sits behind a thin C ABI ([`include/tile57.h`](../../include/tile57.h)): +Add tile57 as a dependency and `@import("tile57")`: -```c -#include "tile57.h" +```zig +const tile57 = @import("tile57"); -tile57_chart *c = tile57_chart_open_bytes(data, len); /* one in-memory S-57 cell */ +// Open an on-disk ENC_ROOT (or a single .000) for rendering + inspection. +var chart = try tile57.Chart.openPath("ENC_ROOT/", null, true); +defer chart.deinit(); -uint8_t *tile; size_t n; -if (tile57_chart_tile(c, z, x, y, &tile, &n) == TILE57_TILE_OK) { - /* … render the decompressed tile bytes (MVT or MLT — - see tile57_chart_info.tile_type) … */ - tile57_free(tile, n); -} -tile57_chart_close(c); +const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null +// … render a view (chart.renderView), query features (chart.featuresJson), +// or read per-cell metadata (chart.cellsJson) … ``` -Link against `libtile57.a`. Three explicit openers cover the inputs: -`tile57_chart_open_bytes` (one in-memory S-57 cell), `tile57_chart_open` (an -on-disk ENC_ROOT directory or a single `.000`, streamed), and -`tile57_chart_open_pmtiles` (a baked archive). See the [C API](./c-api.md) for the -bake, style, and asset-generation entry points. +The Zig `Chart` renders views, queries features, and reads metadata; +`tile57.bakeArchive` bakes an ENC_ROOT to one band-streamed PMTiles archive +offline. The runtime tile compositor (bake per cell, compose on demand) is +exposed through the [C ABI](./c-api.md). See the [Zig API](./zig-api.md). ## ENC_ROOT and updates diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index 9cd7230..289c049 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -20,7 +20,8 @@ Add it as a **path dependency** on a local clone (submodules initialised) — ## The high-level engine: `Chart` -Open a **chart** from bytes, a path, or cells, then fetch tiles by `(z, x, y)`: +Open a **chart** from bytes, a path, or cells, then render views, query features, +and read metadata: ```zig const tile57 = @import("tile57"); @@ -29,12 +30,14 @@ const tile57 = @import("tile57"); var chart = try tile57.Chart.openBytes(cell_bytes, .auto, null); defer chart.deinit(); -if (try chart.tile(z, x, y)) |bytes| { // decompressed tile bytes, or null if empty - defer tile57.freeBytes(bytes); - // … hand to your renderer … -} +const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null +// … chart.renderView(…) / chart.queryPoint(…) / chart.featuresJson(…) … ``` +The Zig `Chart` does not itself serve `(z, x, y)` tiles — the runtime compositor +(bake each cell, then compose on demand) is exposed through the [C ABI](./c-api.md). +`tile57.bakeArchive` bakes a whole ENC_ROOT to one PMTiles archive offline. + `Chart` methods: | Method | Purpose | @@ -43,25 +46,27 @@ if (try chart.tile(z, x, y)) |bytes| { // decompressed tile bytes, or null if | `openPath(path, rules_dir, pick_attrs)` | open an on-disk ENC_ROOT dir (or a single `.000`) as a streaming chart. | | `openCells(cells, rules_dir, pick_attrs)` | open a multi-cell ENC_ROOT from bytes (each cell's `.001…` updates applied). | | `openCellsStreaming(metas, reader, user, rules_dir, pick_attrs)` | low-memory ENC_ROOT: read each cell's bytes on demand, free on eviction. | -| `tile(z, x, y) -> ?[]u8` | the tile's decompressed bytes, in the chart's tile encoding (null = empty). | -| `tileType()` / `setTileFormat(fmt)` | the encoding `tile` returns (MVT/MLT); opt a live cell-backed chart into MLT. | | `renderView(lon, lat, zoom, w, h, palette, settings, output)` | render a view through the native S-52 pixel path — PNG or PDF. | +| `renderSurfaceView(lon, lat, zoom, w, h, palette, settings, cb)` | drive world-space surface callbacks (the GPU vector twin). | | `renderAscii(lon, lat, zoom, cols, rows, palette, settings, ansi)` | the same view as a terminal text grid. | +| `queryPoint(lon, lat, zoom, cb)` | the S-52 cursor pick — features under a point at the view zoom. | | `cellsJson()` / `featuresJson(classes)` | per-cell metadata / GeoJSON feature query (the `cells` / `features` CLI). | +| `coverage()` | the M_COVR data-coverage rings (a live cell only). | | `bounds() -> ?[4]f64` | geographic extent `[w, s, e, n]`, if known. | | `anchor()` | a good initial camera (lat, lon, zoom) on real data. | | `bands() -> u32` | bitmask of navigational bands present. | | `zoomRange()` | the min/max zoom the chart serves. | +| `nativeScale() -> i32` | the compilation scale 1:N (a live cell; 0 if unknown). | | `scamin() -> ![]u32` | the distinct SCAMIN denominators present (the live SCAMIN manifest). | +| `tileType()` | the tile encoding the chart's tiles use (MVT/MLT). | | `format() -> Format` | the resolved backend (after `.auto`). | -| `clearCache()` | drop the in-memory tile cache. | | `deinit()` | release the chart and its cached tiles. | `tile57.Format` is `.auto` / `.pmtiles` / `.s57_cell`. `rules_dir` is the S-101 portrayal rules directory for live S-57 cells; `null` (or `""`) uses the rules embedded in the binary (or `TILE57_S101_RULES` if set), so no on-disk catalogue is required; a path overrides with an on-disk catalogue. Free any bytes returned -by `tile` / `bakeArchive` with `tile57.freeBytes`. +by the render / bake entry points with `tile57.freeBytes`. The streaming open uses the extern types `tile57.CellMeta` (bbox + `cscl`), `tile57.CellBytes` (the cell's base + updates, ownership transferred to the From 0f71162866e2da507730170a185b2c92b7c58129 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 10:57:26 -0400 Subject: [PATCH 058/140] docs(site): refresh architecture/rendering/limitations/tile-schema for compose - architecture: rewrite the layering (Chart render+inspect / bake-cell+compose / style) and the "live-composite bake" section (per-cell tiles + partition.tpart, no merged bundle/manifest); replace the removed smax carry-down with the coverage-clipped ownership partition; drop tile57_chart_set_tile_format / tile57_chart_clear_cache refs. - rendering: stop citing the removed tile57_chart_tile. - limitations: pre-bake advice now describes `tile57 bake` (per-cell + partition) instead of the removed tile57_bake_pmtiles. - tile-schema: drop the removed chart-bundle/manifest.json framing; the tiles + assets still share the tile57/1 schema_version. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/docs/architecture.md | 82 +++++++++++++++++++-------------------- docs/docs/limitations.md | 9 +++-- docs/docs/rendering.md | 3 +- docs/docs/tile-schema.md | 9 ++--- 4 files changed, 52 insertions(+), 51 deletions(-) diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index a18a4ce..7bf2609 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -65,20 +65,19 @@ kitty graphics protocol). One engine, pluggable outputs — see Bakes encode **MLT** ([MapLibre Tile](https://github.com/maplibre/maplibre-tile-spec)) by default; MapLibre GL JS ≥ 5.12 decodes it natively via the vector source `encoding` option, and the generated styles carry that hint. Pass -`--format mvt` (CLI) / `tile57_bake_opts.format` (C ABI) to bake Mapbox Vector -Tiles for consumers without an MLT decoder. Live cell-backed charts open -generating MVT for compatibility; hosts opt in to MLT with -`tile57_chart_set_tile_format`. +`--format mvt` (CLI) to bake Mapbox Vector Tiles for consumers without an MLT +decoder. `tile57_chart_info.tile_type` reports which encoding a chart's tiles +use, so a host hints its renderer correctly. -### Band handoff (`smax`) +### Band handoff (coverage-clipped ownership) Overlapping cells of different compilation scales are resolved per tile to the -best band. At a band's floor zoom — where a finer band hands over to the -coarser one — the coarser band's features are *carried down* into the finer -band's tiles, tagged with an `smax` denominator quantized onto the archive's -SCAMIN ladder. The style (and the pixel resolver) hides a carried copy the -moment the display gets finer than `1:smax`, so band boundaries hand off -without holes or double-draws. +best band. Rather than *carry* a coarser cell's features down into a finer band's +tiles (the old `smax`-tagged carry-down), the compositor clips each cell to the +**ownership partition**: at every tile the finer cell's M_COVR coverage wins the +ground it holds, and a coarser cell renders only where no finer cell covers it. +Band boundaries hand off without holes or double-draws, and there is no +per-feature handoff tag for the style to gate. ### Overscale indication (`oscl`) @@ -111,24 +110,26 @@ libc/Lua) and target-agnostic: imported by the pure test build. The C ABI (`src/capi.zig`) is a thin shim over the same Zig API as the `tile57` module. -## The layering: Chart / bake / style +## The layering: chart / compose / style -The public surface composes the packages into three high-level entry points: +The public surface composes the packages into high-level entry points: - **`Chart`** — open a chart from a path (`openPath`), from bytes (`openBytes`), or - as a multi-cell ENC_ROOT (`openCells` / `openCellsStreaming`), then `tile(z, x, y)`. This is the live - tile-generation path. It also reads a pre-baked **PMTiles** archive — the caller - can't tell the difference. The same handle renders finished views: - `renderView` (PNG or PDF, per its output parameter) and `renderAscii` - (`tile57_chart_render_view` / `tile57_chart_render_pdf` in the C ABI). -- **`bakeArchive`** (`scene/bake_enc.zig`) — bake a whole ENC_ROOT to one PMTiles - archive offline, band-streamed. + as a multi-cell ENC_ROOT (`openCells` / `openCellsStreaming`), then render and + inspect it: `renderView` (PNG or PDF), `renderSurfaceView` (world-space GPU + callbacks), `queryPoint` (the cursor pick), and the metadata getters. It reads a + pre-baked **PMTiles** archive or portrays a live cell — the caller can't tell the + difference. (`tile57_chart_render_view` / `tile57_chart_render_pdf` / + `tile57_chart_render_surface_cb` / `tile57_chart_query` in the C ABI.) +- **Tile production** — bake each cell to its own PMTiles at its compilation scale + (`tile57_bake_cell_bytes`), then a runtime **compositor** stitches the overlapping + cells for any `(z, x, y)` tile on demand through an ownership partition + (`tile57_compose_open` / `tile57_compose_serve`). `bakeArchive` + (`scene/bake_enc.zig`) is the offline alternative — a whole ENC_ROOT to one + band-streamed PMTiles archive. - **`style.build`** (`assets/chartstyle.zig`) + **`assets`** / **`sprite`** — - generate the MapLibre style and the portrayal assets it references. - -The same three are exposed across the C ABI (`tile57_chart_*`, -`tile57_bake_pmtiles` / `tile57_bake_bundle`, `tile57_build_style`, and -`tile57_bake_assets` for the portrayal assets). + generate the MapLibre style and the portrayal assets it references + (`tile57_build_style` / `tile57_bake_assets` in the C ABI). ## The memory design @@ -149,28 +150,27 @@ tile57 is built to hold only its working set: coarsest), so peak memory tracks the largest single band rather than the whole archive. - **Tile cache.** Generated/decoded tiles are memoized per chart (keyed - `z<<48 | x<<24 | y`); `clearCache` / `tile57_chart_clear_cache` drops it to - bound long-running hosts. + `z<<48 | x<<24 | y`) and released with the chart, so a long-running host bounds + memory by closing charts it no longer renders. -## The offline chart bundle +## The live-composite bake -One `bake` command (`tile57 bake -o out/`) emits a -self-contained, relocatable directory in which the tiles and the portrayal that -renders them travel together: +One `bake` command (`tile57 bake -o out/`) writes the +live-composite structure — per-cell tiles plus the ownership partition a runtime +compositor serves them from: ``` out/ - manifest.json pins schema_version, couples the two halves - tiles/chart.pmtiles the DATA half — semantic colour tokens, palette-independent - assets/colortables.json the PORTRAYAL half — token -> hex per day/dusk/night (the only RGB) - assets/linestyles.json complex line-style metadata - assets/sprite-mln*.{json,png} the symbol + pattern atlases - assets/style-{day,dusk,night}.json the MapLibre style layers, colours pre-resolved + tiles/US5MD1MC.pmtiles one PMTiles per cell, baked at its compilation scale + tiles/US4MD81M.pmtiles (M_COVR coverage embedded in each archive's metadata) + partition.tpart the ownership partition: which cell renders which ground ``` -This works because the tiles carry S-52 colour **tokens**, never RGB. Both halves -are emitted from the *same* S-101 catalogue, so they cannot drift, and the -manifest stamps both with a `schema_version` (`tile57/1` — the -[tile-schema](./tile-schema.md) vocabulary) that a renderer checks before loading. +There is no merged archive: any `(z, x, y)` tile is composed from the overlapping +cells on demand (`tile57_compose_serve`), so re-baking one cell doesn't rewrite a +whole district. The portrayal assets are generated separately (`tile57 assets` / +`style`); the tiles carry S-52 colour **tokens**, never RGB, and both halves come +from the *same* S-101 catalogue, so they cannot drift. The tile-schema vocabulary +(`tile57/1`) is the contract a renderer checks. See the [**Tile Schema**](./tile-schema.md) for the vector-tile layer contract. diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index b4511ba..32e5542 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -86,10 +86,11 @@ bounded. Caveats: - **First-view latency.** The first tile over a fresh area parses + portrays its 1–4 cells (tens of ms), then they're cached. Opening the whole NOAA catalogue also pays a one-time index scan (a few seconds, parsing every cell header). -- **Offline bake.** For smooth panning everywhere, bake the ENC_ROOT once to a - cached PMTiles archive (`tile57 bake`, or `bakeArchive` / - `tile57_bake_pmtiles`) and open that instead. The whole catalogue is a - multi-minute one-time bake; a region is far quicker. +- **Pre-bake for smooth panning.** For low first-view latency everywhere, bake the + ENC_ROOT once with `tile57 bake` (per-cell tiles + an ownership partition) and + serve tiles from the compositor; `bakeArchive` bakes an offline PMTiles archive + instead. The whole catalogue is a multi-minute one-time bake; a region is far + quicker. - **Low zoom is style-gated.** The generated style's vector-source `minzoom` is the bake's tile floor (default 8), and MapLibre never requests tiles below a source's minzoom — nothing draws below it regardless of the data. diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index d8ee86c..edbee92 100644 --- a/docs/docs/rendering.md +++ b/docs/docs/rendering.md @@ -115,7 +115,8 @@ tile57 png ... --safety 5 --safety-depth 5 --feet --palette night \ ### From C (and therefore Go, Python, C++, …) -Two calls in `include/tile57.h`, mirroring `tile57_chart_tile`: +Two calls in `include/tile57.h` (same allocate-`*out` / free-with-`tile57_free` +convention as the rest of the ABI): ```c tile57_chart *c = tile57_chart_open("/path/to/ENC_ROOT"); diff --git a/docs/docs/tile-schema.md b/docs/docs/tile-schema.md index 2fd73f3..6907ab8 100644 --- a/docs/docs/tile-schema.md +++ b/docs/docs/tile-schema.md @@ -11,11 +11,10 @@ MapLibre style depends on this schema, so the names are a contract. Do not rename a layer or a field without updating the style generator (`src/assets/style.zig`) to match **and bumping the schema version**. -This vocabulary is versioned as **`tile57/1`**. A -[chart bundle](./architecture.md#the-offline-chart-bundle) stamps both halves — -the tiles and the portrayal assets — with that `schema_version` in its -`manifest.json`, so a renderer can refuse a bundle whose schema it doesn't speak. -Any change to a layer name or field key bumps it. +This vocabulary is versioned as **`tile57/1`**. The tiles and the portrayal +assets are generated from the same S-101 catalogue and stamped with that +`schema_version`, so a renderer can refuse a schema it doesn't speak. Any change +to a layer name or field key bumps it. Every tile uses an extent of **4096** and a buffer of **64**. From 53758bf27b8dac9e344991f482e88d506d5d8d4a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 11:05:35 -0400 Subject: [PATCH 059/140] docs(schema): correct tile-schema.md to the tile57/2 six-layer schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An accuracy audit against src/scene/scene.zig + src/assets/assets.zig found the schema page predates the v2 layer merge: - schema version is tile57/2, not tile57/1 (assets.zig SCHEMA_VERSION). - six layers, not seven: the *_scamin twins fold into their base layers (gated by a per-feature `scamin` prop), and there is no complex_lines layer — symbolized lines fold into `lines` with ls_style/ls_arc0. - fix wrong field keys: soundings emit sym_s/sym_g/sym_s_ft/sym_g_ft/depth (not symbol_names); point_symbols have no offset_x/offset_y/halo_color_token (they carry rot_north + danger_depth/sym_deep); text uses a single `loff` "ux,uy" string (not offset_x/offset_y) plus halo_width/tgrp; lines add valdco. - hoist the shared metadata (class/cell/s57/draw_prio/cat/band/scamin) into one common-properties table instead of repeating it per layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/docs/tile-schema.md | 84 +++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/docs/docs/tile-schema.md b/docs/docs/tile-schema.md index 6907ab8..4b18e8e 100644 --- a/docs/docs/tile-schema.md +++ b/docs/docs/tile-schema.md @@ -11,7 +11,7 @@ MapLibre style depends on this schema, so the names are a contract. Do not rename a layer or a field without updating the style generator (`src/assets/style.zig`) to match **and bumping the schema version**. -This vocabulary is versioned as **`tile57/1`**. The tiles and the portrayal +This vocabulary is versioned as **`tile57/2`**. The tiles and the portrayal assets are generated from the same S-101 catalogue and stamped with that `schema_version`, so a renderer can refuse a schema it doesn't speak. Any change to a layer name or field key bumps it. @@ -54,12 +54,30 @@ covers the Web-Mercator zoom range that matches its scale: | Berthing | 16 – 18 | Vector tiles scale crisply, so a renderer **overzooms** the top level rather than -baking more. Within a band, each feature carries an S-52 **SCAMIN** (the scale -below which it should disappear); honoring it per-feature is what the `*_scamin` -buckets are for, so minor features drop out at their own thresholds and the chart -never clutters. +baking more. Within a band, a feature may carry an S-52 **SCAMIN** (the scale +below which it should disappear) as the per-feature `scamin` property; the style +hides the feature once the display is coarser than `1:scamin`, so minor features +drop out at their own thresholds and the chart never clutters. (Earlier schema +versions split these into separate `*_scamin` layers; `tile57/2` folds them back +into the base layers, gated by the per-feature property.) -## The seven layers +## The six layers + +Every feature also carries shared metadata the style and the pick report read, +regardless of layer: + +| Field | Type | Meaning | +| --- | --- | --- | +| `class` | string | S-57 object-class acronym. | +| `cell` | string | Source cell stem. | +| `s57` | string | The feature's full S-57 attribute set as a JSON object (the pick report). | +| `draw_prio` | int | S-52 draw priority (fill/stroke ordering). | +| `cat` | int | Display category (base / standard / other gating). | +| `band` | int | Navigational band rank. | +| `scamin` | int | SCAMIN `1:N` denominator — present only on features that carry one. | + +`bnd`, `pts`, `plane`, `vg`, and the `date_*` keys are additional gating +properties emitted only when relevant. The layer-specific fields follow. ### areas @@ -68,10 +86,8 @@ Filled polygons, such as depth areas and land. | Field | Type | Meaning | | --- | --- | --- | | `color_token` | string | Fill color name. | -| `class` | string | S-57 object class. | -| `draw_prio` | int | Draw priority. | -| `cat` | — | Category. | -| `bnd` | — | Boundary-pass marker. | +| `drval1`, `drval2` | number | Depth-range min/max for depth areas (DEPARE/DRGARE). | +| `oscl` | int | Overscale denominator, present when the cell shows finer than its compilation scale. | ### area_patterns @@ -80,28 +96,21 @@ Polygons filled with a repeating pattern instead of a flat color. | Field | Type | Meaning | | --- | --- | --- | | `pattern_name` | string | Name of the fill pattern. | -| `class` | string | S-57 object class. | -| `draw_prio` | int | Draw priority. | +| `oscl` | int | Overscale denominator, as above. | ### lines -Stroked lines, such as depth contours. Sector-light legs and arcs also go here. +Stroked lines, such as depth contours and coastline. Symbolized/complex line runs +and sector-light legs fold in here too. | Field | Type | Meaning | | --- | --- | --- | -| `class` | string | S-57 object class. | | `color_token` | string | Stroke color name. | -| `width_px` | int | Stroke width in pixels. | -| `dash` | — | Dash pattern. | - -### complex_lines - -Lines drawn with a named, repeating line style. - -| Field | Type | Meaning | -| --- | --- | --- | -| `class` | string | S-57 object class. | -| `linestyle_name` | string | Name of the line style. | +| `width_px` | number | Stroke width in pixels. | +| `dash` | string | Dash pattern (empty = solid). | +| `valdco` | number | Contour value for DEPCNT lines (including the 0 m drying line), for line-centre labels. | +| `ls_style` | string | Complex line-style name (symbolized lines). | +| `ls_arc0` | number | Sector-arc start bearing (sector-light legs). | ### point_symbols @@ -109,24 +118,21 @@ Single symbols placed at a point, such as buoys and beacons. | Field | Type | Meaning | | --- | --- | --- | -| `class` | string | S-57 object class. | | `symbol_name` | string | Name of the symbol. | | `rotation_deg` | number | Rotation in degrees. | +| `rot_north` | int | `1` = hold the symbol north-up (line-placed and point symbols). | | `scale` | number | Scale factor. | -| `offset_x`, `offset_y` | number | Pixel offset from the point. | -| `halo_color_token` | string | Halo color name. | -| `draw_prio` | int | Draw priority. | +| `danger_depth`, `sym_deep` | number, string | Isolated-danger depth + the deep-water symbol variant (the DANGER01/DANGER02 swap). | ### soundings -Depth soundings, drawn as digit symbols (SNDFRM04 digit composition). +Depth soundings, drawn as digit glyph strings (SNDFRM digit composition). | Field | Type | Meaning | | --- | --- | --- | -| `class` | string | S-57 object class. | -| `symbol_names` | string | The digit symbols that make up the sounding. | -| `scale` | number | Scale factor. | -| `draw_prio` | int | Draw priority. | +| `sym_s`, `sym_g` | string | Bold + faint digit glyph strings, in metres. | +| `sym_s_ft`, `sym_g_ft` | string | The same glyph strings, in feet. | +| `depth` | number | Sounding depth in metres. | ### text @@ -135,11 +141,11 @@ attribute). | Field | Type | Meaning | | --- | --- | --- | -| `class` | string | S-57 object class. | | `text` | string | The label text. | | `font_size_px` | number | Font size in pixels. | | `color_token` | string | Text color name. | -| `halign`, `valign` | — | Horizontal and vertical alignment. | -| `offset_x`, `offset_y` | number | Pixel offset from the anchor. | -| `halo_color_token` | string | Halo color name. | -| `draw_prio` | int | Draw priority. | +| `halo_color_token` | string | Halo color name (`""` = no halo). | +| `halo_width` | number | Halo width in pixels (`0` = none). | +| `halign`, `valign` | string | Horizontal and vertical alignment. | +| `loff` | string | Pixel offset `"ux,uy"` from the anchor, present only when nonzero. | +| `tgrp` | int | S-52 text viewing group. | From d80c8c003b0bee4aba643adc2d472884abce49d2 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 11:09:20 -0400 Subject: [PATCH 060/140] docs: accuracy fixes from the code audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checking the docs against the code turned up several stale claims: - `--format mvt` is not a CLI flag (the composite bake always emits MLT; MVT is a library encoding only) — reword the MLT-default notes in README / getting-started / architecture. - schema version is tile57/2, not tile57/1 (architecture). - `bakeArchive` is the banded bake engine `bakeCellBytes` runs per cell, not a recommended "offline whole-ENC_ROOT archive" workflow — the CLI and C ABI do per-cell bake + compose. Stop promoting the merged-archive path in README / getting-started / limitations / architecture; keep it documented only in zig-api (a real public Zig export). - add the `explore` subcommand to getting-started's list. - zig-api: tile57.style.build maps to assets.buildFromTemplate, not chartstyle.buildStyle. - tile-schema: fix the "Live path coverage" note (six layers, no complex_lines, per-feature scamin instead of *_scamin buckets). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 ++++++-------- docs/docs/architecture.md | 29 +++++++++++++++-------------- docs/docs/getting-started.md | 15 +++++++-------- docs/docs/limitations.md | 5 ++--- docs/docs/tile-schema.md | 6 +++--- docs/docs/zig-api.md | 2 +- 6 files changed, 34 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index d225287..6f4b22f 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ entirely with AI assistance. A few specific goals shape its design: --- **tile57** decodes NOAA/IHO **S-57** ENC cells and generates **vector tiles** by -`(z, x, y)` — MapLibre Tiles (MLT, the default bake format; MapLibre GL JS ≥ 5.12 -decodes them natively) or Mapbox Vector Tiles (`--format mvt`) — running the +`(z, x, y)` — MapLibre Tiles (MLT, the default; MapLibre GL JS ≥ 5.12 +decodes them natively) or Mapbox Vector Tiles — running the official IHO **S-101 Portrayal Catalogue** in embedded Lua to produce S-52 nautical portrayal. Alongside the tiles it emits a **MapLibre GL style** and the portrayal **assets** it references — colour tables, line styles, and the sprite @@ -78,8 +78,7 @@ It is **high-performance and low-memory** by design: catalogue. - **Per-cell bakes.** Each ENC cell bakes to its own PMTiles at its compilation scale, so a bake holds one cell at a time; the runtime compositor stitches the - cells by `(z, x, y)` on demand. (An offline whole-ENC_ROOT archive bake streams - band-by-band, so its peak memory tracks the largest single band.) + cells by `(z, x, y)` on demand. - **Pure-Zig core.** The foundational format/encode packages have no libc; only the Lua portrayal + sprite rasterizer pull in C. @@ -119,10 +118,9 @@ const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null // … render a view (chart.renderView), query features, or bake an archive … ``` -`Chart` renders views, queries features, and reads metadata; `tile57.bakeArchive` -bakes an ENC_ROOT to one band-streamed PMTiles archive offline. The runtime tile -compositor (bake per cell, then compose by `(z, x, y)`) is exposed through the -[C ABI](include/tile57.h). See [the Zig API docs](docs/docs/zig-api.md). +`Chart` renders views, queries features, and reads metadata. The runtime tile +path — bake each cell, then compose by `(z, x, y)` on demand — is exposed through +the [C ABI](include/tile57.h). See [the Zig API docs](docs/docs/zig-api.md). ## Use it from C diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 7bf2609..d807b15 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -60,14 +60,14 @@ Unicode terminal grid (with optional ANSI colour, or real pixels inline via the kitty graphics protocol). One engine, pluggable outputs — see [The Rendering Engine](./rendering.md). -### Tile formats: MLT by default, MVT on request +### Tile formats: MLT by default, MVT optional Bakes encode **MLT** ([MapLibre Tile](https://github.com/maplibre/maplibre-tile-spec)) by default; MapLibre GL JS ≥ 5.12 decodes it natively via the vector source -`encoding` option, and the generated styles carry that hint. Pass -`--format mvt` (CLI) to bake Mapbox Vector Tiles for consumers without an MLT -decoder. `tile57_chart_info.tile_type` reports which encoding a chart's tiles -use, so a host hints its renderer correctly. +`encoding` option, and the generated styles carry that hint. The engine can also +encode Mapbox Vector Tiles for consumers without an MLT decoder. +`tile57_chart_info.tile_type` reports which encoding a chart's tiles use, so a +host hints its renderer correctly. ### Band handoff (coverage-clipped ownership) @@ -122,11 +122,11 @@ The public surface composes the packages into high-level entry points: difference. (`tile57_chart_render_view` / `tile57_chart_render_pdf` / `tile57_chart_render_surface_cb` / `tile57_chart_query` in the C ABI.) - **Tile production** — bake each cell to its own PMTiles at its compilation scale - (`tile57_bake_cell_bytes`), then a runtime **compositor** stitches the overlapping - cells for any `(z, x, y)` tile on demand through an ownership partition - (`tile57_compose_open` / `tile57_compose_serve`). `bakeArchive` - (`scene/bake_enc.zig`) is the offline alternative — a whole ENC_ROOT to one - band-streamed PMTiles archive. + (`tile57_bake_cell_bytes`, which runs the banded bake engine `scene/bake_enc.zig` + on a single cell), then a runtime **compositor** stitches the overlapping cells + for any `(z, x, y)` tile on demand through an ownership partition + (`tile57_compose_open` / `tile57_compose_serve`). The public Zig `bakeArchive` + runs the same engine over a slice of cells to make one merged archive. - **`style.build`** (`assets/chartstyle.zig`) + **`assets`** / **`sprite`** — generate the MapLibre style and the portrayal assets it references (`tile57_build_style` / `tile57_bake_assets` in the C ABI). @@ -146,9 +146,10 @@ tile57 is built to hold only its working set: `tile57_chart_open`) take per-cell metadata (bbox + scale) plus a reader; a cell's bytes are read only on demand and freed on eviction. A host then holds only the working set's bytes, not the whole catalogue — the right choice for a large ENC_ROOT. -- **Band-streamed bakes.** `bakeArchive` streams band-by-band (finest → - coarsest), so peak memory tracks the largest single band rather than the whole - archive. +- **Per-cell bakes.** Each cell bakes independently at its own compilation scale, + so a bake holds a single cell's parsed data at a time — memory doesn't grow with + the size of the catalogue. (The multi-cell `bakeArchive` streams band-by-band, + finest → coarsest, so its peak memory tracks the largest single band.) - **Tile cache.** Generated/decoded tiles are memoized per chart (keyed `z<<48 | x<<24 | y`) and released with the chart, so a long-running host bounds memory by closing charts it no longer renders. @@ -171,6 +172,6 @@ cells on demand (`tile57_compose_serve`), so re-baking one cell doesn't rewrite whole district. The portrayal assets are generated separately (`tile57 assets` / `style`); the tiles carry S-52 colour **tokens**, never RGB, and both halves come from the *same* S-101 catalogue, so they cannot drift. The tile-schema vocabulary -(`tile57/1`) is the contract a renderer checks. +(`tile57/2`) is the contract a renderer checks. See the [**Tile Schema**](./tile-schema.md) for the vector-tile layer contract. diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 9e677d8..596d231 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -44,14 +44,14 @@ $T version Run `tile57 help` for usage. The full subcommand list: `bake`, `compose-tile`, `assets`, `sprite`, `pattern`, `sprite-mln`, `style`, `png`, `pdf`, `ascii`, -`cells`, `catalog`, `features`, `inspect`, `cell`, `objlcount`, `version`, -`help`. +`explore`, `cells`, `catalog`, `features`, `inspect`, `cell`, `objlcount`, +`version`, `help`. :::info Tiles are MLT by default Bakes encode [MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) (MLT) by default; rendering them needs **MapLibre GL JS ≥ 5.12** (which decodes -MLT natively — the generated styles carry the source `encoding` hint). Pass -`--format mvt` to bake Mapbox Vector Tiles for older or other consumers. +MLT natively — the generated styles carry the source `encoding` hint). The +engine can also encode Mapbox Vector Tiles for consumers without an MLT decoder. ::: And a crowd-pleaser — the chart in your terminal, as a Unicode grid with @@ -122,10 +122,9 @@ const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null // or read per-cell metadata (chart.cellsJson) … ``` -The Zig `Chart` renders views, queries features, and reads metadata; -`tile57.bakeArchive` bakes an ENC_ROOT to one band-streamed PMTiles archive -offline. The runtime tile compositor (bake per cell, compose on demand) is -exposed through the [C ABI](./c-api.md). See the [Zig API](./zig-api.md). +The Zig `Chart` renders views, queries features, and reads metadata. Tile +production (bake each cell, then compose on demand) is exposed through the +[C ABI](./c-api.md). See the [Zig API](./zig-api.md). ## ENC_ROOT and updates diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 32e5542..e07c8f3 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -88,9 +88,8 @@ bounded. Caveats: also pays a one-time index scan (a few seconds, parsing every cell header). - **Pre-bake for smooth panning.** For low first-view latency everywhere, bake the ENC_ROOT once with `tile57 bake` (per-cell tiles + an ownership partition) and - serve tiles from the compositor; `bakeArchive` bakes an offline PMTiles archive - instead. The whole catalogue is a multi-minute one-time bake; a region is far - quicker. + serve tiles from the compositor. The whole catalogue is a multi-minute one-time + bake; a region is far quicker. - **Low zoom is style-gated.** The generated style's vector-source `minzoom` is the bake's tile floor (default 8), and MapLibre never requests tiles below a source's minzoom — nothing draws below it regardless of the data. diff --git a/docs/docs/tile-schema.md b/docs/docs/tile-schema.md index 4b18e8e..90b748a 100644 --- a/docs/docs/tile-schema.md +++ b/docs/docs/tile-schema.md @@ -21,9 +21,9 @@ Every tile uses an extent of **4096** and a buffer of **64**. :::note Live path coverage Whether the tiles come from a pre-baked PMTiles archive or are generated live from a raw S-57 cell, the schema is the same. The live path (`src/scene/`) -emits `areas`, `area_patterns`, `lines`, `complex_lines`, `point_symbols`, -`soundings`, and `text`, plus the `*_scamin` declutter buckets (features carrying -SCAMIN, attr 133) and a `draw_prio` property for S-52 fill ordering. DEPCNT lines +emits `areas`, `area_patterns`, `lines`, `point_symbols`, `soundings`, and +`text`. Features carrying SCAMIN (attr 133) keep it as the per-feature `scamin` +property, and every feature has a `draw_prio` for S-52 fill ordering. DEPCNT lines carry `valdco` (the contour value, including the 0 m drying line) for line-centre labels. The same schema is encoded as MLT or MVT (see [architecture](./architecture.md)). diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index 289c049..ff07c7d 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -94,7 +94,7 @@ const json = try tile57.style.build(/* … */); // tile57.style.Mariner sett | Surface | What it does | |---------|--------------| -| `tile57.style.build` (`chartstyle.buildStyle`) | patch a MapLibre style template with mariner settings + colortables. | +| `tile57.style.build` (`assets.buildFromTemplate`) | build a MapLibre style from a template + mariner settings + colortables. | | `tile57.style.Mariner` | the S-52 mariner display options struct. | | `tile57.assets` | colortables / linestyles / style.json / manifest generation. | | `tile57.sprite` | S-101 sprite + area-fill pattern atlases (SVG raster). | From 2a01ecfeb1a3db61be875cd169769c96d1d2f771 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 11:10:19 -0400 Subject: [PATCH 061/140] docs(zig): fix the curated-surface module comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chart no longer exposes a tile fetch (open → render/inspect now), and the style export is style.build (assets.buildFromTemplate), not buildStyle. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tile57.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tile57.zig b/src/tile57.zig index 08826e3..7833a4a 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -12,7 +12,7 @@ //! same Zig API (see capi.zig). //! //! Surface: -//! - High-level engine: `Chart` (open → tile), `bakeArchive`, `buildStyle` [Phase 2] +//! - High-level engine: `Chart` (open → render/inspect), `bakeArchive`, `style.build` //! - Portrayal assets: `assets` (colortables, linestyles, sprite/pattern) //! - Style patching: `chartstyle` //! - Tiling: `mvt`, `tile`, `pmtiles`, `bake_enc`, `scene` From ec2f5c2f36d589baa0dbf7928a7cd6f063cf3d5a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 12:32:40 -0400 Subject: [PATCH 062/140] feat(bake): parallel batch cell-bake (tile57_bake_cells) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bakeCellsParallel + the tile57_bake_cells C ABI: bake N cells to per-cell PMTiles bytes across up to `workers` threads (a MEMORY-bound cap, hard ceiling MAX_BAKE_WORKERS=32), returning BYTES only — the engine never writes an output dir, so the host keeps managing its cache. Warms up the process globals first, which both makes concurrent baking race-free AND populates the complex-linestyle table (so complex lines render with their linestyle geometry, not a plain fallback). Validated byte-identical to serial tile57_bake_cell_bytes on real NOAA cells (16 cells, workers=8) once both are warmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/tile57.h | 11 +++++++++++ src/capi.zig | 34 ++++++++++++++++++++++++++++++++++ src/chart.zig | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/include/tile57.h b/include/tile57.h index 8de9078..aa3b0f5 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -210,6 +210,17 @@ void tile57_chart_close(tile57_chart *chart); * -1=error. */ int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); +/* Bake `n` cells (each a .000 path; its .001.. updates auto-read) to per-cell PMTiles bytes IN + * PARALLEL across up to `workers` threads. The engine returns BYTES only — it never writes an + * output directory; the host writes each archive into the cache it manages. out_bytes[i] / + * out_lens[i] receive cell i's archive (free each with tile57_free) or NULL/0 when that cell + * produced nothing. Both arrays are caller-allocated, length n. `workers` is a MEMORY bound — + * each concurrent bake holds a whole cell's parse+portray+raster working set, so pass a small + * count (not a core count). Warms up the process globals internally, so concurrent baking is + * race-free. Returns the number of cells that produced bytes, or -1 on bad args. */ +int tile57_bake_cells(const char *const *paths, size_t n, uint32_t workers, + uint8_t **out_bytes, size_t *out_lens); + /* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + * cscl + date/name under a "coverage" key, so the composite stitcher rebuilds the diff --git a/src/capi.zig b/src/capi.zig index b5d29c0..2e27a59 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -77,6 +77,40 @@ export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, out: *[*]u8, out_len: *us return 0; } +/// Bake `n` cells to per-cell PMTiles bytes IN PARALLEL across up to `workers` threads. The engine +/// returns BYTES only (never writes an output dir); out_bytes[i]/out_lens[i] get cell i's archive +/// (free each with tile57_free) or NULL/0 when it produced nothing. `workers` is a MEMORY bound — +/// keep it small. Returns the number of cells baked, or -1 on bad args. See tile57.h. +export fn tile57_bake_cells( + paths: ?[*]const ?[*:0]const u8, + n: usize, + workers: u32, + out_bytes: [*c][*c]u8, + out_lens: [*c]usize, +) callconv(.c) c_int { + const ps = paths orelse return -1; + if (out_bytes == null or out_lens == null) return -1; + if (n == 0) return 0; + const list = gpa.alloc([]const u8, n) catch return -1; + defer gpa.free(list); + for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return -1; + const results = gpa.alloc(?[]u8, n) catch return -1; + defer gpa.free(results); + chart.bakeCellsParallel(list, null, workers, results); + var baked: c_int = 0; + for (0..n) |i| { + if (results[i]) |b| { + out_bytes[i] = b.ptr; + out_lens[i] = b.len; + baked += 1; + } else { + out_bytes[i] = null; + out_lens[i] = 0; + } + } + return baked; +} + /// Coverage/zoom summary of a resident compositor, filled by tile57_compose_meta. const CComposeMeta = extern struct { min_zoom: u8, diff --git a/src/chart.zig b/src/chart.zig index 3c54e55..9d5ab72 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -622,6 +622,54 @@ pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 { return bakeArchive(&cell_in, resolveRulesDir(rules_dir), zr.min, zr.max, .mlt, true, null, null, coverage_json); } +// ---- parallel batch cell-bake ------------------------------------------------- +// Bake many cells to their own per-cell PMTiles concurrently. The engine returns BYTES only — it +// never touches an output directory; the host writes each archive into the cache it manages. Each +// concurrent bake holds a whole cell's parse + portray + raster working set, so `workers` is a +// MEMORY bound (keep it small), not a core count. + +// MAX_BAKE_WORKERS is a hard ceiling on batch-bake threads; the host normally passes far fewer. +const MAX_BAKE_WORKERS = 32; + +const BakeCtx = struct { + next: std.atomic.Value(usize), + paths: []const []const u8, + rules_dir: ?[]const u8, + out: []?[]u8, +}; + +fn bakeCellWorker(ctx: *BakeCtx) void { + while (true) { + const i = ctx.next.fetchAdd(1, .monotonic); + if (i >= ctx.paths.len) return; + ctx.out[i] = bakeCellBytes(ctx.paths[i], ctx.rules_dir) catch null; + } +} + +/// Bake each cell in `paths` (a .000 path; its .001.. updates auto-read) to its own native-scale +/// PMTiles bytes IN PARALLEL across up to `workers` threads, writing cell i's archive to out[i] +/// (caller owns it — free each with freeBytes) or leaving it null when that cell produced nothing +/// or failed. out.len must equal paths.len. Race-free: warms up the process globals first, then +/// each bakeCellBytes is independent (thread-safe allocator, thread-local portrayal context). +/// `workers` is clamped to [1, min(paths.len, MAX_BAKE_WORKERS)] and is a MEMORY bound. +pub fn bakeCellsParallel(paths: []const []const u8, rules_dir: ?[]const u8, workers: usize, out: []?[]u8) void { + std.debug.assert(out.len == paths.len); + for (out) |*o| o.* = null; + if (paths.len == 0) return; + warmup(); // idempotent — populate the read-only globals before any worker touches them + var ctx = BakeCtx{ .next = std.atomic.Value(usize).init(0), .paths = paths, .rules_dir = rules_dir, .out = out }; + var n = @min(@max(workers, 1), paths.len); + if (n > MAX_BAKE_WORKERS) n = MAX_BAKE_WORKERS; + if (n <= 1) return bakeCellWorker(&ctx); + var threads: [MAX_BAKE_WORKERS]std.Thread = undefined; + var spawned: usize = 0; + while (spawned < n - 1) : (spawned += 1) { + threads[spawned] = std.Thread.spawn(.{}, bakeCellWorker, .{&ctx}) catch break; + } + bakeCellWorker(&ctx); // this thread participates too + for (threads[0..spawned]) |t| t.join(); +} + /// The metadata JSON blob of a PMTiles archive (decompressed), duped into `a`, or null /// if the archive carries none. For a host to read the embedded scamin / coverage /// without a full open. This engine writes metadata uncompressed; gzip is handled for From 24ca12629ca4d55b8050ef5d9df946634683fb6f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 14:51:06 -0400 Subject: [PATCH 063/140] fix(bake): warm up the portrayal globals before a per-cell bake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bakeCellBytes portrayed cells without populating the complex-linestyle table (+ feature catalogue) first, so complex lines fell back to plain geometry and lost their S-52 linestyle — the CLI `tile57 bake` and the Go host BakeCell both bake this way, so their tiles were missing complex linestyles. Warm up first (idempotent; the parallel batch already pre-warms before spawning workers, so this is a no-op + race-free there). Verified on real NOAA cells: serial vs parallel now byte-identical with no external warmup, tiles gaining their complex-line linestyle geometry. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chart.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/chart.zig b/src/chart.zig index 9d5ab72..c229f39 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -597,6 +597,11 @@ pub fn openCellPath(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool) /// so the composite stitcher rebuilds the ownership partition from the baked archives /// without re-parsing the .000. Read it back with `cellCoverageFromArchive`. pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 { + // Populate the read-only portrayal globals (feature catalogue + complex-linestyle table) + // before portraying: without them, complex lines fall back to plain geometry and their S-52 + // linestyle is dropped from the tile. Idempotent; in the parallel batch path bakeCellsParallel + // has already warmed up before spawning workers, so this is a no-op there (and race-free). + warmup(); var cf = try readCellFiles(cell_path); defer cf.deinit(); From 0ffbacd09a97b99f54287a0d809c435af0b05826 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 15:05:15 -0400 Subject: [PATCH 064/140] refactor(bake): make the cell the sole parallel unit (serial tile-gen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the per-cell tile-generation thread pool (parallelFor -> serialFor at the two TileGenCtx.gen sites) so parallelism lives at ONE level: the cell. Baking a whole ENC_ROOT parallelizes across cells (chart.bakeCellsParallel, one worker per cell) and each cell's tiles generate serially — so W workers are exactly W threads, never W x cpus (no nested pool, no oversubscription). Drops the force_serial thread-local the nested case would have needed. Measured on 24 real NOAA cells (8 cpus): serial 39s -> parallel W=8 9.6s (4.1x), W=4 11.4s (3.4x); byte-identical output. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chart.zig | 2 ++ src/scene/bake_enc.zig | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index c229f39..aae4dae 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -644,6 +644,8 @@ const BakeCtx = struct { }; fn bakeCellWorker(ctx: *BakeCtx) void { + // One cell per thread. Tile generation is serial (bake_enc.serialFor), so a worker is exactly + // one thread — W workers stay W threads, never W x cpus. while (true) { const i = ctx.next.fetchAdd(1, .monotonic); if (i >= ctx.paths.len) return; diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index dec3709..cce74c7 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -606,6 +606,16 @@ fn parWorker(pc: *ParCtx) void { } } +/// Run func(user, i, scratch) for every i in [0, n) SERIALLY on the calling thread (a per-thread +/// scratch arena reset between items, exactly like parallelFor with one thread). Tile generation +/// uses this: the parallel unit is the CELL (chart.bakeCellsParallel runs one worker per cell), so +/// per-cell tile-gen stays single-threaded and W workers stay W threads — no nested thread pool. +pub fn serialFor(gpa: std.mem.Allocator, n: usize, user: *anyopaque, func: *const fn (*anyopaque, usize, std.mem.Allocator) void) void { + if (n == 0) return; + var pc = ParCtx{ .next = std.atomic.Value(usize).init(0), .n = n, .user = user, .func = func, .gpa = gpa }; + parWorker(&pc); +} + /// Run func(user, i, scratch) for every i in [0, n) across the CPU threads. func /// must be safe to call concurrently for distinct i (no shared mutable state). /// `scratch` is a per-thread arena reset between items — use it for transient @@ -1101,7 +1111,7 @@ pub const Baker = struct { const bname: [*:0]const u8 = @tagName(band).ptr; var done = std.atomic.Value(usize).init(0); var tg = TileGenCtx{ .keys = keys, .idx_lists = idx_lists, .results = results, .backends = backends, .gpa = self.gpa, .format = self.format, .pick_attrs = self.pick_attrs, .context = self.context, .progress = progress, .pctx = ctx, .base = self.count, .band_base = self.band_base, .band_total = self.band_total, .band_index = self.band_index, .band_count = self.band_count, .band_name = bname, .done = &done }; - parallelFor(self.gpa, n, &tg, TileGenCtx.gen); + serialFor(self.gpa, n, &tg, TileGenCtx.gen); for (keys, results) |key, mvt_opt| { const mvt_bytes = mvt_opt orelse continue; @@ -1182,7 +1192,7 @@ pub const Baker = struct { const bname: [*:0]const u8 = @tagName(band).ptr; var done = std.atomic.Value(usize).init(0); var tg = TileGenCtx{ .keys = keys, .idx_lists = idx_lists, .results = results, .backends = backends, .gpa = self.gpa, .format = self.format, .pick_attrs = self.pick_attrs, .context = self.context, .progress = progress, .pctx = ctx, .base = self.count, .band_base = self.band_base, .band_total = self.band_total, .band_index = self.band_index, .band_count = self.band_count, .band_name = bname, .done = &done }; - parallelFor(self.gpa, n, &tg, TileGenCtx.gen); + serialFor(self.gpa, n, &tg, TileGenCtx.gen); for (keys, results) |key, mvt_opt| { const mvt_bytes = mvt_opt orelse continue; From 7f3a26e2e6c55ab2bae232da0b2488c6f5b55fc2 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 15:25:54 -0400 Subject: [PATCH 065/140] =?UTF-8?q?feat(bake):=20tile57=5Fbake=5Fcells=5Ft?= =?UTF-8?q?o=5Ffiles=20=E2=80=94=20parallel=20bake=20to=20app-named=20path?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bakeCellsToFiles + the tile57_bake_cells_to_files C ABI: bake N cells in parallel across `workers` threads and WRITE each PMTiles to a caller-provided out_paths[i] (plus an .sha content-hash sidecar), freeing each archive right after the write — so a host never holds N archives in memory (peak ~ the worker count). The APP owns the cache and names every path, so distinct library consumers each own their own chart library without clashing. The bytes-returning tile57_bake_cells stays for other consumers. Validated on real NOAA cells (8 cells, W=8): each written file byte-identical to the serial bake + its .sha sidecar present. Concurrent writeFile on a shared threaded std.Io is safe (each worker writes a distinct path). Co-Authored-By: Claude Opus 4.8 (1M context) --- include/tile57.h | 9 +++++++ src/capi.zig | 27 ++++++++++++++++++++ src/chart.zig | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/include/tile57.h b/include/tile57.h index aa3b0f5..56d179a 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -221,6 +221,15 @@ int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); int tile57_bake_cells(const char *const *paths, size_t n, uint32_t workers, uint8_t **out_bytes, size_t *out_lens); +/* Like tile57_bake_cells, but the engine WRITES each cell's PMTiles to out_paths[i] (plus an + * .sha content-hash sidecar) and frees each archive right after — so the host never + * holds N archives in memory (peak ~ workers). The APP owns the cache and names every out path, + * so distinct library consumers each own their own chart library without clashing. in_paths[i] is + * the .000 to bake, out_paths[i] the file to write; both arrays length n. `workers` is a MEMORY + * bound — pass a small count. Returns the number of cells written, or -1 on bad args. */ +int tile57_bake_cells_to_files(const char *const *in_paths, const char *const *out_paths, + size_t n, uint32_t workers); + /* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + * cscl + date/name under a "coverage" key, so the composite stitcher rebuilds the diff --git a/src/capi.zig b/src/capi.zig index 2e27a59..7cd96b8 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -111,6 +111,33 @@ export fn tile57_bake_cells( return baked; } +/// Bake `n` cells to per-cell PMTiles files IN PARALLEL across up to `workers` threads, WRITING +/// each to out_paths[i] (+ an .sha content-hash sidecar) and freeing each archive right +/// after — so the host never holds N archives in memory. The app owns the cache and names every +/// out path. Returns the number written, or -1 on bad args. See tile57.h. +export fn tile57_bake_cells_to_files( + in_paths: ?[*]const ?[*:0]const u8, + out_paths: ?[*]const ?[*:0]const u8, + n: usize, + workers: u32, +) callconv(.c) c_int { + const ins = in_paths orelse return -1; + const outs = out_paths orelse return -1; + if (n == 0) return 0; + const ilist = gpa.alloc([]const u8, n) catch return -1; + defer gpa.free(ilist); + const olist = gpa.alloc([]const u8, n) catch return -1; + defer gpa.free(olist); + for (0..n) |i| { + ilist[i] = spanOpt(ins[i]) orelse return -1; + olist[i] = spanOpt(outs[i]) orelse return -1; + } + // Stand up a threaded std.Io for the workers' file writes (each writes a distinct path). + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + return @intCast(chart.bakeCellsToFiles(threaded.io(), ilist, olist, null, workers)); +} + /// Coverage/zoom summary of a resident compositor, filled by tile57_compose_meta. const CComposeMeta = extern struct { min_zoom: u8, diff --git a/src/chart.zig b/src/chart.zig index aae4dae..afe1358 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -677,6 +677,70 @@ pub fn bakeCellsParallel(paths: []const []const u8, rules_dir: ?[]const u8, work for (threads[0..spawned]) |t| t.join(); } +// ---- parallel batch cell-bake TO FILES (the host-cache path) ------------------- +// Same parallel bake, but the engine WRITES each cell's PMTiles to a caller-provided path and +// frees it right after — so a host never holds N archives (peak memory ~ the worker count). The +// APP owns the cache: it names every out_path, so distinct library consumers don't clash. A +// .sha content-hash sidecar is written beside each archive for the host's cache token. + +const BakeFileCtx = struct { + next: std.atomic.Value(usize), + in_paths: []const []const u8, + out_paths: []const []const u8, + rules_dir: ?[]const u8, + io: std.Io, + ok: []bool, +}; + +fn bakeFileWorker(ctx: *BakeFileCtx) void { + while (true) { + const i = ctx.next.fetchAdd(1, .monotonic); + if (i >= ctx.in_paths.len) return; + const arc = (bakeCellBytes(ctx.in_paths[i], ctx.rules_dir) catch null) orelse continue; + defer freeBytes(arc); + std.Io.Dir.cwd().writeFile(ctx.io, .{ .sub_path = ctx.out_paths[i], .data = arc }) catch continue; + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(arc, &digest, .{}); + const hex = std.fmt.bytesToHex(digest, .lower); + var sha_buf: [std.fs.max_path_bytes + 8]u8 = undefined; + if (std.fmt.bufPrint(&sha_buf, "{s}.sha", .{ctx.out_paths[i]})) |sha_path| { + std.Io.Dir.cwd().writeFile(ctx.io, .{ .sub_path = sha_path, .data = &hex }) catch {}; + } else |_| {} + ctx.ok[i] = true; + } +} + +/// Bake each in_paths[i] in parallel (up to `workers` threads) and WRITE its PMTiles to +/// out_paths[i] (plus an .sha content-hash sidecar), freeing each archive right after +/// the write — so the host never holds N archives (peak memory ~ the worker count). The app owns +/// the cache and names every out_path. Race-free (warms up first; each bake is independent). +/// Returns the number of cells written. `workers` is a MEMORY bound — keep it small. +pub fn bakeCellsToFiles(io: std.Io, in_paths: []const []const u8, out_paths: []const []const u8, rules_dir: ?[]const u8, workers: usize) usize { + std.debug.assert(out_paths.len == in_paths.len); + if (in_paths.len == 0) return 0; + warmup(); + const ok = gpa.alloc(bool, in_paths.len) catch return 0; + defer gpa.free(ok); + @memset(ok, false); + var ctx = BakeFileCtx{ .next = std.atomic.Value(usize).init(0), .in_paths = in_paths, .out_paths = out_paths, .rules_dir = rules_dir, .io = io, .ok = ok }; + var n = @min(@max(workers, 1), in_paths.len); + if (n > MAX_BAKE_WORKERS) n = MAX_BAKE_WORKERS; + if (n <= 1) { + bakeFileWorker(&ctx); + } else { + var threads: [MAX_BAKE_WORKERS]std.Thread = undefined; + var spawned: usize = 0; + while (spawned < n - 1) : (spawned += 1) threads[spawned] = std.Thread.spawn(.{}, bakeFileWorker, .{&ctx}) catch break; + bakeFileWorker(&ctx); + for (threads[0..spawned]) |t| t.join(); + } + var count: usize = 0; + for (ok) |o| { + if (o) count += 1; + } + return count; +} + /// The metadata JSON blob of a PMTiles archive (decompressed), duped into `a`, or null /// if the archive carries none. For a host to read the embedded scamin / coverage /// without a full open. This engine writes metadata uncompressed; gzip is handled for From bb2c59d36ca6c213caed735541cc5d6e13a08a4c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 15:41:23 -0400 Subject: [PATCH 066/140] =?UTF-8?q?feat(bake):=20tile57=5Fbake=5Ftree=20?= =?UTF-8?q?=E2=80=94=20parallel=20bake=20mirroring=20an=20ENC=20tree=20to?= =?UTF-8?q?=20a=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-cell-paths tile57_bake_cells_to_files with tile57_bake_tree(in_dir, out_dir, workers, progress, ctx): walk in_dir for *.000 and bake each IN PARALLEL to the SAME relative path under out_dir with a .pmtiles extension (in/d1/US4CT1AA.000 -> out/d1/US4CT1AA.pmtiles) plus an .sha sidecar, creating subdirs. The APP owns the cache (passes both dirs, owns the layout); the engine writes + frees each archive, so the host never holds N in memory (peak ~ workers). A (done,total) progress callback fires per cell for the import UI (may be called concurrently — must be thread-safe). bakeCellsToFiles is the internal primitive. Validated on a nested real-cell tree: mirrored east/ + west/ output, byte-identical to serial, .sha written, progress 1..4/4. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/tile57.h | 20 ++++++++----- src/capi.zig | 35 +++++++++------------- src/chart.zig | 78 ++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 89 insertions(+), 44 deletions(-) diff --git a/include/tile57.h b/include/tile57.h index 56d179a..bc8bdb1 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -221,14 +221,18 @@ int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); int tile57_bake_cells(const char *const *paths, size_t n, uint32_t workers, uint8_t **out_bytes, size_t *out_lens); -/* Like tile57_bake_cells, but the engine WRITES each cell's PMTiles to out_paths[i] (plus an - * .sha content-hash sidecar) and frees each archive right after — so the host never - * holds N archives in memory (peak ~ workers). The APP owns the cache and names every out path, - * so distinct library consumers each own their own chart library without clashing. in_paths[i] is - * the .000 to bake, out_paths[i] the file to write; both arrays length n. `workers` is a MEMORY - * bound — pass a small count. Returns the number of cells written, or -1 on bad args. */ -int tile57_bake_cells_to_files(const char *const *in_paths, const char *const *out_paths, - size_t n, uint32_t workers); +/* Walk `in_dir` for S-57 base cells (*.000) and bake each IN PARALLEL to the SAME relative path + * under `out_dir` with a .pmtiles extension (in_dir/d1/US4CT1AA.000 -> out_dir/d1/US4CT1AA.pmtiles), + * plus an .sha content-hash sidecar; output subdirs are created as needed. The engine writes + * and frees each archive as it goes, so the host never holds N archives in memory (peak ~ workers). + * `in_dir` is the source ENC data; `out_dir` is the caller's OWN cache — it owns the location + the + * layout, so distinct library consumers each keep their own chart library without clashing. + * `workers` is a MEMORY bound — pass a small count. `progress(progress_ctx, done, total)` (or NULL) + * fires after each cell for an import progress bar; it may be called CONCURRENTLY from worker + * threads, so it must be thread-safe. Returns the number of cells baked, or -1. */ +typedef void (*tile57_bake_progress)(void *ctx, uint32_t done, uint32_t total); +int tile57_bake_tree(const char *in_dir, const char *out_dir, uint32_t workers, + tile57_bake_progress progress, void *progress_ctx); /* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + diff --git a/src/capi.zig b/src/capi.zig index 7cd96b8..9bfa60d 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -111,31 +111,24 @@ export fn tile57_bake_cells( return baked; } -/// Bake `n` cells to per-cell PMTiles files IN PARALLEL across up to `workers` threads, WRITING -/// each to out_paths[i] (+ an .sha content-hash sidecar) and freeing each archive right -/// after — so the host never holds N archives in memory. The app owns the cache and names every -/// out path. Returns the number written, or -1 on bad args. See tile57.h. -export fn tile57_bake_cells_to_files( - in_paths: ?[*]const ?[*:0]const u8, - out_paths: ?[*]const ?[*:0]const u8, - n: usize, +/// Walk `in_dir` for S-57 base cells (*.000) and bake each IN PARALLEL to the SAME relative path +/// under `out_dir` with a .pmtiles extension (+ an .sha sidecar), creating subdirs as needed. +/// The engine writes + frees each archive, so the host never holds N in memory. `in_dir` is the +/// source ENC data; `out_dir` is the caller's own cache. Returns the count baked, or -1 on bad +/// args. See tile57.h. +export fn tile57_bake_tree( + in_dir: ?[*:0]const u8, + out_dir: ?[*:0]const u8, workers: u32, + progress: chart.BakeProgress, + progress_ctx: ?*anyopaque, ) callconv(.c) c_int { - const ins = in_paths orelse return -1; - const outs = out_paths orelse return -1; - if (n == 0) return 0; - const ilist = gpa.alloc([]const u8, n) catch return -1; - defer gpa.free(ilist); - const olist = gpa.alloc([]const u8, n) catch return -1; - defer gpa.free(olist); - for (0..n) |i| { - ilist[i] = spanOpt(ins[i]) orelse return -1; - olist[i] = spanOpt(outs[i]) orelse return -1; - } - // Stand up a threaded std.Io for the workers' file writes (each writes a distinct path). + const in_d = spanOpt(in_dir) orelse return -1; + const out_d = spanOpt(out_dir) orelse return -1; + // Stand up a threaded std.Io for the tree walk + the workers' file writes. var threaded: std.Io.Threaded = .init(gpa, .{}); defer threaded.deinit(); - return @intCast(chart.bakeCellsToFiles(threaded.io(), ilist, olist, null, workers)); + return @intCast(chart.bakeTree(threaded.io(), in_d, out_d, null, workers, progress, progress_ctx)); } /// Coverage/zoom summary of a resident compositor, filled by tile57_compose_meta. diff --git a/src/chart.zig b/src/chart.zig index afe1358..4978d5b 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -683,6 +683,12 @@ pub fn bakeCellsParallel(paths: []const []const u8, rules_dir: ?[]const u8, work // APP owns the cache: it names every out_path, so distinct library consumers don't clash. A // .sha content-hash sidecar is written beside each archive for the host's cache token. +/// Progress callback: invoked with (ctx, done, total) after each cell is processed, so a host can +/// drive an import progress bar. It may be called CONCURRENTLY from worker threads (done arrives +/// monotonically per fetch but can be delivered slightly out of order), so the callback must be +/// thread-safe. Null to skip. +pub const BakeProgress = ?*const fn (?*anyopaque, u32, u32) callconv(.c) void; + const BakeFileCtx = struct { next: std.atomic.Value(usize), in_paths: []const []const u8, @@ -690,39 +696,48 @@ const BakeFileCtx = struct { rules_dir: ?[]const u8, io: std.Io, ok: []bool, + progress: BakeProgress, + progress_ctx: ?*anyopaque, + done: std.atomic.Value(u32), }; +fn bakeOneToFile(ctx: *BakeFileCtx, i: usize) void { + const arc = (bakeCellBytes(ctx.in_paths[i], ctx.rules_dir) catch null) orelse return; + defer freeBytes(arc); + std.Io.Dir.cwd().writeFile(ctx.io, .{ .sub_path = ctx.out_paths[i], .data = arc }) catch return; + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(arc, &digest, .{}); + const hex = std.fmt.bytesToHex(digest, .lower); + var sha_buf: [std.fs.max_path_bytes + 8]u8 = undefined; + if (std.fmt.bufPrint(&sha_buf, "{s}.sha", .{ctx.out_paths[i]})) |sha_path| { + std.Io.Dir.cwd().writeFile(ctx.io, .{ .sub_path = sha_path, .data = &hex }) catch {}; + } else |_| {} + ctx.ok[i] = true; +} + fn bakeFileWorker(ctx: *BakeFileCtx) void { while (true) { const i = ctx.next.fetchAdd(1, .monotonic); if (i >= ctx.in_paths.len) return; - const arc = (bakeCellBytes(ctx.in_paths[i], ctx.rules_dir) catch null) orelse continue; - defer freeBytes(arc); - std.Io.Dir.cwd().writeFile(ctx.io, .{ .sub_path = ctx.out_paths[i], .data = arc }) catch continue; - var digest: [32]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(arc, &digest, .{}); - const hex = std.fmt.bytesToHex(digest, .lower); - var sha_buf: [std.fs.max_path_bytes + 8]u8 = undefined; - if (std.fmt.bufPrint(&sha_buf, "{s}.sha", .{ctx.out_paths[i]})) |sha_path| { - std.Io.Dir.cwd().writeFile(ctx.io, .{ .sub_path = sha_path, .data = &hex }) catch {}; - } else |_| {} - ctx.ok[i] = true; + bakeOneToFile(ctx, i); + const d = ctx.done.fetchAdd(1, .monotonic) + 1; // attempted count (smooth progress) + if (ctx.progress) |cb| cb(ctx.progress_ctx, d, @intCast(ctx.in_paths.len)); } } /// Bake each in_paths[i] in parallel (up to `workers` threads) and WRITE its PMTiles to /// out_paths[i] (plus an .sha content-hash sidecar), freeing each archive right after /// the write — so the host never holds N archives (peak memory ~ the worker count). The app owns -/// the cache and names every out_path. Race-free (warms up first; each bake is independent). -/// Returns the number of cells written. `workers` is a MEMORY bound — keep it small. -pub fn bakeCellsToFiles(io: std.Io, in_paths: []const []const u8, out_paths: []const []const u8, rules_dir: ?[]const u8, workers: usize) usize { +/// the cache and names every out_path. `progress(progress_ctx, done, total)` fires (serialised) +/// after each cell. Race-free (warms up first; each bake is independent). Returns the count written. +pub fn bakeCellsToFiles(io: std.Io, in_paths: []const []const u8, out_paths: []const []const u8, rules_dir: ?[]const u8, workers: usize, progress: BakeProgress, progress_ctx: ?*anyopaque) usize { std.debug.assert(out_paths.len == in_paths.len); if (in_paths.len == 0) return 0; warmup(); const ok = gpa.alloc(bool, in_paths.len) catch return 0; defer gpa.free(ok); @memset(ok, false); - var ctx = BakeFileCtx{ .next = std.atomic.Value(usize).init(0), .in_paths = in_paths, .out_paths = out_paths, .rules_dir = rules_dir, .io = io, .ok = ok }; + var ctx = BakeFileCtx{ .next = std.atomic.Value(usize).init(0), .in_paths = in_paths, .out_paths = out_paths, .rules_dir = rules_dir, .io = io, .ok = ok, .progress = progress, .progress_ctx = progress_ctx, .done = std.atomic.Value(u32).init(0) }; var n = @min(@max(workers, 1), in_paths.len); if (n > MAX_BAKE_WORKERS) n = MAX_BAKE_WORKERS; if (n <= 1) { @@ -741,6 +756,39 @@ pub fn bakeCellsToFiles(io: std.Io, in_paths: []const []const u8, out_paths: []c return count; } +/// Walk `in_dir` for S-57 base cells (*.000) and bake each, IN PARALLEL, to the SAME relative path +/// under `out_dir` with a .pmtiles extension (in_dir/d1/US4CT1AA.000 -> out_dir/d1/US4CT1AA.pmtiles), +/// plus an .sha sidecar. Output subdirs are created as needed. `in_dir` is the source ENC data; +/// `out_dir` is the caller's own cache (it owns the location + names, so consumers don't clash). The +/// engine writes + frees each archive, so the host never holds N in memory. `progress` fires per +/// cell (serialised) for an import progress bar. Returns the count baked. +pub fn bakeTree(io: std.Io, in_dir: []const u8, out_dir: []const u8, rules_dir: ?[]const u8, workers: usize, progress: BakeProgress, progress_ctx: ?*anyopaque) usize { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + + var in_paths = std.ArrayList([]const u8).empty; + var out_paths = std.ArrayList([]const u8).empty; + + var dir = std.Io.Dir.cwd().openDir(io, in_dir, .{ .iterate = true }) catch return 0; + defer dir.close(io); + var walker = dir.walk(a) catch return 0; + defer walker.deinit(); + while (walker.next(io) catch null) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; + const in_path = std.fs.path.join(a, &.{ in_dir, entry.path }) catch continue; + // Mirror the relative path, swapping .000 -> .pmtiles. + const rel_noext = entry.path[0 .. entry.path.len - ".000".len]; + const out_rel = std.fmt.allocPrint(a, "{s}.pmtiles", .{rel_noext}) catch continue; + const out_path = std.fs.path.join(a, &.{ out_dir, out_rel }) catch continue; + if (std.fs.path.dirname(out_path)) |d| std.Io.Dir.cwd().createDirPath(io, d) catch {}; + in_paths.append(a, in_path) catch continue; + out_paths.append(a, out_path) catch continue; + } + if (in_paths.items.len == 0) return 0; + return bakeCellsToFiles(io, in_paths.items, out_paths.items, rules_dir, workers, progress, progress_ctx); +} + /// The metadata JSON blob of a PMTiles archive (decompressed), duped into `a`, or null /// if the archive carries none. For a host to read the embedded scamin / coverage /// without a full open. This engine writes metadata uncompressed; gzip is handled for From e523ee79377608aa7b0d85e84185ffd6842e41d4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 15:51:09 -0400 Subject: [PATCH 067/140] feat(bake): incremental reuse in bakeTree Skip a cell whose mirrored /.pmtiles already exists and is at least as new as its source .000, so re-baking a provider (e.g. adding a district) only bakes what changed. Validated on real cells: 4 baked, then 0 (all reused), then 1 after touching one .000. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chart.zig | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/chart.zig b/src/chart.zig index 4978d5b..43b56ba 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -781,6 +781,14 @@ pub fn bakeTree(io: std.Io, in_dir: []const u8, out_dir: []const u8, rules_dir: const rel_noext = entry.path[0 .. entry.path.len - ".000".len]; const out_rel = std.fmt.allocPrint(a, "{s}.pmtiles", .{rel_noext}) catch continue; const out_path = std.fs.path.join(a, &.{ out_dir, out_rel }) catch continue; + // Incremental: skip a cell whose mirrored archive already exists and is at least as new as + // its source .000 — so re-baking a provider (e.g. after adding a district) only bakes what + // changed. A re-downloaded cell rewrites the .000 → its mtime advances → we re-bake it. + if (fileModNs(io, out_path)) |out_ns| { + if (fileModNs(io, in_path)) |in_ns| { + if (out_ns >= in_ns) continue; + } + } if (std.fs.path.dirname(out_path)) |d| std.Io.Dir.cwd().createDirPath(io, d) catch {}; in_paths.append(a, in_path) catch continue; out_paths.append(a, out_path) catch continue; @@ -789,6 +797,14 @@ pub fn bakeTree(io: std.Io, in_dir: []const u8, out_dir: []const u8, rules_dir: return bakeCellsToFiles(io, in_paths.items, out_paths.items, rules_dir, workers, progress, progress_ctx); } +/// The file's modification time in nanoseconds, or null if it doesn't exist / can't be statted. +fn fileModNs(io: std.Io, path: []const u8) ?i96 { + var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch return null; + defer f.close(io); + const st = f.stat(io) catch return null; + return st.mtime.nanoseconds; +} + /// The metadata JSON blob of a PMTiles archive (decompressed), duped into `a`, or null /// if the archive carries none. For a host to read the embedded scamin / coverage /// without a full open. This engine writes metadata uncompressed; gzip is handled for From 597e39d68d71dab5a1d9290093dca6f4d231dd4c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 15:56:28 -0400 Subject: [PATCH 068/140] fix(bake): reuse checks the whole update chain, not just the .000 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bakeTree's incremental skip compared only the base .000 mtime, so dropping a NEW update (.001/.002/…) next to an unchanged .000 would wrongly reuse the stale archive. Compare the archive against the NEWEST of the .000 and its contiguous update chain (newestInputNs, the same discovery readCellFiles uses), so a dropped update forces a re-bake. Validated: dropping a .001 re-bakes exactly that cell. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chart.zig | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index 43b56ba..8b5ab2d 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -782,10 +782,10 @@ pub fn bakeTree(io: std.Io, in_dir: []const u8, out_dir: []const u8, rules_dir: const out_rel = std.fmt.allocPrint(a, "{s}.pmtiles", .{rel_noext}) catch continue; const out_path = std.fs.path.join(a, &.{ out_dir, out_rel }) catch continue; // Incremental: skip a cell whose mirrored archive already exists and is at least as new as - // its source .000 — so re-baking a provider (e.g. after adding a district) only bakes what - // changed. A re-downloaded cell rewrites the .000 → its mtime advances → we re-bake it. + // its whole input — the .000 AND its update chain (.001, .002, …) — so re-baking a provider + // (adding a district, or dropping a new .001 update) only re-bakes what actually changed. if (fileModNs(io, out_path)) |out_ns| { - if (fileModNs(io, in_path)) |in_ns| { + if (newestInputNs(io, in_path)) |in_ns| { if (out_ns >= in_ns) continue; } } @@ -805,6 +805,27 @@ fn fileModNs(io: std.Io, path: []const u8) ?i96 { return st.mtime.nanoseconds; } +/// The NEWEST mtime across a cell's whole input: its base .000 (`in_path`) and its contiguous +/// update chain .001, .002, … (stopping at the first gap — the same discovery readCellFiles +/// uses to apply them). Null if the base is missing. So a freshly-dropped .001 makes the cell newer +/// than a previously-baked archive, forcing a re-bake. +fn newestInputNs(io: std.Io, in_path: []const u8) ?i96 { + var newest = fileModNs(io, in_path) orelse return null; + const dir = std.fs.path.dirname(in_path) orelse "."; + const bn = std.fs.path.basename(in_path); + if (bn.len > 4) { + const stem = bn[0 .. bn.len - 4]; // strip ".000" + var u: u32 = 1; + while (u <= 999) : (u += 1) { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const up = std.fmt.bufPrint(&buf, "{s}{s}{s}.{d:0>3}", .{ dir, std.fs.path.sep_str, stem, u }) catch break; + const ns = fileModNs(io, up) orelse break; // gap → the chain ends here + if (ns > newest) newest = ns; + } + } + return newest; +} + /// The metadata JSON blob of a PMTiles archive (decompressed), duped into `a`, or null /// if the archive carries none. For a host to read the embedded scamin / coverage /// without a full open. This engine writes metadata uncompressed; gzip is handled for From cd5f05db8c364ceeddc9aea166f8558beaffd81e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 15:57:35 -0400 Subject: [PATCH 069/140] feat(go): BakeTree binding for the parallel tree bake Wrap tile57_bake_tree: BakeTree(inDir, outDir, workers, onProgress) walks the ENC tree, bakes each cell in parallel to the mirrored /.pmtiles (+ .sha), and reports (done, total) via a cgo.Handle progress trampoline. The library writes + frees each archive, so Go never holds the bytes. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/go/bake.go | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/bindings/go/bake.go b/bindings/go/bake.go index c833ea3..f331268 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -5,14 +5,25 @@ package tile57 /* #include #include "tile57.h" + +// Trampoline: the C progress callback calls back into this exported Go function. +extern void tile57GoBakeProgress(void *ctx, uint32_t done, uint32_t total); */ import "C" import ( "fmt" + "runtime/cgo" "unsafe" ) +//export tile57GoBakeProgress +func tile57GoBakeProgress(ctx unsafe.Pointer, done, total C.uint32_t) { + if fn, ok := cgo.Handle(uintptr(ctx)).Value().(func(int, int)); ok && fn != nil { + fn(int(done), int(total)) + } +} + // BakeCell bakes ONE on-disk cell (a .000 path + its .001.. updates) to a PMTiles // archive over its NATIVE band zoom range and returns the bytes — the per-cell tile // store the composite stitcher consumes (the stitcher handles any cross-band zoom @@ -37,6 +48,41 @@ func BakeCell(path string) ([]byte, error) { return nil, fmt.Errorf("tile57: cell bake failed") } } + +// BakeTree walks inDir for S-57 base cells (*.000) and bakes each IN PARALLEL to the SAME relative +// path under outDir with a .pmtiles extension (+ an .sha content-hash sidecar), creating +// subdirs. A cell whose archive is already up to date (newer than its .000 and its update chain) is +// skipped. The engine writes and frees each archive as it goes, so this never holds N archives in +// memory (peak ~ workers). outDir is the caller's OWN cache — it owns the location + layout, so +// distinct consumers don't clash. onProgress(done, total) fires per baked cell (may be called +// concurrently from workers, so it must be safe for concurrent use). Returns the number baked. +func BakeTree(inDir, outDir string, workers int, onProgress func(done, total int)) (int, error) { + if inDir == "" || outDir == "" { + return 0, fmt.Errorf("tile57: BakeTree needs input + output dirs: %w", ErrEmptyInput) + } + if workers < 1 { + workers = 1 + } + cIn := C.CString(inDir) + defer C.free(unsafe.Pointer(cIn)) + cOut := C.CString(outDir) + defer C.free(unsafe.Pointer(cOut)) + + var cb C.tile57_bake_progress + var ctx unsafe.Pointer + if onProgress != nil { + h := cgo.NewHandle(onProgress) + defer h.Delete() + cb = C.tile57_bake_progress(C.tile57GoBakeProgress) + ctx = unsafe.Pointer(h) //nolint:govet // cgo.Handle passed as the void* ctx, retrieved verbatim + } + rc := C.tile57_bake_tree(cIn, cOut, C.uint32_t(workers), cb, ctx) + if rc < 0 { + return 0, fmt.Errorf("tile57: bake tree failed") + } + return int(rc), nil +} + // PMTilesMetadata returns a PMTiles archive's metadata JSON blob (decompressed), or // nil if the archive carries none. A [BakeCell] archive embeds the cell's coverage // under a "coverage" key. The pmtiles bytes are read but not retained. From a6c252fe0a76d5b364731175aa4a6b6682f602ea Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 17:33:35 -0400 Subject: [PATCH 070/140] refactor(iso8211): promote to a standalone module Move src/s57/iso8211.zig to src/iso8211/ and register it as its own build module. s57 now imports it by name, so a consumer can depend on the ISO/IEC 8211 container reader without pulling in S-57 semantics. The s57.iso8211 re-export is unchanged, and the bottom of the layer stack is now grabbable on its own. Co-Authored-By: Claude Fable 5 --- build.zig | 15 +++++++++++++-- src/{s57 => iso8211}/iso8211.zig | 0 src/s57/s57.zig | 7 ++++--- 3 files changed, 17 insertions(+), 5 deletions(-) rename src/{s57 => iso8211}/iso8211.zig (100%) diff --git a/build.zig b/build.zig index b10ab9b..7cc1254 100644 --- a/build.zig +++ b/build.zig @@ -162,10 +162,18 @@ pub fn build(b: *std.Build) void { // libc/Lua) and target-agnostic: they omit target/optimize so the same module // objects compile under both the glibc test/lib build and the static-musl // baker build, inheriting each consumer's target. - // DAG: s57 (incl. iso8211) <- s100; tiles (leaf) <- render <- scene. + // DAG: iso8211 <- s57 <- s100; tiles (leaf) <- render <- scene. // The embedded catalogue JSON rides on s100 (catalogue.zig @embedFile's it). + // + // ISO/IEC 8211 container reader (src/iso8211/): the bottom layer, a pure + // std-only leaf. Its own module so a consumer can decode 8211 records + // without depending on any S-57 semantics. + const iso8211_mod = b.addModule("iso8211", .{ + .root_source_file = b.path("src/iso8211/iso8211.zig"), + }); const s57_mod = b.addModule("s57", .{ .root_source_file = b.path("src/s57/s57.zig"), + .imports = &.{.{ .name = "iso8211", .module = iso8211_mod }}, }); const s100_mod = b.addModule("s100", .{ .root_source_file = b.path("src/s100/s100.zig"), @@ -541,7 +549,10 @@ pub fn build(b: *std.Build) void { sprite_test.link_libc = true; addSvgRaster(b, sprite_test); - _ = addPkgTest(b, test_step, "src/s57/s57.zig", target, optimize, &.{}); + _ = addPkgTest(b, test_step, "src/iso8211/iso8211.zig", target, optimize, &.{}); + _ = addPkgTest(b, test_step, "src/s57/s57.zig", target, optimize, &.{ + .{ .name = "iso8211", .module = iso8211_mod }, + }); const s100_test = addPkgTest(b, test_step, "src/s100/s100.zig", target, optimize, &.{ .{ .name = "s57", .module = s57_mod }, }); diff --git a/src/s57/iso8211.zig b/src/iso8211/iso8211.zig similarity index 100% rename from src/s57/iso8211.zig rename to src/iso8211/iso8211.zig diff --git a/src/s57/s57.zig b/src/s57/s57.zig index 9225911..4125fdb 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -10,9 +10,10 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -/// The ISO/IEC 8211 record parser S-57 rides on (folded into this module — -/// its only consumer). Re-exported for the CLI inspector + lib root. -pub const iso8211 = @import("iso8211.zig"); +/// The ISO/IEC 8211 container reader S-57 rides on, re-exported for consumers +/// that want the raw records. It is its own module (`@import("iso8211")`), so a +/// caller can depend on the 8211 layer without pulling in S-57 semantics. +pub const iso8211 = @import("iso8211"); const iso = iso8211; // Geographic coordinate stored in S-57's native integer ×1e7 units (lon ±1.8e9, From fe06f61ca6182d073545b535b3ba26ac3cfaf77f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 17:43:21 -0400 Subject: [PATCH 071/140] docs(code): refresh stale module headers and drop specs/ refs Rewrite the module headers that described shipped-and-gone milestones: root.zig (M4/M5/M6 build order), scene.zig ("M6c demo, BYPASSING S-101 portrayal"), and geo/geo.zig ("not yet wired to the baker") now describe what each module does today. Remove every specs/*.md reference from committed code comments and test names. specs/ is local-only working notes, so the pointers dangle for any other reader; the design is described inline instead, keeping the real S-52 section citations. Co-Authored-By: Claude Fable 5 --- bindings/go/style.go | 4 ++-- bindings/go/style_test.go | 6 +++--- src/assets/style.zig | 19 +++++++++---------- src/geo/geo.zig | 15 ++++++++------- src/render/resolve.zig | 2 +- src/root.zig | 15 +++++++-------- src/scene/bake_enc.zig | 13 ++++++------- src/scene/scene.zig | 32 +++++++++++++++++--------------- tools/s101_coverage.zig | 13 ++++++------- 9 files changed, 59 insertions(+), 60 deletions(-) diff --git a/bindings/go/style.go b/bindings/go/style.go index fd31f0a..7c0e13a 100644 --- a/bindings/go/style.go +++ b/bindings/go/style.go @@ -147,8 +147,8 @@ func BuildStyle(template []byte, m Mariner, colortables []byte, enabledBands []i // inputs of BuildStyle so the two styles are comparable. The result is "[]" when // nothing changed, one op per differing filter/paint/layout key, or // [{"op":"rebuild"}] when the host should fall back to a full setStyle. The host -// applies each op with map.setFilter / setPaintProperty / setLayoutProperty (see -// specs/style-diff.md); the raw JSON is returned so a server can forward it to the +// applies each op with map.setFilter / setPaintProperty / setLayoutProperty; +// the raw JSON is returned so a server can forward it to the // browser untouched. func StyleDiff(template []byte, from, to Mariner, colortables []byte, enabledBands []int32, scamin []int32, scaminLat float64) ([]byte, error) { if len(template) == 0 { diff --git a/bindings/go/style_test.go b/bindings/go/style_test.go index 064c71c..506b305 100644 --- a/bindings/go/style_test.go +++ b/bindings/go/style_test.go @@ -45,7 +45,7 @@ func TestStyle(t *testing.T) { func TestIgnoreScamin(t *testing.T) { t.Skip("asserts the retired per-value SCAMIN model (log2 per-feature gate): dropped in " + "f9887c9 for the merged band-independent gate. Update to the merged/filter-gate model " + - "(specs/scamin-layers.md, host-side pending).") + "(host-side pending).") build := func(ignore bool) string { m := MarinerDefaults() m.IgnoreScamin = ignore @@ -99,7 +99,7 @@ func TestViewingGroupsOff(t *testing.T) { func TestScaminBuckets(t *testing.T) { t.Skip("asserts the retired per-value SCAMIN #sm bucket layers: dropped in f9887c9 for " + "the merged band-independent gate (buckets no longer emitted). Update to the merged/" + - "filter-gate model (specs/scamin-layers.md, host-side pending).") + "filter-gate model (host-side pending).") m := MarinerDefaults() scamin := []int32{89999, 119999, 259999} withManifest, err := Style(SchemeDay, "tile57://{z}/{x}/{y}", "sprite", @@ -185,7 +185,7 @@ func TestStyleEncodingHint(t *testing.T) { func TestScaminFilterGate(t *testing.T) { t.Skip("asserts the non-gated path emits per-value #sm bucket layers, but buckets were " + "dropped in f9887c9 (merged band-independent gate is the only mode). Rewrite against " + - "the merged gate vs filter-gate output (specs/scamin-layers.md, host-side pending).") + "the merged gate vs filter-gate output (host-side pending).") ct, _ := ColortablesDefault() tmpl, err := StyleTemplate(SchemeDay, "tile57://{z}/{x}/{y}", "sprite", "glyphs/{fontstack}/{range}.pbf", 0, 0, FormatMVT) diff --git a/src/assets/style.zig b/src/assets/style.zig index 18122d3..a9c4ad6 100644 --- a/src/assets/style.zig +++ b/src/assets/style.zig @@ -247,7 +247,7 @@ const DenomGate = union(enum) { zoom_k: f64, }; -// The overscale (oscl) clause — S-52 §10.1.10 (specs/overscale.md). The +// The overscale (oscl) clause — S-52 §10.1.10. The // filter-gate form is EXACTLY // [">", ["coalesce", ["get","oscl"], 0], DENOM] // ("the display is FINER than the cell's quantized compilation scale"), or its @@ -320,12 +320,12 @@ const Bucket = struct { filter_gate: bool = false, // scamin-layers.md: the live client-driven SCAMIN clause (?scaminexact) cur_denom: f64 = 0, // filter_gate: the current-display-scale denominator literal (client-overwritten) suffix: []const u8 = "", // id suffix: "#oscl" (overscaled fill pass) / "" (plain) - // Overscale (oscl) clause role (S-52 §10.1.10, specs/overscale.md) — see OverscaleRole. + // Overscale (oscl) clause role (S-52 §10.1.10) — see OverscaleRole. oscl: OverscaleRole = .none, }; // Overscale (oscl) clause role a fill/pattern layer plays in the AP(OVERSC01) -// occlusion sandwich (S-52 §10.1.10, specs/overscale.md): +// occlusion sandwich (S-52 §10.1.10): // .none — no oscl clause (every layer outside the sandwich). // .overscaled — [">", coalesce(oscl,0), DENOM]: this cell's data is displayed FINER // than its compilation scale (the fills under the hatch + the hatch itself). @@ -669,7 +669,7 @@ fn patternLayer(js: *Stringify, s: *const SCtx, sl: []const u8, bkt: Bucket) !vo try js.endObject(); } -// The AP(OVERSC01) overscale-indication layer (S-52 §10.1.10, specs/overscale.md): +// The AP(OVERSC01) overscale-indication layer (S-52 §10.1.10): // every contributing cell's M_COVR coverage polygon (baked into `area_patterns` // as pattern OVERSC01, tagged `oscl`), shown only while the display is FINER than // the cell's quantized compilation scale (the oscl clause). Sandwiched between @@ -936,7 +936,7 @@ pub fn styleJson(alloc: std.mem.Allocator, opts: StyleOpts) ![]u8 { // the hatch) each bucket's fill splits around the AP(OVERSC01) overscale layer: // overscaled cells' fills (#oscl) UNDER the hatch, at-scale fills ABOVE it — so // finer opaque DEPARE/LNDARE occlude a coarser cell's hatch and it survives only - // on coarse-only patches (S-52 §10.1.10, specs/overscale.md). ignore_scamin (gate + // on coarse-only patches (S-52 §10.1.10). ignore_scamin (gate // off) or no sprite keeps a single plain fill layer per bucket. if (sprite_on) { for (scamin_buckets) |bkt| try fillLayer(js, &s, "areas", try bucketWithOverscale(ba, bkt, .overscaled)); @@ -1534,10 +1534,9 @@ test "buildFromTemplateScamin: a manifest no longer buckets — the merged zoom- // ---- smax removal ----------------------------------------------------------- test "styleJson: no smax clause in any gating mode (band-handoff gate retired)" { - // The coverage-clipped composite owns cross-band occlusion geometrically - // (specs/cross-band-composition-redesign.md §3): no layer carries a - // band-handoff smax clause any more, in any gating mode — and the overscale - // gate survives ?ignoreScamin (decoupled per spec §5). + // The coverage-clipped composite owns cross-band occlusion geometrically, so + // no layer carries a band-handoff smax clause in any gating mode, and the + // overscale gate survives ?ignoreScamin (the two gates are decoupled). const a = std.testing.allocator; const ct = \\{"day":{"DEPDW":"#c9edff"},"dusk":{},"night":{}} @@ -1580,7 +1579,7 @@ test "styleJson: no smax clause in any gating mode (band-handoff gate retired)" try std.testing.expect(std.mem.indexOf(u8, out_ign, "oscl") != null); } -// ---- overscale (oscl) gate tests (specs/overscale.md) ----------------------- +// ---- overscale (oscl) gate tests ------------------------------------------- test "styleJson: the overscale oscl clause has the EXACT client-matched shape" { const a = std.testing.allocator; diff --git a/src/geo/geo.zig b/src/geo/geo.zig index 3bb173c..ecd2e2d 100644 --- a/src/geo/geo.zig +++ b/src/geo/geo.zig @@ -1,13 +1,14 @@ -//! Integer computational geometry for cross-band chart composition — the geometry -//! core, not yet wired to the baker, the live oracle, or the client: +//! Integer computational geometry for chart composition. Pure (std-only), so the +//! module self-tests via `zig build test`. The scene baker and the tile +//! compositor use it to decide which cell owns each tile. //! -//! * `boolean` — a Martinez–Rueda–Feito polygon boolean (union / intersection / -//! difference / symmetric-difference) on integer coordinates, with +//! * `boolean` — a Martinez–Rueda–Feito polygon boolean (union / intersection +//! / difference / symmetric-difference) on integer coordinates, with //! overlap-edge typing and a deterministic total order, plus `unionAll`. -//! * `plane` — the per-tier coverage partition (`ownedAtTier`), the FULL / +//! * `plane` — the per-tier coverage partition (`ownedAtTier`), the FULL / //! EMPTY / SEAM tile classifier (`EdgeGrid`), and `clipLineOutsidePolys`. -//! -//! Both are pure (std-only) so they self-test via `zig build test`. +//! * `partition` — the cell-ownership partition and its `.tpart` sidecar +//! (`serialize` / `deserialize` / `inputKey`). pub const boolean = @import("boolean.zig"); pub const plane = @import("plane.zig"); diff --git a/src/render/resolve.zig b/src/render/resolve.zig index d317aa4..6cf3724 100644 --- a/src/render/resolve.zig +++ b/src/render/resolve.zig @@ -245,7 +245,7 @@ test "scaminVisible mirrors the style SCAMIN_GATE" { try std.testing.expect(scaminVisible(0, 0)); // degenerate 0 -> always } -test "osclVisible: the X2 hatch never fires at/below 1x, fires past 2x (specs/overscale.md v3)" { +test "osclVisible: the X2 hatch never fires at/below 1x, fires past 2x" { // A 1:260000 cell bakes oscl = cscl / OVERSCALE_FACTOR (X2) = 130000 (see // bake_enc.overscaleGateDenom). The hatch (denom < oscl) must: // - stay OFF at & below 1x compilation scale (denom >= 260000), and diff --git a/src/root.zig b/src/root.zig index f427174..4c7e654 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,11 +1,10 @@ -//! engine — the Zig chart tile generator. +//! The pure-Zig engine surface: the format, geometry, and encoding packages that +//! need no libc. It is the root of the unit-test build, which stays free of libc +//! and the embedded Lua so `zig build test` links no system C runtime. //! -//! Bottom-up build order (see ../../docs/docs/architecture.md): -//! M4: mvt + pmtiles + tile (encode MVT, write PMTiles) <- in progress -//! M5: capi (libtile57.a) for live in-process generation -//! M6: iso8211 + s57 decode -> embedded-Lua S-101 portrayal -//! -//! The Go project at ../../reference/chartplotter-go is the parity oracle. +//! The S-101 Lua portrayal runner lives in the separate `portray` module, added +//! on top of this surface by `bake_root.zig` (the CLI) and `lib_root.zig` +//! (libtile57.a). See build.zig for how the roots compose. const std = @import("std"); @@ -22,7 +21,7 @@ pub const s101_instr = s100.s101_instr; pub const s101_adapt = s100.s101_adapt; pub const catalogue = s100.catalogue; pub const bake_enc = @import("scene").bake_enc; // banded multi-cell ENC_ROOT -> PMTiles -pub const assets = @import("assets"); // chart-bundle asset/manifest generation +pub const assets = @import("assets"); // colortables, line styles, and style.json generation pub const chartstyle = @import("assets").chartstyle; // mariner-driven MapLibre style patching // capi (the C ABI) lives in lib_root.zig so the test/bake exes stay pure Zig. diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index cce74c7..2f608ad 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -221,9 +221,8 @@ fn ringsTouchRect(rings: []const []const s57.LonLat, w: f64, so: f64, e: f64, n: /// The finer-scale coverage to subtract from cell `cscl_self`'s fills in tile /// (z,x,y): the union of every strictly-finer cell's M_COVR (CATCOV=1), projected /// into the tile's i32 space and box-clipped, as a clean ring set. This is the -/// coverage-clipped best-available composite's exact subtrahend -/// (specs/cross-band-composition-redesign.md) — it replaces the point-sampled -/// whole-tile suppress_whole with true geometry, so a coarse fill is cut exactly +/// coverage-clipped best-available composite's exact subtrahend — it uses true +/// geometry rather than a point-sampled whole-tile flag, so a coarse fill is cut exactly /// where a finer cell owns the ground (no seam double-draw) and kept everywhere /// else, including inside a finer no-data hole (no blank). null = nothing finer /// covers this tile (the cell is finest here → draw everything). Allocated in `a` @@ -399,8 +398,8 @@ pub const OVERSCALE_FACTOR = 2; /// /// Baking the halved value (rather than the raw cscl quantized UP the SCAMIN /// ladder, the old behaviour) fixes the "fires early" defect: the zoom-derived -/// clause `oscl > K/2^zoom` now flips EXACTLY at 2x and never before 1x -/// (specs/overscale.md v3 defect 1). 0 when the compilation scale is unknown. +/// clause `oscl > K/2^zoom` flips EXACTLY at 2x and never before 1x. +/// Returns 0 when the compilation scale is unknown. pub fn overscaleGateDenom(cscl: i32) i64 { if (cscl <= 0) return 0; return @divTrunc(cscl, OVERSCALE_FACTOR); @@ -748,7 +747,7 @@ const TileGenCtx = struct { // composite (coverClipForCell + subtractCoverage + coveredByFiner) does it // exactly per-feature. All that remains here is the overscale scale-boundary // scan: the finest compilation scale CONTRIBUTING to this tile. - // Scale-boundary overscale refinement (specs/overscale.md): the finest + // Scale-boundary overscale refinement: the finest // compilation scale CONTRIBUTING to this tile — over the quilting's own // participant list (any overlapping cell, reach-only riders excluded), // not point-sampled coverage. A cell hatches its OVERSC01 coverage only @@ -1587,7 +1586,7 @@ test "fill-down bake: a coastal-only bay gets low-zoom tiles the overview extend try std.testing.expect(ov_t6[0] != bay_t6[0] or ov_t6[1] != bay_t6[1]); } -test "overscale: a coarse cell occluded everywhere by finer coverage emits NO hatch (specs/overscale.md v3 defect 2)" { +test "overscale: a coarse cell occluded everywhere by finer coverage emits NO hatch" { const gpa = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 2a64ff6..c70cf40 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1,9 +1,14 @@ -//! Direct S-57 -> MVT tile generation (M6c demo, BYPASSING S-101 portrayal). +//! The tile engine: S-57 features plus their S-101 portrayal instructions become +//! a tile Surface, then an MVT or MLT vector tile for a given (z, x, y). //! -//! Generates a vector tile for (z,x,y) straight from an S-57 cell with a small -//! hardcoded object-class -> S-52 color-token mapping, so the existing chart -//! style renders it. This proves cell -> MVT -> MapLibre end to end before the -//! S-101 Lua portrayal engine lands and replaces classify() with real rules. +//! A cell's features carry S-101 portrayal instructions from the Lua rules (see +//! the `portray` module). This module projects each feature's geometry to tile +//! space, resolves its layer and draw properties, and serializes the result. A +//! `classify()` fallback supplies a small object-class -> S-52 mapping for the +//! few paths that run without portrayal instructions. +//! +//! This module also hosts the banded multi-cell ENC_ROOT baker (`bake_enc`), +//! the engine's batch driver. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -22,10 +27,7 @@ pub const bake_enc = @import("bake_enc.zig"); pub const coverage = @import("coverage.zig"); // per-cell coverage sidecar (in PMTiles metadata) pub const compose = @import("compose.zig"); // per-cell-composite clip-to-owned-face + face projection -/// SCAMIN standalone (specs/scamin-standalone.md): cross-cell point-object -/// matching + SCAMIN union + scale-window eligibility for the *_scamin point/ -/// text layers. Shared by the bake pre-pass and the live per-tile dedup. -/// Output tile encoding: classic Mapbox Vector Tile, or MapLibre Tile (optional). +/// Output tile encoding: classic Mapbox Vector Tile, or MapLibre Tile. pub const TileFormat = enum { mvt, mlt }; const s101 = @import("s100").s101_instr; const catalogue = @import("s100").catalogue; @@ -317,7 +319,7 @@ fn clipSimplifyPoly(a: Allocator, proj: []const mvt.Point, box: tile.Box) ![]con return if (ring.len >= 3) ring else clipped[0..0]; } -// Coverage-clipped best-available composite (specs/cross-band-composition-redesign.md): +// Coverage-clipped best-available composite: // subtract the finer-scale coverage `clip` (already projected + box-clipped into this // tile's i32 space, a clean union) from a coarser cell's box-clipped area rings, so a // feature is emitted by exactly one cell — the finest whose M_COVR covers it. Replaces @@ -602,7 +604,7 @@ pub const CellOpts = struct { /// and emitted as the AP(OVERSC01) overscale hatch's gate (hatch shows while /// the display denominator is FINER/smaller than oscl). See emitOverscaleHatch. oscl: i64 = 0, - /// Scale-boundary overscale (specs/overscale.md, S-52 §10.1.10.2): emit this + /// Scale-boundary overscale (S-52 §10.1.10.2): emit this /// cell's AP(OVERSC01) coverage hatch into the tile. Set only when BOTH (a) a /// strictly-finer-CSCL cell also contributes to the SAME tile — a scale /// boundary exists (else whole-view overscale, the HUD "overscale ×n" readout's @@ -743,7 +745,7 @@ pub const TileSurface = struct { // The cell's quantized compilation scale: the style's overscale machinery // splits area fills into a below-the-hatch (overscaled) and an above-the- // hatch (at-scale) pass on this tag — so a finer cell's opaque fill - // occludes a coarser cell's OVERSC01 hatch (specs/overscale.md). + // occludes a coarser cell's OVERSC01 hatch. if (s.cur.oscl > 0) try props.append(s.a, .{ .key = "oscl", .value = .{ .int = s.cur.oscl } }); try appendMeta(s.a, &props, s.cur); try s.areasL().append(s.a, .{ .geom_type = .polygon, .parts = rings, .properties = props.items }); @@ -2513,7 +2515,7 @@ fn isCoverageFeature(f: s57.Feature) bool { /// CellOpts.oscl). The style shows it only while the display is grossly overscale /// (denominator FINER/smaller than oscl, i.e. X2+) and paints it ABOVE overscaled /// cells' fills but BELOW at-scale (finer) cells' fills — the occlusion trick that -/// leaves the hatch only on coarse-only patches (specs/overscale.md). +/// leaves the hatch only on coarse-only patches. fn emitOverscaleHatch(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { const geo_parts = featureParts(a, cell, geo, fi, f) catch return; if (geo_parts.len == 0) return; @@ -2852,8 +2854,8 @@ fn appendCellFeatures( if (!fopts.suppress_points and hasAdditionalInfo(f)) { try emitCentredSymbol(a, cell.*, f, fi, geo, "INFORM01", 8, 2, z, x, y, tb, fopts, surf); } - // S-52 §10.1.10.2 overscale area at a chart scale boundary - // (specs/overscale.md): a cell's M_COVR (CATCOV=1) coverage polygon rides + // S-52 §10.1.10.2 overscale area at a chart scale boundary: + // a cell's M_COVR (CATCOV=1) coverage polygon rides // the tile as an AP(OVERSC01) hatch gated on `oscl` (X2) ONLY when // overscale_hatch is set — a strictly-finer-CSCL cell rides the tile (a // scale boundary) AND this cell wins the pure quilt somewhere (it is the diff --git a/tools/s101_coverage.zig b/tools/s101_coverage.zig index dc5ae57..a27519b 100644 --- a/tools/s101_coverage.zig +++ b/tools/s101_coverage.zig @@ -1,9 +1,8 @@ -//! S-57 -> S-101 portrayal attribute-coverage check -//! (Tasks 2-3 of the conformance-testability brief; see -//! specs/conformance-testability.md). Run it: `zig build s101-coverage` +//! S-57 -> S-101 portrayal attribute-coverage check. Run it: +//! `zig build s101-coverage` //! (append `-- --json out.json` / `-- --fail-on-new tools/s101_coverage_baseline.json`). -//! The gate baseline lives at tools/s101_coverage_baseline.json (tracked; /specs/ -//! is gitignored). Regenerate it after an intended change with --write-baseline. +//! The gate baseline lives at tools/s101_coverage_baseline.json (tracked). +//! Regenerate it after an intended change with --write-baseline. //! //! 1. STATIC READ-SET (Task 2): scan the vendored S-101 Portrayal Catalogue Lua //! rules and, per feature-class rule, collect every attribute the rule reads @@ -96,8 +95,8 @@ const ADAPTER_SYNTHESIZED = [_][]const u8{ // The coverage buckets above answer "is the attribute supplied?". These answer // "is the supplied VALUE valid S-101?". A slot is at-risk when the adapter // forwards a raw S-57 value that S-65 says must be remapped, dropped, or is off -// the S-101 allowable list. Keyed by S-57 acronym/object (matching S-65 and -// specs/s57-s101-conversion-gaps.md); translated to S-101 names at run time via +// the S-101 allowable list. Keyed by S-57 acronym/object (matching S-65); +// translated to S-101 names at run time via // the catalogue alias maps, so only attributes a rule ACTUALLY reads surface. const AttrNote = struct { acr: []const u8, note: []const u8 }; From 624724d1be9da8fc860a187df0728852b6e4f6ae Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 18:03:09 -0400 Subject: [PATCH 072/140] refactor(style): rename the assets package to style The package generates MapLibre styles: color tables, line styles, the style.json layer set, and the S-52 mariner settings model and expression builders. The binary things "assets" implied (sprite/glyph/ pattern atlases) live in the sprite module, so "assets" was a misnomer; "style" names the real job. src/assets/ -> src/style/ assets.zig (barrel) -> style.zig style.zig (builder) -> maplibre.zig (the style.json assembler) chartstyle.zig (unchanged: mariner + builders) Consumers import @import("style"); the module is registered as "style". The C ABI tile57_bake_assets / tile57_assets_* names are unchanged (they emit sprite/glyph atlases, a separate concern). A few local variables that held a built style JSON string are renamed style_json, and the scene TextStyle/line-style locals and params get clearer names, to avoid shadowing the style import. Co-Authored-By: Claude Fable 5 --- bindings/parity/parity.zig | 10 ++-- bindings/shared/settings.zig | 2 +- bindings/wasm/style_wasm.zig | 8 +-- build.zig | 50 ++++++++--------- src/bundle.zig | 20 +++---- src/capi.zig | 28 +++++----- src/chart.zig | 12 ++-- src/render/resolve.zig | 16 +++--- src/root.zig | 6 +- src/scene/bake_enc.zig | 6 +- src/scene/scene.zig | 58 ++++++++++---------- src/{assets => style}/chartstyle.zig | 0 src/{assets/style.zig => style/maplibre.zig} | 11 ++-- src/{assets/assets.zig => style/style.zig} | 58 ++++++++++---------- src/tile57.zig | 4 +- tools/render.zig | 4 +- tools/style.zig | 10 ++-- 17 files changed, 151 insertions(+), 152 deletions(-) rename src/{assets => style}/chartstyle.zig (100%) rename src/{assets/style.zig => style/maplibre.zig} (99%) rename src/{assets/assets.zig => style/style.zig} (91%) diff --git a/bindings/parity/parity.zig b/bindings/parity/parity.zig index 56678ab..0fc52d1 100644 --- a/bindings/parity/parity.zig +++ b/bindings/parity/parity.zig @@ -12,8 +12,8 @@ //! out.json : output path for the generated MapLibre style.json const std = @import("std"); -const assets = @import("assets"); -const chartstyle = @import("assets").chartstyle; +const style = @import("style"); +const chartstyle = @import("style").chartstyle; const settings = @import("settings"); const template_json = @embedFile("template_json"); @@ -33,7 +33,7 @@ pub fn main(init: std.process.Init) !void { const out_path = args[3]; const m = settings.parse(a, settings_json); - const style = try assets.buildFromTemplate(a, template_json, &m, colortables_json, null, now_unix); - try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = style }); - std.debug.print("style-parity: wrote {s} ({d} bytes)\n", .{ out_path, style.len }); + const style_json = try style.buildFromTemplate(a, template_json, &m, colortables_json, null, now_unix); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = style_json }); + std.debug.print("style-parity: wrote {s} ({d} bytes)\n", .{ out_path, style_json.len }); } diff --git a/bindings/shared/settings.zig b/bindings/shared/settings.zig index 472dd9e..21cf18d 100644 --- a/bindings/shared/settings.zig +++ b/bindings/shared/settings.zig @@ -10,7 +10,7 @@ //! default, so a partial (or empty, or invalid) blob still yields a usable style. const std = @import("std"); -const chartstyle = @import("assets").chartstyle; +const chartstyle = @import("style").chartstyle; pub const MarinerSettings = chartstyle.MarinerSettings; diff --git a/bindings/wasm/style_wasm.zig b/bindings/wasm/style_wasm.zig index d6f73f0..ba491d1 100644 --- a/bindings/wasm/style_wasm.zig +++ b/bindings/wasm/style_wasm.zig @@ -4,7 +4,7 @@ //! //! The MapLibre style *template* and the S-52 *colortables* are @embedFile'd at //! build time (generated once by `tile57 style` / `tile57 assets`; see -//! bindings/scripts/gen-assets.sh), so the wasm needs NO external file inputs. +//! bindings/scripts/gen-style.sh), so the wasm needs NO external file inputs. //! The JS host only passes the mariner settings as a small JSON blob. //! //! ABI — every pointer is a byte OFFSET (usize) into the wasm linear memory, so @@ -21,8 +21,8 @@ //! style_free(result_ptr, result_len). const std = @import("std"); -const assets = @import("assets"); -const chartstyle = @import("assets").chartstyle; +const style = @import("style"); +const chartstyle = @import("style").chartstyle; const settings = @import("settings"); // Base MapLibre style template + S-52 colortables, embedded at build time. @@ -86,7 +86,7 @@ export fn style_build(settings_ptr: usize, settings_len: usize, now_unix: f64) i }; const m = settings.parse(arena.allocator(), settings_bytes); const now: i64 = @intFromFloat(now_unix); - const out = assets.buildFromTemplate(gpa, template_json, &m, colortables_json, null, now) catch return 0; + const out = style.buildFromTemplate(gpa, template_json, &m, colortables_json, null, now) catch return 0; g_result_ptr = @intFromPtr(out.ptr); g_result_len = out.len; return 1; diff --git a/build.zig b/build.zig index 7cc1254..05eba64 100644 --- a/build.zig +++ b/build.zig @@ -191,9 +191,9 @@ pub fn build(b: *std.Build) void { // Render engine (src/render/): the semantic Surface contract + noop // surface, the resolver (colors at palette, display gates), and the pixel // machinery (Canvas primitive seam, RasterCanvas, PNG encoder, PixelSurface). - // One pure module; imports tiles (TilePoint alias) + assets (settings model) - // only — never s57/s100/portray. NOTE: declared before assets_mod exists, - // so that edge is attached right after assets_mod below. + // One pure module; imports tiles (TilePoint alias) + style (settings model) + // only — never s57/s100/portray. NOTE: declared before style_mod exists, + // so that edge is attached right after style_mod below. const render_mod = b.addModule("render", .{ .root_source_file = b.path("src/render/render.zig"), .imports = &.{.{ .name = "tiles", .module = tiles_mod }}, @@ -247,18 +247,18 @@ pub fn build(b: *std.Build) void { // S-57 cells with no on-disk catalogue. An explicit rules dir still overrides. portray_mod.addImport("rules_registry", embedDir(b, "rules_registry", PORTRAYAL_CATALOG ++ "/Rules", ".lua")); - // Asset/style generation for the chart bundle (src/assets/): colortables, - // manifest, style.json + the S-52 mariner expression builders - // (chartstyle.zig, a Zig port of the web client's s52-style.mjs builders — - // folded in; consumed by the style builder, the C ABI, and the render - // resolver's settings model). Pure + target-agnostic. - const assets_mod = b.addModule("assets", .{ - .root_source_file = b.path("src/assets/assets.zig"), + // MapLibre style generation (src/style/): color tables, line styles, the + // style.json layer set (maplibre.zig), and the S-52 mariner settings model + + // expression builders (chartstyle.zig, a Zig port of the web client's + // s52-style.mjs builders). Consumed by the C ABI, the CLI, and the render + // resolver's settings model. Pure + target-agnostic. + const style_mod = b.addModule("style", .{ + .root_source_file = b.path("src/style/style.zig"), }); - // The render module's settings-model edge (declared above assets_mod). - render_mod.addImport("assets", assets_mod); + // The render module's settings-model edge (declared above style_mod). + render_mod.addImport("style", style_mod); // The scene module's complex-linestyle XML analysis (also declared above). - scene_mod.addImport("assets", assets_mod); + scene_mod.addImport("style", style_mod); // S-101 sprite/pattern atlas builder (nanosvg + stb PNG). libc (the C libs), // target-less so it inherits the consumer's target (only the bake tool, which @@ -280,7 +280,7 @@ pub fn build(b: *std.Build) void { .{ .name = "tiles", .module = tiles_mod }, .{ .name = "scene", .module = scene_mod }, .{ .name = "render", .module = render_mod }, - .{ .name = "assets", .module = assets_mod }, + .{ .name = "style", .module = style_mod }, .{ .name = "geo", .module = geo_mod }, }; @@ -323,7 +323,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, .imports = &.{ .{ .name = "engine", .module = engine_full }, - .{ .name = "assets", .module = assets_mod }, + .{ .name = "style", .module = style_mod }, .{ .name = "sprite", .module = sprite_mod }, .{ .name = "catalog", .module = catalog_embed }, }, @@ -432,7 +432,7 @@ pub fn build(b: *std.Build) void { .{ .name = "catalog", .module = catalog_embed }, }, }); - chart_mod.addImport("assets", assets_mod); // linestyle XML analysis + chart_mod.addImport("style", style_mod); // linestyle XML analysis const bake_target = if (target.result.os.tag == .linux and target.result.abi != .musl) b.resolveTargetQuery(.{ .cpu_arch = target.result.cpu.arch, .os_tag = .linux, .abi = .musl }) @@ -448,7 +448,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, .imports = &.{ .{ .name = "engine", .module = engine_full }, - .{ .name = "assets", .module = assets_mod }, + .{ .name = "style", .module = style_mod }, .{ .name = "sprite", .module = sprite_mod }, .{ .name = "catalog", .module = catalog_embed }, .{ .name = "bundle", .module = bundle_mod }, @@ -490,7 +490,7 @@ pub fn build(b: *std.Build) void { // build test` is unaffected; `zig build wasm` builds the wasm. const style_settings_mod = b.addModule("style_settings", .{ .root_source_file = b.path("bindings/shared/settings.zig"), - .imports = &.{.{ .name = "assets", .module = assets_mod }}, + .imports = &.{.{ .name = "style", .module = style_mod }}, }); // Attach the embedded template + colortables to a bindings consumer module. @@ -507,7 +507,7 @@ pub fn build(b: *std.Build) void { .target = wasm_target, .optimize = .ReleaseSmall, // smallest wasm; this isn't a hot path .imports = &.{ - .{ .name = "assets", .module = assets_mod }, + .{ .name = "style", .module = style_mod }, .{ .name = "settings", .module = style_settings_mod }, }, }); @@ -526,7 +526,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, .imports = &.{ - .{ .name = "assets", .module = assets_mod }, + .{ .name = "style", .module = style_mod }, .{ .name = "settings", .module = style_settings_mod }, }, }); @@ -564,10 +564,10 @@ pub fn build(b: *std.Build) void { .{ .name = "s100", .module = s100_mod }, .{ .name = "tiles", .module = tiles_mod }, .{ .name = "render", .module = render_mod }, - .{ .name = "assets", .module = assets_mod }, + .{ .name = "style", .module = style_mod }, .{ .name = "geo", .module = geo_mod }, }); - _ = addPkgTest(b, test_step, "src/assets/assets.zig", target, optimize, &.{}); + _ = addPkgTest(b, test_step, "src/style/style.zig", target, optimize, &.{}); // Geometry core for the cross-band composition redesign (pure, std-only). _ = addPkgTest(b, test_step, "src/geo/geo.zig", target, optimize, &.{}); // De-risk probe for the per-cell composite ownership partition: runs the E7 @@ -598,7 +598,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, .imports = &.{ .{ .name = "engine", .module = engine_full }, - .{ .name = "assets", .module = assets_mod }, + .{ .name = "style", .module = style_mod }, .{ .name = "sprite", .module = sprite_mod }, .{ .name = "catalog", .module = catalog_embed }, }, @@ -616,7 +616,7 @@ pub fn build(b: *std.Build) void { // PixelSurface. const render_test = addPkgTest(b, test_step, "src/render/render.zig", target, optimize, &.{ .{ .name = "tiles", .module = tiles_mod }, - .{ .name = "assets", .module = assets_mod }, + .{ .name = "style", .module = style_mod }, }); addFont(b, render_test); // Golden portrayal-instruction test (assertion #5): drives the real embedded Lua @@ -660,7 +660,7 @@ pub fn build(b: *std.Build) void { }); // bindings/ shared settings parser (used by the wasm engine + parity oracle). _ = addPkgTest(b, test_step, "bindings/shared/settings.zig", target, optimize, &.{ - .{ .name = "assets", .module = assets_mod }, + .{ .name = "style", .module = style_mod }, }); // The public root (src/tile57.zig) is compile-checked via lib_root.zig in the // libtile57.a build — it imports source.zig (Lua/libc), so it can't be a pure diff --git a/src/bundle.zig b/src/bundle.zig index a794da4..dab2910 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -2,7 +2,7 @@ //! emission (colortables / linestyles / sprite / patterns / sprite-mln) built from //! the embedded catalogue (or an on-disk PortrayalCatalog dir). Moved out of the //! `tile57` CLI so the CLI is a thin wrapper and any consumer (the C ABI, a Go/JS -//! binding, another UI) can build the same assets. Alongside the asset emitters it +//! binding, another UI) can build the same style. Alongside the asset emitters it //! holds the per-cell composite: the ownership partition + the on-demand tile //! compositor (composeTile / ComposeSource) that serves a live map from per-cell //! archives. Pure-Zig + libc (the sprite atlas builder needs nanosvg/stb). @@ -15,7 +15,7 @@ const std = @import("std"); const engine = @import("engine"); -const assets = @import("assets"); +const style = @import("style"); const sprite = @import("sprite"); const embedded_assets = @import("catalog"); // S-101 portrayal assets embedded into the binary @@ -43,8 +43,8 @@ fn embeddedFills(a: std.mem.Allocator) ![]sprite.AreaFillSrc { } // Embedded LineStyles/*.xml as line-style sources (id = file stem). -pub fn embeddedLinestyles(a: std.mem.Allocator) ![]assets.LineStyleSrc { - const out = try a.alloc(assets.LineStyleSrc, embedded_assets.linestyles.len); +pub fn embeddedLinestyles(a: std.mem.Allocator) ![]style.LineStyleSrc { + const out = try a.alloc(style.LineStyleSrc, embedded_assets.linestyles.len); for (embedded_assets.linestyles, 0..) |e, i| out[i] = .{ .id = e.name, .xml = e.bytes }; return out; } @@ -54,10 +54,10 @@ pub fn embeddedLinestyles(a: std.mem.Allocator) ![]assets.LineStyleSrc { // real dash runs + embedded symbols instead of a generic dashed stroke. Mirrors Go // lsInfoFromCatalog; mm geometry is converted at the PresLib feature scale. Best // effort — a malformed/short style is simply skipped. `gpa` outlives the bake. -pub fn registerLinestyles(gpa: std.mem.Allocator, srcs: []const assets.LineStyleSrc) void { +pub fn registerLinestyles(gpa: std.mem.Allocator, srcs: []const style.LineStyleSrc) void { const px = engine.scene.LINESTYLE_PX_PER_MM; for (srcs) |s| { - const parsed = assets.parseLineStyle(gpa, s.xml) catch continue; + const parsed = style.parseLineStyle(gpa, s.xml) catch continue; const period = parsed.interval_length * px; if (period < 0.5) continue; // no interval to tile (pure-symbol style) var runs = std.ArrayList([2]f64).empty; @@ -113,7 +113,7 @@ pub fn colorTablesBytes(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u const xml_path = try std.fs.path.join(a, &.{ catalog_dir, COLOR_PROFILE_REL }); break :blk try std.Io.Dir.cwd().readFileAlloc(io, xml_path, a, .unlimited); }; - return assets.colorTablesJson(a, xml); + return style.colorTablesJson(a, xml); } pub fn emitColorTables(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8, out_path: []const u8) ![]u8 { @@ -126,12 +126,12 @@ pub fn emitColorTables(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8 // patterns + placed symbols). Returns the bytes (arena owned). Shared by the // assets + bundle paths. Mirrors the Go oracle's EmitS101 linestyles step. pub fn linestylesBytes(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8) ![]u8 { - if (catalog_dir.len == 0) return assets.linestylesJson(a, try embeddedLinestyles(a)); + if (catalog_dir.len == 0) return style.linestylesJson(a, try embeddedLinestyles(a)); const ls_dir_path = try std.fs.path.join(a, &.{ catalog_dir, LINESTYLES_REL }); var dir = try std.Io.Dir.cwd().openDir(io, ls_dir_path, .{ .iterate = true }); defer dir.close(io); - var srcs = std.ArrayList(assets.LineStyleSrc).empty; + var srcs = std.ArrayList(style.LineStyleSrc).empty; var walker = try dir.walk(a); defer walker.deinit(); while (try walker.next(io)) |entry| { @@ -142,7 +142,7 @@ pub fn linestylesBytes(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8 const id = std.fs.path.stem(std.fs.path.basename(path)); try srcs.append(a, .{ .id = id, .xml = xml }); } - return assets.linestylesJson(a, srcs.items); + return style.linestylesJson(a, srcs.items); } pub fn emitLinestyles(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8, out_path: []const u8) ![]u8 { diff --git a/src/capi.zig b/src/capi.zig index 9bfa60d..a6f9bf7 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -8,8 +8,8 @@ const std = @import("std"); const chart = @import("chart.zig"); const s57 = @import("s57"); const bundle = @import("bundle"); // the whole chart-bundle pipeline (tiles + assets + manifest) -const chartstyle = @import("assets").chartstyle; -const assets = @import("assets"); +const chartstyle = @import("style").chartstyle; +const style = @import("style"); // The S-52 ColorProfiles/colorProfile.xml baked into the library (build.zig), so // the style C ABI generates colortables + a base style template with no on-disk // catalogue. Symbols/linestyles are NOT embedded here (only the bake exe needs them). @@ -663,7 +663,7 @@ fn embeddedColorProfileXml() ?[]const u8 { /// 1=ok + out/out_len (free with tile57_free), 0=error. export fn tile57_colortables_default(out: *[*]u8, out_len: *usize) callconv(.c) c_int { const xml = embeddedColorProfileXml() orelse return 0; - const json = assets.colorTablesJson(gpa, xml) catch return 0; + const json = style.colorTablesJson(gpa, xml) catch return 0; out.* = json.ptr; out_len.* = json.len; return 1; @@ -942,9 +942,9 @@ export fn tile57_build_style( // Single style builder: regenerate the full style with the mariner baked in // (chartstyle.buildStyle's template-patch pass is retired). buildFromTemplate lifts // the source config out of the passed template and drives the one styleJson. - const style = assets.buildFromTemplateScamin(gpa, tmpl, &m, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; - out.* = style.ptr; - out_len.* = style.len; + const style_json = style.buildFromTemplateScamin(gpa, tmpl, &m, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; + out.* = style_json.ptr; + out_len.* = style_json.len; return 1; } @@ -981,12 +981,12 @@ export fn tile57_style_diff( // on both sides — otherwise a clock tick could show as a spurious date-filter op. const now_unix: i64 = @intCast(time(null)); - const old_style = assets.buildFromTemplateScamin(gpa, tmpl, &om, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; + const old_style = style.buildFromTemplateScamin(gpa, tmpl, &om, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; defer gpa.free(old_style); - const new_style = assets.buildFromTemplateScamin(gpa, tmpl, &nm, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; + const new_style = style.buildFromTemplateScamin(gpa, tmpl, &nm, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; defer gpa.free(new_style); - const ops = assets.styleDiff(gpa, old_style, new_style) catch return 0; + const ops = style.styleDiff(gpa, old_style, new_style) catch return 0; out.* = ops.ptr; out_len.* = ops.len; return 1; @@ -1018,9 +1018,9 @@ export fn tile57_style_template( out_len: *usize, ) callconv(.c) c_int { const xml = embeddedColorProfileXml() orelse return 0; - const cts = assets.colorTablesJson(gpa, xml) catch return 0; + const cts = style.colorTablesJson(gpa, xml) catch return 0; defer gpa.free(cts); - var opts = assets.StyleOpts{ + var opts = style.StyleOpts{ .scheme = switch (scheme) { 1 => "dusk", 2 => "night", @@ -1034,9 +1034,9 @@ export fn tile57_style_template( opts.minzoom = minzoom; if (maxzoom != 0) opts.maxzoom = maxzoom; if (tile_encoding == TILE_TYPE_MLT) opts.encoding = "mlt"; - const style = assets.styleJson(gpa, opts) catch return 0; - out.* = style.ptr; - out_len.* = style.len; + const style_json = style.styleJson(gpa, opts) catch return 0; + out.* = style_json.ptr; + out_len.* = style_json.len; return 1; } diff --git a/src/chart.zig b/src/chart.zig index 8b5ab2d..30ef884 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -28,7 +28,7 @@ const tile = @import("tiles").tile; const render = @import("render"); const sprite = @import("sprite"); const embedded_assets = @import("catalog"); // S-101 portrayal assets (renderView store) -const assets = @import("assets"); // displayDenomZ (the physical display-scale formula) +const style = @import("style"); // displayDenomZ (the physical display-scale formula) // smp_allocator (Zig's fast thread-safe GPA), not page_allocator: the engine // makes many small, short-lived allocations (tile cache, cell dupes, index @@ -860,7 +860,7 @@ pub fn cellCoverageFromArchive(a: std.mem.Allocator, archive: []const u8) !?scen /// thread-local, and these two globals are already populated so nobody writes them). pub fn warmup() void { catalogue.warmUp(); - var ls_srcs = std.ArrayList(assets.LineStyleSrc).empty; + var ls_srcs = std.ArrayList(style.LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; scene.registerLinestylesXml(gpa, ls_srcs.items); @@ -1283,7 +1283,7 @@ pub const Chart = struct { // Complex-linestyle table (idempotent): without it MARSYS51/cable/ // pipeline styles degrade to generic dashed strokes. gpa-backed: the // registry outlives this render (shared with any later bake). - var ls_srcs = std.ArrayList(@import("assets").LineStyleSrc).empty; + var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; scene.registerLinestylesXml(gpa, ls_srcs.items); @@ -1358,7 +1358,7 @@ pub const Chart = struct { const store = try sprite.CatalogStore.init(a, sym_srcs, fill_srcs, css_data); defer store.deinit(); - var ls_srcs = std.ArrayList(@import("assets").LineStyleSrc).empty; + var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; scene.registerLinestylesXml(gpa, ls_srcs.items); @@ -1727,7 +1727,7 @@ pub fn renderFeature( defer store.deinit(); // Complex-linestyle table (idempotent), same as renderView. - var ls_srcs = std.ArrayList(@import("assets").LineStyleSrc).empty; + var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; scene.registerLinestylesXml(gpa, ls_srcs.items); @@ -1804,7 +1804,7 @@ pub fn renderCellView( defer store.deinit(); // Complex-linestyle table (idempotent), same as renderView / renderFeature. - var ls_srcs = std.ArrayList(@import("assets").LineStyleSrc).empty; + var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; scene.registerLinestylesXml(gpa, ls_srcs.items); diff --git a/src/render/resolve.zig b/src/render/resolve.zig index 6cf3724..25bb953 100644 --- a/src/render/resolve.zig +++ b/src/render/resolve.zig @@ -4,7 +4,7 @@ //! SCAMIN) evaluated at the scene zoom. //! //! Tile surfaces (MVT/MLT) never resolve: they serialize tokens/names verbatim -//! and the MapLibre client resolves live (assets/colortables.json + the +//! and the MapLibre client resolves live (the color tables + the //! chartstyle expressions). The resolver mirrors those exact semantics so the //! two styling paths can't silently drift; each gate cites the expression it //! mirrors. @@ -12,7 +12,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const rs = @import("surface.zig"); -const chartstyle = @import("assets").chartstyle; +const chartstyle = @import("style").chartstyle; pub const MarinerSettings = chartstyle.MarinerSettings; @@ -24,8 +24,8 @@ pub const PaletteId = enum(u2) { day, dusk, night }; pub const Rgb = struct { r: u8, g: u8, b: u8 }; /// Token -> RGB for all three palettes, parsed once from colorProfile.xml — -/// the same source assets.colorTablesJson serializes for the MapLibre client -/// (parse mirrored from src/assets/assets.zig; keep in sync). Token keys are +/// the same source style.colorTablesJson serializes for the MapLibre client +/// (parse mirrored from src/style/style.zig; keep in sync). Token keys are /// slices INTO `xml`, so the xml must outlive the Colors (the embedded /// catalogue profile is static, so this is free in practice). pub const Colors = struct { @@ -57,7 +57,7 @@ pub const Colors = struct { // Read the decimal byte inside the first NNN within `s` — the // // children of an 's block (unambiguous: the -// sibling block carries //). Mirrors assets.zig tagByte. +// sibling block carries //). Mirrors style.zig tagByte. fn tagByte(s: []const u8, comptime tag: []const u8) ?u8 { const open = "<" ++ tag ++ ">"; const i = std.mem.indexOf(u8, s, open) orelse return null; @@ -117,11 +117,11 @@ pub fn categoryVisible(cat: ?i64, class: []const u8, symbol_name: ?[]const u8, m } /// 1:N scale denominator of the whole world in one 256px tile at z0 — the -/// constant the style's SCAMIN gate divides by (assets/style.zig SCAMIN_GATE). +/// constant the style's SCAMIN gate divides by (style/maplibre.zig SCAMIN_GATE). pub const DENOM_Z0 = 279541132.0; /// SCAMIN gate at a (fractional) display zoom — mirrors the style expression -/// `zoom >= log2(DENOM_Z0 / scamin)` (assets/style.zig SCAMIN_GATE). A feature +/// `zoom >= log2(DENOM_Z0 / scamin)` (style/maplibre.zig SCAMIN_GATE). A feature /// without SCAMIN (null) always shows. pub fn scaminVisible(scamin: ?i64, zoom: f64) bool { const s = scamin orelse return true; @@ -134,7 +134,7 @@ pub fn scaminVisible(scamin: ?i64, zoom: f64) bool { /// i.e. zoom > log2(DENOM_Z0 / oscl). `oscl` is the baked X2 gate denominator /// (bake_enc.overscaleGateDenom = cscl/OVERSCALE_FACTOR), so this fires from X2 and /// NEVER before 1x compilation scale. The style clause is a strict `>` (oscl > -/// DENOM); oscl 0 (unknown) never shows. Mirrors assets/style.zig writeOsclClause. +/// DENOM); oscl 0 (unknown) never shows. Mirrors style/maplibre.zig writeOsclClause. pub fn osclVisible(oscl: i64, zoom: f64) bool { if (oscl <= 0) return false; return zoom > std.math.log2(DENOM_Z0 / @as(f64, @floatFromInt(oscl))); diff --git a/src/root.zig b/src/root.zig index 4c7e654..922f8d4 100644 --- a/src/root.zig +++ b/src/root.zig @@ -21,8 +21,8 @@ pub const s101_instr = s100.s101_instr; pub const s101_adapt = s100.s101_adapt; pub const catalogue = s100.catalogue; pub const bake_enc = @import("scene").bake_enc; // banded multi-cell ENC_ROOT -> PMTiles -pub const assets = @import("assets"); // colortables, line styles, and style.json generation -pub const chartstyle = @import("assets").chartstyle; // mariner-driven MapLibre style patching +pub const style = @import("style"); // colortables, line styles, and style.json generation +pub const chartstyle = @import("style").chartstyle; // mariner-driven MapLibre style patching // capi (the C ABI) lives in lib_root.zig so the test/bake exes stay pure Zig. test { @@ -37,7 +37,7 @@ test { _ = s101_adapt; _ = catalogue; _ = bake_enc; - _ = assets; + _ = style; _ = chartstyle; _ = @import("mvt_parity_test.zig"); } diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index 2f608ad..7f6787f 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -22,7 +22,7 @@ const s57 = @import("s57"); const scene = @import("scene.zig"); const pmtiles = @import("tiles").pmtiles; const tile = @import("tiles").tile; -const assets = @import("assets"); // displayDenomZ: the physical display-scale formula +const style = @import("style"); // displayDenomZ: the physical display-scale formula const geometry = @import("geo"); // Martinez boolean for the coverage-clipped composite /// A parsed + portrayed cell ready to bake. `portrayal` is the per-feature S-101 @@ -407,7 +407,7 @@ pub fn overscaleGateDenom(cscl: i32) i64 { /// The effScamin floor for a cell (spec §4): the display denominator of the /// cell's band-floor zoom under the client's static gate constant -/// (assets.scaminGateK at the equator — the largest K over all latitudes, so +/// (style.scaminGateK at the equator — the largest K over all latitudes, so /// the clamp holds everywhere). A feature's emitted `scamin` is floored here so /// an aggressive SCAMIN cannot blank a point between the cell's floor tier and /// its own activation zoom — the finer cell owns that ground geometrically, so @@ -422,7 +422,7 @@ pub fn effScaminFloor(cscl: i32) i64 { // window can open — leave its SCAMINs raw (a z0 clamp would disable // decluttering at world view entirely). if (floor_z == 0) return 0; - const k = assets.scaminGateK(0); + const k = style.scaminGateK(0); return @intFromFloat(@ceil(k / @as(f64, @floatFromInt(@as(u64, 1) << @intCast(floor_z))))); } diff --git a/src/scene/scene.zig b/src/scene/scene.zig index c70cf40..8bb8755 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -17,7 +17,7 @@ const tile = @import("tiles").tile; const mvt = @import("tiles").mvt; const mlt = @import("tiles").mlt; const render = @import("render"); -const assets = @import("assets"); +const style = @import("style"); const geometry = @import("geo"); // Martinez boolean; `geo` is a common param name here const rs = render.surface; @@ -655,7 +655,7 @@ pub const TileSurface = struct { texts: std.ArrayList(mvt.Feature) = .empty, // SCAMIN buckets: a feature carrying SCAMIN (s57 attr 133) routes here instead // of the base list, and carries a `scamin` property so the style gates its - // display below the feature's 1:N scale (see ATTR_SCAMIN / assets/style.zig). + // display below the feature's 1:N scale (see ATTR_SCAMIN / style/maplibre.zig). areas_scamin: std.ArrayList(mvt.Feature) = .empty, area_patterns_scamin: std.ArrayList(mvt.Feature) = .empty, lines_scamin: std.ArrayList(mvt.Feature) = .empty, @@ -780,12 +780,12 @@ pub const TileSurface = struct { /// Store one clipped complex-linestyle run un-tessellated (display-independent /// bake): a `lines` feature tagged with `ls_style`/`ls_arc0` so replayTile /// re-looks-up the LsInfo and re-walks the period display-scaled at render time. - fn storeComplexRun(ctx: *anyopaque, style: []const u8, color: rs.ColorToken, width_px: f64, arc0: f64, run: []const rs.TilePoint) anyerror!void { + fn storeComplexRun(ctx: *anyopaque, line_style: []const u8, color: rs.ColorToken, width_px: f64, arc0: f64, run: []const rs.TilePoint) anyerror!void { const s = sp(ctx); var props = std.ArrayList(mvt.Prop).empty; try props.append(s.a, .{ .key = "color_token", .value = .{ .string = color } }); try props.append(s.a, .{ .key = "width_px", .value = .{ .double = width_px } }); - try props.append(s.a, .{ .key = "ls_style", .value = .{ .string = style } }); + try props.append(s.a, .{ .key = "ls_style", .value = .{ .string = line_style } }); try props.append(s.a, .{ .key = "ls_arc0", .value = .{ .double = arc0 } }); try appendMeta(s.a, &props, s.cur); const parts = try s.a.alloc([]const mvt.Point, 1); @@ -838,10 +838,10 @@ pub const TileSurface = struct { try s.soundings.append(s.a, .{ .geom_type = .point, .parts = parts, .properties = props.items }); } - fn drawText(ctx: *anyopaque, text: []const u8, style: *const rs.TextStyle, at: rs.TilePoint) anyerror!void { + fn drawText(ctx: *anyopaque, text: []const u8, text_style: *const rs.TextStyle, at: rs.TilePoint) anyerror!void { const s = sp(ctx); var props = std.ArrayList(mvt.Prop).empty; - try appendTextProps(s.a, &props, text, style); // text already shortened by engine + try appendTextProps(s.a, &props, text, text_style); // text already shortened by engine try appendMeta(s.a, &props, s.cur); const parts = try s.a.alloc([]const mvt.Point, 1); const single = try s.a.alloc(mvt.Point, 1); @@ -1111,16 +1111,16 @@ fn shortenName(a: Allocator, text: []const u8) ![]const u8 { /// Serialize a text label's props in the tile schema order. `text` arrives already /// shortened/resolved by the engine. A minimal label (empty halign — see /// rs.TextStyle) carries only text/color/size, as the native fallbacks always did. -fn appendTextProps(a: Allocator, props: *std.ArrayList(mvt.Prop), text: []const u8, style: *const rs.TextStyle) !void { +fn appendTextProps(a: Allocator, props: *std.ArrayList(mvt.Prop), text: []const u8, text_style: *const rs.TextStyle) !void { // Resolved body size: the FontSize modifier px, or 12 (oracle default). Drives // both the emitted font_size_px and the halo gate below. - const font_px: f64 = if (style.font_size > 0) style.font_size else 12; + const font_px: f64 = if (text_style.font_size > 0) text_style.font_size else 12; try props.append(a, .{ .key = "text", .value = .{ .string = text } }); - try props.append(a, .{ .key = "color_token", .value = .{ .string = style.color } }); + try props.append(a, .{ .key = "color_token", .value = .{ .string = text_style.color } }); try props.append(a, .{ .key = "font_size_px", .value = .{ .double = font_px } }); - if (style.halign.len == 0) return; // minimal label: no alignment/halo/group spec - try props.append(a, .{ .key = "halign", .value = .{ .string = style.halign } }); - try props.append(a, .{ .key = "valign", .value = .{ .string = style.valign } }); + if (text_style.halign.len == 0) return; // minimal label: no alignment/halo/group spec + try props.append(a, .{ .key = "halign", .value = .{ .string = text_style.halign } }); + try props.append(a, .{ .key = "valign", .value = .{ .string = text_style.valign } }); // S-101 LocalOffset -> label-offset key in text-body units (3.51 mm = one text // body height = 1 em): the style's text-offset match keys on "ux,uy" to shift a // name clear of its symbol (PortrayFeatureName emits 0,-3.51 = one body up). @@ -1128,21 +1128,21 @@ fn appendTextProps(a: Allocator, props: *std.ArrayList(mvt.Prop), text: []const // right / +y down, which matches MapLibre's text-offset. { const TEXT_BODY_MM = 3.51; - const ux: i64 = @intFromFloat(@round(style.offset_x / TEXT_BODY_MM)); - const uy: i64 = @intFromFloat(@round(style.offset_y / TEXT_BODY_MM)); + const ux: i64 = @intFromFloat(@round(text_style.offset_x / TEXT_BODY_MM)); + const uy: i64 = @intFromFloat(@round(text_style.offset_y / TEXT_BODY_MM)); if (ux != 0 or uy != 0) try props.append(a, .{ .key = "loff", .value = .{ .string = try std.fmt.allocPrint(a, "{d},{d}", .{ ux, uy }) } }); } // §10 text halo: the oracle attaches a CHWHT, 1px halo to text >= 10 px // (s101emit.go:130-133) and emits it as halo_color_token / halo_width tile // properties (bake.go:1147-1148); smaller text gets no halo ("" / 0). Our served - // style still renders text solid (no halo) by deliberate S-52 choice (style.zig + // style still renders text solid (no halo) by deliberate S-52 choice (maplibre.zig // textPaint passes halo_width 0) — these are tile-parity properties for a client // that wants the legibility halo. const haloed = font_px >= 10; try props.append(a, .{ .key = "halo_color_token", .value = .{ .string = if (haloed) "CHWHT" else "" } }); try props.append(a, .{ .key = "halo_width", .value = .{ .double = if (haloed) 1 else 0 } }); - try props.append(a, .{ .key = "tgrp", .value = .{ .int = style.group } }); + try props.append(a, .{ .key = "tgrp", .value = .{ .int = text_style.group } }); } /// JSON-escape `s` (surrounding quotes included) into `buf`: escapes ", \, and the @@ -1693,7 +1693,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, try surf.endFeature(); } for (p.texts) |t| { - const style = rs.TextStyle{ + const ts = rs.TextStyle{ .color = t.color, .font_size = t.font_size, .halign = t.halign, @@ -1703,7 +1703,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, .group = t.group, }; try surf.beginFeature(&fmeta); - try surf.drawText(try expandSeabedText(a, fmeta.class, try shortenName(a, t.text)), &style, pt); + try surf.drawText(try expandSeabedText(a, fmeta.class, try shortenName(a, t.text)), &ts, pt); try surf.endFeature(); } } @@ -1829,7 +1829,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, if (rp.lon() >= tb[0] and rp.lon() <= tb[2] and rp.lat() >= tb[1] and rp.lat() <= tb[3]) { const cpt = tile.project(rp.lon(), rp.lat(), z, x, y, tile.EXTENT); for (p.texts) |t| { - const style = rs.TextStyle{ + const ts = rs.TextStyle{ .color = t.color, .font_size = t.font_size, .halign = t.halign, @@ -1839,7 +1839,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, .group = t.group, }; try surf.beginFeature(&fmeta); - try surf.drawText(try expandSeabedText(a, fmeta.class, try shortenName(a, t.text)), &style, cpt); + try surf.drawText(try expandSeabedText(a, fmeta.class, try shortenName(a, t.text)), &ts, cpt); try surf.endFeature(); } } @@ -2160,14 +2160,14 @@ fn buildSyminsPortrayal(a: Allocator, f: s57.Feature) !?s101.Portrayal { // tessellated per zoom: walk the line by arc length and emit, per period, the dash // "on" runs as line segments + each embedded symbol as a point rotated to the local // tangent. Mirrors Go bake/complexline.go + linestyle_catalog.go. The mm geometry is -// parsed by assets.parseLineStyle; the baker converts it to LsInfo at the PresLib +// parsed by style.parseLineStyle; the baker converts it to LsInfo at the PresLib // FEATURE scale (ls_px_per_mm) and registers it before baking. const ls_feature_scale: f64 = 0.01 / 0.35278; // px per 0.01-mm PresLib unit (= SYMBOL_SCALE) const ls_px_per_mm: f64 = 100.0 * ls_feature_scale; // mm -> screen px /// The mm->px feature scale the baker must apply when building an LsInfo from the raw -/// millimetre LineStyles geometry (assets.parseLineStyle), so the tessellator and the -/// table agree. Differs from the symbol-scale assets.analysePattern uses for the +/// millimetre LineStyles geometry (style.parseLineStyle), so the tessellator and the +/// table agree. Differs from the symbol-scale style.analysePattern uses for the /// client linestyles.json. pub const LINESTYLE_PX_PER_MM = ls_px_per_mm; @@ -2204,11 +2204,11 @@ pub fn clearLinestyles(gpa: Allocator) void { /// (MARSYS51, cables, pipelines, …) to generic dashed strokes. Mirrors the /// Go lsInfoFromCatalog: mm geometry at the PresLib feature scale, S-52 /// minimum pen width. `gpa` + `srcs` must outlive all tile generation. -pub fn registerLinestylesXml(gpa: Allocator, srcs: []const assets.LineStyleSrc) void { +pub fn registerLinestylesXml(gpa: Allocator, srcs: []const style.LineStyleSrc) void { if (g_linestyles.count() > 0) return; const px = LINESTYLE_PX_PER_MM; for (srcs) |s| { - const parsed = assets.parseLineStyle(gpa, s.xml) catch continue; + const parsed = style.parseLineStyle(gpa, s.xml) catch continue; const period = parsed.interval_length * px; if (period < 0.5) continue; // no interval to tile (pure-symbol style) var runs = std.ArrayList([2]f64).empty; @@ -2332,7 +2332,7 @@ fn walkComplexRun(a: Allocator, rp: []const tile.FPoint, arc0: f64, info: LsInfo /// `style` is the linestyle id: at BAKE we store each clipped run un-tessellated /// (tagged with the id) so replay can re-walk the period display-scaled; on a live /// render surface we walk it here at that surface's size_scale. -fn emitComplexLine(a: Allocator, parts: []const []s57.LonLat, info: LsInfo, style: []const u8, color: []const u8, emit_symbols: bool, z: u8, x: u32, y: u32, box: tile.Box, fmeta: *const rs.FeatureMeta, surf: rs.Surface) !void { +fn emitComplexLine(a: Allocator, parts: []const []s57.LonLat, info: LsInfo, line_style: []const u8, color: []const u8, emit_symbols: bool, z: u8, x: u32, y: u32, box: tile.Box, fmeta: *const rs.FeatureMeta, surf: rs.Surface) !void { const store = surf.canStoreComplexRun(); const ss = surf.sizeScale(); try surf.beginFeature(fmeta); @@ -2349,7 +2349,7 @@ fn emitComplexLine(a: Allocator, parts: []const []s57.LonLat, info: LsInfo, styl // BAKE: store the clipped run un-tessellated (display-independent). const qpts = try a.alloc(mvt.Point, run.points.len); for (run.points, 0..) |p, i| qpts[i] = tile.quantizeF(p); - try surf.storeComplexRun(style, color, info.width_px, run.arc0, qpts); + try surf.storeComplexRun(line_style, color, info.width_px, run.arc0, qpts); } else { // LIVE / CLI render: walk the period at this surface's display scale. try walkComplexRun(a, run.points, run.arc0, info, color, ss, emit_symbols, surf); @@ -3714,7 +3714,7 @@ pub fn replayTile(a: Allocator, surf: rs.Surface, layers: []const mvt.DecodedLay ox = (std.fmt.parseFloat(f64, it.next() orelse "0") catch 0) * TEXT_BODY_MM; oy = (std.fmt.parseFloat(f64, it.next() orelse "0") catch 0) * TEXT_BODY_MM; } - const style = rs.TextStyle{ + const ts = rs.TextStyle{ .color = propStr(f.properties, "color_token"), .font_size = propF64(f.properties, "font_size_px") orelse 12, .halign = propStr(f.properties, "halign"), @@ -3723,7 +3723,7 @@ pub fn replayTile(a: Allocator, surf: rs.Surface, layers: []const mvt.DecodedLay .offset_y = oy, .group = propInt(f.properties, "tgrp", 0), }; - try surf.drawText(propStr(f.properties, "text"), &style, f.parts[0][0]); + try surf.drawText(propStr(f.properties, "text"), &ts, f.parts[0][0]); } } } diff --git a/src/assets/chartstyle.zig b/src/style/chartstyle.zig similarity index 100% rename from src/assets/chartstyle.zig rename to src/style/chartstyle.zig diff --git a/src/assets/style.zig b/src/style/maplibre.zig similarity index 99% rename from src/assets/style.zig rename to src/style/maplibre.zig index a9c4ad6..ec2cbae 100644 --- a/src/assets/style.zig +++ b/src/style/maplibre.zig @@ -1,9 +1,8 @@ -//! style.zig — MapLibre GL style.json generation for the chart bundle. Resolves -//! each S-52 colour token to hex for a palette and emits the fill / line / symbol -//! / text layer set. Ported from the chartplotter web frontend's s52-style.mjs / -//! chart-style.mjs (and the now-removed style/build_style.py, kept in git history; -//! it was verified layer-for-layer identical during the port). The sole style -//! generator now. Part of the `assets` module. +//! maplibre.zig — MapLibre GL style.json generation. Resolves each S-52 colour +//! token to hex for a palette and emits the fill / line / symbol / text layer +//! set. Ported from the chartplotter web frontend's s52-style.mjs / +//! chart-style.mjs, verified layer-for-layer identical during the port. The one +//! style.json generator; part of the `style` module. //! //! MapLibre expressions are written as Zig comptime tuples — `.{ "get", "drval1" }` //! serialises to `["get","drval1"]` — through std.json's Stringify write-stream, diff --git a/src/assets/assets.zig b/src/style/style.zig similarity index 91% rename from src/assets/assets.zig rename to src/style/style.zig index 3068dea..2285360 100644 --- a/src/assets/assets.zig +++ b/src/style/style.zig @@ -1,39 +1,39 @@ -//! assets — offline portrayal-asset generation for the chart bundle. Mirrors the -//! Go oracle's internal/engine/assets (EmitS101): the rendering half of the -//! tile/style contract, emitted from the same S-101 catalogue that drives the -//! tiles so the two can't drift. Mirrors the Go oracle's bundle assets. +//! style — MapLibre style generation for the chart tiles: color tables, line +//! styles, and the style.json layer set, plus the S-52 mariner expression +//! builders. The style and the tiles come from the same S-101 catalogue, so the +//! two stay in sync. //! //! Pure Zig (no libc/fs): callers read the catalogue bytes and pass them in, the -//! same shape as s100/catalogue.zig. RGB lives ONLY in colortables.json; the -//! tiles stay colour *tokens*. +//! same shape as s100/catalogue.zig. RGB lives only in the color tables; the +//! tiles carry color *tokens*. //! -//! Implemented now: colortables.json (token -> hex, per day/dusk/night palette), -//! the MapLibre style.json layer set (style.zig), and manifest.json (pins -//! schema_version; couples tiles <-> portrayal). TODO, tracked in -//! next: linestyles.json, sprite/pattern atlases (SVG raster), -//! and glyphs (SDF) — to light up the symbol/text/pattern layers. +//! * color tables — token -> hex, one per day/dusk/night palette +//! * the MapLibre style.json layer set (maplibre.zig) +//! * line styles and the S-52 mariner expression builders (chartstyle.zig) const std = @import("std"); -/// The tile-vocabulary version both halves of a bundle are stamped with: the MVT -/// layer/property set in scene.zig and the style/colortables that render it. -/// Bump on ANY change to layer names or feature property keys. tile57/2 = the -/// 6-source-layer schema (the `_scamin` twins folded into their base; SCAMIN is a -/// per-feature `scamin` property the style gates band-independently). +/// The tile-vocabulary version both the tiles and the style are stamped with: +/// the MVT layer/property set in scene.zig and the style/color tables that +/// render it. Bump on any change to layer names or feature property keys. +/// tile57/2 = the 6-source-layer schema (the `_scamin` twins folded into their +/// base; SCAMIN is a per-feature `scamin` property the style gates +/// band-independently). pub const SCHEMA_VERSION = "tile57/2"; -// MapLibre style.json generation lives in style.zig. -pub const StyleOpts = @import("style.zig").StyleOpts; -pub const styleJson = @import("style.zig").styleJson; -pub const displayDenom = @import("style.zig").displayDenom; -pub const displayDenomZ = @import("style.zig").displayDenomZ; -pub const scaminGateK = @import("style.zig").scaminGateK; -pub const buildFromTemplate = @import("style.zig").buildFromTemplate; -pub const buildFromTemplateScamin = @import("style.zig").buildFromTemplateScamin; -pub const styleDiff = @import("style.zig").styleDiff; - -/// The S-52 mariner expression builders (MapLibre style patching) — folded -/// in: assets is its only sibling and every consumer wants both. +// MapLibre style.json generation lives in maplibre.zig. +pub const StyleOpts = @import("maplibre.zig").StyleOpts; +pub const styleJson = @import("maplibre.zig").styleJson; +pub const displayDenom = @import("maplibre.zig").displayDenom; +pub const displayDenomZ = @import("maplibre.zig").displayDenomZ; +pub const scaminGateK = @import("maplibre.zig").scaminGateK; +pub const buildFromTemplate = @import("maplibre.zig").buildFromTemplate; +pub const buildFromTemplateScamin = @import("maplibre.zig").buildFromTemplateScamin; +pub const styleDiff = @import("maplibre.zig").styleDiff; + +/// The S-52 mariner settings model and expression builders (MapLibre style +/// patching). Every consumer of this module wants both these and the color +/// tables, so they live together. pub const chartstyle = @import("chartstyle.zig"); // ---- colortables.json ---------------------------------------------------- @@ -545,5 +545,5 @@ test "manifestJson: pins schema_version and couples tiles to portrayal" { test { _ = chartstyle; - _ = @import("style.zig"); // run style.zig's styleJson / buildFromTemplate tests too + _ = @import("maplibre.zig"); // run the style.json builder tests too } diff --git a/src/tile57.zig b/src/tile57.zig index 7833a4a..d1c56a2 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -50,9 +50,9 @@ pub const style = struct { }; // ---- portrayal asset + style generation ---------------------------------- -pub const assets = @import("assets"); // colortables / linestyles / style / manifest +pub const assets = @import("style"); // colortables / line styles / style.json pub const sprite = @import("sprite"); // S-101 sprite + area-fill pattern atlases -pub const chartstyle = @import("assets").chartstyle; // mariner-driven MapLibre style patching +pub const chartstyle = @import("style").chartstyle; // mariner-driven MapLibre style patching // ---- tiling / encoding --------------------------------------------------- pub const mvt = @import("tiles").mvt; // Mapbox Vector Tile encode/decode diff --git a/tools/render.zig b/tools/render.zig index 8926fcf..f8047a7 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -1,6 +1,6 @@ const std = @import("std"); const engine = @import("engine"); -const assets = @import("assets"); +const style = @import("style"); const sprite = @import("sprite"); const render = @import("render"); const chart = @import("chart"); @@ -182,7 +182,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: ps.output = output; // Complex-linestyle table (idempotent; arena-backed — this run only). - const ls_srcs = try a.alloc(assets.LineStyleSrc, catalog_embed.linestyles.len); + const ls_srcs = try a.alloc(style.LineStyleSrc, catalog_embed.linestyles.len); for (catalog_embed.linestyles, 0..) |e, li| ls_srcs[li] = .{ .id = e.name, .xml = e.bytes }; engine.scene.registerLinestylesXml(a, ls_srcs); diff --git a/tools/style.zig b/tools/style.zig index be8d750..72f2bdd 100644 --- a/tools/style.zig +++ b/tools/style.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assets = @import("assets"); +const style = @import("style"); const common = @import("common.zig"); const Flags = common.Flags; const usageErr = common.usageErr; @@ -15,7 +15,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var colortables: ?[]const u8 = null; var out: ?[]const u8 = null; var scheme: []const u8 = "day"; - var opts = assets.StyleOpts{ .scheme = "day", .colortables_json = "" }; + var opts = style.StyleOpts{ .scheme = "day", .colortables_json = "" }; var f = Flags{ .args = args }; while (f.next()) |arg| { if (std.mem.eql(u8, arg, "-o") or std.mem.eql(u8, arg, "--output")) { @@ -50,7 +50,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { try std.Io.Dir.cwd().readFileAlloc(io, ctf, a, .unlimited) else try colorTablesBytes(io, a, resolveCatalogDir(catalog)); - const style = try assets.styleJson(a, opts); - try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = style }); - std.debug.print("wrote {s} ({s}, {d} bytes)\n", .{ out_path, scheme, style.len }); + const style_json = try style.styleJson(a, opts); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = style_json }); + std.debug.print("wrote {s} ({s}, {d} bytes)\n", .{ out_path, scheme, style_json.len }); } From 8495d88d0c0a5a2dbb526f7bb700919175865682 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 18:08:31 -0400 Subject: [PATCH 073/140] refactor(style): drop the redundant "style" prefix from member names Inside a module named style, the style prefix on members stutters. Rename the public surface: style.styleJson -> style.json style.styleDiff -> style.diff style.StyleOpts -> style.Options Options is spelled out rather than abbreviated. The impl names in maplibre.zig match. Also drop a couple of bare doc-file refs from comments. Co-Authored-By: Claude Fable 5 --- src/capi.zig | 6 +- src/style/chartstyle.zig | 8 +-- src/style/maplibre.zig | 134 +++++++++++++++++++-------------------- src/style/style.zig | 6 +- tools/style.zig | 4 +- 5 files changed, 79 insertions(+), 79 deletions(-) diff --git a/src/capi.zig b/src/capi.zig index a6f9bf7..ecd7233 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -986,7 +986,7 @@ export fn tile57_style_diff( const new_style = style.buildFromTemplateScamin(gpa, tmpl, &nm, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; defer gpa.free(new_style); - const ops = style.styleDiff(gpa, old_style, new_style) catch return 0; + const ops = style.diff(gpa, old_style, new_style) catch return 0; out.* = ops.ptr; out_len.* = ops.len; return 1; @@ -1020,7 +1020,7 @@ export fn tile57_style_template( const xml = embeddedColorProfileXml() orelse return 0; const cts = style.colorTablesJson(gpa, xml) catch return 0; defer gpa.free(cts); - var opts = style.StyleOpts{ + var opts = style.Options{ .scheme = switch (scheme) { 1 => "dusk", 2 => "night", @@ -1034,7 +1034,7 @@ export fn tile57_style_template( opts.minzoom = minzoom; if (maxzoom != 0) opts.maxzoom = maxzoom; if (tile_encoding == TILE_TYPE_MLT) opts.encoding = "mlt"; - const style_json = style.styleJson(gpa, opts) catch return 0; + const style_json = style.json(gpa, opts) catch return 0; out.* = style_json.ptr; out_len.* = style_json.len; return 1; diff --git a/src/style/chartstyle.zig b/src/style/chartstyle.zig index 3a452fa..8f6d18f 100644 --- a/src/style/chartstyle.zig +++ b/src/style/chartstyle.zig @@ -90,13 +90,13 @@ pub const MarinerSettings = struct { // -- SCAMIN gating override (host ?ignoreScamin debug toggle) — NOT an S-52 // display setting; a render-time transport flag the C ABI carries through to - // the style builder. When true, styleJson drops SCAMIN scale-gating so every + // the style builder. When true, the style drops SCAMIN scale-gating so every // feature shows in-band regardless of its 1:N min-display-scale. Default off. ignore_scamin: bool = false, - // -- scamin-layers.md: gate SCAMIN with a live client-driven filter instead of - // per-value bucket layers — NOT an S-52 display setting; a render-time transport - // flag the C ABI carries to the style builder. When true, styleJson emits one + // -- gate SCAMIN with a live client-driven filter instead of per-value bucket + // layers — NOT an S-52 display setting; a render-time transport flag the C ABI + // carries to the style builder. When true, the style emits one // *_scamin layer per render-type (no minzoom buckets) and the client rewrites the // SCAMIN clause on boundary crossings. Default off = per-value buckets. scamin_filter_gate: bool = false, diff --git a/src/style/maplibre.zig b/src/style/maplibre.zig index ec2cbae..c629d5c 100644 --- a/src/style/maplibre.zig +++ b/src/style/maplibre.zig @@ -23,7 +23,7 @@ const FONT = .{"Noto Sans Regular"}; // builder); only the mariner-INDEPENDENT layout exprs remain comptime tuples here. // The static (no-manifest) SCAMIN/oscl gate is now expressed as K / 2^zoom — -// the SAME display-denominator form the bucket path uses (styleJson computes +// the SAME display-denominator form the bucket path uses (json computes // K = M_PER_PX_Z0·cos(scamin_lat)/(pitch/1000) once per archive). The old hardcoded // DENOM_Z0 (0.28 mm OGC pixel, equator) gated ~0.4 z late at mid-latitude and used // the uncalibrated CSS pixel; K is latitude-corrected and calibrated. See @@ -50,7 +50,7 @@ pub fn scaminDisplayZoom(scamin: f64, lat: f64) f64 { /// The per-archive display-denominator constant K such that the on-screen 1:N /// denominator at Web-Mercator `zoom` is K / 2^zoom (== displayDenom). Baked into the /// static SCAMIN/oscl gate at the archive-center latitude; the SAME constant the -/// bucket path computes (styleJson). Replaces the old equator-only OGC DENOM_Z0. +/// bucket path computes (json). Replaces the old equator-only OGC DENOM_Z0. pub fn scaminGateK(lat: f64) f64 { return M_PER_PX_Z0 * @cos(lat * std.math.pi / 180.0) / (DEFAULT_PX_PITCH_MM / 1000.0); } @@ -156,7 +156,7 @@ const TEXT_OFFSET = .{ .{ "literal", .{ 0, 0 } }, }; -pub const StyleOpts = struct { +pub const Options = struct { scheme: []const u8, // "day" | "dusk" | "night" colortables_json: []const u8, source_tiles: ?[]const u8 = null, // tiles template; else pmtiles_url @@ -212,7 +212,7 @@ pub const StyleOpts = struct { }; // Precomputed, mariner-aware style expressions shared by every layer of one -// styleJson call — the single style builder. Colours / depth shading / icon images +// json call — the single style builder. Colours / depth shading / icon images // resolve ONCE through the chartstyle builders (so chartstyle.buildStyle's patch pass // is retired); `common`/`text_group` are the S-52 display filters, empty/null in // template mode (opts.mariner == null) so a template renders without baked gating. @@ -445,7 +445,7 @@ fn pointLayout(js: *Stringify, alignment: []const u8, icon: std.json.Value, scal // first = underneath). draw_prio is the SOLE axis; the danger-over-sounding // deviation is a sort VALUE (effective 19), not a class tier. z-order "auto" makes // the sort-key take effect. Mirrors fill-sort-key on the fill layers. NOTE: this - // orders WITHIN one layer only; LIGHTS get their own top layer set (see styleJson). + // orders WITHIN one layer only; LIGHTS get their own top layer set (see json). try js.objectField("symbol-sort-key"); try js.write(SYMBOL_SORT); try js.objectField("symbol-z-order"); @@ -672,7 +672,7 @@ fn patternLayer(js: *Stringify, s: *const SCtx, sl: []const u8, bkt: Bucket) !vo // every contributing cell's M_COVR coverage polygon (baked into `area_patterns` // as pattern OVERSC01, tagged `oscl`), shown only while the display is FINER than // the cell's quantized compilation scale (the oscl clause). Sandwiched between -// the overscaled and at-scale fill passes (styleJson §2), so a finer cell's +// the overscaled and at-scale fill passes (json §2), so a finer cell's // opaque fills occlude a coarser cell's hatch — the hatch survives only on // coarse-only patches. The showOverscale mariner toggle drives layout.visibility // alone (layer set unchanged -> a one-op style diff). @@ -793,7 +793,7 @@ fn dangerSoundingsLayer(js: *Stringify, s: *const SCtx, bkt: Bucket) !void { } /// Emit a MapLibre style.json for `opts.scheme`. Returns allocator-owned bytes. -pub fn styleJson(alloc: std.mem.Allocator, opts: StyleOpts) ![]u8 { +pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { var parsed = std.json.parseFromSlice(std.json.Value, alloc, opts.colortables_json, .{}) catch return error.BadColortables; defer parsed.deinit(); @@ -1012,7 +1012,7 @@ pub fn styleJson(alloc: std.mem.Allocator, opts: StyleOpts) ![]u8 { /// builder behind the C-ABI / WASM / parity callers (replaces chartstyle.buildStyle's /// template-patch pass). The passed template carries ONLY the host's source config /// (sprite / glyphs / chart tiles+zoom); this lifts that out and regenerates every -/// layer via styleJson with the mariner baked in. Signature mirrors the retired +/// layer via json with the mariner baked in. Signature mirrors the retired /// buildStyle so callers are a one-line change. A bad template or unusable colortables /// returns the template bytes unchanged (alloc-owned dup), as buildStyle did. pub fn buildFromTemplate( @@ -1047,7 +1047,7 @@ pub fn buildFromTemplateScamin( var parsed = std.json.parseFromSlice(std.json.Value, alloc, template_json, .{}) catch return alloc.dupe(u8, template_json); defer parsed.deinit(); - var opts = StyleOpts{ + var opts = Options{ .scheme = switch (m.scheme) { .dusk => "dusk", .night => "night", @@ -1095,12 +1095,12 @@ pub fn buildFromTemplateScamin( }; } } - return styleJson(alloc, opts) catch alloc.dupe(u8, template_json); + return json(alloc, opts) catch alloc.dupe(u8, template_json); } -// ---- style diff (style-diff.md) -------------------------------------------- +// ---- style diff -------------------------------------------- // Compute the minimal MapLibre style-mutation ops that turn one built style into -// another. The engine knows both styles come from styleJson (same layer set + +// another. The engine knows both styles come from json (same layer set + // deterministic key order), so a structural layer-by-layer compare yields exactly // the ops MapLibre's setStyle(diff:true) would — but scoped to the chart layers and // returned as data the host applies with setFilter / setPaintProperty / @@ -1126,7 +1126,7 @@ fn layerId(v: std.json.Value) ?[]const u8 { } /// Structural deep-equality for two parsed JSON values. std.json.Value has no -/// built-in equal; both operands here come from the same styleJson generator, so +/// built-in equal; both operands here come from the same json generator, so /// corresponding keys share a representation (integer stays integer, object key /// order matches) and a recursive compare is exact. fn jsonEql(a: std.json.Value, b: std.json.Value) bool { @@ -1210,12 +1210,12 @@ fn diffSubObject(js: *Stringify, op: []const u8, id: []const u8, old_v: ?std.jso } /// Compute the minimal MapLibre style-mutation ops (a JSON array) to turn the -/// serialized style `old_json` into `new_json`. Both must be styleJson output (same +/// serialized style `old_json` into `new_json`. Both must be json output (same /// template/colortables/bands/scamin inputs, differing only in mariner). Returns /// allocator-owned bytes: `"[]"` when nothing differs; one op per differing /// `filter` / `paint.*` / `layout.*` key; `[{"op":"rebuild"}]` when the two styles /// carry a different SET of layer ids (the host then falls back to a full setStyle). -pub fn styleDiff(alloc: std.mem.Allocator, old_json: []const u8, new_json: []const u8) ![]u8 { +pub fn diff(alloc: std.mem.Allocator, old_json: []const u8, new_json: []const u8) ![]u8 { var old_parsed = std.json.parseFromSlice(std.json.Value, alloc, old_json, .{}) catch return alloc.dupe(u8, rebuild_ops); defer old_parsed.deinit(); @@ -1262,11 +1262,11 @@ pub fn styleDiff(alloc: std.mem.Allocator, old_json: []const u8, new_json: []con return aw.toOwnedSlice(); } -test "styleJson: valid JSON, expected layers, palette-resolved colour" { +test "json: valid JSON, expected layers, palette-resolved colour" { const ct = \\{"day":{"DEPDW":"#c9edff","CHGRD":"#4c5b63","CHBLK":"#000000"},"dusk":{},"night":{"DEPDW":"#0a141e"}} ; - const out = try styleJson(std.testing.allocator, .{ + const out = try json(std.testing.allocator, .{ .scheme = "day", .colortables_json = ct, .source_tiles = "tile57://{z}/{x}/{y}", @@ -1286,13 +1286,13 @@ test "styleJson: valid JSON, expected layers, palette-resolved colour" { try std.testing.expect(std.mem.indexOf(u8, out, "#c9edff") != null); } -test "styleJson: ignore_scamin drops SCAMIN gating (no buckets, no zoom-gate)" { +test "json: ignore_scamin drops SCAMIN gating (no buckets, no zoom-gate)" { const a = std.testing.allocator; const ct = \\{"day":{"DEPDW":"#c9edff"},"dusk":{},"night":{}} ; const sm = [_]u32{ 30000, 90000 }; - const base = StyleOpts{ + const base = Options{ .scheme = "day", .colortables_json = ct, .sprite = "sprite", @@ -1302,7 +1302,7 @@ test "styleJson: ignore_scamin drops SCAMIN gating (no buckets, no zoom-gate)" { // Manifest present, gating ON -> the merged zoom-gate rides every layer (per-value // #sm buckets are retired — a manifest never buckets now). - const gated = try styleJson(a, base); + const gated = try json(a, base); defer a.free(gated); try std.testing.expect(std.mem.indexOf(u8, gated, "[\">=\",[\"coalesce\",[\"get\",\"scamin\"],1000000000000],[\"/\",") != null); try std.testing.expect(std.mem.indexOf(u8, gated, "#sm") == null); @@ -1310,7 +1310,7 @@ test "styleJson: ignore_scamin drops SCAMIN gating (no buckets, no zoom-gate)" { // Manifest present, ignore_scamin -> no buckets at all. var ign = base; ign.ignore_scamin = true; - const out_ign = try styleJson(a, ign); + const out_ign = try json(a, ign); defer a.free(out_ign); try std.testing.expect(std.mem.indexOf(u8, out_ign, "#sm") == null); @@ -1318,7 +1318,7 @@ test "styleJson: ignore_scamin drops SCAMIN gating (no buckets, no zoom-gate)" { // scamin>=D(zoom) fallback shape, no per-value buckets). var nomanifest = base; nomanifest.scamin = &.{}; - const out_fb = try styleJson(a, nomanifest); + const out_fb = try json(a, nomanifest); defer a.free(out_fb); try std.testing.expect(std.mem.indexOf(u8, out_fb, "[\">=\",[\"coalesce\",[\"get\",\"scamin\"],1000000000000],[\"/\",") != null); try std.testing.expect(std.mem.indexOf(u8, out_fb, "#sm") == null); @@ -1327,7 +1327,7 @@ test "styleJson: ignore_scamin drops SCAMIN gating (no buckets, no zoom-gate)" { // No manifest + ignore_scamin -> even the static gate is gone. var nm_ign = nomanifest; nm_ign.ignore_scamin = true; - const out_nm_ign = try styleJson(a, nm_ign); + const out_nm_ign = try json(a, nm_ign); defer a.free(out_nm_ign); try std.testing.expect(std.mem.indexOf(u8, out_nm_ign, "[\">=\",[\"coalesce\",[\"get\",\"scamin\"],1000000000000],[\"/\",") == null); try std.testing.expect(std.mem.indexOf(u8, out_nm_ign, "#sm") == null); @@ -1338,12 +1338,12 @@ fn layerIndexById(layers: []std.json.Value, id: []const u8) ?usize { return null; } -test "styleJson: point z-order = draw_prio alone (threshold partition + danger sort-value)" { +test "json: point z-order = draw_prio alone (threshold partition + danger sort-value)" { const a = std.testing.allocator; const ct = \\{"day":{"DEPDW":"#c9edff"},"dusk":{},"night":{}} ; - const out = try styleJson(a, .{ + const out = try json(a, .{ .scheme = "day", .colortables_json = ct, .sprite = "sprite", @@ -1378,12 +1378,12 @@ test "styleJson: point z-order = draw_prio alone (threshold partition + danger s try std.testing.expect(i_over < i_lt); } -test "styleJson: size_scale wraps icon/line/text sizes in a multiplier" { +test "json: size_scale wraps icon/line/text sizes in a multiplier" { const a = std.testing.allocator; const ct = \\{"day":{"DEPDW":"#c9edff"},"dusk":{},"night":{}} ; - const base = StyleOpts{ + const base = Options{ .scheme = "day", .colortables_json = ct, .sprite = "sprite", @@ -1391,7 +1391,7 @@ test "styleJson: size_scale wraps icon/line/text sizes in a multiplier" { }; // Default scale 1.0: sizes written verbatim, no multiplier wrapper. - const def = try styleJson(a, base); + const def = try json(a, base); defer a.free(def); try std.testing.expect(std.mem.indexOf(u8, def, "\"line-width\":[\"coalesce\",[\"get\",\"width_px\"],1]") != null); try std.testing.expect(std.mem.indexOf(u8, def, "\"line-width\":[\"*\"") == null); @@ -1399,7 +1399,7 @@ test "styleJson: size_scale wraps icon/line/text sizes in a multiplier" { // Scaled: icon-size / line-width / text-size each wrap in ["*", scale, expr]. var scaled = base; scaled.size_scale = 2.0; - const sc = try styleJson(a, scaled); + const sc = try json(a, scaled); defer a.free(sc); try std.testing.expect(std.mem.indexOf(u8, sc, "\"line-width\":[\"*\"") != null); try std.testing.expect(std.mem.indexOf(u8, sc, "\"icon-size\":[\"*\"") != null); @@ -1409,7 +1409,7 @@ test "styleJson: size_scale wraps icon/line/text sizes in a multiplier" { } // ---- single-builder (buildFromTemplate) tests — ported from the retired -// chartstyle.buildStyle tests, now exercising the one styleJson path. ------- +// chartstyle.buildStyle tests, now exercising the one json path. ------- const cs_template = \\{"version":8,"sources":{"chart":{"type":"vector","url":"pmtiles://x"}},"sprite":"x","glyphs":"x","layers":[]} ; @@ -1532,7 +1532,7 @@ test "buildFromTemplateScamin: a manifest no longer buckets — the merged zoom- // ---- smax removal ----------------------------------------------------------- -test "styleJson: no smax clause in any gating mode (band-handoff gate retired)" { +test "json: no smax clause in any gating mode (band-handoff gate retired)" { // The coverage-clipped composite owns cross-band occlusion geometrically, so // no layer carries a band-handoff smax clause in any gating mode, and the // overscale gate survives ?ignoreScamin (the two gates are decoupled). @@ -1541,7 +1541,7 @@ test "styleJson: no smax clause in any gating mode (band-handoff gate retired)" \\{"day":{"DEPDW":"#c9edff"},"dusk":{},"night":{}} ; const sm = [_]u32{ 45000, 90000 }; - const base = StyleOpts{ + const base = Options{ .scheme = "day", .colortables_json = ct, .sprite = "sprite", @@ -1553,25 +1553,25 @@ test "styleJson: no smax clause in any gating mode (band-handoff gate retired)" var gated_opts = base; gated_opts.scamin_filter_gate = true; gated_opts.scamin_cur_denom = 50000; - const gated = try styleJson(a, gated_opts); + const gated = try json(a, gated_opts); defer a.free(gated); try std.testing.expect(std.mem.indexOf(u8, gated, "smax") == null); // The scamin clause still gates (same injected DENOM literal). try std.testing.expect(std.mem.indexOf(u8, gated, "[\">=\",[\"coalesce\",[\"get\",\"scamin\"],1000000000000],50000]") != null); - const bucketed = try styleJson(a, base); + const bucketed = try json(a, base); defer a.free(bucketed); try std.testing.expect(std.mem.indexOf(u8, bucketed, "smax") == null); var nm = base; nm.scamin = &.{}; - const fb = try styleJson(a, nm); + const fb = try json(a, nm); defer a.free(fb); try std.testing.expect(std.mem.indexOf(u8, fb, "smax") == null); var ign = base; ign.ignore_scamin = true; - const out_ign = try styleJson(a, ign); + const out_ign = try json(a, ign); defer a.free(out_ign); try std.testing.expect(std.mem.indexOf(u8, out_ign, "smax") == null); // ignoreScamin no longer kills the overscale indication (spec §5). @@ -1580,13 +1580,13 @@ test "styleJson: no smax clause in any gating mode (band-handoff gate retired)" // ---- overscale (oscl) gate tests ------------------------------------------- -test "styleJson: the overscale oscl clause has the EXACT client-matched shape" { +test "json: the overscale oscl clause has the EXACT client-matched shape" { const a = std.testing.allocator; const ct = \\{"day":{"DEPDW":"#c9edff"},"dusk":{},"night":{}} ; const sm = [_]u32{ 45000, 90000 }; - const gated = try styleJson(a, .{ + const gated = try json(a, .{ .scheme = "day", .colortables_json = ct, .sprite = "sprite", @@ -1610,7 +1610,7 @@ test "styleJson: the overscale oscl clause has the EXACT client-matched shape" { // The boot/diff PLACEHOLDER (cur_denom 0) maps to 1e12 — hide the hatch and // keep every fill in the at-scale pass (today's rendering) until the client // injects the live denominator (the placeholder lesson, cb91c4d). - const boot = try styleJson(a, .{ + const boot = try json(a, .{ .scheme = "day", .colortables_json = ct, .sprite = "sprite", @@ -1647,13 +1647,13 @@ test "styleJson: the overscale oscl clause has the EXACT client-matched shape" { try std.testing.expectEqualStrings("visible", hatch.get("layout").?.object.get("visibility").?.string); } -test "styleJson: showOverscale=false hides the hatch layer; ignoreScamin keeps it (decoupled)" { +test "json: showOverscale=false hides the hatch layer; ignoreScamin keeps it (decoupled)" { const a = std.testing.allocator; const ct = \\{"day":{"DEPDW":"#c9edff"},"dusk":{},"night":{}} ; const sm = [_]u32{ 45000, 90000 }; - const base = StyleOpts{ + const base = Options{ .scheme = "day", .colortables_json = ct, .sprite = "sprite", @@ -1665,14 +1665,14 @@ test "styleJson: showOverscale=false hides the hatch layer; ignoreScamin keeps i // so a style diff is a single setLayoutProperty op). var off = base; off.mariner = .{ .show_overscale = false }; - const hidden = try styleJson(a, off); + const hidden = try json(a, off); defer a.free(hidden); try std.testing.expect(std.mem.indexOf(u8, hidden, "\"id\":\"overscale\",") != null); try std.testing.expect(std.mem.indexOf(u8, hidden, "{\"visibility\":\"none\"}") != null); try std.testing.expect(std.mem.indexOf(u8, hidden, "\"oscl\"") != null); // Bucket mode derives the oscl DENOM from zoom (same K as the scamin clause). - const bucketed = try styleJson(a, base); + const bucketed = try json(a, base); defer a.free(bucketed); try std.testing.expect(std.mem.indexOf(u8, bucketed, "[\">\",[\"coalesce\",[\"get\",\"oscl\"],0],[\"/\",") != null); @@ -1680,7 +1680,7 @@ test "styleJson: showOverscale=false hides the hatch layer; ignoreScamin keeps i // (spec §5) — the oscl clauses + hatch layer survive the debug toggle. var ign = base; ign.ignore_scamin = true; - const out_ign = try styleJson(a, ign); + const out_ign = try json(a, ign); defer a.free(out_ign); try std.testing.expect(std.mem.indexOf(u8, out_ign, "oscl") != null); try std.testing.expect(std.mem.indexOf(u8, out_ign, "\"id\":\"overscale\",") != null); @@ -1688,15 +1688,15 @@ test "styleJson: showOverscale=false hides the hatch layer; ignoreScamin keeps i // No sprite -> nothing can draw the hatch: single plain fill layer, no sandwich. var nospr = base; nospr.sprite = null; - const out_ns = try styleJson(a, nospr); + const out_ns = try json(a, nospr); defer a.free(out_ns); try std.testing.expect(std.mem.indexOf(u8, out_ns, "oscl") == null); try std.testing.expect(std.mem.indexOf(u8, out_ns, "\"id\":\"overscale\",") == null); } -// ---- styleDiff tests (style-diff.md §5) ------------------------------------ +// ---- diff tests ------------------------------------ -test "styleDiff: filter + paint + layout changes emit precise, scoped ops" { +test "diff: filter + paint + layout changes emit precise, scoped ops" { const a = std.testing.allocator; const old_j = \\{"layers":[ @@ -1710,7 +1710,7 @@ test "styleDiff: filter + paint + layout changes emit precise, scoped ops" { \\{"id":"text","type":"symbol","paint":{"text-color":"#000"}} \\]} ; - const ops = try styleDiff(a, old_j, new_j); + const ops = try diff(a, old_j, new_j); defer a.free(ops); // areas: filter, paint fill-color, layout visibility all changed -> one op each. try std.testing.expect(std.mem.indexOf(u8, ops, "{\"op\":\"setFilter\",\"layer\":\"areas\",\"value\":[\"==\",\"x\",2]}") != null); @@ -1720,11 +1720,11 @@ test "styleDiff: filter + paint + layout changes emit precise, scoped ops" { try std.testing.expect(std.mem.indexOf(u8, ops, "\"layer\":\"text\"") == null); } -test "styleDiff: a removed paint key emits value:null" { +test "diff: a removed paint key emits value:null" { const a = std.testing.allocator; const old_j = "{\"layers\":[{\"id\":\"l\",\"paint\":{\"fill-color\":\"#111\",\"fill-opacity\":0.5}}]}"; const new_j = "{\"layers\":[{\"id\":\"l\",\"paint\":{\"fill-color\":\"#111\"}}]}"; - const ops = try styleDiff(a, old_j, new_j); + const ops = try diff(a, old_j, new_j); defer a.free(ops); try std.testing.expectEqualStrings( "[{\"op\":\"setPaintProperty\",\"layer\":\"l\",\"property\":\"fill-opacity\",\"value\":null}]", @@ -1732,31 +1732,31 @@ test "styleDiff: a removed paint key emits value:null" { ); } -test "styleDiff: a differing layer-id set -> rebuild" { +test "diff: a differing layer-id set -> rebuild" { const a = std.testing.allocator; // id renamed - const r1 = try styleDiff(a, "{\"layers\":[{\"id\":\"x\"}]}", "{\"layers\":[{\"id\":\"y\"}]}"); + const r1 = try diff(a, "{\"layers\":[{\"id\":\"x\"}]}", "{\"layers\":[{\"id\":\"y\"}]}"); defer a.free(r1); try std.testing.expectEqualStrings(rebuild_ops, r1); // count differs - const r2 = try styleDiff(a, "{\"layers\":[{\"id\":\"x\"}]}", "{\"layers\":[{\"id\":\"x\"},{\"id\":\"z\"}]}"); + const r2 = try diff(a, "{\"layers\":[{\"id\":\"x\"}]}", "{\"layers\":[{\"id\":\"x\"},{\"id\":\"z\"}]}"); defer a.free(r2); try std.testing.expectEqualStrings(rebuild_ops, r2); } -test "styleDiff: same mariner -> [] (no ops)" { +test "diff: same mariner -> [] (no ops)" { const a = std.testing.allocator; const m = chartstyle.MarinerSettings{}; const s1 = try buildFromTemplate(a, cs_template, &m, cs_ct, null, 1700000000); defer a.free(s1); const s2 = try buildFromTemplate(a, cs_template, &m, cs_ct, null, 1700000000); defer a.free(s2); - const ops = try styleDiff(a, s1, s2); + const ops = try diff(a, s1, s2); defer a.free(ops); try std.testing.expectEqualStrings("[]", ops); } -test "styleDiff: display_other flip emits only setFilter ops" { +test "diff: display_other flip emits only setFilter ops" { const a = std.testing.allocator; const base = chartstyle.MarinerSettings{}; const other = chartstyle.MarinerSettings{ .display_other = true }; @@ -1764,7 +1764,7 @@ test "styleDiff: display_other flip emits only setFilter ops" { defer a.free(s1); const s2 = try buildFromTemplate(a, cs_template, &other, cs_ct, null, 1700000000); defer a.free(s2); - const ops = try styleDiff(a, s1, s2); + const ops = try diff(a, s1, s2); defer a.free(ops); try std.testing.expect(std.mem.indexOf(u8, ops, "setFilter") != null); try std.testing.expect(std.mem.indexOf(u8, ops, "setPaintProperty") == null); @@ -1772,7 +1772,7 @@ test "styleDiff: display_other flip emits only setFilter ops" { try std.testing.expect(!std.mem.eql(u8, ops, "[]")); } -test "styleDiff: day vs night emits setPaintProperty colour ops, no filter change" { +test "diff: day vs night emits setPaintProperty colour ops, no filter change" { const a = std.testing.allocator; const day = chartstyle.MarinerSettings{ .scheme = .day }; const night = chartstyle.MarinerSettings{ .scheme = .night }; @@ -1780,7 +1780,7 @@ test "styleDiff: day vs night emits setPaintProperty colour ops, no filter chang defer a.free(s1); const s2 = try buildFromTemplate(a, cs_template, &night, cs_ct, null, 1700000000); defer a.free(s2); - const ops = try styleDiff(a, s1, s2); + const ops = try diff(a, s1, s2); defer a.free(ops); try std.testing.expect(std.mem.indexOf(u8, ops, "setPaintProperty") != null); try std.testing.expect(std.mem.indexOf(u8, ops, "setFilter") == null); @@ -1794,13 +1794,13 @@ fn layerCount(a: std.mem.Allocator, style: []const u8) !usize { return p.value.object.get("layers").?.array.items.len; } -test "styleJson: both merged modes (zoom-gate default, filter-gate exact) give one layer per render-type — no per-value buckets" { +test "json: both merged modes (zoom-gate default, filter-gate exact) give one layer per render-type — no per-value buckets" { const a = std.testing.allocator; const ct = \\{"day":{"DEPDW":"#c9edff"},"dusk":{},"night":{}} ; const sm = [_]u32{ 3000, 8000, 12000, 22000, 45000, 90000, 180000 }; // 7 denominators - const base = StyleOpts{ + const base = Options{ .scheme = "day", .colortables_json = ct, .sprite = "sprite", @@ -1811,7 +1811,7 @@ test "styleJson: both merged modes (zoom-gate default, filter-gate exact) give o // Default (a manifest present): the merged zoom-gate — ONE self-gating layer per // family, NO per-value #sm buckets, NO native minzoom. - const merged = try styleJson(a, base); + const merged = try json(a, base); defer a.free(merged); try std.testing.expect(std.mem.indexOf(u8, merged, "#sm") == null); try std.testing.expect(std.mem.indexOf(u8, merged, "minzoom") == null); @@ -1821,7 +1821,7 @@ test "styleJson: both merged modes (zoom-gate default, filter-gate exact) give o // set, with the client-driven curDenom clause instead of the zoom expression. var fg = base; fg.scamin_filter_gate = true; - const gated = try styleJson(a, fg); + const gated = try json(a, fg); defer a.free(gated); try std.testing.expect(std.mem.indexOf(u8, gated, "#sm") == null); // no per-value buckets try std.testing.expect(std.mem.indexOf(u8, gated, "minzoom") == null); // no native minzoom gating @@ -1833,13 +1833,13 @@ test "styleJson: both merged modes (zoom-gate default, filter-gate exact) give o try std.testing.expectEqual(try layerCount(a, merged), try layerCount(a, gated)); } -test "styleJson: scamin_filter_gate honors the cur_denom literal + ignore_scamin drops the clause" { +test "json: scamin_filter_gate honors the cur_denom literal + ignore_scamin drops the clause" { const a = std.testing.allocator; const ct = \\{"day":{"DEPDW":"#c9edff"},"dusk":{},"night":{}} ; const sm = [_]u32{ 45000, 90000 }; - const base = StyleOpts{ + const base = Options{ .scheme = "day", .colortables_json = ct, .sprite = "sprite", @@ -1849,7 +1849,7 @@ test "styleJson: scamin_filter_gate honors the cur_denom literal + ignore_scamin .scamin_filter_gate = true, .scamin_cur_denom = 50000, }; - const gated = try styleJson(a, base); + const gated = try json(a, base); defer a.free(gated); // The baked default denominator appears in the clause. try std.testing.expect(std.mem.indexOf(u8, gated, "50000") != null); @@ -1857,7 +1857,7 @@ test "styleJson: scamin_filter_gate honors the cur_denom literal + ignore_scamin // ignore_scamin overrides filter_gate: single plain layer, no SCAMIN clause at all. var ign = base; ign.ignore_scamin = true; - const out_ign = try styleJson(a, ign); + const out_ign = try json(a, ign); defer a.free(out_ign); try std.testing.expect(std.mem.indexOf(u8, out_ign, "1000000000000") == null); try std.testing.expect(std.mem.indexOf(u8, out_ign, "#sm") == null); diff --git a/src/style/style.zig b/src/style/style.zig index 2285360..876aaab 100644 --- a/src/style/style.zig +++ b/src/style/style.zig @@ -22,14 +22,14 @@ const std = @import("std"); pub const SCHEMA_VERSION = "tile57/2"; // MapLibre style.json generation lives in maplibre.zig. -pub const StyleOpts = @import("maplibre.zig").StyleOpts; -pub const styleJson = @import("maplibre.zig").styleJson; +pub const Options = @import("maplibre.zig").Options; +pub const json = @import("maplibre.zig").json; pub const displayDenom = @import("maplibre.zig").displayDenom; pub const displayDenomZ = @import("maplibre.zig").displayDenomZ; pub const scaminGateK = @import("maplibre.zig").scaminGateK; pub const buildFromTemplate = @import("maplibre.zig").buildFromTemplate; pub const buildFromTemplateScamin = @import("maplibre.zig").buildFromTemplateScamin; -pub const styleDiff = @import("maplibre.zig").styleDiff; +pub const diff = @import("maplibre.zig").diff; /// The S-52 mariner settings model and expression builders (MapLibre style /// patching). Every consumer of this module wants both these and the color diff --git a/tools/style.zig b/tools/style.zig index 72f2bdd..0aa1958 100644 --- a/tools/style.zig +++ b/tools/style.zig @@ -15,7 +15,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var colortables: ?[]const u8 = null; var out: ?[]const u8 = null; var scheme: []const u8 = "day"; - var opts = style.StyleOpts{ .scheme = "day", .colortables_json = "" }; + var opts = style.Options{ .scheme = "day", .colortables_json = "" }; var f = Flags{ .args = args }; while (f.next()) |arg| { if (std.mem.eql(u8, arg, "-o") or std.mem.eql(u8, arg, "--output")) { @@ -50,7 +50,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { try std.Io.Dir.cwd().readFileAlloc(io, ctf, a, .unlimited) else try colorTablesBytes(io, a, resolveCatalogDir(catalog)); - const style_json = try style.styleJson(a, opts); + const style_json = try style.json(a, opts); try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = style_json }); std.debug.print("wrote {s} ({s}, {d} bytes)\n", .{ out_path, scheme, style_json.len }); } From c30a2f000b89b6f2d7b5f860b45dbd6d059296cf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 18:21:29 -0400 Subject: [PATCH 074/140] refactor(s101): rename the s100 package to s101 and spell out its members The package is the S-101 portrayal model (the distilled S-101 catalogue plus the S-57->S-101 adaptation and instruction layers), so s101 names it and makes "the S-101 portrayal" one coherent grab-point. The Lua runner stays in the separate portray module. src/s100/ -> src/s101/ s100.zig (barrel) -> s101.zig s101_adapt.zig -> adapter.zig (member s101.adapter) s101_instr.zig -> instructions.zig (member s101.instructions) catalogue.zig (member s101.catalogue) Members drop the redundant prefix and abbreviations: s101.adapter, s101.instructions, s101.catalogue. Consumers import @import("s101"). The root re-exports become s101_adapter / s101_instructions. Co-Authored-By: Claude Fable 5 --- build.zig | 28 +++++++------- src/bake_root.zig | 5 ++- src/chart.zig | 2 +- src/lib_root.zig | 2 +- src/portray/portray.zig | 28 +++++++------- src/render/ascii.zig | 2 +- src/render/inspect.zig | 2 +- src/render/inspect_view_test.zig | 6 +-- src/render/render.zig | 2 +- src/render/surface.zig | 4 +- src/root.zig | 12 +++--- src/s100/s100.zig | 15 -------- src/{s100/s101_adapt.zig => s101/adapter.zig} | 0 src/{s100 => s101}/catalogue.zig | 2 +- .../s101_instr.zig => s101/instructions.zig} | 0 src/s101/s101.zig | 18 +++++++++ src/scene/scene.zig | 38 +++++++++---------- src/style/style.zig | 2 +- src/tile57.zig | 4 +- tools/ascii.zig | 2 +- tools/explore.zig | 4 +- tools/s101_coverage.zig | 34 ++++++++--------- 22 files changed, 108 insertions(+), 104 deletions(-) delete mode 100644 src/s100/s100.zig rename src/{s100/s101_adapt.zig => s101/adapter.zig} (100%) rename src/{s100 => s101}/catalogue.zig (99%) rename src/{s100/s101_instr.zig => s101/instructions.zig} (100%) create mode 100644 src/s101/s101.zig diff --git a/build.zig b/build.zig index 05eba64..a3344a0 100644 --- a/build.zig +++ b/build.zig @@ -162,8 +162,8 @@ pub fn build(b: *std.Build) void { // libc/Lua) and target-agnostic: they omit target/optimize so the same module // objects compile under both the glibc test/lib build and the static-musl // baker build, inheriting each consumer's target. - // DAG: iso8211 <- s57 <- s100; tiles (leaf) <- render <- scene. - // The embedded catalogue JSON rides on s100 (catalogue.zig @embedFile's it). + // DAG: iso8211 <- s57 <- s101; tiles (leaf) <- render <- scene. + // The embedded catalogue JSON rides on s101 (catalogue.zig @embedFile's it). // // ISO/IEC 8211 container reader (src/iso8211/): the bottom layer, a pure // std-only leaf. Its own module so a consumer can decode 8211 records @@ -175,11 +175,11 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/s57/s57.zig"), .imports = &.{.{ .name = "iso8211", .module = iso8211_mod }}, }); - const s100_mod = b.addModule("s100", .{ - .root_source_file = b.path("src/s100/s100.zig"), + const s101_mod = b.addModule("s101", .{ + .root_source_file = b.path("src/s101/s101.zig"), .imports = &.{.{ .name = "s57", .module = s57_mod }}, }); - addCatalogueJson(b, s100_mod); + addCatalogueJson(b, s101_mod); // Tile encoding + addressing (src/tiles/): MVT + MLT encoders, gzip, the // PMTiles container, and web-mercator tile math. One pure leaf module @@ -192,7 +192,7 @@ pub fn build(b: *std.Build) void { // surface, the resolver (colors at palette, display gates), and the pixel // machinery (Canvas primitive seam, RasterCanvas, PNG encoder, PixelSurface). // One pure module; imports tiles (TilePoint alias) + style (settings model) - // only — never s57/s100/portray. NOTE: declared before style_mod exists, + // only — never s57/s101/portray. NOTE: declared before style_mod exists, // so that edge is attached right after style_mod below. const render_mod = b.addModule("render", .{ .root_source_file = b.path("src/render/render.zig"), @@ -219,7 +219,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/scene/scene.zig"), .imports = &.{ .{ .name = "s57", .module = s57_mod }, - .{ .name = "s100", .module = s100_mod }, + .{ .name = "s101", .module = s101_mod }, .{ .name = "tiles", .module = tiles_mod }, .{ .name = "render", .module = render_mod }, .{ .name = "geo", .module = geo_mod }, @@ -238,7 +238,7 @@ pub fn build(b: *std.Build) void { .pic = true, .imports = &.{ .{ .name = "s57", .module = s57_mod }, - .{ .name = "s100", .module = s100_mod }, + .{ .name = "s101", .module = s101_mod }, }, }); addLua(b, portray_mod, lua_posix); @@ -276,7 +276,7 @@ pub fn build(b: *std.Build) void { // (portray is libc, wired separately into the lib + baker only.) const pure_pkgs = [_]std.Build.Module.Import{ .{ .name = "s57", .module = s57_mod }, - .{ .name = "s100", .module = s100_mod }, + .{ .name = "s101", .module = s101_mod }, .{ .name = "tiles", .module = tiles_mod }, .{ .name = "scene", .module = scene_mod }, .{ .name = "render", .module = render_mod }, @@ -423,7 +423,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, // portray (embedded Lua) .imports = &.{ .{ .name = "s57", .module = s57_mod }, - .{ .name = "s100", .module = s100_mod }, + .{ .name = "s101", .module = s101_mod }, .{ .name = "tiles", .module = tiles_mod }, .{ .name = "scene", .module = scene_mod }, .{ .name = "render", .module = render_mod }, @@ -553,15 +553,15 @@ pub fn build(b: *std.Build) void { _ = addPkgTest(b, test_step, "src/s57/s57.zig", target, optimize, &.{ .{ .name = "iso8211", .module = iso8211_mod }, }); - const s100_test = addPkgTest(b, test_step, "src/s100/s100.zig", target, optimize, &.{ + const s101_test = addPkgTest(b, test_step, "src/s101/s101.zig", target, optimize, &.{ .{ .name = "s57", .module = s57_mod }, }); - addCatalogueJson(b, s100_test); // catalogue.zig @embedFile's the JSON + addCatalogueJson(b, s101_test); // catalogue.zig @embedFile's the JSON const tiles_test = addPkgTest(b, test_step, "src/tiles/tiles.zig", target, optimize, &.{}); addMvtFixture(b, tiles_test); // pmtiles.zig's round-trip test embeds it _ = addPkgTest(b, test_step, "src/scene/scene.zig", target, optimize, &.{ .{ .name = "s57", .module = s57_mod }, - .{ .name = "s100", .module = s100_mod }, + .{ .name = "s101", .module = s101_mod }, .{ .name = "tiles", .module = tiles_mod }, .{ .name = "render", .module = render_mod }, .{ .name = "style", .module = style_mod }, @@ -653,7 +653,7 @@ pub fn build(b: *std.Build) void { _ = addPkgTest(b, test_step, "src/render/inspect_view_test.zig", target, optimize, &.{ .{ .name = "portray", .module = portray_mod }, .{ .name = "s57", .module = s57_mod }, - .{ .name = "s100", .module = s100_mod }, + .{ .name = "s101", .module = s101_mod }, .{ .name = "scene", .module = scene_mod }, .{ .name = "render", .module = render_mod }, .{ .name = "tiles", .module = tiles_mod }, diff --git a/src/bake_root.zig b/src/bake_root.zig index bc4beb2..fc7bd7a 100644 --- a/src/bake_root.zig +++ b/src/bake_root.zig @@ -23,8 +23,9 @@ pub const tile = root.tile; pub const iso8211 = root.iso8211; pub const s57 = root.s57; pub const scene = root.scene; -pub const s101_instr = root.s101_instr; -pub const s101_adapt = root.s101_adapt; +pub const s101 = root.s101; +pub const s101_instructions = root.s101_instructions; +pub const s101_adapter = root.s101_adapter; pub const catalogue = root.catalogue; pub const bake_enc = root.bake_enc; pub const geo = @import("geo"); // integer geometry: boolean, plane, partition diff --git a/src/chart.zig b/src/chart.zig index 30ef884..d3062de 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -23,7 +23,7 @@ const s57 = @import("s57"); const scene = @import("scene"); const portray = @import("portray"); const bake_enc = @import("scene").bake_enc; -const catalogue = @import("s100").catalogue; +const catalogue = @import("s101").catalogue; const tile = @import("tiles").tile; const render = @import("render"); const sprite = @import("sprite"); diff --git a/src/lib_root.zig b/src/lib_root.zig index a8612fe..7ac6f64 100644 --- a/src/lib_root.zig +++ b/src/lib_root.zig @@ -13,7 +13,7 @@ pub const engine = @import("engine"); pub const api = @import("tile57.zig"); // the public Zig API (Chart/bake/style) pub const capi = @import("capi.zig"); pub const portray = @import("portray"); -pub const catalogue = @import("s100").catalogue; +pub const catalogue = @import("s101").catalogue; pub const bundle = @import("bundle"); // the chart-bundle pipeline (tile57_bake_bundle rides on capi) comptime { diff --git a/src/portray/portray.zig b/src/portray/portray.zig index 10e8ab2..f783464 100644 --- a/src/portray/portray.zig +++ b/src/portray/portray.zig @@ -1,15 +1,15 @@ -//! Cell-driven S-101 portrayal. Adapts a cell's features (s101_adapt), exposes +//! Cell-driven S-101 portrayal. Adapts a cell's features (s101.adapter), exposes //! them to the embedded Lua via C-ABI accessors (tgp_*), runs the rules once //! (tg_portray_run in lua_shim.c), and returns per-feature instruction streams -//! for translation to MVT (s101_instr). +//! for translation to MVT (s101.instructions). //! //! Lib-only (links Lua via the C shim) — not part of the pure root.zig used by //! the tests/bake exe. const std = @import("std"); const s57 = @import("s57"); -const adapt = @import("s100").s101_adapt; -const catalogue = @import("s100").catalogue; // S-57 OBJL -> acronym for the quesmrk diagnostic +const adapter = @import("s101").adapter; +const catalogue = @import("s101").catalogue; // S-57 OBJL -> acronym for the quesmrk diagnostic // The embedded S-101 rules accessor (tg_embedded_lua) — referenced so its C-ABI // exports land in the compiled module; the Lua `require` searcher in lua_shim.c @@ -19,7 +19,7 @@ comptime { } const Ctx = struct { - adapted: []adapt.Adapted, + adapted: []adapter.Adapted, results: [][]const u8, // per adapted index -> instruction stream (arena-owned) arena: std.mem.Allocator, cell: *const s57.Cell, // for FFPT feature-to-feature association resolution @@ -285,7 +285,7 @@ pub const Context = struct { /// Run the S-101 rules over `adapted` with `ov`, returning a stream array indexed /// by cell.features index (null where the adapted subset doesn't cover a feature, /// or it emitted nothing). `features_len` is cell.features.len. -fn runAdapted(arena: std.mem.Allocator, cell: *const s57.Cell, adapted: []adapt.Adapted, rules_dir: []const u8, pctx: Context) ![]?[]const u8 { +fn runAdapted(arena: std.mem.Allocator, cell: *const s57.Cell, adapted: []adapter.Adapted, rules_dir: []const u8, pctx: Context) ![]?[]const u8 { const results = try arena.alloc([]const u8, adapted.len); for (results) |*r| r.* = ""; @@ -320,7 +320,7 @@ fn runAdapted(arena: std.mem.Allocator, cell: *const s57.Cell, adapted: []adapt. /// feature's S-101 instruction stream (or null if the class is unmapped / it /// emitted nothing). Allocates into `arena` (must outlive tile generation). pub fn portrayCell(arena: std.mem.Allocator, cell: *const s57.Cell, rules_dir: []const u8) ![]?[]const u8 { - const adapted = try adapt.adaptCell(arena, cell); + const adapted = try adapter.adaptCell(arena, cell); return runAdapted(arena, cell, adapted, rules_dir, .{}); } @@ -329,7 +329,7 @@ pub fn portrayCell(arena: std.mem.Allocator, cell: *const s57.Cell, rules_dir: [ /// boundary + point styles evaluate inside the rules, so the output needs none /// of the tile path's live-swap props. One pass, one context. pub fn portrayCellWith(arena: std.mem.Allocator, cell: *const s57.Cell, rules_dir: []const u8, ctx: Context) ![]?[]const u8 { - const adapted = try adapt.adaptCell(arena, cell); + const adapted = try adapter.adaptCell(arena, cell); return runAdapted(arena, cell, adapted, rules_dir, ctx); } @@ -354,9 +354,9 @@ pub const CellPortrayal = struct { /// platforms etc.) matches the full-cell pass exactly; only the rule /// evaluation is subset. Returned arrays are indexed by cell.features index. pub fn portrayCellSubset(arena: std.mem.Allocator, cell: *const s57.Cell, rules_dir: []const u8, mask: []const bool) !CellPortrayal { - const adapted = try adapt.adaptCell(arena, cell); - var subset = std.ArrayList(adapt.Adapted).empty; - var points = std.ArrayList(adapt.Adapted).empty; + const adapted = try adapter.adaptCell(arena, cell); + var subset = std.ArrayList(adapter.Adapted).empty; + var points = std.ArrayList(adapter.Adapted).empty; for (adapted) |ad| { if (ad.feature_index >= mask.len or !mask[ad.feature_index]) continue; try subset.append(arena, ad); @@ -375,14 +375,14 @@ pub fn portrayCellSubset(arena: std.mem.Allocator, cell: *const s57.Cell, rules_ /// vary (areas for bnd, points for pts) — lines/soundings never read either /// override — so the extra rule evaluation is bounded to the relevant features. pub fn portrayCellVariants(arena: std.mem.Allocator, cell: *const s57.Cell, rules_dir: []const u8) !CellPortrayal { - const adapted = try adapt.adaptCell(arena, cell); + const adapted = try adapter.adaptCell(arena, cell); const base = try runAdapted(arena, cell, adapted, rules_dir, .{}); // Partition the adapted features by the variant they can contribute. "Surface" // → the plain-boundary variant; "Point" → the simplified-symbol variant // (SOUNDG is already excluded from `adapted`, and "Curve" varies under neither). - var areas = std.ArrayList(adapt.Adapted).empty; - var points = std.ArrayList(adapt.Adapted).empty; + var areas = std.ArrayList(adapter.Adapted).empty; + var points = std.ArrayList(adapter.Adapted).empty; for (adapted) |ad| { if (std.mem.eql(u8, ad.primitive, "Surface")) { try areas.append(arena, ad); diff --git a/src/render/ascii.zig b/src/render/ascii.zig index f9d95c3..c7bc880 100644 --- a/src/render/ascii.zig +++ b/src/render/ascii.zig @@ -16,7 +16,7 @@ //! Buffering exists for the same reason as pixel.zig: the engine emits //! features in CELL order, but a grid must be WRITTEN in S-52 priority order. //! -//! Rule (surface.zig): no s57/s100/portray imports — everything a character +//! Rule (surface.zig): no s57/s101/portray imports — everything a character //! needs is already on the call. const std = @import("std"); diff --git a/src/render/inspect.zig b/src/render/inspect.zig index 2494154..549e20e 100644 --- a/src/render/inspect.zig +++ b/src/render/inspect.zig @@ -8,7 +8,7 @@ //! It is the mirror image of noop.zig (which discards every call) and ascii.zig //! (which resolves each call to a character): here the recording IS the output. //! -//! Same rule as every surface (surface.zig): no s57 / s100 / portray imports. +//! Same rule as every surface (surface.zig): no s57 / s101 / portray imports. //! The FeatureMeta the engine hands beginFeature already carries the S-57 class //! acronym + the acronym→value pick blob, and the draw calls carry color tokens / //! symbol names verbatim — everything the record needs is on the contract. diff --git a/src/render/inspect_view_test.zig b/src/render/inspect_view_test.zig index 08de6da..939bef2 100644 --- a/src/render/inspect_view_test.zig +++ b/src/render/inspect_view_test.zig @@ -5,7 +5,7 @@ //! CLI performs: //! //! 1. raw S-57 — FeatureMeta.class / .s57_json on the recorded feature -//! 2. S-101 stream — portray.portrayCell → s101_instr.parse +//! 2. S-101 stream — portray.portrayCell → s101.instructions.parse //! 3. resolved calls — the Surface calls the InspectSurface captured //! //! End-to-end seam: portray -> instruction parse -> geometry/clip -> Surface @@ -120,7 +120,7 @@ test "inspect view: real rules -> InspectSurface records the 3 levels per featur try std.testing.expect(std.mem.startsWith(u8, depare_fill.?, "DEP")); // a depth-shade token // Its level-2 stream parses to a fill instruction (ColorFill:DEP..). - const depare_parsed = try @import("s100").s101_instr.parse(a, streams[0].?); + const depare_parsed = try @import("s101").instructions.parse(a, streams[0].?); try std.testing.expect(depare_parsed.fill_token != null); // --- COALNE: a line feature resolves to a stroke. --- @@ -137,7 +137,7 @@ test "inspect view: real rules -> InspectSurface records the 3 levels per featur const buoy = byClass(&is, "BOYLAT") orelse return error.NoBuoy; try std.testing.expect(std.mem.indexOf(u8, buoy.meta.s57_json, "OBJNAM") != null); // level 1 - const buoy_parsed = try @import("s100").s101_instr.parse(a, streams[2].?); + const buoy_parsed = try @import("s101").instructions.parse(a, streams[2].?); try std.testing.expect(buoy_parsed.points.len >= 1); // level 2: a symbol instruction var buoy_symbol: ?[]const u8 = null; diff --git a/src/render/render.zig b/src/render/render.zig index 1d62f53..99839c4 100644 --- a/src/render/render.zig +++ b/src/render/render.zig @@ -16,7 +16,7 @@ //! ascii — AsciiSurface: the chart as a Unicode text grid (optional //! ANSI-256 color). The worked example of adding a backend. //! -//! Rule: nothing in this module imports s57, s100, or portray. If a surface +//! Rule: nothing in this module imports s57, s101, or portray. If a surface //! needs a fact the Surface calls don't carry, that's an engine bug — extend //! the contract, never back-channel. diff --git a/src/render/surface.zig b/src/render/surface.zig index 0f03235..979ea03 100644 --- a/src/render/surface.zig +++ b/src/render/surface.zig @@ -6,7 +6,7 @@ //! can serialize them for clients that re-style without re-baking; pixel //! surfaces resolve them through a shared lowering layer. //! -//! Rule: surfaces must NOT import s57, s100, or portray. If a surface needs a +//! Rule: surfaces must NOT import s57, s101, or portray. If a surface needs a //! fact the calls don't carry, that's an engine bug — extend the contract. //! //! Mirrors the original Go RenderSurface interface (internal/s52render). @@ -74,7 +74,7 @@ pub const TextStyle = struct { /// Per-feature S-52 metadata, bracketed around each feature's draw calls via /// beginFeature / endFeature. All pick data is pre-computed by the engine so -/// surfaces need not import s57/s100. +/// surfaces need not import s57/s101. pub const FeatureMeta = struct { draw_prio: i64 = 0, plane: i64 = 0, // S-101 DisplayPlane: 0 UnderRadar (default), 1 OverRadar diff --git a/src/root.zig b/src/root.zig index 922f8d4..846e337 100644 --- a/src/root.zig +++ b/src/root.zig @@ -16,10 +16,10 @@ pub const tile = @import("tiles").tile; pub const iso8211 = @import("s57").iso8211; pub const s57 = @import("s57"); pub const scene = @import("scene"); -pub const s100 = @import("s100"); -pub const s101_instr = s100.s101_instr; -pub const s101_adapt = s100.s101_adapt; -pub const catalogue = s100.catalogue; +pub const s101 = @import("s101"); +pub const s101_instructions = s101.instructions; +pub const s101_adapter = s101.adapter; +pub const catalogue = s101.catalogue; pub const bake_enc = @import("scene").bake_enc; // banded multi-cell ENC_ROOT -> PMTiles pub const style = @import("style"); // colortables, line styles, and style.json generation pub const chartstyle = @import("style").chartstyle; // mariner-driven MapLibre style patching @@ -33,8 +33,8 @@ test { _ = iso8211; _ = s57; _ = scene; - _ = s101_instr; - _ = s101_adapt; + _ = s101_instructions; + _ = s101_adapter; _ = catalogue; _ = bake_enc; _ = style; diff --git a/src/s100/s100.zig b/src/s100/s100.zig deleted file mode 100644 index 6248526..0000000 --- a/src/s100/s100.zig +++ /dev/null @@ -1,15 +0,0 @@ -//! s100 — S-100 portrayal model. The distilled S-101 catalogue plus the -//! S-57 → S-101 adaptation and instruction layers. Pure Zig (no libc/Lua); -//! the Lua portrayal *runner* lives in engine's portray.zig. Mirrors the Go -//! oracle's `pkg/s100/*`. Built as a standalone Zig module; the embedded -//! catalogue/s57-codes JSON is attached in build.zig via addCatalogueJson. - -pub const catalogue = @import("catalogue.zig"); -pub const s101_adapt = @import("s101_adapt.zig"); -pub const s101_instr = @import("s101_instr.zig"); - -test { - _ = catalogue; - _ = s101_adapt; - _ = s101_instr; -} diff --git a/src/s100/s101_adapt.zig b/src/s101/adapter.zig similarity index 100% rename from src/s100/s101_adapt.zig rename to src/s101/adapter.zig diff --git a/src/s100/catalogue.zig b/src/s101/catalogue.zig similarity index 99% rename from src/s100/catalogue.zig rename to src/s101/catalogue.zig index ce5c564..cb61dfc 100644 --- a/src/s100/catalogue.zig +++ b/src/s101/catalogue.zig @@ -290,7 +290,7 @@ pub fn attrAcronym(attl: u16) ?[]const u8 { return c.attr_acronym.get(attl); } -// ---- Zig-side lookups (for s101_adapt) ----------------------------------- +// ---- Zig-side lookups (for the S-101 adapter) ---------------------------- pub fn resolveFeature(s57_acronym: []const u8) ?[]const u8 { ensureLoaded(); diff --git a/src/s100/s101_instr.zig b/src/s101/instructions.zig similarity index 100% rename from src/s100/s101_instr.zig rename to src/s101/instructions.zig diff --git a/src/s101/s101.zig b/src/s101/s101.zig new file mode 100644 index 0000000..8d5d2d0 --- /dev/null +++ b/src/s101/s101.zig @@ -0,0 +1,18 @@ +//! s101 — the S-101 portrayal model: the distilled S-101 catalogue plus the +//! S-57 -> S-101 adaptation and instruction layers. Pure Zig (no libc/Lua); the +//! Lua portrayal *runner* lives in the separate `portray` module. The embedded +//! catalogue / S-57-code JSON is attached in build.zig via addCatalogueJson. +//! +//! * catalogue — the distilled S-101 feature/attribute catalogue +//! * adapter — S-57 cell features -> S-101 feature/attribute records +//! * instructions — the portrayal instruction stream (points, lines, text) + +pub const catalogue = @import("catalogue.zig"); +pub const adapter = @import("adapter.zig"); +pub const instructions = @import("instructions.zig"); + +test { + _ = catalogue; + _ = adapter; + _ = instructions; +} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 8bb8755..87873e4 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -29,9 +29,9 @@ pub const compose = @import("compose.zig"); // per-cell-composite clip-to-owned- /// Output tile encoding: classic Mapbox Vector Tile, or MapLibre Tile. pub const TileFormat = enum { mvt, mlt }; -const s101 = @import("s100").s101_instr; -const catalogue = @import("s100").catalogue; -const s101_adapt = @import("s100").s101_adapt; +const instructions = @import("s101").instructions; +const catalogue = @import("s101").catalogue; +const adapter = @import("s101").adapter; // S-52 symbol scale the Go baker emits for every point symbol / sounding. The // style's icon-size = scale / ATLAS_PPU (0.08), so this renders symbols at @@ -220,7 +220,7 @@ fn quaposSolidClass(objl: u16) bool { } /// True if the S-57 comma-separated list value `csv` contains any of `targets`. -/// (Mirrors s101_adapt.hasListVal; S-57 list attributes are comma-joined.) +/// (Mirrors adapter.hasListVal; S-57 list attributes are comma-joined.) fn listHasAny(csv: []const u8, targets: []const i64) bool { var it = std.mem.splitScalar(u8, csv, ','); while (it.next()) |p| { @@ -1505,11 +1505,11 @@ fn variantDiffers(base: []const u8, variant: ?[]const u8) bool { /// processFeatureParsed, splitting into two passes only when a variant differs /// (S-52 boundary §8.6.1 / point-symbol §11.2.2 axes -> the bnd/pts tags). fn processFeatureInstr(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, geo_world: ?GeoWorld, instr: []const u8, plain: ?[]const u8, simplified: ?[]const u8, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { - const base = try s101.parse(a, instr); + const base = try instructions.parse(a, instr); if (f.prim == 1) { if (variantDiffers(instr, simplified)) { try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, 2, 0, z, x, y, tb, box, opts, surf); - const sp2 = try s101.parse(a, simplified.?); + const sp2 = try instructions.parse(a, simplified.?); try processFeatureParsed(a, cell, f, fi, geo, geo_world, sp2, 2, 1, z, x, y, tb, box, opts, surf); } else { try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, 2, 2, z, x, y, tb, box, opts, surf); @@ -1518,7 +1518,7 @@ fn processFeatureInstr(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, } if (f.prim == 3 and variantDiffers(instr, plain)) { try processFeatureParsed(a, cell, f, fi, geo, geo_world, base, 1, 2, z, x, y, tb, box, opts, surf); - const pl = try s101.parse(a, plain.?); + const pl = try instructions.parse(a, plain.?); try processFeatureParsed(a, cell, f, fi, geo, geo_world, pl, 0, 2, z, x, y, tb, box, opts, surf); return; } @@ -1551,10 +1551,10 @@ fn bearingToScreen(deg: f64) [2]f64 { /// (bake_enc.bandZooms(.approach).min = 11). Partial sector arcs are unaffected. const RING_MIN_ZOOM: u8 = 11; -fn emitAugFigures(a: Allocator, figs: []const s101.AugFigure, anchor: s57.LonLat, fmeta: rs.FeatureMeta, z: u8, x: u32, y: u32, box: tile.Box, surf: rs.Surface) !void { +fn emitAugFigures(a: Allocator, figs: []const instructions.AugFigure, anchor: s57.LonLat, fmeta: rs.FeatureMeta, z: u8, x: u32, y: u32, box: tile.Box, surf: rs.Surface) !void { if (figs.len == 0) return; const world_px = 256.0 * @as(f64, @floatFromInt(@as(u64, 1) << @intCast(z))); - const pxmm = s101.PX_PER_MM; + const pxmm = instructions.PX_PER_MM; for (figs) |fig| { const alon = if (fig.has_anchor) fig.anchor_lon else anchor.lon(); const alat = if (fig.has_anchor) fig.anchor_lat else anchor.lat(); @@ -1630,7 +1630,7 @@ fn appendDepthVals(a: Allocator, props: *std.ArrayList(mvt.Prop), f: s57.Feature /// every primitive with the pass's meta (draw_prio/cat/vg/scamin/bnd/pts + pick /// attrs). The engine work happens here — geometry assembly, projection, tile /// clipping/simplification, anchoring — so surfaces only ever see draw calls. -fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, geo_world: ?GeoWorld, p: s101.Portrayal, bnd: i64, pts: i64, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { +fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, geo_world: ?GeoWorld, p: instructions.Portrayal, bnd: i64, pts: i64, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { const scamin = effScamin(f, opts); const s57_json = if (opts.pick_attrs) try encodeS57Attrs(a, f) else ""; const cell_name = if (opts.pick_attrs) cell.name else ""; @@ -2062,7 +2062,7 @@ fn syminsFormatSubstitute(a: Allocator, f: s57.Feature, format: []const u8, name /// Parse a SYMINS TX()/TE() instruction into a Text. The Text model carries the /// label string, colour and viewing group; the S-52 justification / offset / font / /// halo fields are dropped (tracked OpText findings), matching the current text path. -fn syminsText(a: Allocator, f: s57.Feature, op: []const u8, params: []const u8) !?s101.Text { +fn syminsText(a: Allocator, f: s57.Feature, op: []const u8, params: []const u8) !?instructions.Text { const args = try syminsSplitArgs(a, params); var text: []const u8 = ""; var color_idx: usize = undefined; @@ -2093,20 +2093,20 @@ fn syminsText(a: Allocator, f: s57.Feature, op: []const u8, params: []const u8) var color = std.mem.trim(u8, syminsArgAt(args, color_idx), " \t"); if (color.len == 0) color = "CHBLK"; const group = std.fmt.parseInt(i64, std.mem.trim(u8, syminsArgAt(args, display_idx), " \t"), 10) catch 0; - return s101.Text{ .text = text, .color = color, .group = group }; + return instructions.Text{ .text = text, .color = color, .group = group }; } /// Build an S-101 Portrayal from a NEWOBJ's SYMINS attribute, or null when there is /// no usable SYMINS (caller then falls back to the default new-object symbology). /// Geometry/anchoring/clipping is handled by processFeatureParsed exactly like a rule stream. -fn buildSyminsPortrayal(a: Allocator, f: s57.Feature) !?s101.Portrayal { +fn buildSyminsPortrayal(a: Allocator, f: s57.Feature) !?instructions.Portrayal { const raw0 = f.attr(SYMINS_ATTR) orelse return null; const raw = std.mem.trim(u8, raw0, " "); if (raw.len == 0) return null; - var points = std.ArrayList(s101.Point).empty; - var texts = std.ArrayList(s101.Text).empty; - var lines = std.ArrayList(s101.Line).empty; + var points = std.ArrayList(instructions.Point).empty; + var texts = std.ArrayList(instructions.Text).empty; + var lines = std.ArrayList(instructions.Line).empty; var patterns = std.ArrayList([]const u8).empty; var fill_token: ?[]const u8 = null; @@ -2146,7 +2146,7 @@ fn buildSyminsPortrayal(a: Allocator, f: s57.Feature) !?s101.Portrayal { } if (points.items.len == 0 and texts.items.len == 0 and lines.items.len == 0 and patterns.items.len == 0 and fill_token == null) return null; - return s101.Portrayal{ + return instructions.Portrayal{ .fill_token = fill_token, .patterns = patterns.items, .lines = lines.items, @@ -2415,7 +2415,7 @@ fn emitSweptAreaFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize /// limit: stroke each ring with the MARSYS51 complex linestyle — the dashed "—A——B—" /// pattern carrying the EMMARS01 (IALA-A) and EMMARS02 (IALA-B) letter symbols — or /// NAVARE51 when ORIENT marks a direction of buoyage. The S-101 catalogue routes -/// M_NSYS to nothing (s101_adapt excludes it so this rule owns it), so without this +/// M_NSYS to nothing (the S-101 adapter excludes it so this rule owns it), so without this /// the boundary draws nothing. DrawingPriority 12. NOTE: the ORIENT-only DIRBOY /// direction-of-buoyage arrow (DIRBOY01/A1/B1, CentreOnArea) is not yet ported — /// absent on the reference data; the A/B boundary line is the visible feature. @@ -2909,7 +2909,7 @@ fn appendCellFeatures( try emitNavSystemFallback(a, cell.*, f, fi, geo, z, x, y, tb, box, fopts, surf); continue; } - if (f.objl != s57.OBJL_TOPMAR and s101_adapt.resolveClass(f) == null) { + if (f.objl != s57.OBJL_TOPMAR and adapter.resolveClass(f) == null) { if (!fopts.suppress_points) try emitCentredSymbol(a, cell.*, f, fi, geo, "QUESMRK1", 6, 1, z, x, y, tb, fopts, surf); continue; } diff --git a/src/style/style.zig b/src/style/style.zig index 876aaab..41a8f36 100644 --- a/src/style/style.zig +++ b/src/style/style.zig @@ -4,7 +4,7 @@ //! two stay in sync. //! //! Pure Zig (no libc/fs): callers read the catalogue bytes and pass them in, the -//! same shape as s100/catalogue.zig. RGB lives only in the color tables; the +//! same shape as s101/catalogue.zig. RGB lives only in the color tables; the //! tiles carry color *tokens*. //! //! * color tables — token -> hex, one per day/dusk/night palette diff --git a/src/tile57.zig b/src/tile57.zig index d1c56a2..f19594c 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -16,7 +16,7 @@ //! - Portrayal assets: `assets` (colortables, linestyles, sprite/pattern) //! - Style patching: `chartstyle` //! - Tiling: `mvt`, `tile`, `pmtiles`, `bake_enc`, `scene` -//! - Raw formats: `formats.{iso8211, s57, s100}` +//! - Raw formats: `formats.{iso8211, s57, s101}` const std = @import("std"); @@ -65,7 +65,7 @@ pub const scene = @import("scene"); // S-57 feature → MVT tile pub const formats = struct { pub const iso8211 = @import("s57").iso8211; // ISO/IEC 8211 records pub const s57 = @import("s57"); // S-57 ENC cell parser + geometry - pub const s100 = @import("s100"); // S-100/S-101 catalogue + adaptation + pub const s101 = @import("s101"); // S-101 catalogue + adaptation + instructions }; test { diff --git a/tools/ascii.zig b/tools/ascii.zig index 50b8aa1..bb27e4b 100644 --- a/tools/ascii.zig +++ b/tools/ascii.zig @@ -274,7 +274,7 @@ fn runAsciiTui(io: std.Io, a: std.mem.Allocator, c: *chart.Chart, lon0: f64, lat // 2. S-101 portrayal — the ';'-separated Key:Value instruction stream the Lua // rules emit (portray.portrayCell), RAW and PARSED into // symbols / lines / fills / texts / aug figures -// (s101_instr.parse). +// (s101.instructions.parse). // 3. Resolved calls — what the portrayal BECOMES after geometry resolution: // the Surface vtable calls, captured by the recording // InspectSurface (render/inspect.zig) driven through diff --git a/tools/explore.zig b/tools/explore.zig index efbd621..6d48499 100644 --- a/tools/explore.zig +++ b/tools/explore.zig @@ -95,7 +95,7 @@ const ExRow = struct { s101: []const u8, // S-101 feature-class name ("Light") or "" attrs: []const ExAttr, raw: ?[]const u8, // level 2: raw instruction stream - parsed: ?engine.s101_instr.Portrayal, // level 2: parsed + parsed: ?engine.s101_instructions.Portrayal, // level 2: parsed resolved: ?ExLevel3, // level 3 thumb: ?ExThumb, // --kitty: where to render this feature's thumbnail (null = no geometry) }; @@ -422,7 +422,7 @@ fn exBuildRow(a: std.mem.Allocator, cell: *engine.s57.Cell, cell_name: []const u } const raw: ?[]const u8 = if (portrayal) |p| (if (fi < p.len) p[fi] else null) else null; - const parsed: ?engine.s101_instr.Portrayal = if (raw) |s| (engine.s101_instr.parse(a, s) catch null) else null; + const parsed: ?engine.s101_instructions.Portrayal = if (raw) |s| (engine.s101_instructions.parse(a, s) catch null) else null; const resolved: ?ExLevel3 = if (ctx) |c| try exFoldResolved(a, f, class, c) else null; const thumb: ?ExThumb = if (F.kitty) exThumbView(a, cell, f) else null; diff --git a/tools/s101_coverage.zig b/tools/s101_coverage.zig index a27519b..bb61e4a 100644 --- a/tools/s101_coverage.zig +++ b/tools/s101_coverage.zig @@ -15,7 +15,7 @@ //! //! 2. ADAPTER COVERAGE (Task 3): classify each consumed attribute as //! (a) SOURCED - non-empty S-57 `alias` in catalogue.json, so the adapter's -//! resolveAttrByCode forwards it (s101_adapt.zig:468). +//! resolveAttrByCode forwards it (adapter.zig:468). //! (b) DEFAULTED - the adapter synthesizes it without an S-57 alias (light //! sectors, topmark, featureName<-OBJNAM, zoneOfConfidence //! <-CATZOC, ...; ADAPTER_SYNTHESIZED below). @@ -36,10 +36,10 @@ const PERMITTED = "vendor/s101/permitted.json"; // per-class permitted enum valu const RULES_DIR = "vendor/S-101_Portrayal-Catalogue/PortrayalCatalog/Rules"; // Attributes the adapter synthesizes/derives WITHOUT a direct S-57 alias. -// Sourced verbatim from src/s100/s101_adapt.zig (2026-07-01); grow this when the +// Sourced verbatim from src/s101/adapter.zig (2026-07-01); grow this when the // adapter grows new synthesis. const ADAPTER_SYNTHESIZED = [_][]const u8{ - // orientation / clearance complexes (s101_adapt.zig:122-127, 494-501) + // orientation / clearance complexes (adapter.zig:122-127, 494-501) "orientation", "orientationValue", "verticalClearanceClosed", "verticalClearanceFixed", "verticalClearanceOpen", "verticalClearanceValue", @@ -47,13 +47,13 @@ const ADAPTER_SYNTHESIZED = [_][]const u8{ // Gate's HORCLR -> horizontalClearanceOpen (class-keyed; the other 8 classes that // reference it bind …Fixed, so its absence there is an expected-absence) "horizontalClearanceOpen", - // openingBridge synthesized from CATBRG 2..8 for BRIDGE->Bridge (s101_adapt.zig) + // openingBridge synthesized from CATBRG 2..8 for BRIDGE->Bridge (adapter.zig) "openingBridge", - // current velocity CURVEL -> speed.speedMaximum (s101_adapt.zig complex_from_simple) + // current velocity CURVEL -> speed.speedMaximum (adapter.zig complex_from_simple) "speed", "speedMaximum", "speedMinimum", // NATSUR list -> surfaceCharacteristics[].natureOfSurface for SeabedArea - // (s101_adapt.zig buildSurfaceCharacteristics; off-list values dropped) + // (adapter.zig buildSurfaceCharacteristics; off-list values dropped) "surfaceCharacteristics", // CATMOR {1,2} -> categoryOfDolphin for MORFAC-routed Dolphin (same coding) "categoryOfDolphin", @@ -61,9 +61,9 @@ const ADAPTER_SYNTHESIZED = [_][]const u8{ "valueOfLocalMagneticAnomaly", "magneticAnomalyValue", // inTheWater: producer spatial derivation (point over DEPARE and not over LNDARE), - // no S-57 source (s101_adapt.zig readsInTheWater); true-only, else absent + // no S-57 source (adapter.zig readsInTheWater); true-only, else absent "inTheWater", - // light sector + rhythm synthesis (s101_adapt.zig:376-431, 697-710) + // light sector + rhythm synthesis (adapter.zig:376-431, 697-710) "sectorCharacteristics", "lightSector", "sectorLimit", "sectorLimitOne", "sectorLimitTwo", "sectorBearing", @@ -71,21 +71,21 @@ const ADAPTER_SYNTHESIZED = [_][]const u8{ "lightCharacteristic", "signalGroup", "signalPeriod", "valueOfNominalRange", "lightVisibility", - // featureName from OBJNAM (s101_adapt.zig:466, 477-480) + // featureName from OBJNAM (adapter.zig:466, 477-480) "featureName", "name", "language", "nameUsage", - // zoneOfConfidence from M_QUAL CATZOC (s101_adapt.zig:482-489) + // zoneOfConfidence from M_QUAL CATZOC (adapter.zig:482-489) "zoneOfConfidence", "categoryOfZoneOfConfidenceInData", - // topmark fold (s101_adapt.zig:513-522, 641-643) + // topmark fold (adapter.zig:513-522, 641-643) "topmark", "topmarkDaymarkShape", - // QUAPOS aggregate -> feature attr (s101_adapt.zig:552-557) + // QUAPOS aggregate -> feature attr (adapter.zig:552-557) "qualityOfHorizontalMeasurement", - // depth range served for DepthArea/DepthContour (s101_adapt.zig:607-610) + // depth range served for DepthArea/DepthContour (adapter.zig:607-610) "depthRangeMinimumValue", - // underwater-hazard depths for UDWHAZ05/OBSTRN07/WRECKS05 (s101_adapt.zig:544-549): + // underwater-hazard depths for UDWHAZ05/OBSTRN07/WRECKS05 (adapter.zig:544-549): // defaultClearanceDepth always, surroundingDepth when the danger sits in a depth area "surroundingDepth", "defaultClearanceDepth", @@ -109,7 +109,7 @@ const VALUE_REMAP = [_]AttrNote{ }; // Acronyms whose VALUE_REMAP the adapter now applies before the value reaches a -// rule (s101_adapt.zig s65RemapValue / s65RemapQuapos): the prohibited/remapped +// rule (adapter.zig s65RemapValue / s65RemapQuapos): the prohibited/remapped // values are dropped or mapped, and every surviving value is inside the S-101 // allowable list — so the "remap" concern is retired. The per-object RESTRICTED // allowable-list axis, if any, is still reported (a separate concern). Grow this as @@ -142,11 +142,11 @@ const RESTRICTED = [_]AttrObjs{ .{ .acr = "BOYSHP", .objs = &.{"MORFAC"} }, }; -// `enforced` = the adapter drops this attribute write-side (s101_adapt.zig DROP_ATTRS / +// `enforced` = the adapter drops this attribute write-side (adapter.zig DROP_ATTRS / // isDroppedAttr), so the value never reaches a rule and the slot is NOT at-risk even if // a rule reads it. Defaults true: every §E pair below is enforced. A future §E entry the // adapter hasn't wired yet sets `.enforced = false` and still flags (the CI safety net, -// like VALUE_REMAP vs VALUE_REMAP_DONE). Keep this table in sync with s101_adapt.zig. +// like VALUE_REMAP vs VALUE_REMAP_DONE). Keep this table in sync with adapter.zig. const ObjAttr = struct { obj: []const u8, acr: []const u8, enforced: bool = true }; // Per-object attribute drops — "will not be converted" for that feature (gaps doc §E). // Enforced write-side by the adapter; the entries stay as the §E reference + safety net. From a910301120713c5dbc4e68ee1b4bf051d532e036 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 18:31:46 -0400 Subject: [PATCH 075/140] refactor(style): rename chartstyle to mariner; MarinerSettings to Settings chartstyle.zig read as a second MapLibre-style generator alongside maplibre.zig. It is really the S-52 mariner settings model plus the builders that encode those settings as MapLibre expressions, which maplibre.zig calls for the mariner-driven parts of the style. src/style/chartstyle.zig -> src/style/mariner.zig member style.chartstyle -> style.mariner type MarinerSettings -> Settings (style.mariner.Settings, render.resolve.Settings) The header now states the role plainly and drops the C++/Go provenance cross-references. The C ABI tile57_mariner / Go Mariner names are unchanged. Co-Authored-By: Claude Fable 5 --- bindings/parity/parity.zig | 4 +- bindings/shared/settings.zig | 22 +++---- bindings/wasm/style_wasm.zig | 4 +- src/capi.zig | 18 ++--- src/chart.zig | 10 +-- src/render/ascii.zig | 10 +-- src/render/ascii_view_test.zig | 2 +- src/render/pixel.zig | 22 +++---- src/render/pixel_golden_test.zig | 2 +- src/render/resolve.zig | 40 ++++++------ src/render/vector.zig | 6 +- src/root.zig | 4 +- src/scene/scene.zig | 2 +- src/style/maplibre.zig | 80 +++++++++++------------ src/style/{chartstyle.zig => mariner.zig} | 62 ++++++++---------- src/style/style.zig | 12 ++-- src/tile57.zig | 6 +- tools/ascii.zig | 4 +- tools/explore.zig | 10 +-- tools/render.zig | 2 +- 20 files changed, 159 insertions(+), 163 deletions(-) rename src/style/{chartstyle.zig => mariner.zig} (90%) diff --git a/bindings/parity/parity.zig b/bindings/parity/parity.zig index 0fc52d1..aff1b19 100644 --- a/bindings/parity/parity.zig +++ b/bindings/parity/parity.zig @@ -1,7 +1,7 @@ //! style-parity — the NATIVE oracle for the wasm style engine. //! //! It embeds the SAME template + colortables as bindings/wasm/style_wasm.zig and -//! drives the SAME `chartstyle.buildStyle` through the SAME shared settings +//! drives the SAME `the mariner builders` through the SAME shared settings //! parser — only the compilation target differs (native vs wasm32). So a diff of //! this tool's output against the wasm/JS output for identical settings + now_unix //! is a true byte-for-byte parity check of the engine across the two backends. @@ -13,7 +13,7 @@ const std = @import("std"); const style = @import("style"); -const chartstyle = @import("style").chartstyle; +const mariner = @import("style").mariner; const settings = @import("settings"); const template_json = @embedFile("template_json"); diff --git a/bindings/shared/settings.zig b/bindings/shared/settings.zig index 21cf18d..1d492f6 100644 --- a/bindings/shared/settings.zig +++ b/bindings/shared/settings.zig @@ -1,18 +1,18 @@ -//! settings — parse a mariner-settings JSON blob into `chartstyle.MarinerSettings`. +//! settings — parse a mariner-settings JSON blob into `mariner.Settings`. //! //! Shared by the wasm entry point (bindings/wasm/style_wasm.zig) and the native //! parity harness (bindings/parity/parity.zig) so the two CANNOT drift: a parity //! diff then exercises the identical settings->buildStyle path on both targets. //! -//! Schema: a JSON object whose keys mirror the MarinerSettings field names. Enums +//! Schema: a JSON object whose keys mirror the Settings field names. Enums //! are their string forms ("day"/"dusk"/"night", "meters"/"feet", //! "symbolized"/"plain"). Any absent or malformed field keeps its canonical //! default, so a partial (or empty, or invalid) blob still yields a usable style. const std = @import("std"); -const chartstyle = @import("style").chartstyle; +const mariner = @import("style").mariner; -pub const MarinerSettings = chartstyle.MarinerSettings; +pub const Settings = mariner.Settings; fn asF64(v: std.json.Value) ?f64 { return switch (v) { @@ -38,10 +38,10 @@ fn getStr(o: *const std.json.ObjectMap, key: []const u8) ?[]const u8 { return null; } -/// Parse `json` into MarinerSettings. `a` must outlive the returned settings AND +/// Parse `json` into Settings. `a` must outlive the returned settings AND /// the subsequent buildStyle call (a pinned `date_view` string is duped into it). -pub fn parse(a: std.mem.Allocator, json: []const u8) MarinerSettings { - var m = MarinerSettings{}; +pub fn parse(a: std.mem.Allocator, json: []const u8) Settings { + var m = Settings{}; if (json.len == 0) return m; const parsed = std.json.parseFromSliceLeaky(std.json.Value, a, json, .{}) catch return m; if (parsed != .object) return m; @@ -92,7 +92,7 @@ pub fn parse(a: std.mem.Allocator, json: []const u8) MarinerSettings { test "parse: empty / invalid -> all defaults" { const a = std.testing.allocator; - const d = MarinerSettings{}; + const d = Settings{}; { var arena = std.heap.ArenaAllocator.init(a); defer arena.deinit(); @@ -117,9 +117,9 @@ test "parse: fields + enums + partial override" { \\ "safety_contour":12.5,"deep_contour":40,"four_shade_water":false, \\ "display_other":true,"date_view":"20240115"} ); - try std.testing.expectEqual(chartstyle.Scheme.night, m.scheme); - try std.testing.expectEqual(chartstyle.DepthUnit.feet, m.depth_unit); - try std.testing.expectEqual(chartstyle.BoundaryStyle.plain, m.boundary_style); + try std.testing.expectEqual(mariner.Scheme.night, m.scheme); + try std.testing.expectEqual(mariner.DepthUnit.feet, m.depth_unit); + try std.testing.expectEqual(mariner.BoundaryStyle.plain, m.boundary_style); try std.testing.expectEqual(@as(f64, 12.5), m.safety_contour); try std.testing.expectEqual(@as(f64, 40), m.deep_contour); try std.testing.expectEqual(false, m.four_shade_water); diff --git a/bindings/wasm/style_wasm.zig b/bindings/wasm/style_wasm.zig index ba491d1..d1f4c00 100644 --- a/bindings/wasm/style_wasm.zig +++ b/bindings/wasm/style_wasm.zig @@ -1,5 +1,5 @@ //! style_wasm — a tiny `wasm32-freestanding` entry point around the pure-Zig -//! `chartstyle.buildStyle`, so a browser / Node front-end can turn S-52 "mariner +//! `the mariner builders`, so a browser / Node front-end can turn S-52 "mariner //! settings" into a concrete MapLibre style.json entirely client-side. //! //! The MapLibre style *template* and the S-52 *colortables* are @embedFile'd at @@ -22,7 +22,7 @@ const std = @import("std"); const style = @import("style"); -const chartstyle = @import("style").chartstyle; +const mariner = @import("style").mariner; const settings = @import("settings"); // Base MapLibre style template + S-52 colortables, embedded at build time. diff --git a/src/capi.zig b/src/capi.zig index ecd7233..d635404 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -8,7 +8,7 @@ const std = @import("std"); const chart = @import("chart.zig"); const s57 = @import("s57"); const bundle = @import("bundle"); // the whole chart-bundle pipeline (tiles + assets + manifest) -const chartstyle = @import("style").chartstyle; +const mariner = @import("style").mariner; const style = @import("style"); // The S-52 ColorProfiles/colorProfile.xml baked into the library (build.zig), so // the style C ABI generates colortables + a base style template with no on-disk @@ -450,7 +450,7 @@ export fn tile57_chart_render_view( ) callconv(.c) c_int { const c = handle orelse return -1; if (width == 0 or height == 0 or width > 16384 or height > 16384) return -2; - const settings: chartstyle.MarinerSettings = if (m) |p| marinerFromC(p) else .{}; + const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; const palette: RenderPalette = switch (settings.scheme) { .day => .day, .dusk => .dusk, @@ -554,7 +554,7 @@ export fn tile57_chart_render_pdf( ) callconv(.c) c_int { const c = handle orelse return -1; if (width == 0 or height == 0 or width > 16384 or height > 16384) return -2; - const settings: chartstyle.MarinerSettings = if (m) |p| marinerFromC(p) else .{}; + const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; const palette: RenderPalette = switch (settings.scheme) { .day => .day, .dusk => .dusk, @@ -590,7 +590,7 @@ export fn tile57_chart_render_view_cb( const c = handle orelse return -1; const cb = canvas orelse return -2; if (width == 0 or height == 0 or width > 16384 or height > 16384) return -2; - const settings: chartstyle.MarinerSettings = if (m) |p| marinerFromC(p) else .{}; + const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; const palette: RenderPalette = switch (settings.scheme) { .day => .day, .dusk => .dusk, @@ -626,7 +626,7 @@ export fn tile57_chart_render_surface_cb( const c = handle orelse return -1; const sfc = surface orelse return -2; if (width == 0 or height == 0 or width > 16384 or height > 16384) return -2; - const settings: chartstyle.MarinerSettings = if (m) |p| marinerFromC(p) else .{}; + const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; const palette: RenderPalette = switch (settings.scheme) { .day => .day, .dusk => .dusk, @@ -856,11 +856,11 @@ fn dateViewSlice(buf: *const [9]u8) []const u8 { return buf[0..@min(n, 8)]; } -// Translate the extern CMariner into the internal chartstyle.MarinerSettings the +// Translate the extern CMariner into the internal mariner.Settings the // style builders take. The returned value borrows `cm`'s date_view and // viewing_groups_off storage, so `cm` (and its viewing_groups_off array) must // outlive every use of the result — true within a single ABI call. -fn marinerFromC(cm: *const CMariner) chartstyle.MarinerSettings { +fn marinerFromC(cm: *const CMariner) mariner.Settings { return .{ .scheme = switch (cm.scheme) { 1 => .dusk, @@ -940,7 +940,7 @@ export fn tile57_build_style( defer if (scamin_buf.len > 0) gpa.free(scamin_buf); const now_unix: i64 = @intCast(time(null)); // Single style builder: regenerate the full style with the mariner baked in - // (chartstyle.buildStyle's template-patch pass is retired). buildFromTemplate lifts + // (mariner.buildStyle's template-patch pass is retired). buildFromTemplate lifts // the source config out of the passed template and drives the one styleJson. const style_json = style.buildFromTemplateScamin(gpa, tmpl, &m, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; out.* = style_json.ptr; @@ -1042,7 +1042,7 @@ export fn tile57_style_template( /// Fill `cm` with the canonical default mariner settings. date_view = "". export fn tile57_mariner_defaults(cm: *CMariner) callconv(.c) void { - const d = chartstyle.MarinerSettings{}; + const d = mariner.Settings{}; cm.* = .{ .scheme = @intCast(@intFromEnum(d.scheme)), .shallow_contour = d.shallow_contour, diff --git a/src/chart.zig b/src/chart.zig index d3062de..f22e6f4 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -1258,7 +1258,7 @@ pub const Chart = struct { /// labels + declutter over the whole canvas. Returns PNG bytes (gpa-owned; /// free with freeBytes). Cell-backed sources only; a baked PMTiles source /// has no portrayal to render from (bundle-sourced rendering is future work). - pub fn renderView(self: *Chart, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.MarinerSettings, output: render.pixel.Output, cb_table: ?*const render.cb_canvas.CCanvas) ![]u8 { + pub fn renderView(self: *Chart, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, output: render.pixel.Output, cb_table: ?*const render.cb_canvas.CCanvas) ![]u8 { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); @@ -1336,7 +1336,7 @@ pub const Chart = struct { /// tiles, emitting a WORLD-SPACE tagged stream to the C surface callback /// (see render/vector.zig). Live for both a baked bundle (.reader tile /// replay) and a live cell (.cell portrayal). No bytes are produced. - pub fn renderSurfaceView(self: *Chart, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.MarinerSettings, cb: *const render.vector.CSurface) !void { + pub fn renderSurfaceView(self: *Chart, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, cb: *const render.vector.CSurface) !void { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); @@ -1461,7 +1461,7 @@ pub const Chart = struct { /// one Unicode character per terminal cell (cols x rows), optional /// ANSI-256 color. Returns UTF-8 bytes, one '\n'-terminated row per grid /// row (gpa-owned; free with freeBytes). Same backends as renderView. - pub fn renderAscii(self: *Chart, lon: f64, lat: f64, zoom: f64, cols: u32, rows: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.MarinerSettings, ansi: bool) ![]u8 { + pub fn renderAscii(self: *Chart, lon: f64, lat: f64, zoom: f64, cols: u32, rows: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, ansi: bool) ![]u8 { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); @@ -1701,7 +1701,7 @@ pub fn renderFeature( w: u32, h: u32, palette: render.resolve.PaletteId, - settings: *const render.resolve.MarinerSettings, + settings: *const render.resolve.Settings, bg: []const u8, output: render.pixel.Output, ) ![]u8 { @@ -1778,7 +1778,7 @@ pub fn renderCellView( w: u32, h: u32, palette: render.resolve.PaletteId, - settings: *const render.resolve.MarinerSettings, + settings: *const render.resolve.Settings, output: render.pixel.Output, highlight: ?Highlight, ) ![]u8 { diff --git a/src/render/ascii.zig b/src/render/ascii.zig index c7bc880..54edbb4 100644 --- a/src/render/ascii.zig +++ b/src/render/ascii.zig @@ -106,7 +106,7 @@ pub const AsciiSurface = struct { a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, - settings: *const resolve.MarinerSettings, + settings: *const resolve.Settings, /// Fractional display zoom the gates evaluate at. zoom: f64, /// Output size in characters. @@ -141,7 +141,7 @@ pub const AsciiSurface = struct { /// `a` should be a scratch arena, like PixelSurface: buffered ops live /// until endScene. `px_per_tile` is the view driver's usual /// 256 * 2^(zoom - round(zoom)) — one character column plays one CSS px. - pub fn initView(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.MarinerSettings, zoom: f64, cols: u32, rows: u32, px_per_tile: f32, tile_extent: u32) AsciiSurface { + pub fn initView(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.Settings, zoom: f64, cols: u32, rows: u32, px_per_tile: f32, tile_extent: u32) AsciiSurface { return .{ .a = a, .colors = colors, @@ -577,7 +577,7 @@ test "AsciiSurface: fills shade by token, strokes pick slope chars, glyphs and l const a = arena.allocator(); var colors = try resolve.Colors.init(a, test_profile); - const settings = resolve.MarinerSettings{}; + const settings = resolve.Settings{}; // 32x16 chars = a 32x32 px canvas; tile extent 32 makes tile units = px. var as = AsciiSurface.initView(a, &colors, .day, &settings, 14.0, 32, 16, 32, 32); const surf = as.asSurface(); @@ -635,7 +635,7 @@ test "labels declutter: highest priority claims the cells, overlap is dropped" { const a = arena.allocator(); var colors = try resolve.Colors.init(a, test_profile); - const settings = resolve.MarinerSettings{}; + const settings = resolve.Settings{}; var as = AsciiSurface.initView(a, &colors, .day, &settings, 14.0, 32, 16, 32, 32); const surf = as.asSurface(); @@ -667,7 +667,7 @@ test "ANSI mode: tokens quantize to xterm-256, rows reset, plain mode stays esca try std.testing.expectEqual(@as(u8, 243), ansi256(.{ .r = 128, .g = 128, .b = 128 })); var colors = try resolve.Colors.init(a, test_profile); - const settings = resolve.MarinerSettings{}; + const settings = resolve.Settings{}; var as = AsciiSurface.initView(a, &colors, .day, &settings, 14.0, 8, 4, 8, 8); as.ansi = true; const surf = as.asSurface(); diff --git a/src/render/ascii_view_test.zig b/src/render/ascii_view_test.zig index 292f375..d83c40d 100644 --- a/src/render/ascii_view_test.zig +++ b/src/render/ascii_view_test.zig @@ -85,7 +85,7 @@ test "ascii view: water shades left, land '#' right, coastline between" { const streams = try portray.portrayCell(a, &cell, ""); var colors = try render.resolve.Colors.init(a, colorprofile_registry.entries[0].bytes); - const settings = render.resolve.MarinerSettings{}; + const settings = render.resolve.Settings{}; // 64x32 chars = a 64x64 px view at z8 (px_per_tile 256), centred on the // tile: the whole grid sits inside the fixture, split down the middle. const cols: u32 = 64; diff --git a/src/render/pixel.zig b/src/render/pixel.zig index a479c72..e681165 100644 --- a/src/render/pixel.zig +++ b/src/render/pixel.zig @@ -86,7 +86,7 @@ pub const PixelSurface = struct { a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, - settings: *const resolve.MarinerSettings, + settings: *const resolve.Settings, /// Fractional display zoom the gates evaluate at. zoom: f64, /// Output size in pixels; a tile render is square (w == h == px_per_tile), @@ -138,13 +138,13 @@ pub const PixelSurface = struct { /// `a` should be the same scratch arena the engine allocates geometry /// from — buffered ops live until endScene, exactly like the tile /// surface's feature lists. - pub fn init(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.MarinerSettings, zoom: f64, size_px: u32, tile_extent: u32) PixelSurface { + pub fn init(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.Settings, zoom: f64, size_px: u32, tile_extent: u32) PixelSurface { return initView(a, colors, palette, settings, zoom, size_px, size_px, @floatFromInt(size_px), tile_extent); } /// A multi-tile view scene: w x h output px, each source tile occupying /// px_per_tile px (fractional zoom = a non-power-of-two px_per_tile). - pub fn initView(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.MarinerSettings, zoom: f64, w_px: u32, h_px: u32, px_per_tile: f32, tile_extent: u32) PixelSurface { + pub fn initView(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.Settings, zoom: f64, w_px: u32, h_px: u32, px_per_tile: f32, tile_extent: u32) PixelSurface { return .{ .a = a, .colors = colors, @@ -257,7 +257,7 @@ pub const PixelSurface = struct { try self.push(.line, .{ .stroke = .{ .lines = canvas_lines, .width = w, .dash = d, .color = self.resolveColor(token) } }); // Depth-contour label: the value in the mariner's unit, placed at the // midpoint of the longest segment (v1 horizontal placement; collision - // culls the rest). Value formatting mirrors chartstyle.contourLabelField: + // culls the rest). Value formatting mirrors mariner.contourLabelField: // metres round, feet floor-to-tenth (a depth errs SHALLOW). if (valdco) |v| { var longest: f32 = 0; @@ -296,7 +296,7 @@ pub const PixelSurface = struct { const store = self.store orelse return; // Re-gate with the symbol name: ISODGR01 rides its own toggle. if (!resolve.visible(&self.cur, name, self.zoom, self.settings)) return; - // Live danger swap (mirrors chartstyle.pointSymbolImage): a danger lying + // Live danger swap (mirrors mariner.pointSymbolImage): a danger lying // DEEPER than the mariner's safety contour draws the subdued DANGER02. var eff = name; if (danger_depth) |dd| eff = if (dd > self.settings.safety_contour) "DANGER02" else "DANGER01"; @@ -617,7 +617,7 @@ test "PixelSurface: resolves tokens, gates SCAMIN, sorts by draw_prio" { const a = arena.allocator(); var colors = try resolve.Colors.init(a, test_profile); - const settings = resolve.MarinerSettings{}; + const settings = resolve.Settings{}; var ps = PixelSurface.init(a, &colors, .day, &settings, 14.0, 64, 64); const surf = ps.asSurface(); @@ -662,7 +662,7 @@ test "PixelSurface: unknown token falls back magenta, dashed maps to [4w,3w]" { const a = arena.allocator(); var colors = try resolve.Colors.init(a, test_profile); - const settings = resolve.MarinerSettings{}; + const settings = resolve.Settings{}; var ps = PixelSurface.init(a, &colors, .day, &settings, 14.0, 64, 4096); const surf = ps.asSurface(); @@ -718,7 +718,7 @@ test "drawSymbol: pivot/scale/rotate transform, even-odd fill, danger swap, soun }; var colors = try resolve.Colors.init(a, test_profile); - const settings = resolve.MarinerSettings{ .display_other = true }; + const settings = resolve.Settings{ .display_other = true }; var ps = PixelSurface.init(a, &colors, .day, &settings, 14.0, 256, 256); ps.store = .{ .ptr = &fake, .vtable = &Fake.vt }; const surf = ps.asSurface(); @@ -764,7 +764,7 @@ test "drawText: shaping, group gate, halo, and collision declutter" { const a = arena.allocator(); var colors = try resolve.Colors.init(a, test_profile); - const settings = resolve.MarinerSettings{}; + const settings = resolve.Settings{}; var ps = PixelSurface.init(a, &colors, .day, &settings, 14.0, 256, 256); const surf = ps.asSurface(); @@ -788,7 +788,7 @@ test "drawText: shaping, group gate, halo, and collision declutter" { try surf.drawText("Overlap", &style, .{ .x = 102, .y = 101 }); try surf.endFeature(); // A light description with the toggle OFF: gated before buffering. - const no_lights = resolve.MarinerSettings{ .show_light_descriptions = false }; + const no_lights = resolve.Settings{ .show_light_descriptions = false }; var ps2 = PixelSurface.init(a, &colors, .day, &no_lights, 14.0, 256, 256); const lstyle = rs.TextStyle{ .color = "CHBLK", .font_size = 11, .halign = "left", .valign = "bottom", .offset_x = 0, .offset_y = 0, .group = 23 }; try ps2.asSurface().beginFeature(&hi); @@ -834,7 +834,7 @@ test "drawHighlight: reticle straddles the anchor, bbox adds a dashed box, hi-vi }; var colors = try resolve.Colors.init(a, test_profile); - const settings = resolve.MarinerSettings{}; + const settings = resolve.Settings{}; var ps = PixelSurface.init(a, &colors, .day, &settings, 14.0, 400, 400); // POINT: reticle only (no dashed box). Both halo + core passes straddle the diff --git a/src/render/pixel_golden_test.zig b/src/render/pixel_golden_test.zig index 92e0494..a154075 100644 --- a/src/render/pixel_golden_test.zig +++ b/src/render/pixel_golden_test.zig @@ -78,7 +78,7 @@ test "golden PNG: depth-area fill + coastline stroke through the pixel path" { const streams = try portray.portrayCell(a, &cell, ""); var colors = try render.resolve.Colors.init(a, colorprofile_registry.entries[0].bytes); - const settings = render.resolve.MarinerSettings{}; + const settings = render.resolve.Settings{}; var ps = render.pixel.PixelSurface.init(a, &colors, .day, &settings, @floatFromInt(z), 256, tile.EXTENT); const cells = [_]scene.CellRef{.{ .cell = &cell, .portrayal = streams, .geo = geo }}; diff --git a/src/render/resolve.zig b/src/render/resolve.zig index 25bb953..355355d 100644 --- a/src/render/resolve.zig +++ b/src/render/resolve.zig @@ -5,16 +5,16 @@ //! //! Tile surfaces (MVT/MLT) never resolve: they serialize tokens/names verbatim //! and the MapLibre client resolves live (the color tables + the -//! chartstyle expressions). The resolver mirrors those exact semantics so the +//! mariner expressions). The resolver mirrors those exact semantics so the //! two styling paths can't silently drift; each gate cites the expression it //! mirrors. const std = @import("std"); const Allocator = std.mem.Allocator; const rs = @import("surface.zig"); -const chartstyle = @import("style").chartstyle; +const mariner = @import("style").mariner; -pub const MarinerSettings = chartstyle.MarinerSettings; +pub const Settings = mariner.Settings; // ---- colors --------------------------------------------------------------- @@ -96,13 +96,13 @@ fn collectItems(a: Allocator, block: []const u8, map: *std.StringHashMapUnmanage // ---- display gates ---------------------------------------------------------- -/// S-52 §10.3.4 display-category gate — mirrors chartstyle.categoryFilter: +/// S-52 §10.3.4 display-category gate — mirrors mariner.categoryFilter: /// the effective category is the feature's `cat` (0 base / 1 standard / /// 2 other; null defaults to standard, like the style's coalesce), except /// ISODGR01 which rides the isolated-dangers-shallow toggle instead of its /// baked category. M_QUAL is the data-quality overlay: shown iff the overlay /// is on (then regardless of category), hidden otherwise. -pub fn categoryVisible(cat: ?i64, class: []const u8, symbol_name: ?[]const u8, m: *const MarinerSettings) bool { +pub fn categoryVisible(cat: ?i64, class: []const u8, symbol_name: ?[]const u8, m: *const Settings) bool { if (std.mem.eql(u8, class, "M_QUAL")) return m.data_quality; var c = cat orelse 1; if (symbol_name) |sn| { @@ -141,7 +141,7 @@ pub fn osclVisible(oscl: i64, zoom: f64) bool { } /// Viewing-group gate (S-52 §14.5) — the deny-list model of -/// chartstyle.MarinerSettings.viewing_groups_off (the host's viewingGroupsOff model): +/// mariner.Settings.viewing_groups_off (the host's viewingGroupsOff model): /// a feature with no viewing group (vg 0) always shows; otherwise it hides iff /// its group is in the mariner's off-list. Any group not listed defaults ON. pub fn viewingGroupVisible(vg: i64, off: ?[]const i32) bool { @@ -153,10 +153,10 @@ pub fn viewingGroupVisible(vg: i64, off: ?[]const i32) bool { return true; } -/// S-52 §14.5 text-group gate — mirrors chartstyle.textGroupFilter: important +/// S-52 §14.5 text-group gate — mirrors mariner.textGroupFilter: important /// text (group 11) is always on; 21/26/29 ride text_names; 23 rides /// show_light_descriptions; everything else rides text_other. -pub fn textGroupVisible(group: i64, m: *const MarinerSettings) bool { +pub fn textGroupVisible(group: i64, m: *const Settings) bool { if (group == 11) return true; if (group == 21 or group == 26 or group == 29) return m.text_names; if (group == 23) return m.show_light_descriptions; @@ -166,7 +166,7 @@ pub fn textGroupVisible(group: i64, m: *const MarinerSettings) bool { /// Combined per-feature gate for pixel surfaces: display category + viewing /// group + SCAMIN at the scene zoom. `symbol_name` is the symbol about to be /// drawn (null for fills/lines/text) — only consulted for the ISODGR01 case. -pub fn visible(meta: *const rs.FeatureMeta, symbol_name: ?[]const u8, zoom: f64, m: *const MarinerSettings) bool { +pub fn visible(meta: *const rs.FeatureMeta, symbol_name: ?[]const u8, zoom: f64, m: *const Settings) bool { if (!categoryVisible(meta.cat, meta.class, symbol_name, m)) return false; if (!viewingGroupVisible(meta.vg, m.viewing_groups_off)) return false; if (!m.ignore_scamin and !scaminVisible(meta.scamin, zoom)) return false; @@ -178,7 +178,7 @@ pub fn visible(meta: *const rs.FeatureMeta, symbol_name: ?[]const u8, zoom: f64, if (!m.show_overscale or m.ignore_scamin) return false; if (!osclVisible(meta.oscl, zoom)) return false; } - // S-52 display-variant passes (mirrors chartstyle.boundaryFilter / + // S-52 display-variant passes (mirrors mariner.boundaryFilter / // pointStyleFilter): a feature portrayed twice carries bnd 1/0 (symbolized/ // plain boundary) or pts 0/1 (paper/simplified points); show the common // pass (2) + the mariner's active style — otherwise both passes double-draw. @@ -218,22 +218,22 @@ test "Colors: token -> RGB per palette, unknown -> null" { try std.testing.expectEqual(@as(?Rgb, null), c.get(.day, "NOSUCH")); } -test "categoryVisible mirrors chartstyle.categoryFilter" { - const def = MarinerSettings{}; // base+standard on, other off, no overlays +test "categoryVisible mirrors mariner.categoryFilter" { + const def = Settings{}; // base+standard on, other off, no overlays try std.testing.expect(categoryVisible(0, "DEPARE", null, &def)); try std.testing.expect(categoryVisible(1, "DEPARE", null, &def)); try std.testing.expect(!categoryVisible(2, "DEPARE", null, &def)); try std.testing.expect(categoryVisible(null, "DEPARE", null, &def)); // null -> standard // M_QUAL: data-quality overlay only. try std.testing.expect(!categoryVisible(0, "M_QUAL", null, &def)); - const dq = MarinerSettings{ .data_quality = true }; + const dq = Settings{ .data_quality = true }; try std.testing.expect(categoryVisible(2, "M_QUAL", null, &dq)); // shown regardless of cat // ISODGR01 rides its own toggle: off -> cat 0 (base on -> visible); // base ALSO off -> hidden; toggle on -> cat 1 (standard). try std.testing.expect(categoryVisible(2, "UWTROC", "ISODGR01", &def)); - const no_base = MarinerSettings{ .display_base = false }; + const no_base = Settings{ .display_base = false }; try std.testing.expect(!categoryVisible(2, "UWTROC", "ISODGR01", &no_base)); - const iso = MarinerSettings{ .display_base = false, .show_isolated_dangers_shallow = true }; + const iso = Settings{ .display_base = false, .show_isolated_dangers_shallow = true }; try std.testing.expect(categoryVisible(2, "UWTROC", "ISODGR01", &iso)); } @@ -267,15 +267,15 @@ test "osclVisible: the X2 hatch never fires at/below 1x, fires past 2x" { } test "visible: the overscale hatch honours show_overscale + the oscl gate" { - const m = MarinerSettings{}; + const m = Settings{}; // Baked X2 gate denom for a 1:260000 cell (cscl/OVERSCALE_FACTOR = 130000). // z_2x ~= log2(279541132/130000) ~= 11.07 — grossly overscale past there. const hatch = rs.FeatureMeta{ .cat = 0, .oscl = 130000, .overscale = true }; try std.testing.expect(!visible(&hatch, null, 10.5, &m)); // < 2x: no hatch try std.testing.expect(visible(&hatch, null, 12.0, &m)); // grossly overscale: hatch shows - const off = MarinerSettings{ .show_overscale = false }; + const off = Settings{ .show_overscale = false }; try std.testing.expect(!visible(&hatch, null, 12.0, &off)); - const ign = MarinerSettings{ .ignore_scamin = true }; + const ign = Settings{ .ignore_scamin = true }; try std.testing.expect(!visible(&hatch, null, 12.0, &ign)); // debug view: no hatch // An ordinary fill carrying the oscl TAG (not the hatch) is never oscl-gated. const fill = rs.FeatureMeta{ .cat = 0, .oscl = 130000 }; @@ -292,10 +292,10 @@ test "viewingGroupVisible: deny-list, vg 0 always shows" { } test "visible combines gates + honours ignore_scamin" { - const m = MarinerSettings{}; + const m = Settings{}; const meta = rs.FeatureMeta{ .cat = 1, .vg = 0, .scamin = 30000, .class = "BOYLAT" }; try std.testing.expect(!visible(&meta, "BOYLAT01", 12.0, &m)); // SCAMIN gates it try std.testing.expect(visible(&meta, "BOYLAT01", 14.0, &m)); - const ig = MarinerSettings{ .ignore_scamin = true }; + const ig = Settings{ .ignore_scamin = true }; try std.testing.expect(visible(&meta, "BOYLAT01", 12.0, &ig)); } diff --git a/src/render/vector.zig b/src/render/vector.zig index 25ec185..7349e59 100644 --- a/src/render/vector.zig +++ b/src/render/vector.zig @@ -140,7 +140,7 @@ pub const VectorSurface = struct { a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, - settings: *const resolve.MarinerSettings, + settings: *const resolve.Settings, cb: *const CSurface, store: ?sym.SymbolStore = null, fnt: ?fontmod.Font = null, @@ -175,7 +175,7 @@ pub const VectorSurface = struct { .size_scale = sizeScale, }; - pub fn init(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.MarinerSettings, cb: *const CSurface) VectorSurface { + pub fn init(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.Settings, cb: *const CSurface) VectorSurface { return .{ .a = a, .colors = colors, @@ -334,7 +334,7 @@ pub const VectorSurface = struct { const store = self.store orelse return; if (!resolve.visible(&self.cur, name, GATE_ZOOM, self.settings)) return; // The style path gates INFORM01 information callouts behind - // show_inform_callouts (chartstyle.zig); the live Surface path bypasses + // show_inform_callouts (mariner.zig); the live Surface path bypasses // the style, so mirror that toggle here. if (!self.settings.show_inform_callouts and std.mem.eql(u8, name, "INFORM01")) return; var eff = name; diff --git a/src/root.zig b/src/root.zig index 846e337..26e2820 100644 --- a/src/root.zig +++ b/src/root.zig @@ -22,7 +22,7 @@ pub const s101_adapter = s101.adapter; pub const catalogue = s101.catalogue; pub const bake_enc = @import("scene").bake_enc; // banded multi-cell ENC_ROOT -> PMTiles pub const style = @import("style"); // colortables, line styles, and style.json generation -pub const chartstyle = @import("style").chartstyle; // mariner-driven MapLibre style patching +pub const mariner = @import("style").mariner; // mariner-driven MapLibre style patching // capi (the C ABI) lives in lib_root.zig so the test/bake exes stay pure Zig. test { @@ -38,6 +38,6 @@ test { _ = catalogue; _ = bake_enc; _ = style; - _ = chartstyle; + _ = mariner; _ = @import("mvt_parity_test.zig"); } diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 87873e4..6232b1b 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -44,7 +44,7 @@ const SYMBOL_SCALE: f64 = @import("render").sndfrm.SYMBOL_SCALE; // so the generated style can show soundings in feet when the mariner picks that unit — // a recreational-chartplotter convenience, not ECDIS behaviour. The feet value runs // through the same SNDFRM04 glyph composition as metres, so it keeps one decimal place -// for shallow soundings (the depth-contour feet label, chartstyle.contourLabelField, +// for shallow soundings (the depth-contour feet label, mariner.contourLabelField, // rounds to whole feet — contour valdco values are whole metres). const M_TO_FT: f64 = @import("render").sndfrm.M_TO_FT; diff --git a/src/style/maplibre.zig b/src/style/maplibre.zig index c629d5c..71c847b 100644 --- a/src/style/maplibre.zig +++ b/src/style/maplibre.zig @@ -12,14 +12,14 @@ const std = @import("std"); const Stringify = std.json.Stringify; -const chartstyle = @import("chartstyle.zig"); +const mariner = @import("mariner.zig"); const FALLBACK = "#ff00ff"; const FONT = .{"Noto Sans Regular"}; // ---- MapLibre expressions, as comptime tuples ---------------------------- // SEABED01 depth shading, the danger-symbol swap, and the sounding bold/faint split -// are mariner-dependent and now resolve through the chartstyle builders (one style +// are mariner-dependent and now resolve through the mariner builders (one style // builder); only the mariner-INDEPENDENT layout exprs remain comptime tuples here. // The static (no-manifest) SCAMIN/oscl gate is now expressed as K / 2^zoom — @@ -182,9 +182,9 @@ pub const Options = struct { // baked in — the bundle + tile57_style_template path; the client gates live). // Non-null = the full style with the mariner's display filters + depth shading / // sounding-split / danger-swap baked in (the C-ABI tile57_build_style path). - // Colour + layout always resolve through the chartstyle builders (default mariner - // when null), so there is ONE style builder — chartstyle.buildStyle is retired. - mariner: ?chartstyle.MarinerSettings = null, + // Colour + layout always resolve through the mariner builders (default mariner + // when null), so there is ONE style builder — the mariner builders is retired. + mariner: ?mariner.Settings = null, enabled_bands: ?[]const i32 = null, // mariner NOAA-band filter (null = all bands) now_unix: i64 = 0, // host wall-clock (epoch s) for the date filter's "today" // §2 ?ignoreScamin host debug toggle: when true, disable SCAMIN scale-gating @@ -213,7 +213,7 @@ pub const Options = struct { // Precomputed, mariner-aware style expressions shared by every layer of one // json call — the single style builder. Colours / depth shading / icon images -// resolve ONCE through the chartstyle builders (so chartstyle.buildStyle's patch pass +// resolve ONCE through the mariner builders // is retired); `common`/`text_group` are the S-52 display filters, empty/null in // template mode (opts.mariner == null) so a template renders without baked gating. const SCtx = struct { @@ -834,28 +834,28 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { const scamin_buckets: []const Bucket = &.{gate}; // The single style builder: resolve every mariner-aware colour / icon / display - // filter ONCE through the chartstyle builders (retiring chartstyle.buildStyle's + // filter ONCE through the mariner builders // template-patch pass). scheme always comes from opts.scheme (the bundle emits one // style per scheme); a null opts.mariner is a TEMPLATE — default mariner for the // colour/layout exprs, but NO display filters baked in (the client gates live). - const scheme_e: chartstyle.Scheme = if (std.mem.eql(u8, opts.scheme, "night")) + const scheme_e: mariner.Scheme = if (std.mem.eql(u8, opts.scheme, "night")) .night else if (std.mem.eql(u8, opts.scheme, "dusk")) .dusk else .day; - var m: chartstyle.MarinerSettings = opts.mariner orelse .{}; + var m: mariner.Settings = opts.mariner orelse .{}; m.scheme = scheme_e; const filters_on = opts.mariner != null; - const b = chartstyle.B{ .a = ba }; + const b = mariner.B{ .a = ba }; const s = SCtx{ - .fill_color = try chartstyle.areasFillColor(b, &palette, &m), - .line_color = try chartstyle.lineColor(b, &palette), - .text_color = try chartstyle.textColor(b, m.scheme, &palette), - .halo = chartstyle.textHaloColor(b, m.scheme), - .contour_color = try chartstyle.contourLabelColor(b, m.scheme, &palette), - .sound_img = try chartstyle.soundingsIconImage(b, &m), - .point_img = try chartstyle.pointSymbolImage(b, &m), - .contour_field = try chartstyle.contourLabelField(b, &m), - .common = if (filters_on) try chartstyle.commonChartFilters(ba, &m, opts.enabled_bands, opts.now_unix) else &.{}, - .text_group = if (filters_on) try chartstyle.textGroupFilter(b, &m) else null, + .fill_color = try mariner.areasFillColor(b, &palette, &m), + .line_color = try mariner.lineColor(b, &palette), + .text_color = try mariner.textColor(b, m.scheme, &palette), + .halo = mariner.textHaloColor(b, m.scheme), + .contour_color = try mariner.contourLabelColor(b, m.scheme, &palette), + .sound_img = try mariner.soundingsIconImage(b, &m), + .point_img = try mariner.pointSymbolImage(b, &m), + .contour_field = try mariner.contourLabelField(b, &m), + .common = if (filters_on) try mariner.commonChartFilters(ba, &m, opts.enabled_bands, opts.now_unix) else &.{}, + .text_group = if (filters_on) try mariner.textGroupFilter(b, &m) else null, .size_scale = opts.size_scale, // Overscale gate mode follows the SCAMIN gating mode: the filter-gate // literal when the live client drives it (same injected DENOM), else the @@ -1009,7 +1009,7 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { } /// Build a full MapLibre style from a base template + mariner settings — the single -/// builder behind the C-ABI / WASM / parity callers (replaces chartstyle.buildStyle's +/// builder behind the C-ABI / WASM / parity callers (replaces the mariner builders's /// template-patch pass). The passed template carries ONLY the host's source config /// (sprite / glyphs / chart tiles+zoom); this lifts that out and regenerates every /// layer via json with the mariner baked in. Signature mirrors the retired @@ -1018,7 +1018,7 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { pub fn buildFromTemplate( alloc: std.mem.Allocator, template_json: []const u8, - m: *const chartstyle.MarinerSettings, + m: *const mariner.Settings, colortables_json: []const u8, enabled_bands: ?[]const i32, now_unix: i64, @@ -1037,7 +1037,7 @@ pub fn buildFromTemplate( pub fn buildFromTemplateScamin( alloc: std.mem.Allocator, template_json: []const u8, - m: *const chartstyle.MarinerSettings, + m: *const mariner.Settings, colortables_json: []const u8, enabled_bands: ?[]const i32, now_unix: i64, @@ -1409,7 +1409,7 @@ test "json: size_scale wraps icon/line/text sizes in a multiplier" { } // ---- single-builder (buildFromTemplate) tests — ported from the retired -// chartstyle.buildStyle tests, now exercising the one json path. ------- +// the mariner builders tests, now exercising the one json path. ------- const cs_template = \\{"version":8,"sources":{"chart":{"type":"vector","url":"pmtiles://x"}},"sprite":"x","glyphs":"x","layers":[]} ; @@ -1419,7 +1419,7 @@ const cs_ct = test "buildFromTemplate: defaults bake SEABED fill + category/M_QUAL filter (single-pass)" { const a = std.testing.allocator; - const m = chartstyle.MarinerSettings{}; + const m = mariner.Settings{}; const out = try buildFromTemplate(a, cs_template, &m, cs_ct, null, 1700000000); defer a.free(out); try std.testing.expect(std.mem.indexOf(u8, out, "DEPMD") != null); // SEABED01 band @@ -1431,7 +1431,7 @@ test "buildFromTemplate: defaults bake SEABED fill + category/M_QUAL filter (sin test "buildFromTemplate: night scheme -> neutral ink + dark halo" { const a = std.testing.allocator; - const m = chartstyle.MarinerSettings{ .scheme = .night }; + const m = mariner.Settings{ .scheme = .night }; const out = try buildFromTemplate(a, cs_template, &m, cs_ct, null, 1700000000); defer a.free(out); try std.testing.expect(std.mem.indexOf(u8, out, "#aab7bf") != null); @@ -1440,7 +1440,7 @@ test "buildFromTemplate: night scheme -> neutral ink + dark halo" { test "buildFromTemplate: feet depth unit -> contour label uses M_TO_FT" { const a = std.testing.allocator; - const m = chartstyle.MarinerSettings{ .depth_unit = .feet }; + const m = mariner.Settings{ .depth_unit = .feet }; const out = try buildFromTemplate(a, cs_template, &m, cs_ct, null, 1700000000); defer a.free(out); try std.testing.expect(std.mem.indexOf(u8, out, "3.280839895") != null); @@ -1461,7 +1461,7 @@ test "buildFromTemplate: feet picks the sounding feet glyph variant; metres does test "buildFromTemplate: enabled bands add a band filter" { const a = std.testing.allocator; - const m = chartstyle.MarinerSettings{}; + const m = mariner.Settings{}; const bands = [_]i32{ 2, 3 }; const out = try buildFromTemplate(a, cs_template, &m, cs_ct, &bands, 1700000000); defer a.free(out); @@ -1470,18 +1470,18 @@ test "buildFromTemplate: enabled bands add a band filter" { test "buildFromTemplate: date resolution (pinned + today + off)" { const a = std.testing.allocator; - const m1 = chartstyle.MarinerSettings{ .date_view = "20240115" }; + const m1 = mariner.Settings{ .date_view = "20240115" }; const o1 = try buildFromTemplate(a, cs_template, &m1, cs_ct, null, 1700000000); defer a.free(o1); try std.testing.expect(std.mem.indexOf(u8, o1, "20240115") != null); try std.testing.expect(std.mem.indexOf(u8, o1, "0115") != null); - const m2 = chartstyle.MarinerSettings{}; + const m2 = mariner.Settings{}; const o2 = try buildFromTemplate(a, cs_template, &m2, cs_ct, null, 1700000000); defer a.free(o2); try std.testing.expect(std.mem.indexOf(u8, o2, "20231114") != null); - const m3 = chartstyle.MarinerSettings{ .date_dependent = false }; + const m3 = mariner.Settings{ .date_dependent = false }; const o3 = try buildFromTemplate(a, cs_template, &m3, cs_ct, null, 1700000000); defer a.free(o3); try std.testing.expect(std.mem.indexOf(u8, o3, "date_recurring") == null); @@ -1492,7 +1492,7 @@ test "buildFromTemplate: viewing-group deny-list filter gates by vg" { // A non-empty off-set hides the listed groups -> the style references the `vg` // property and the off ids, negated (deny-list). const off = [_]i32{ 26070, 27070 }; - const m = chartstyle.MarinerSettings{ .viewing_groups_off = &off }; + const m = mariner.Settings{ .viewing_groups_off = &off }; const out = try buildFromTemplate(a, cs_template, &m, cs_ct, null, 1700000000); defer a.free(out); try std.testing.expect(std.mem.indexOf(u8, out, "\"vg\"") != null); @@ -1500,13 +1500,13 @@ test "buildFromTemplate: viewing-group deny-list filter gates by vg" { // The filter is a deny-list: ["!",["in",...]] so the off groups are EXCLUDED. try std.testing.expect(std.mem.indexOf(u8, out, "\"in\"") != null); // null off-set -> no vg filter at all. - const m2 = chartstyle.MarinerSettings{}; + const m2 = mariner.Settings{}; const o2 = try buildFromTemplate(a, cs_template, &m2, cs_ct, null, 1700000000); defer a.free(o2); try std.testing.expect(std.mem.indexOf(u8, o2, "\"vg\"") == null); // empty off-set -> also no filter (show all). const empty = [_]i32{}; - const m3 = chartstyle.MarinerSettings{ .viewing_groups_off = &empty }; + const m3 = mariner.Settings{ .viewing_groups_off = &empty }; const o3 = try buildFromTemplate(a, cs_template, &m3, cs_ct, null, 1700000000); defer a.free(o3); try std.testing.expect(std.mem.indexOf(u8, o3, "\"vg\"") == null); @@ -1514,7 +1514,7 @@ test "buildFromTemplate: viewing-group deny-list filter gates by vg" { test "buildFromTemplateScamin: a manifest no longer buckets — the merged zoom-gate rides every layer" { const a = std.testing.allocator; - const m = chartstyle.MarinerSettings{}; + const m = mariner.Settings{}; // No manifest -> the static K/2^zoom SCAMIN zoom-gate, no #sm buckets. const plain = try buildFromTemplate(a, cs_template, &m, cs_ct, null, 1700000000); defer a.free(plain); @@ -1746,7 +1746,7 @@ test "diff: a differing layer-id set -> rebuild" { test "diff: same mariner -> [] (no ops)" { const a = std.testing.allocator; - const m = chartstyle.MarinerSettings{}; + const m = mariner.Settings{}; const s1 = try buildFromTemplate(a, cs_template, &m, cs_ct, null, 1700000000); defer a.free(s1); const s2 = try buildFromTemplate(a, cs_template, &m, cs_ct, null, 1700000000); @@ -1758,8 +1758,8 @@ test "diff: same mariner -> [] (no ops)" { test "diff: display_other flip emits only setFilter ops" { const a = std.testing.allocator; - const base = chartstyle.MarinerSettings{}; - const other = chartstyle.MarinerSettings{ .display_other = true }; + const base = mariner.Settings{}; + const other = mariner.Settings{ .display_other = true }; const s1 = try buildFromTemplate(a, cs_template, &base, cs_ct, null, 1700000000); defer a.free(s1); const s2 = try buildFromTemplate(a, cs_template, &other, cs_ct, null, 1700000000); @@ -1774,8 +1774,8 @@ test "diff: display_other flip emits only setFilter ops" { test "diff: day vs night emits setPaintProperty colour ops, no filter change" { const a = std.testing.allocator; - const day = chartstyle.MarinerSettings{ .scheme = .day }; - const night = chartstyle.MarinerSettings{ .scheme = .night }; + const day = mariner.Settings{ .scheme = .day }; + const night = mariner.Settings{ .scheme = .night }; const s1 = try buildFromTemplate(a, cs_template, &day, cs_ct, null, 1700000000); defer a.free(s1); const s2 = try buildFromTemplate(a, cs_template, &night, cs_ct, null, 1700000000); diff --git a/src/style/chartstyle.zig b/src/style/mariner.zig similarity index 90% rename from src/style/chartstyle.zig rename to src/style/mariner.zig index 8f6d18f..42e0132 100644 --- a/src/style/chartstyle.zig +++ b/src/style/mariner.zig @@ -1,22 +1,18 @@ -//! chartstyle — MapLibre chart-style generation, client-side. A faithful 1:1 Zig -//! port of the C++ chartstyle/ module (chart_style.cpp + mariner.hpp), which is -//! itself a port of the Go web client's s52-style.mjs builders. +//! mariner — the S-52 mariner display settings and the builders that turn those +//! settings into MapLibre expressions. This is the settings model, not a style +//! generator: maplibre.zig assembles the full style.json and calls these builders +//! for the mariner-driven parts. //! -//! It PATCHES the mariner-driven parts of a MapLibre style template rather than -//! regenerating the whole style: SEABED01 depth shading, the sounding bold/faint -//! split (SNDFRM04), the danger-symbol safety swap (OBSTRN06/WRECKS05), contour -//! label units (SAFCON01), the per-scheme recolour (background/fills/lines/text + -//! halos/contour labels), and the client-side display filters AND-ed onto every -//! `source:"chart"` layer (category + M_QUAL, band, boundary/point style, INFORM01/ -//! CHDATD01 callout toggles, date validity, meta-bounds, text groups). +//! The settings drive SEABED01 depth shading, the sounding bold/faint split +//! (SNDFRM04), the danger-symbol safety swap (OBSTRN06/WRECKS05), contour label +//! units (SAFCON01), the per-scheme recolour (background/fills/lines/text + halos/ +//! contour labels), and the display filters AND-ed onto every `source:"chart"` +//! layer (category + M_QUAL, band, boundary/point style, INFORM01/CHDATD01 callout +//! toggles, date validity, meta-bounds, text groups). //! -//! Pure Zig (no libc): the host reads the template + colortables bytes and passes -//! them in; the C ABI wrapper lives in capi.zig. std.json.Value uses an -//! insertion-ordered ObjectMap, so the patched style keeps the template's key -//! order (the C++/nlohmann build alphabetises keys via std::map — semantically -//! identical, just a different serialisation). Colour `match` arms ARE emitted in -//! sorted-token order to mirror nlohmann's palette iteration, so they're byte-equal -//! to the C++ output. See ../../../chartstyle/src/chart_style.cpp for the oracle. +//! Pure Zig (no libc): the host passes in the template + colortables bytes; the C +//! ABI wrapper lives in capi.zig. Colour `match` arms are emitted in sorted-token +//! order so the output is byte-stable across builds. const std = @import("std"); @@ -27,15 +23,15 @@ const ObjectMap = std.json.ObjectMap; const M_TO_FT: f64 = 3.280839895; const FALLBACK = "#ff00ff"; -// ---- public model (mirrors chartstyle/include/chartstyle/mariner.hpp) ------- +// ---- public model -------------------------------------------------------- pub const Scheme = enum(c_int) { day = 0, dusk = 1, night = 2 }; pub const DepthUnit = enum(c_int) { meters = 0, feet = 1 }; pub const BoundaryStyle = enum(c_int) { symbolized = 0, plain = 1 }; // S-52 §8.6.1 -/// The S-52 mariner display options. Defaults match mariner.hpp (the Go web -/// client's defaults). -pub const MarinerSettings = struct { +/// The S-52 mariner display options. Defaults match the recreational web +/// client's defaults. +pub const Settings = struct { // -- colour scheme (S-52 day/dusk/night palette) -- scheme: Scheme = .day, @@ -239,7 +235,7 @@ pub fn contourLabelColor(b: B, scheme: Scheme, palette: *const ObjectMap) !Value // DRVAL1/DRVAL2 vs the mariner's contours -> a depth colour token. Deepest band // first (first match in a `case` wins). `>= X && > X` on both bounds per spec. -pub fn seabedTokenExpr(b: B, m: *const MarinerSettings) !Value { +pub fn seabedTokenExpr(b: B, m: *const Settings) !Value { const d1 = try b.coalesce(try b.get("drval1"), b.int(-1)); const d2 = try b.coalesce(try b.get("drval2"), b.int(0)); const band = struct { @@ -275,7 +271,7 @@ pub fn seabedTokenExpr(b: B, m: *const MarinerSettings) !Value { // Fill colour for the `areas` layer: depth areas (carry drval1) shade live via // SEABED01; everything else uses its baked colour token. -pub fn areasFillColor(b: B, palette: *const ObjectMap, m: *const MarinerSettings) !Value { +pub fn areasFillColor(b: B, palette: *const ObjectMap, m: *const Settings) !Value { return b.arr(&.{ b.s("case"), try b.arr(&.{ b.s("has"), b.s("drval1") }), @@ -291,7 +287,7 @@ pub fn areasFillColor(b: B, palette: *const ObjectMap, m: *const MarinerSettings // metres); when the mariner selects feet, the displayed digits come from the baked // whole-feet glyph variant (sym_s_ft/sym_g_ft) instead — a recreational unit option, // not ECDIS (see scene.appendSoundingProps), mirroring contourLabelField. -pub fn soundingsIconImage(b: B, m: *const MarinerSettings) !Value { +pub fn soundingsIconImage(b: B, m: *const Settings) !Value { const ss = if (m.depth_unit == .feet) "sym_s_ft" else "sym_s"; const sg = if (m.depth_unit == .feet) "sym_g_ft" else "sym_g"; const depthLE = try b.arr(&.{ b.s("<="), try b.coalesce(try b.get("depth"), b.int(0)), try b.flt(m.safety_depth) }); @@ -305,7 +301,7 @@ pub fn soundingsIconImage(b: B, m: *const MarinerSettings) !Value { // OBSTRN06/WRECKS05: a danger symbol deeper than the live safety contour swaps to // the less-prominent DANGER02 (sym_deep). pivot_center draws the "ctr:" variant. -pub fn pointSymbolImage(b: B, m: *const MarinerSettings) !Value { +pub fn pointSymbolImage(b: B, m: *const Settings) !Value { const name = try b.arr(&.{ b.s("case"), try b.arr(&.{ @@ -329,7 +325,7 @@ pub fn pointSymbolImage(b: B, m: *const MarinerSettings) !Value { // up. A 2 m contour reads "6" ft (6.56 truncated), not "6.5" or "7": a depth always // errs shallow (toward the surface), the safe direction, matching SNDFRM04's whole-feet // truncation. -pub fn contourLabelField(b: B, m: *const MarinerSettings) !Value { +pub fn contourLabelField(b: B, m: *const Settings) !Value { const v = if (m.depth_unit == .feet) try b.arr(&.{ b.s("floor"), try b.arr(&.{ b.s("*"), try b.get("valdco"), try b.flt(M_TO_FT) }) }) else @@ -345,7 +341,7 @@ pub fn contourLabelField(b: B, m: *const MarinerSettings) !Value { // ---- client-side display filters ------------------------------------------- // Display category (S-52 §10.3.4) + M_QUAL data-quality overlay. -pub fn categoryFilter(b: B, m: *const MarinerSettings) !Value { +pub fn categoryFilter(b: B, m: *const Settings) !Value { var en = Array.init(b.a); if (m.display_base) try en.append(b.int(0)); if (m.display_standard) try en.append(b.int(1)); @@ -372,7 +368,7 @@ pub fn bandFilter(b: B, enabled: []const i32) !Value { } // Boundary symbolization (S-52 §8.6.1): show common (2) + the active style. -pub fn boundaryFilter(b: B, m: *const MarinerSettings) !Value { +pub fn boundaryFilter(b: B, m: *const Settings) !Value { const rank: i64 = if (m.boundary_style == .plain) 0 else 1; return b.arr(&.{ b.s("in"), @@ -382,7 +378,7 @@ pub fn boundaryFilter(b: B, m: *const MarinerSettings) !Value { } // Point-symbol style (S-52 §11.2.2): show common (2) + the active style. -pub fn pointStyleFilter(b: B, m: *const MarinerSettings) !Value { +pub fn pointStyleFilter(b: B, m: *const Settings) !Value { const rank: i64 = if (m.simplified_points) 1 else 0; return b.arr(&.{ b.s("in"), @@ -392,7 +388,7 @@ pub fn pointStyleFilter(b: B, m: *const MarinerSettings) !Value { } // S-52 §14.5 text-group selection. Important text (11) is always on. -pub fn textGroupFilter(b: B, m: *const MarinerSettings) !Value { +pub fn textGroupFilter(b: B, m: *const Settings) !Value { const g = try b.coalesce(try b.get("tgrp"), b.int(-1)); const namedSet = struct { fn make(bb: B) !Value { @@ -422,7 +418,7 @@ pub fn textGroupFilter(b: B, m: *const MarinerSettings) !Value { // (unbanded — always shown) OR its `vg` is NOT in the off-set, so any group the // host didn't list stays visible. Byte-identical to the host's s52-style.mjs // expression, so whichever backend builds the style produces the same filter. -pub fn viewingGroupFilter(b: B, m: *const MarinerSettings) !?Value { +pub fn viewingGroupFilter(b: B, m: *const Settings) !?Value { const off = m.viewing_groups_off orelse return null; if (off.len == 0) return null; var en = Array.init(b.a); @@ -485,7 +481,7 @@ pub fn dateFilter(b: B, today_str: []const u8) !Value { // The viewing date "YYYYMMDD": the mariner's pinned date if set, else `now_unix` // (Unix epoch seconds, supplied by the host) rendered as a UTC calendar date. -pub fn viewingDate(b: B, m: *const MarinerSettings, now_unix: i64) ![]const u8 { +pub fn viewingDate(b: B, m: *const Settings, now_unix: i64) ![]const u8 { if (m.date_view.len == 8) { var digits = true; for (m.date_view) |c| digits = digits and (c >= '0' and c <= '9'); @@ -507,7 +503,7 @@ pub fn viewingDate(b: B, m: *const MarinerSettings, now_unix: i64) ![]const u8 { // so it is NOT included here. Used by the single-pass style builder (style.zig) to // compose each layer's filter inline — the consolidation that retired buildStyle's // template-patch pass. Allocated in `a` (caller's arena). -pub fn commonChartFilters(a: std.mem.Allocator, m: *const MarinerSettings, enabled_bands: ?[]const i32, now_unix: i64) ![]Value { +pub fn commonChartFilters(a: std.mem.Allocator, m: *const Settings, enabled_bands: ?[]const i32, now_unix: i64) ![]Value { const b = B{ .a = a }; var clauses = Array.init(a); try clauses.append(try categoryFilter(b, m)); diff --git a/src/style/style.zig b/src/style/style.zig index 41a8f36..4e92249 100644 --- a/src/style/style.zig +++ b/src/style/style.zig @@ -9,7 +9,7 @@ //! //! * color tables — token -> hex, one per day/dusk/night palette //! * the MapLibre style.json layer set (maplibre.zig) -//! * line styles and the S-52 mariner expression builders (chartstyle.zig) +//! * line styles and the S-52 mariner expression builders (mariner.zig) const std = @import("std"); @@ -31,10 +31,10 @@ pub const buildFromTemplate = @import("maplibre.zig").buildFromTemplate; pub const buildFromTemplateScamin = @import("maplibre.zig").buildFromTemplateScamin; pub const diff = @import("maplibre.zig").diff; -/// The S-52 mariner settings model and expression builders (MapLibre style -/// patching). Every consumer of this module wants both these and the color -/// tables, so they live together. -pub const chartstyle = @import("chartstyle.zig"); +/// The S-52 mariner display settings model (`mariner.Settings`) and the builders +/// that encode those settings as MapLibre expressions. maplibre.zig calls these +/// for the mariner-driven parts of the style. +pub const mariner = @import("mariner.zig"); // ---- colortables.json ---------------------------------------------------- @@ -544,6 +544,6 @@ test "manifestJson: pins schema_version and couples tiles to portrayal" { } test { - _ = chartstyle; + _ = mariner; _ = @import("maplibre.zig"); // run the style.json builder tests too } diff --git a/src/tile57.zig b/src/tile57.zig index f19594c..5abd833 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -14,7 +14,7 @@ //! Surface: //! - High-level engine: `Chart` (open → render/inspect), `bakeArchive`, `style.build` //! - Portrayal assets: `assets` (colortables, linestyles, sprite/pattern) -//! - Style patching: `chartstyle` +//! - Style patching: `mariner` //! - Tiling: `mvt`, `tile`, `pmtiles`, `bake_enc`, `scene` //! - Raw formats: `formats.{iso8211, s57, s101}` @@ -46,13 +46,13 @@ pub const freeBytes = chart.freeBytes; /// baked in; the old template-patch pass is retired) — see assets.buildFromTemplate. pub const style = struct { pub const build = assets.buildFromTemplate; - pub const Mariner = chartstyle.MarinerSettings; + pub const Mariner = mariner.Settings; }; // ---- portrayal asset + style generation ---------------------------------- pub const assets = @import("style"); // colortables / line styles / style.json pub const sprite = @import("sprite"); // S-101 sprite + area-fill pattern atlases -pub const chartstyle = @import("style").chartstyle; // mariner-driven MapLibre style patching +pub const mariner = @import("style").mariner; // mariner-driven MapLibre style patching // ---- tiling / encoding --------------------------------------------------- pub const mvt = @import("tiles").mvt; // Mapbox Vector Tile encode/decode diff --git a/tools/ascii.zig b/tools/ascii.zig index bb27e4b..8fbde40 100644 --- a/tools/ascii.zig +++ b/tools/ascii.zig @@ -90,7 +90,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } else chart.Chart.openPath(path, rules, false) catch return usageErr("cannot open source"); defer c.deinit(); - var m = render.resolve.MarinerSettings{ .display_other = true }; + var m = render.resolve.Settings{ .display_other = true }; m.scheme = switch (palette) { .day => .day, .dusk => .dusk, @@ -121,7 +121,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // ctrl-c) quits. cbreak-style input (no echo/canonical, OPOST kept so \n still // carriage-returns), alternate screen + hidden cursor, terminal re-measured // every frame so a resize just repaints. -fn runAsciiTui(io: std.Io, a: std.mem.Allocator, c: *chart.Chart, lon0: f64, lat0: f64, zoom0: f64, palette: render.resolve.PaletteId, m: *render.resolve.MarinerSettings, ansi: bool, kitty: bool) !void { +fn runAsciiTui(io: std.Io, a: std.mem.Allocator, c: *chart.Chart, lon0: f64, lat0: f64, zoom0: f64, palette: render.resolve.PaletteId, m: *render.resolve.Settings, ansi: bool, kitty: bool) !void { // The interactive TUI is POSIX-only: std.posix.termios is `void` on Windows, // so gate the whole raw-mode body out at comptime (same idiom as common.zig's // terminalSize). The non-interactive `ascii` render paths stay cross-platform. diff --git a/tools/explore.zig b/tools/explore.zig index 6d48499..a31406e 100644 --- a/tools/explore.zig +++ b/tools/explore.zig @@ -709,7 +709,7 @@ fn exStreamCell( out: *OutBuf, first: *bool, palette: render.resolve.PaletteId, - m: *const render.resolve.MarinerSettings, + m: *const render.resolve.Settings, ) !void { const ca = ca_arena.allocator(); const portrayal: ?[]const ?[]const u8 = engine.portray.portrayCell(ca, cell, rules) catch null; @@ -838,7 +838,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // (chart.renderFeature); the --tui LIVE CELL MAP instead frames the selection // over the real chart (chart.renderCellView — see exTuiMap). const palette: render.resolve.PaletteId = .day; - var m = render.resolve.MarinerSettings{ .display_other = true }; + var m = render.resolve.Settings{ .display_other = true }; m.scheme = .day; // explore inspects one or more source cells. `dir` stays open for the whole run @@ -994,7 +994,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // ISOLATED — only this feature's portrayal (chart.renderFeature, only_fi = fi) // on a solid background, NOT a map crop of the surrounding scene. Any failure // prints a short note instead of an image (graceful degradation), never an error. -fn exAppendThumb(a: std.mem.Allocator, out: *std.ArrayList(u8), cell: *engine.s57.Cell, portrayal: ?[]const ?[]const u8, fi: usize, row: ExRow, palette: render.resolve.PaletteId, m: *const render.resolve.MarinerSettings) !void { +fn exAppendThumb(a: std.mem.Allocator, out: *std.ArrayList(u8), cell: *engine.s57.Cell, portrayal: ?[]const ?[]const u8, fi: usize, row: ExRow, palette: render.resolve.PaletteId, m: *const render.resolve.Settings) !void { const tv = row.thumb orelse { try out.appendSlice(a, " resolved render: (no renderable geometry)\n"); return; @@ -1393,7 +1393,7 @@ fn exLoadCell( // + alt-screen scaffolding as `tile57 ascii --tui`; dependency-free. The map is // transmit-once-per-view + place, deleted each frame — the same cached-region // pattern as the ascii kitty TUI, so it never scrolls the layout. -fn exploreTui(io: std.Io, a: std.mem.Allocator, rows: []const ExIndexRow, cells: []const ExCellSrc, dir: std.Io.Dir, rules: []const u8, F: ExFilters, kitty: bool, palette: render.resolve.PaletteId, m: *const render.resolve.MarinerSettings, source: []const u8) !void { +fn exploreTui(io: std.Io, a: std.mem.Allocator, rows: []const ExIndexRow, cells: []const ExCellSrc, dir: std.Io.Dir, rules: []const u8, F: ExFilters, kitty: bool, palette: render.resolve.PaletteId, m: *const render.resolve.Settings, source: []const u8) !void { // The interactive TUI is POSIX-only: std.posix.termios is `void` on Windows, // so gate the whole raw-mode body out at comptime (same idiom as common.zig's // terminalSize). The non-interactive `explore` paths stay cross-platform. @@ -1820,7 +1820,7 @@ fn exMapGeom(right_w: usize, left_w: usize, term_rows: usize, text_rows: usize, // cursor) is the SAME escape shape as the console `--kitty` path. The image stays // strictly within the body rows (footer clear) so its cursor-advance can't scroll // the text away. Any failure clears the image and leaves the text intact. -fn exTuiMap(io: std.Io, stdout: std.Io.File, st: *ThumbState, cell: *engine.s57.Cell, portrayal: ?[]const ?[]const u8, view: MapView, highlight: ?chart.Highlight, palette: render.resolve.PaletteId, m: *const render.resolve.MarinerSettings, geom: MapGeom) void { +fn exTuiMap(io: std.Io, stdout: std.Io.File, st: *ThumbState, cell: *engine.s57.Cell, portrayal: ?[]const ?[]const u8, view: MapView, highlight: ?chart.Highlight, palette: render.resolve.PaletteId, m: *const render.resolve.Settings, geom: MapGeom) void { const clear = struct { fn f(io_: std.Io, out: std.Io.File, s: *ThumbState) void { out.writeStreamingAll(io_, render.kitty.delete_all) catch {}; diff --git a/tools/render.zig b/tools/render.zig index f8047a7..9bf5106 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -44,7 +44,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: var size_scale: f64 = 1.0; // physical-size multiplier (S-52 mm -> true mm) var view: ?struct { lon: f64, lat: f64, zoom: f64 } = null; // Mariner settings (defaults match the app: other ON for spot soundings). - var m = render.resolve.MarinerSettings{ .display_other = true }; + var m = render.resolve.Settings{ .display_other = true }; var f = Flags{ .args = args, .i = if (tile_mode) 5 else 2 }; while (f.next()) |arg| { if (std.mem.eql(u8, arg, "-o")) { From 45ac5f6f44b53ccd8be6ce4b11063e69d4effc3c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 18:36:30 -0400 Subject: [PATCH 076/140] docs: propagate the module/member renames and show the S-101 adapter Update the pipeline diagrams and module tables for the renames: iso8211 is its own module, s100 -> s101 (adapter/instructions/catalogue), assets -> style, chartstyle -> mariner, styleJson -> style.json, MarinerSettings -> style.mariner.Settings. The pipeline diagrams now show the S-57 -> S-101 adapter as its own step, before the Lua portrayal, and the instruction-stream parse after it. The "portrayal assets" concept and the tile57 assets CLI / C ABI tile57_bake_assets names are unchanged. Co-Authored-By: Claude Fable 5 --- README.md | 15 +++++++++------ bindings/js/README.md | 16 ++++++++-------- docs/docs/architecture.md | 18 +++++++++++------- docs/docs/intro.md | 16 +++++++++------- docs/docs/limitations.md | 2 +- docs/docs/tile-schema.md | 2 +- docs/docs/zig-api.md | 10 +++++----- 7 files changed, 44 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 6f4b22f..bdbc404 100644 --- a/README.md +++ b/README.md @@ -86,21 +86,24 @@ It is **high-performance and low-memory** by design: ``` S-57 ENC cell (.000) - │ ISO 8211 decode src/s57/iso8211.zig + │ ISO 8211 decode src/iso8211/ ▼ S-57 feature + geometry model src/s57/ - │ S-101 portrayal (embedded Lua) src/portray/ + src/s100/ + │ adapt S-57 → S-101 features src/s101/ (adapter) ▼ -portrayal instruction stream +S-101 feature records + │ S-101 portrayal (embedded Lua) src/portray/ + rules + ▼ +portrayal instruction stream src/s101/ (instructions) │ scene generation src/scene/ (project + clip + draw calls) ▼ render Surface ──► MVT / MLT tiles (src/tiles/) + MapLibre style.json + assets └───► PNG raster / vector PDF / terminal text (src/render/) ``` -The stages are separate Zig modules — `s57` (including its ISO 8211 decoder), -`s100`, `tiles`, `render`, `scene`, `assets` — pure Zig with no libc; only the -Lua portrayal (`portray`) and the sprite rasterizer (`sprite`) pull in C. See +The stages are separate Zig modules — `iso8211`, `s57`, `s101`, `tiles`, +`render`, `scene`, `style` — pure Zig with no libc; only the Lua portrayal +(`portray`) and the sprite rasterizer (`sprite`) pull in C. See [the architecture docs](docs/docs/architecture.md). ## Use it from Zig diff --git a/bindings/js/README.md b/bindings/js/README.md index 8da40a0..164ecd5 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -4,7 +4,7 @@ Generate a [MapLibre GL](https://maplibre.org) `style.json` for nautical (S-57 / ENC) charts from **S-52 "mariner settings"** — colour scheme, depth units, safety contour, display category, and the rest — **entirely client-side**. -Under the hood it runs the chartplotter **`tile57` chartstyle engine** (pure Zig) +Under the hood it runs the chartplotter **`tile57` style engine** (pure Zig) compiled to a ~145 KB WebAssembly module. The same engine ships in the native `libtile57` C ABI (`tile57_build_style`) and the `tile57` CLI, so the style your front-end produces is **byte-for-byte identical** to the native build (see @@ -18,7 +18,7 @@ sync. ┌──────────────────────────────────────────────┐ │ WebAssembly (style-engine.wasm) │ │ │ - │ settings.parse ──► chartstyle.buildStyle ──┐ │ + │ settings.parse ──► style.buildFromTemplate ─┐ │ │ ▲ ▲ ▲ │ │ │ embedded embedded embedded │ │ │ template.json colortables (S-52) │ │ @@ -105,7 +105,7 @@ map.setStyle(style); - **`DEFAULT_SETTINGS`** — the canonical defaults, for seeding a settings UI. Full types are in [`index.d.ts`](./index.d.ts). The `MarinerSettings` fields -mirror the Zig `MarinerSettings` struct and the C `tile57_mariner`. +mirror the Zig `style.mariner.Settings` struct and the C `tile57_mariner`. ### Mariner settings @@ -146,13 +146,13 @@ The wasm engine and the native build are the **same Zig source** compiled for tw targets. `bindings/scripts/parity-check.sh` generates a style with both for a range of settings (and a fixed `nowUnix`) and asserts they are **byte-identical**: -- native: `zig-out/bin/style-parity` (`chartstyle.buildStyle`, host target) -- wasm: this package (`chartstyle.buildStyle`, `wasm32-freestanding`) +- native: `zig-out/bin/style-parity` (`style.buildFromTemplate`, host target) +- wasm: this package (`style.buildFromTemplate`, `wasm32-freestanding`) > Note: the `tile57 style` CLI is **not** the right oracle — it generates the base -> *template* (`assets.styleJson`), it does not apply mariner settings. The mariner -> patcher is `chartstyle.buildStyle`, which both this module and the -> `style-parity` oracle call, hence the dedicated parity tool. +> *template* (`style.json`), it does not apply mariner settings. The builder that +> bakes the mariner settings in is `style.buildFromTemplate`, which both this +> module and the `style-parity` oracle call, hence the dedicated parity tool. ## Building from source diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index d807b15..2259192 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -15,19 +15,22 @@ A chart cell flows through these stages, all inside the engine: ``` S-57 ENC cell (.000) - │ decode the binary container src/s57/iso8211.zig + │ decode the binary container src/iso8211/ (module: iso8211) ▼ S-57 feature + geometry model src/s57/ (module: s57) + │ adapt S-57 → S-101 features src/s101/ adapter.zig + ▼ +S-101 feature + attribute records │ apply S-101 portrayal src/portray/ + embedded Lua 5.4 ▼ (vendor/S-101_Portrayal-Catalogue) portrayal instruction stream - │ adapt to drawing primitives src/s100/ (module: s100) + │ parse the instruction stream src/s101/ instructions.zig ▼ scene generation src/scene/ (project + clip + draw calls) ▼ render Surface src/render/surface.zig ├─► tile surfaces: MVT / MLT encode + PMTiles src/tiles/ - │ + MapLibre style.json + portrayal assets src/assets/, src/sprite/ + │ + MapLibre style.json + portrayal assets src/style/, src/sprite/ └─► pixel surfaces: PNG raster · vector PDF · terminal text (src/render/) ``` @@ -95,13 +98,14 @@ libc/Lua) and target-agnostic: | Module | Role | |--------|------| -| `s57` | S-57 ENC cell parser + geometry model (includes the ISO/IEC 8211 decoder, `src/s57/iso8211.zig`) | -| `s100` | S-100/S-101 catalogue + portrayal adaptation | +| `iso8211` | the ISO/IEC 8211 container reader (the bottom layer; std-only) | +| `s57` | S-57 ENC cell parser + geometry model (reads 8211 records through `iso8211`) | +| `s101` | the S-101 catalogue, the S-57 → S-101 adapter, and the portrayal instruction stream | | `portray` | the embedded-Lua S-101 runner (links libc) | | `tiles` | MVT + MLT encoders, gzip, the PMTiles container, web-mercator tile math | | `render` | the Surface contract, the resolver (colours, display gates), and the pixel machinery (Canvas, PNG, PDF, ASCII) | | `scene` | S-57 → tile-surface scene generation + the banded ENC_ROOT baker (`bake_enc.zig`) | -| `assets` | colortables / linestyles / style / manifest generation (includes `chartstyle.zig`, the mariner-driven style builder) | +| `style` | color tables, line styles, and the MapLibre style.json layer set (includes `mariner.zig`, the S-52 mariner settings model) | | `sprite` | S-101 sprite + area-fill pattern atlases (SVG raster; links libc) | | `engine` | the pure packages re-exported as one import (the test root) | | `tile57` | the curated public surface (`src/tile57.zig`) | @@ -127,7 +131,7 @@ The public surface composes the packages into high-level entry points: for any `(z, x, y)` tile on demand through an ownership partition (`tile57_compose_open` / `tile57_compose_serve`). The public Zig `bakeArchive` runs the same engine over a slice of cells to make one merged archive. -- **`style.build`** (`assets/chartstyle.zig`) + **`assets`** / **`sprite`** — +- **`style.build`** (`style/maplibre.zig`) + **`style`** / **`sprite`** — generate the MapLibre style and the portrayal assets it references (`tile57_build_style` / `tile57_bake_assets` in the C ABI). diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 45d2cdc..82dac1a 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -66,12 +66,15 @@ almost entirely with AI assistance. A few specific goals shape its design: ``` S-57 ENC cell (.000) - │ ISO 8211 decode src/s57/iso8211.zig + │ ISO 8211 decode src/iso8211/ ▼ S-57 feature + geometry model src/s57/ - │ S-101 portrayal (embedded Lua) src/portray/ + src/s100/ + │ adapt S-57 → S-101 features src/s101/ (adapter) ▼ -portrayal instruction stream +S-101 feature records + │ S-101 portrayal (embedded Lua) src/portray/ + rules + ▼ +portrayal instruction stream src/s101/ (instructions) │ scene generation src/scene/ (project + clip + draw calls) ▼ render Surface ──► MVT / MLT tiles + PMTiles (src/tiles/) + MapLibre style.json + assets @@ -90,10 +93,9 @@ The engine is **high-performance and low-memory by design**: - **Band-streamed bakes.** Baking an ENC_ROOT to one PMTiles archive streams band-by-band (finest → coarsest, best-band dedup), so peak memory tracks the largest single band. -- **Pure-Zig core.** The foundational format/encode modules (`s57` — including - its ISO 8211 decoder — `s100`, `tiles`, `render`, `scene`, `assets`) have no - libc; only the Lua portrayal (`portray`) and the sprite rasterizer (`sprite`) - pull in C. +- **Pure-Zig core.** The foundational format/encode modules (`iso8211`, `s57`, + `s101`, `tiles`, `render`, `scene`, `style`) have no libc; only the Lua + portrayal (`portray`) and the sprite rasterizer (`sprite`) pull in C. ## Portrayal diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index e07c8f3..38e6a52 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -25,7 +25,7 @@ domain and not for navigation; this renderer adds its own gaps on top. ## Portrayal gaps - **Ignored portrayal-instruction keys.** The instruction translator - (`src/s100/s101_instr.zig`) lowers the drawing vocabulary the catalogue + (`src/s101/instructions.zig`) lowers the drawing vocabulary the catalogue actually leans on (point/line/text instructions, colour fills, area-fill and viewing-group references, `AugmentedRay` / `ArcByRadius` / `AugmentedPoint` construction — that is how light-sector legs and arcs render). A few keys are diff --git a/docs/docs/tile-schema.md b/docs/docs/tile-schema.md index 90b748a..01c940f 100644 --- a/docs/docs/tile-schema.md +++ b/docs/docs/tile-schema.md @@ -9,7 +9,7 @@ sidebar_position: 7 tile57's vector tiles use a fixed set of layers and fields. The generated MapLibre style depends on this schema, so the names are a contract. Do not rename a layer or a field without updating the style generator -(`src/assets/style.zig`) to match **and bumping the schema version**. +(`src/style/maplibre.zig`) to match **and bumping the schema version**. This vocabulary is versioned as **`tile57/2`**. The tiles and the portrayal assets are generated from the same S-101 catalogue and stamped with that diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index ff07c7d..215fddd 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -94,11 +94,11 @@ const json = try tile57.style.build(/* … */); // tile57.style.Mariner sett | Surface | What it does | |---------|--------------| -| `tile57.style.build` (`assets.buildFromTemplate`) | build a MapLibre style from a template + mariner settings + colortables. | -| `tile57.style.Mariner` | the S-52 mariner display options struct. | -| `tile57.assets` | colortables / linestyles / style.json / manifest generation. | +| `tile57.style.build` (`style.buildFromTemplate`) | build a MapLibre style from a template + mariner settings + colortables. | +| `tile57.style.Mariner` | the S-52 mariner display options struct (`style.mariner.Settings`). | +| `tile57.style` | color tables, line styles, and style.json generation. | | `tile57.sprite` | S-101 sprite + area-fill pattern atlases (SVG raster). | -| `tile57.chartstyle` | the mariner-driven style-patching module. | +| `tile57.style.mariner` | the S-52 mariner settings model and expression builders. | ## Tiling + encoding @@ -120,7 +120,7 @@ The pure-Zig foundational parsers under `tile57.formats`: |--------|------| | `tile57.formats.iso8211` | ISO/IEC 8211 records | | `tile57.formats.s57` | S-57 ENC cell parser + geometry | -| `tile57.formats.s100` | S-100/S-101 catalogue + adaptation | +| `tile57.formats.s101` | the S-101 catalogue, adapter, and instruction stream | `tile57.version` is the package version string (`"0.1.0"`), matching `build.zig.zon` and `tile57_version()`. From 5020b52fdb3bca919d76e4233b88354f2d313254 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 18:45:23 -0400 Subject: [PATCH 077/140] refactor(geometry): rename the geo module to geometry; drop dead partition_adapt geo is an abbreviation of geometry; spell it out. The module is the integer computational geometry core (polygon boolean, coverage partition, plane clipping). src/geo/ -> src/geometry/ geo.zig (barrel) -> geometry.zig module "geo" -> "geometry" (engine.geo -> engine.geometry) Also delete src/scene/partition_adapt.zig: an orphan de-risk probe with zero importers and a hardcoded local ENC path, plus its partition-probe build step. Co-Authored-By: Claude Fable 5 --- build.zig | 26 +- src/bake_root.zig | 2 +- src/bundle.zig | 52 +-- src/{geo => geometry}/boolean.zig | 0 src/{geo/geo.zig => geometry/geometry.zig} | 0 src/{geo => geometry}/partition.zig | 0 src/{geo => geometry}/plane.zig | 0 src/scene/bake_enc.zig | 2 +- src/scene/compose.zig | 6 +- src/scene/partition_adapt.zig | 368 --------------------- src/scene/scene.zig | 2 +- 11 files changed, 41 insertions(+), 417 deletions(-) rename src/{geo => geometry}/boolean.zig (100%) rename src/{geo/geo.zig => geometry/geometry.zig} (100%) rename src/{geo => geometry}/partition.zig (100%) rename src/{geo => geometry}/plane.zig (100%) delete mode 100644 src/scene/partition_adapt.zig diff --git a/build.zig b/build.zig index a3344a0..462f8f5 100644 --- a/build.zig +++ b/build.zig @@ -207,10 +207,10 @@ pub fn build(b: *std.Build) void { }.f; addFont(b, render_mod); - // Integer computational geometry (src/geo/): the Martinez polygon boolean + + // Integer computational geometry (src/geometry/): the Martinez polygon boolean + // the coverage-clipped best-available partition. Pure (std-only); the scene // engine + baker use it for the cross-band composite. - const geo_mod = b.addModule("geo", .{ .root_source_file = b.path("src/geo/geo.zig") }); + const geometry_mod = b.addModule("geometry", .{ .root_source_file = b.path("src/geometry/geometry.zig") }); // The tile engine (src/scene/): S-57 -> tile-surface generation plus the // banded ENC_ROOT baker (bake_enc.zig, mirrors the Go oracle's @@ -222,7 +222,7 @@ pub fn build(b: *std.Build) void { .{ .name = "s101", .module = s101_mod }, .{ .name = "tiles", .module = tiles_mod }, .{ .name = "render", .module = render_mod }, - .{ .name = "geo", .module = geo_mod }, + .{ .name = "geometry", .module = geometry_mod }, }, }); @@ -281,7 +281,7 @@ pub fn build(b: *std.Build) void { .{ .name = "scene", .module = scene_mod }, .{ .name = "render", .module = render_mod }, .{ .name = "style", .module = style_mod }, - .{ .name = "geo", .module = geo_mod }, + .{ .name = "geometry", .module = geometry_mod }, }; // Full engine surface (the pure root.zig packages + the embedded-Lua `portray` @@ -565,24 +565,16 @@ pub fn build(b: *std.Build) void { .{ .name = "tiles", .module = tiles_mod }, .{ .name = "render", .module = render_mod }, .{ .name = "style", .module = style_mod }, - .{ .name = "geo", .module = geo_mod }, + .{ .name = "geometry", .module = geometry_mod }, }); _ = addPkgTest(b, test_step, "src/style/style.zig", target, optimize, &.{}); - // Geometry core for the cross-band composition redesign (pure, std-only). - _ = addPkgTest(b, test_step, "src/geo/geo.zig", target, optimize, &.{}); - // De-risk probe for the per-cell composite ownership partition: runs the E7 - // partition on a real ENC district (slivers / perf / float-oracle agreement). - // Its own step so it is isolated from `zig build test`; needs only s57+geo. - const partition_probe_step = b.step("partition-probe", "Run the ownership-partition de-risk probe on a real ENC district"); - _ = addPkgTest(b, partition_probe_step, "src/scene/partition_adapt.zig", target, optimize, &.{ - .{ .name = "s57", .module = s57_mod }, - .{ .name = "geo", .module = geo_mod }, - }); - // Compose core (clip-to-face): pure over mvt + geo. Its own step for fast iteration, + // Geometry core for the cross-band composition (pure, std-only). + _ = addPkgTest(b, test_step, "src/geometry/geometry.zig", target, optimize, &.{}); + // Compose core (clip-to-face): pure over mvt + geometry. Its own step for fast iteration, // and part of the main suite. const compose_deps = [_]std.Build.Module.Import{ .{ .name = "tiles", .module = tiles_mod }, - .{ .name = "geo", .module = geo_mod }, + .{ .name = "geometry", .module = geometry_mod }, }; const compose_step = b.step("compose-test", "Run the compose-core (clip-to-face) tests"); _ = addPkgTest(b, compose_step, "src/scene/compose.zig", target, optimize, &compose_deps); diff --git a/src/bake_root.zig b/src/bake_root.zig index fc7bd7a..fe29765 100644 --- a/src/bake_root.zig +++ b/src/bake_root.zig @@ -28,6 +28,6 @@ pub const s101_instructions = root.s101_instructions; pub const s101_adapter = root.s101_adapter; pub const catalogue = root.catalogue; pub const bake_enc = root.bake_enc; -pub const geo = @import("geo"); // integer geometry: boolean, plane, partition +pub const geometry = @import("geometry"); // integer geometry: boolean, plane, partition pub const portray = @import("portray"); diff --git a/src/bundle.zig b/src/bundle.zig index dab2910..421229f 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -333,11 +333,11 @@ const DEBUG_PALETTE = [_][]const u8{ /// NO portrayed content, for eyeballing the composite quilt. `band` < 0 emits the band /// GOVERNING each zoom (the natural view); 0..5 (berthing..overview) emits only that /// band's own map, at every zoom. Composes the single paths — loadCells + toPlaneCells -/// + geo.plane partition + the mvt/tile/pmtiles primitives — and STREAMS tiles (one +/// + geometry.plane partition + the mvt/tile/pmtiles primitives — and STREAMS tiles (one /// tier + one zoom resident) so it scales to the whole corpus. Returns the cell count; /// `out_path` is a single `.pmtiles` file. pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const u8, out_path: []const u8, minzoom: u8, maxzoom_arg: u8, band: i8) !usize { - const geo = engine.geo; + const geometry = engine.geometry; const maxzoom = @min(maxzoom_arg, @as(u8, 16)); var arena = std.heap.ArenaAllocator.init(gpa); @@ -373,8 +373,8 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const // One band's own map: build its tier once, emit across every zoom. const bandv: engine.bake_enc.Band = @enumFromInt(@as(u8, @intCast(@min(band, @as(i8, 5))))); const tier = engine.bake_enc.bandZooms(bandv).min; - const faces = try geo.plane.ownedAtTierIndexed(gpa, cells, tier); - defer geo.plane.freeOwned(gpa, faces); + const faces = try geometry.plane.ownedAtTierIndexed(gpa, cells, tier); + defer geometry.plane.freeOwned(gpa, faces); var z: u8 = minzoom; while (z <= maxzoom) : (z += 1) { _ = zoom_arena.reset(.retain_capacity); @@ -385,14 +385,14 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const // a time (coarse→fine as z rises), so the finest tier is only built if reached. const floors = try distinctFloorsDesc(a, cells); var cur_tier: i16 = -1; - var cur_faces: []geo.plane.OwnedCell = &.{}; - defer if (cur_tier >= 0) geo.plane.freeOwned(gpa, cur_faces); + var cur_faces: []geometry.plane.OwnedCell = &.{}; + defer if (cur_tier >= 0) geometry.plane.freeOwned(gpa, cur_faces); var z: u8 = minzoom; while (z <= maxzoom) : (z += 1) { const t = governingTier(floors, z); if (cur_tier != @as(i16, t)) { - if (cur_tier >= 0) geo.plane.freeOwned(gpa, cur_faces); - cur_faces = try geo.plane.ownedAtTierIndexed(gpa, cells, t); + if (cur_tier >= 0) geometry.plane.freeOwned(gpa, cur_faces); + cur_faces = try geometry.plane.ownedAtTierIndexed(gpa, cells, t); cur_tier = t; } _ = zoom_arena.reset(.retain_capacity); @@ -425,7 +425,7 @@ fn deg7(d: f64) i32 { } // Distinct band floors, DESCENDING (finest floor first) — the tier ladder. -fn distinctFloorsDesc(a: std.mem.Allocator, cells: []const engine.geo.plane.Cell) ![]u8 { +fn distinctFloorsDesc(a: std.mem.Allocator, cells: []const engine.geometry.plane.Cell) ![]u8 { var seen = std.AutoHashMap(u8, void).init(a); for (cells) |c| try seen.put(c.band_floor, {}); const out = try a.alloc(u8, seen.count()); @@ -443,12 +443,12 @@ fn governingTier(floors_desc: []const u8, z: u8) u8 { return floors_desc[floors_desc.len - 1]; } -/// The s57-coverage → geo.plane.Cell adapter: widen each cell's M_COVR to integer +/// The s57-coverage → geometry.plane.Cell adapter: widen each cell's M_COVR to integer /// points, set the band floor (bandOf), and assign the deterministic DSID order (same /// tie-break as the bake) across the whole set. Arena-allocated in `a`. THE single /// conversion path; the partition-debug bake and the compositor both use it. -fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]engine.geo.plane.Cell { - const geo = engine.geo; +fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]engine.geometry.plane.Cell { + const geometry = engine.geometry; const n = loaded.len; const rank = try a.alloc(usize, n); for (rank, 0..) |*v, i| v.* = i; @@ -460,13 +460,13 @@ fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]engine.geo.p const order = try a.alloc(u64, n); for (rank, 0..) |ci, r| order[ci] = r; - const cells = try a.alloc(geo.plane.Cell, n); + const cells = try a.alloc(geometry.plane.Cell, n); for (loaded, 0..) |lc, i| { - const out = try a.alloc(geo.plane.Poly, lc.coverage.len); + const out = try a.alloc(geometry.plane.Poly, lc.coverage.len); for (lc.coverage, 0..) |feat, fi| { - const rings = try a.alloc([]const geo.plane.Pt, feat.len); + const rings = try a.alloc([]const geometry.plane.Pt, feat.len); for (feat, 0..) |ring, ri| { - const pts = try a.alloc(geo.plane.Pt, ring.len); + const pts = try a.alloc(geometry.plane.Pt, ring.len); for (ring, 0..) |p, pi| pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; rings[ri] = pts; } @@ -486,7 +486,7 @@ fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]engine.geo.p /// (layer "partition"). `sa` is a per-zoom arena the caller resets, so only one zoom's /// tiles are resident. Reuses tile.project/clipPolygon + scene.orientAreaRings (the MVT /// winding authority) + mvt.encode. -fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: []const engine.geo.plane.OwnedCell, loaded: []const LoadedCov, z: u8, tier: u8) !void { +fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: []const engine.geometry.plane.OwnedCell, loaded: []const LoadedCov, z: u8, tier: u8) !void { const mvt = engine.mvt; const tile = engine.tile; const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); @@ -596,7 +596,7 @@ const N_COMPOSE_LAYERS = engine.scene.VECTOR_LAYERS.len; // The tile-index cover (nw..se) of an owner face's lon/lat bbox at zoom `scale = 1< bb.tx1 or ty < bb.ty0 or ty > bb.ty1) continue; - var grid = try engine.geo.plane.EdgeGrid.init(ta, face.owned, tileWidthE7(z)); + var grid = try engine.geometry.plane.EdgeGrid.init(ta, face.owned, tileWidthE7(z)); defer grid.deinit(); switch (grid.classify(tileClassifyBox(z, tx, ty))) { .full => continue, // owns none of this tile @@ -739,7 +739,7 @@ pub const ComposeSource = struct { arena: std.heap.ArenaAllocator, // owns the readers/maps arrays + adapted cells (borrowed by part) maps: []const []align(std.heap.page_size_min) const u8, readers: []const *engine.pmtiles.Reader, - part: engine.geo.partition.Partition, + part: engine.geometry.partition.Partition, minz: u8, maxz: u8, loop_max: u8, // deepest zoom the sources can serve (native windows + one fill-up overscale zoom) @@ -754,7 +754,7 @@ pub const ComposeSource = struct { /// Serialize the resident ownership partition to a sidecar blob (gpa-owned) a later open can /// load to skip the owned-face build. pub fn serializePartition(self: *ComposeSource, gpa: std.mem.Allocator) ![]u8 { - return engine.geo.partition.serialize(gpa, &self.part); + return engine.geometry.partition.serialize(gpa, &self.part); } pub fn deinit(self: *ComposeSource) void { const gpa = self.gpa; @@ -855,12 +855,12 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const var loaded = false; if (load_partition) |bytes| { - if (engine.geo.partition.deserialize(gpa, bytes, cells)) |p| { + if (engine.geometry.partition.deserialize(gpa, bytes, cells)) |p| { src.part = p; loaded = true; } else |err| std.debug.print(" partition sidecar unusable ({s}); building\n", .{@errorName(err)}); } - if (!loaded) src.part = try engine.geo.partition.build(gpa, cells); + if (!loaded) src.part = try engine.geometry.partition.build(gpa, cells); built_part = true; const fill_max = @min(maxz + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); diff --git a/src/geo/boolean.zig b/src/geometry/boolean.zig similarity index 100% rename from src/geo/boolean.zig rename to src/geometry/boolean.zig diff --git a/src/geo/geo.zig b/src/geometry/geometry.zig similarity index 100% rename from src/geo/geo.zig rename to src/geometry/geometry.zig diff --git a/src/geo/partition.zig b/src/geometry/partition.zig similarity index 100% rename from src/geo/partition.zig rename to src/geometry/partition.zig diff --git a/src/geo/plane.zig b/src/geometry/plane.zig similarity index 100% rename from src/geo/plane.zig rename to src/geometry/plane.zig diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index 7f6787f..9278e7d 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -23,7 +23,7 @@ const scene = @import("scene.zig"); const pmtiles = @import("tiles").pmtiles; const tile = @import("tiles").tile; const style = @import("style"); // displayDenomZ: the physical display-scale formula -const geometry = @import("geo"); // Martinez boolean for the coverage-clipped composite +const geometry = @import("geometry"); // Martinez boolean for the coverage-clipped composite /// A parsed + portrayed cell ready to bake. `portrayal` is the per-feature S-101 /// instruction stream (null = bake with the classify() fallback); `bounds` is diff --git a/src/scene/compose.zig b/src/scene/compose.zig index 759abbb..90562ba 100644 --- a/src/scene/compose.zig +++ b/src/scene/compose.zig @@ -14,9 +14,9 @@ const std = @import("std"); const mvt = @import("tiles").mvt; const tile = @import("tiles").tile; -const geo = @import("geo"); -const boolean = geo.boolean; -const plane = geo.plane; +const geometry = @import("geometry"); +const boolean = geometry.boolean; +const plane = geometry.plane; const Pt = boolean.Pt; diff --git a/src/scene/partition_adapt.zig b/src/scene/partition_adapt.zig deleted file mode 100644 index 15a884a..0000000 --- a/src/scene/partition_adapt.zig +++ /dev/null @@ -1,368 +0,0 @@ -//! S-57 → ownership-partition adapter, plus a real-ENC de-risk probe. -//! -//! Fills `geo.plane.Cell` (the pure partition input) from parsed S-57 cells: the -//! M_COVR(CATCOV=1) coverage rings — whose integer lon/lat (degrees × 10⁷) widen -//! from i32 to i64 — plus the compilation scale, the band floor, and the -//! deterministic equal-scale tie-break order. -//! -//! The probe test (gated on a real ENC district being present) answers the three -//! questions the design review flagged as unproven before we commit to building -//! the whole module on `plane.ownedAtTier`: -//! 1. SLIVERS — do independently-digitised adjacent cells produce sliver -//! overlaps/gaps at their shared seam? Measured area-exact: Σ(face areas) -//! vs area(union of all eligible coverage). A true partition makes them equal. -//! 2. PERFORMANCE — how long does the naive `ownedAtTier` (global-union operands, -//! no bbox reject / no prune) take on a real district? Is a spatial index -//! required for Step 1, or a later optimisation? -//! 3. ORACLE AGREEMENT — does the integer partition assign the same owner as -//! the live float M_COVR oracle (`s57.coverageContains`) at sampled points? - -const std = @import("std"); -const s57 = @import("s57"); -const geo = @import("geo"); -const plane = geo.plane; -const boolean = geo.boolean; - -const Pt = plane.Pt; -const Poly = plane.Poly; // []const []const Pt — one M_COVR feature's rings - -// --------------------------------------------------------------------------- -// Band rules — mirror scene/bake_enc.zig bandOf/bandZooms exactly. Kept local so -// this file imports only s57+geo (fast, isolated probe build). The production -// adapter will call bake_enc directly to avoid two encodings drifting. -// --------------------------------------------------------------------------- - -/// The lowest zoom at which a cell of this compilation scale participates — its -/// band floor. Matches bandZooms(bandOf(cscl)).min. -pub fn bandFloor(cscl: i32) u8 { - const n: i64 = if (cscl <= 0) 50_000 else cscl; - if (n <= 8_000) return 16; // berthing - if (n <= 32_000) return 13; // harbor - if (n <= 130_000) return 11; // approach - if (n <= 500_000) return 9; // coastal - if (n <= 2_300_000) return 7; // general - return 0; // overview -} - -/// Band rank 0=berthing (finest) .. 5=overview (coarsest), for labels/colour. -pub fn bandRank(cscl: i32) u8 { - const n: i64 = if (cscl <= 0) 50_000 else cscl; - if (n <= 8_000) return 0; - if (n <= 32_000) return 1; - if (n <= 130_000) return 2; - if (n <= 500_000) return 3; - if (n <= 2_300_000) return 4; - return 5; -} - -/// Equal-scale tie-break, identical to bake_enc.ordersBefore: newer DSID -/// issue/update date first (YYYYMMDD lexical; a dated cell before an undated one), -/// then cell name ascending — a total, deterministic order. -pub fn ordersBefore(da: []const u8, na: []const u8, db: []const u8, nb: []const u8) bool { - if (!std.mem.eql(u8, da, db)) return std.mem.lessThan(u8, db, da); // newer first - return std.mem.lessThan(u8, na, nb); -} - -/// Widen a cell's M_COVR rings — integer lon/lat in degrees × 10⁷ (i32) — to the -/// boolean i64 point type. One output Poly per M_COVR feature (its rings). -/// Allocated in `a`. -pub fn widenCoverage(a: std.mem.Allocator, cov: []const []const []const s57.LonLat) ![]Poly { - var out = try a.alloc(Poly, cov.len); - for (cov, 0..) |feat, fi| { - const rings = try a.alloc([]const Pt, feat.len); - for (feat, 0..) |ring, ri| { - const pts = try a.alloc(Pt, ring.len); - for (ring, 0..) |p, pi| pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; - rings[ri] = pts; - } - out[fi] = rings; - } - return out; -} - -// --------------------------------------------------------------------------- -// Area (shoelace, exact i128 → f64) — for the sliver conservation check. -// --------------------------------------------------------------------------- - -fn ringArea2(ring: []const Pt) i128 { - if (ring.len < 3) return 0; - var acc: i128 = 0; - var j = ring.len - 1; - for (ring, 0..) |p, i| { - const q = ring[j]; - j = i; - acc += (@as(i128, q.x) * p.y) - (@as(i128, p.x) * q.y); - } - return acc; // 2*signed area -} - -/// Net area of one even-odd polygon (exterior CCW +, holes CW −, disjoint pieces -/// add) as f64. `rings` is a single polygon's ring set ([][]Pt from a face or a -/// boolean result). -fn polyArea(rings: []const []const Pt) f64 { - var acc: i128 = 0; - for (rings) |ring| acc += ringArea2(ring); - const a: f64 = @floatFromInt(if (acc < 0) -acc else acc); - return a / 2.0; -} - -// =========================================================================== -// De-risk probe — real ENC district. Skipped unless the district dir exists. -// =========================================================================== - -const testing = std.testing; - -// A real ENC district to validate against. Override with TILE57_PROBE_ROOT; the -// default is a NOAA District 1 tree on the dev box (the old ~/.local corpus moved). -const PROBE_ROOT_DEFAULT = "/home/jcollins/Charts/enc-src/d01/ENC_ROOT"; -// Which scale bands to load, by cell-name prefix. US2=overview, US3=coastal, -// US4=approach, US5=harbor. Start coarse (few, fast) — includes cross-band -// overlap (US2 under US3) AND same-band adjacency (US3↔US3). -const PROBE_PREFIXES = [_][]const u8{"US5"}; // harbor: densest same-band adjacency -const PROBE_CAP: usize = 250; // bound the parse cost; also stresses the O(cells^2) build -const PROBE_TIER: u8 = 13; // harbor floor — all loaded US5 eligible - -const ProbeCell = struct { - name: []const u8, - cscl: i32, - date: []const u8, - cov_ll: []const []const []const s57.LonLat, // float rings, for the oracle - bbox: [4]f64, // w,s,e,n degrees -}; - -fn hasPrefix(name: []const u8) bool { - for (PROBE_PREFIXES) |p| if (std.mem.startsWith(u8, name, p)) return true; - return false; -} - -test "PROBE: ownership partition on a real ENC district (slivers, perf, oracle)" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const a = arena.allocator(); - - var threaded: std.Io.Threaded = .init(testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); - - const root = PROBE_ROOT_DEFAULT; - var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch { - std.debug.print("\n[probe] district {s} not present — skipping\n", .{root}); - return; - }; - defer dir.close(io); - - // --- Load matching cells: parse .000, extract M_COVR, keep float rings + bbox. - var pcells = std.ArrayList(ProbeCell).empty; - var walker = dir.walk(a) catch return; - defer walker.deinit(); - while (walker.next(io) catch null) |entry| { - if (pcells.items.len >= PROBE_CAP) break; - if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; - const base = std.fs.path.basename(entry.path); - const name = base[0 .. base.len - 4]; - if (!hasPrefix(name)) continue; - - const bytes = dir.readFileAlloc(io, entry.path, a, .unlimited) catch continue; - var cell = s57.parseCell(a, bytes) catch continue; - defer cell.deinit(); - const cov = cell.mcovrCoverage(a); // copied into `a`; survives deinit - if (cov.len == 0) continue; // no-M_COVR cell: separate case, skip in probe - - var b = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; - for (cov) |rings| for (rings) |ring| for (ring) |p| { - b[0] = @min(b[0], p.lon()); - b[1] = @min(b[1], p.lat()); - b[2] = @max(b[2], p.lon()); - b[3] = @max(b[3], p.lat()); - }; - pcells.append(a, .{ - .name = try a.dupe(u8, name), - .cscl = cell.params.cscl, - .date = try a.dupe(u8, cell.dsid.isdt), - .cov_ll = cov, - .bbox = b, - }) catch {}; - } - - const n = pcells.items.len; - if (n == 0) { - std.debug.print("\n[probe] no matching cells with M_COVR — skipping\n", .{}); - return; - } - - // --- Deterministic `order`: rank cells by ordersBefore (newer/name). - const idx = try a.alloc(usize, n); - for (idx, 0..) |*v, i| v.* = i; - std.mem.sort(usize, idx, pcells.items, struct { - fn lt(cs: []const ProbeCell, x: usize, y: usize) bool { - return ordersBefore(cs[x].date, cs[x].name, cs[y].date, cs[y].name); - } - }.lt); - const order = try a.alloc(u64, n); - for (idx, 0..) |ci, rank| order[ci] = rank; - - // --- Build geo.plane.Cell[] (widen M_COVR coverage to i64 points). - const cells = try a.alloc(plane.Cell, n); - for (pcells.items, 0..) |pc, i| { - cells[i] = .{ - .cscl = pc.cscl, - .band_floor = bandFloor(pc.cscl), - .order = order[i], - .cov1 = try widenCoverage(a, pc.cov_ll), - }; - } - - // Eligible set at the probe tier (matches ownedAtTier's rule). - var n_elig: usize = 0; - for (cells) |c| if (c.band_floor <= PROBE_TIER) { - n_elig += 1; - }; - - // --- BUILD both, timed: naive global-union kernel vs the bbox-indexed one. - const t0 = std.Io.Clock.now(.awake, io); - const faces_naive = try plane.ownedAtTier(a, cells, PROBE_TIER); - const naive_ms: f64 = @as(f64, @floatFromInt((std.Io.Clock.now(.awake, io).nanoseconds - t0.nanoseconds))) / 1e6; - const t1 = std.Io.Clock.now(.awake, io); - const faces = try plane.ownedAtTierIndexed(a, cells, PROBE_TIER); - const idx_ms: f64 = @as(f64, @floatFromInt((std.Io.Clock.now(.awake, io).nanoseconds - t1.nanoseconds))) / 1e6; - - // --- SLIVER check: Σ(face area) vs area(union of all eligible coverage). - var sum_faces: f64 = 0; - for (faces) |f| sum_faces += polyArea(f.owned); - - var elig_polys = std.ArrayList(Poly).empty; - for (cells) |c| if (c.band_floor <= PROBE_TIER) { - for (c.cov1) |feat| try elig_polys.append(a, feat); - }; - const uni = try boolean.unionAll(a, elig_polys.items); - const union_area = polyArea(uni); - const conservation = if (union_area > 0) sum_faces / union_area else 0; - - // Definitive area-exact check (robust to shoelace/winding): symmetric - // difference of (∪ faces) vs (∪ eligible coverage). A perfect partition has - // ZERO symdiff; nonzero = real lost/gained area (slivers) invisible to points. - var face_polys = std.ArrayList(plane.Poly).empty; - for (faces) |f| if (f.owned.len > 0) { - try face_polys.append(a, f.owned); - }; - const uni_faces = try boolean.unionAll(a, face_polys.items); - const sd = try boolean.compute(a, uni_faces, uni, .sym_diff); - const symdiff_area = polyArea(sd); - const symdiff_ppm = if (union_area > 0) 1e6 * symdiff_area / union_area else 0; - - // --- ORACLE agreement: sample the union bbox; compare partition owner (integer - // even-odd over faces) to the float finest-cscl-covering oracle. - var wb = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; - for (pcells.items) |pc| { - wb[0] = @min(wb[0], pc.bbox[0]); - wb[1] = @min(wb[1], pc.bbox[1]); - wb[2] = @max(wb[2], pc.bbox[2]); - wb[3] = @max(wb[3], pc.bbox[3]); - } - const GRID = 120; - var samples: usize = 0; - var overlaps: usize = 0; - var matches: usize = 0; - var mismatches: usize = 0; - var mismatch_same_scale: usize = 0; // seam tiebreak / edge-proximity (benign) - var mismatch_diff_scale: usize = 0; // real overlap-resolution divergence - var part_gap_oracle_owns: usize = 0; // oracle owns, partition says gap - var builder_disagree: usize = 0; // indexed builder owner != naive builder owner - var gy: usize = 0; - while (gy < GRID) : (gy += 1) { - var gx: usize = 0; - while (gx < GRID) : (gx += 1) { - const lon = wb[0] + (wb[2] - wb[0]) * (@as(f64, @floatFromInt(gx)) + 0.5) / GRID; - const lat = wb[1] + (wb[3] - wb[1]) * (@as(f64, @floatFromInt(gy)) + 0.5) / GRID; - const xe: i64 = @intFromFloat(@round(lon * 1e7)); - const ye: i64 = @intFromFloat(@round(lat * 1e7)); - - // Partition owner (integer): indexed faces containing the point. - var pj_owner: ?usize = null; - var pj_count: usize = 0; - for (faces) |f| { - if (boolean.pointInEvenOdd(f.owned, xe, ye)) { - pj_count += 1; - pj_owner = f.index; - } - } - // Naive-builder owner at the same point, to prove indexed == naive on - // real data (the unit test only covers synthetic cells). - var naive_owner: ?usize = null; - for (faces_naive) |f| { - if (boolean.pointInEvenOdd(f.owned, xe, ye)) naive_owner = f.index; - } - if (pj_owner != naive_owner) builder_disagree += 1; - // Oracle owner (float): finest eligible covering cell, broken by the - // SAME (cscl, then DSID order) rule the partition uses — so a same-scale - // adjacency seam is not a spurious mismatch, isolating true divergence. - var or_owner: ?usize = null; - for (pcells.items, 0..) |pc, i| { - if (cells[i].band_floor > PROBE_TIER) continue; - if (!s57.coverageContains(pc.cov_ll, lon, lat)) continue; - if (or_owner) |cur| { - const finer = pc.cscl < pcells.items[cur].cscl or - (pc.cscl == pcells.items[cur].cscl and order[i] < order[cur]); - if (finer) or_owner = i; - } else or_owner = i; - } - - if (or_owner == null and pj_owner == null) continue; // both agree: gap - samples += 1; - if (pj_count > 1) overlaps += 1; - if (or_owner != null and pj_owner == null) { - part_gap_oracle_owns += 1; - } else if (pj_owner == or_owner) { - matches += 1; - } else { - mismatches += 1; - // Same-scale mismatch = a seam tiebreak/edge-proximity artifact; - // different-scale = a real overlap-resolution divergence to chase. - if (pj_owner != null and or_owner != null and - pcells.items[pj_owner.?].cscl == pcells.items[or_owner.?].cscl) - mismatch_same_scale += 1 - else - mismatch_diff_scale += 1; - } - } - } - - // --- Report. - std.debug.print( - \\ - \\========== ownership-partition PROBE ({s}) ========== - \\ cells loaded ....... {d} eligible@z{d} ... {d} - \\ faces (owners) ..... {d} - \\ BUILD naive {d:.1} ms indexed {d:.1} ms (speedup {d:.1}x) - \\ builder cross-check {d} disagreements (indexed vs naive owner; MUST be 0) - \\ SLIVER check: - \\ Σ face area ...... {e:.3} - \\ union area ....... {e:.3} - \\ conservation ..... {d:.6} (shoelace Σ/union — UNRELIABLE for multi-piece faces; see symdiff) - \\ symdiff (faces△cov) {d:.1} ppm of union area (AUTHORITATIVE: 0 == exact partition, >0 == real slivers) - \\ ORACLE agreement (float M_COVR, {d} owned samples): - \\ matches .......... {d} - \\ mismatches ....... {d} (same-scale {d} = seam tiebreak/edge; diff-scale {d} = real divergence) - \\ partition-overlap {d} (>1 owner at a point — MUST be 0) - \\ partition-gap ..... {d} (oracle owns, partition empty) - \\==================================================== - \\ - , .{ - root, n, PROBE_TIER, n_elig, - faces.len, naive_ms, idx_ms, naive_ms / @max(0.001, idx_ms), - builder_disagree, - sum_faces, union_area, conservation, symdiff_ppm, - samples, matches, mismatches, mismatch_same_scale, - mismatch_diff_scale, overlaps, part_gap_oracle_owns, - }); - - // Guardrails: the partition must never double-own a point, and the union of - // owned faces must equal the union of coverage (area-exact — the gate point - // sampling cannot provide). Adjacent cells' shared borders round to the same - // integers, so the boolean set algebra is exact and slivers must stay below a - // tiny fraction of a percent. - try testing.expectEqual(@as(usize, 0), overlaps); - try testing.expectEqual(@as(usize, 0), part_gap_oracle_owns); - try testing.expectEqual(@as(usize, 0), builder_disagree); // indexed == naive on real data - try testing.expect(symdiff_ppm < 100.0); // < 0.01% of union area -} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 6232b1b..63651f2 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -18,7 +18,7 @@ const mvt = @import("tiles").mvt; const mlt = @import("tiles").mlt; const render = @import("render"); const style = @import("style"); -const geometry = @import("geo"); // Martinez boolean; `geo` is a common param name here +const geometry = @import("geometry"); // Martinez boolean; `geo` is a common param name here const rs = render.surface; /// The banded multi-cell ENC_ROOT -> PMTiles baker (folded in: it is the From fc4a6f93a2c7280a8f8d7ff77115ac9335d924c1 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 18:47:04 -0400 Subject: [PATCH 078/140] docs(architecture): clarify style/sprite as S-101 portrayal-asset generators The style (color tables, line styles) and sprite (symbols, patterns) modules generate the S-101/S-52 portrayal assets from the S-101 Portrayal Catalogue. They read the catalogue bytes as input rather than importing s101, so they stay independent, grabbable modules. Co-Authored-By: Claude Fable 5 --- docs/docs/architecture.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 2259192..a90a341 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -105,8 +105,13 @@ libc/Lua) and target-agnostic: | `tiles` | MVT + MLT encoders, gzip, the PMTiles container, web-mercator tile math | | `render` | the Surface contract, the resolver (colours, display gates), and the pixel machinery (Canvas, PNG, PDF, ASCII) | | `scene` | S-57 → tile-surface scene generation + the banded ENC_ROOT baker (`bake_enc.zig`) | -| `style` | color tables, line styles, and the MapLibre style.json layer set (includes `mariner.zig`, the S-52 mariner settings model) | -| `sprite` | S-101 sprite + area-fill pattern atlases (SVG raster; links libc) | +| `style` | the S-101 color tables and line styles, the MapLibre style.json layer set, and the S-52 `mariner` settings model (`mariner.zig`) | +| `sprite` | the S-101 sprite + area-fill pattern atlases from the catalogue Symbols/AreaFills (SVG raster; links libc) | + +The `style` and `sprite` modules generate the S-101/S-52 portrayal assets — color +tables, line styles, sprites, and patterns — from the S-101 Portrayal Catalogue. +They read the catalogue bytes as input rather than importing `s101`, so they stay +independent modules a caller can grab on their own. | `engine` | the pure packages re-exported as one import (the test root) | | `tile57` | the curated public surface (`src/tile57.zig`) | From 6d8e9e392a4b459f926781c23932a9173aba3a9a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 19:01:14 -0400 Subject: [PATCH 079/140] refactor(tiles): move the shared band + MVT-schema tethers into tiles Relocate the three pieces the compositor shares with the scene engine into the pure tiles leaf, so a compositor module can depend on tiles without pulling in scene (and its s57/s101/render stack): - band/scale->zoom math (Band, bandOf, bandZooms, FILLUP_*) -> tiles.band (bake_enc re-exports it, so chart/coverage are unchanged) - VECTOR_LAYERS (the tile57/2 layer set) -> tiles.mvt - orientAreaRings + ring helpers (MVT polygon winding) -> tiles.mvt Pure moves, no logic change: 54 composed tiles across z9-14 are byte-identical to the pre-move baseline. Co-Authored-By: Claude Fable 5 --- src/bundle.zig | 10 +-- src/geometry/boolean.zig | 4 +- src/s57/s57.zig | 2 +- src/scene/bake_enc.zig | 63 +++------------ src/scene/scene.zig | 164 ++------------------------------------- src/tiles/band.zig | 70 +++++++++++++++++ src/tiles/mvt.zig | 149 +++++++++++++++++++++++++++++++++++ src/tiles/tiles.zig | 3 + 8 files changed, 249 insertions(+), 216 deletions(-) create mode 100644 src/tiles/band.zig diff --git a/src/bundle.zig b/src/bundle.zig index 421229f..8b7e66e 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -535,7 +535,7 @@ fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: if (clipped.len >= 3) try parts.append(sa, clipped); } if (parts.items.len == 0) continue; - const oriented = try engine.scene.orientAreaRings(sa, parts.items); + const oriented = try engine.mvt.orientAreaRings(sa, parts.items); const gop = try tilemap.getOrPut(.{ .x = tx, .y = ty }); if (!gop.found_existing) gop.value_ptr.* = .{ .polys = .empty, .labels = .empty }; try gop.value_ptr.polys.append(sa, .{ .geom_type = .polygon, .parts = oriented, .properties = props }); @@ -591,7 +591,7 @@ fn worldAxisToTile(w: f64, scale: f64) u32 { // a tile is cosmetic). This retires the streaming in-bake cross-cell combiner: the per-cell // bakes stay dumb + cacheable, and all cross-cell logic is precomputed as the partition. -const N_COMPOSE_LAYERS = engine.scene.VECTOR_LAYERS.len; +const N_COMPOSE_LAYERS = engine.mvt.VECTOR_LAYERS.len; // The tile-index cover (nw..se) of an owner face's lon/lat bbox at zoom `scale = 1<zoom mapping lives in tiles.band (a pure leaf +// both the baker and the compositor share). Re-exported here so this module's +// callers (chart, coverage) keep reaching it through bake_enc. (Inline @import +// rather than a `band` binding, so the many `band: Band` parameters below don't +// shadow it.) +pub const ZoomRange = @import("tiles").band.ZoomRange; +pub const Band = @import("tiles").band.Band; +pub const bands_fine_to_coarse = @import("tiles").band.bands_fine_to_coarse; +pub const bandOf = @import("tiles").band.bandOf; +pub const FILLUP_DZ = @import("tiles").band.FILLUP_DZ; +pub const FILLUP_CEIL = @import("tiles").band.FILLUP_CEIL; +pub const bandZooms = @import("tiles").band.bandZooms; /// Restricts a bakeBand pass to the tiles under one "super-tile" (a tile at the /// coarser zoom `zs`): only (z,x,y) with z >= zs whose ancestor at zs is (sx,sy) @@ -435,55 +445,6 @@ pub const ZoomRange = struct { min: u8, max: u8 }; /// time — loading only the cells overlapping it — instead of the whole band. pub const TileClip = struct { zs: u8, sx: u32, sy: u32 }; -/// Navigational-purpose bands, finest → coarsest (the order bands must be baked in -/// for best-band dedup). Mirrors chartplotter-go bake.Band. -pub const Band = enum(u8) { berthing = 0, harbor, approach, coastal, general, overview }; - -/// All bands finest → coarsest (the bakeBand call order). -pub const bands_fine_to_coarse = [_]Band{ .berthing, .harbor, .approach, .coastal, .general, .overview }; - -/// Map a compilation-scale denominator (CSCL, 1:N) to its band (Go BandForScale). -pub fn bandOf(cscl: i32) Band { - const n: i64 = if (cscl <= 0) 50_000 else cscl; - if (n <= 8_000) return .berthing; - if (n <= 32_000) return .harbor; - if (n <= 130_000) return .approach; - if (n <= 500_000) return .coastal; - if (n <= 2_300_000) return .general; - return .overview; -} - -/// Overscale fill-up depth DEFAULT: how many zooms past its native max a -/// band's own cells keep baking (only where nothing finer already emitted). -/// Every extension zoom ~4x that band's tile count over its uncovered -/// footprint (measured: +2 turned a 5.6k-tile approach pass into 41k), so the -/// default is ONE crisp overscale zoom; TILE57_FILLUP_DZ=0..2 overrides per -/// bake (Baker.fillup_dz). 0 never blanks — the client camera stops at the -/// probed data depth and MapLibre stretches one level past it. -pub const FILLUP_DZ: u8 = 1; - -/// Absolute fill-up ceiling: extension zooms never exceed this. The fill-up -/// serves the MID-ZOOM seam where a coarse chart is the finest coverage (the -/// blank bay at z12-15); letting fine bands extend too (harbor→z17-18, -/// berthing→z19-20) quadruples the tile count per extra zoom across every -/// harbor footprint for content nobody needs — a district pack ballooned from -/// ~800k to 13M+ planned tiles. A band's NATIVE window is never clamped by -/// this; past its data the camera stops at the probed depth instead. -pub const FILLUP_CEIL: u8 = 15; - -/// A band's native zoom span (Go Band.ZoomRange). Adjacent bands overlap by one -/// zoom; best-band dedup resolves the overlap to the finer band. -pub fn bandZooms(band: Band) ZoomRange { - return switch (band) { - .berthing => .{ .min = 16, .max = 18 }, - .harbor => .{ .min = 13, .max = 16 }, - .approach => .{ .min = 11, .max = 13 }, - .coastal => .{ .min = 9, .max = 11 }, - .general => .{ .min = 7, .max = 9 }, - .overview => .{ .min = 0, .max = 7 }, - }; -} - /// The live-path fallback band for a tile whose zoom sits OUTSIDE every /// overlapping cell's native window (chart.zig tileRefs). ABOVE every window /// (z > the finest band's max — band maxima are monotonic in fineness, so the diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 63651f2..58133f1 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -454,103 +454,6 @@ fn clipSimplifyLine(a: Allocator, proj: []const mvt.Point, box: tile.Box) ![]con return out.items; } -/// Shoelace signed area (x2) of a ring in tile space; only its sign is used. -/// y is down, so a positive value is a clockwise (exterior) ring per the MVT spec. -fn ringSignedArea(ring: []const mvt.Point) i64 { - if (ring.len < 3) return 0; - var area: i64 = 0; - var j: usize = ring.len - 1; - for (ring, 0..) |p, i| { - const q = ring[j]; - area += @as(i64, q.x) * @as(i64, p.y) - @as(i64, p.x) * @as(i64, q.y); - j = i; - } - return area; -} - -/// Even-odd ray test: is tile-space point `pt` inside `ring`? -fn ringContains(ring: []const mvt.Point, pt: mvt.Point) bool { - if (ring.len < 3) return false; - var inside = false; - const px: f64 = @floatFromInt(pt.x); - const py: f64 = @floatFromInt(pt.y); - var j: usize = ring.len - 1; - for (ring, 0..) |p, i| { - const q = ring[j]; - const ax: f64 = @floatFromInt(p.x); - const ay: f64 = @floatFromInt(p.y); - const bx: f64 = @floatFromInt(q.x); - const by: f64 = @floatFromInt(q.y); - if ((ay > py) != (by > py) and - px < (bx - ax) * (py - ay) / (by - ay) + ax) - { - inside = !inside; - } - j = i; - } - return inside; -} - -/// Orient + order a feature's clipped area rings into MVT multipolygon parts so -/// holes are SUBTRACTED instead of filled (e.g. an island inside a sea/depth -/// area). Mirrors the Go reference encodePolygon: classify each ring by geometric -/// nesting depth (even = exterior, odd = hole), force exteriors to a positive -/// signed area (clockwise in y-down tile space) and holes to negative, and emit -/// each exterior immediately followed by the holes it directly contains. This is -/// independent of the FSPT USAG tags, and keeps disjoint multi-part areas -/// (multiple exteriors) working as a proper multipolygon. `rings` are the clipped -/// rings (open, >= 3 pts); returned parts may reverse a ring into a fresh copy. -/// Public so the ownership-partition debug bake gets the same MVT winding. -pub fn orientAreaRings(a: Allocator, rings: []const []const mvt.Point) ![]const []const mvt.Point { - const n = rings.len; - const depth = try a.alloc(usize, n); - for (rings, 0..) |ri, i| { - var d: usize = 0; - for (rings, 0..) |rj, j| { - if (i != j and ringContains(rj, ri[0])) d += 1; - } - depth[i] = d; - } - - const done = try a.alloc(bool, n); - @memset(done, false); - var out = std.ArrayList([]const mvt.Point).empty; - - const emit = struct { - fn one(al: Allocator, list: *std.ArrayList([]const mvt.Point), ring: []const mvt.Point, d: usize) !void { - const want_pos = (d % 2) == 0; // even depth = exterior (positive), odd = hole - if ((ringSignedArea(ring) >= 0) == want_pos) { - try list.append(al, ring); - } else { - const rev = try al.alloc(mvt.Point, ring.len); - for (ring, 0..) |p, k| rev[ring.len - 1 - k] = p; - try list.append(al, rev); - } - } - }.one; - - // Each exterior (even depth) followed by the holes it directly contains, so a - // decoder attaches each hole to the right exterior (depth exactly +1, inside). - for (0..n) |i| { - if (done[i] or depth[i] % 2 != 0) continue; - done[i] = true; - try emit(a, &out, rings[i], depth[i]); - for (0..n) |k| { - if (done[k] or depth[k] != depth[i] + 1) continue; - if (ringContains(rings[i], rings[k][0])) { - done[k] = true; - try emit(a, &out, rings[k], depth[k]); - } - } - } - // Safety net: emit anything not placed (malformed nesting) on its own. - for (0..n) |i| { - if (done[i]) continue; - done[i] = true; - try emit(a, &out, rings[i], depth[i]); - } - return out.items; -} fn geomBounds(g: []const s57.LonLat) [4]f64 { var b = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; @@ -939,16 +842,10 @@ test "hasAdditionalInfo: INFORM/TXTDSC trigger; blank/absent/other don't" { try std.testing.expect(!hasAdditionalInfo(.{ .rcnm = 100, .rcid = 5, .prim = 1, .objl = 75, .attrs = &.{.{ .code = inform, .value = " \t " }} })); } -/// The vector layers this engine emits, in emit order — the source-layer ids the -/// generated MapLibre style reads. v2 tile schema (tile57/2): 6 layers, one per -/// render family. Each former `_scamin` twin is folded into its base at emit time -/// (endScene) — SCAMIN is now a per-feature `scamin` property the style gates, not a -/// separate layer — and complex/sector lines fold into `lines`. Static: an archive -/// may omit empties, but the TileJSON advertises the full set. Keep in sync with the -/// layer appends in endScene. -pub const VECTOR_LAYERS = [_][]const u8{ - "areas", "area_patterns", "lines", "point_symbols", "soundings", "text", -}; +/// The vector layers this engine emits, in emit order — the tile57/2 source-layer +/// set. Defined in tiles.mvt (shared with the compositor) and re-exported here; +/// keep the layer appends in endScene in sync with it. +pub const VECTOR_LAYERS = mvt.VECTOR_LAYERS; /// PMTiles archive metadata JSON: the static vector_layers list MapLibre reads from /// the TileJSON, plus a "scamin" array of the distinct SCAMIN denominators present @@ -1746,7 +1643,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, // Best-available: cut this cell's fill where a finer cell covers the ground. const arings = if (opts.cover_clip) |cc| subtractCoverage(a, rings.items, cc) else rings.items; if (arings.len > 0) { - const rparts = try orientAreaRings(a, arings); + const rparts = try mvt.orientAreaRings(a, arings); const dv = depthVals(f); const dr: ?rs.DepthRange = if (dv) |d| .{ .d1 = d[0], .d2 = d[1] } else null; const fillmeta = fmeta; @@ -2529,7 +2426,7 @@ fn emitOverscaleHatch(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, g if (ring.len >= 3) try rings.append(a, ring); } if (rings.items.len == 0) return; - const parts = try orientAreaRings(a, rings.items); + const parts = try mvt.orientAreaRings(a, rings.items); const fmeta = rs.FeatureMeta{ // S-52 §10.1.10.2: the overscale pattern draws at display priority 3 in // DISPLAY BASE (the indication is never optional content). No class/pick @@ -2934,7 +2831,7 @@ fn appendCellFeatures( if (ring.len >= 3) try rings.append(a, ring); } if (rings.items.len == 0) continue; - const parts = try orientAreaRings(a, rings.items); + const parts = try mvt.orientAreaRings(a, rings.items); var aprops = std.ArrayList(mvt.Prop).empty; try aprops.append(a, .{ .key = "class", .value = .{ .string = cls.name } }); try aprops.append(a, .{ .key = "color_token", .value = .{ .string = cls.color } }); @@ -3111,53 +3008,6 @@ test "listHasAny splits S-57 comma lists and matches any target" { try std.testing.expect(!listHasAny("", &.{18})); } -test "orientAreaRings subtracts a hole: exterior CW (+), interior CCW (-)" { - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - const a = arena.allocator(); - - // A sea-area exterior square (CCW as authored) with a smaller island hole - // inside it (also CCW as authored). y is down in tile space. - const ext = [_]mvt.Point{ - .{ .x = 0, .y = 0 }, .{ .x = 0, .y = 100 }, - .{ .x = 100, .y = 100 }, .{ .x = 100, .y = 0 }, - }; - const hole = [_]mvt.Point{ - .{ .x = 40, .y = 40 }, .{ .x = 40, .y = 60 }, - .{ .x = 60, .y = 60 }, .{ .x = 60, .y = 40 }, - }; - // Pass the hole first to prove ordering is by geometry, not input order. - const rings = [_][]const mvt.Point{ hole[0..], ext[0..] }; - const out = try orientAreaRings(a, &rings); - - try std.testing.expectEqual(@as(usize, 2), out.len); - // First emitted ring is the exterior (positive signed area), then its hole - // (negative). This is the winding MapLibre reads to cut the hole out. - try std.testing.expect(ringSignedArea(out[0]) > 0); - try std.testing.expect(ringSignedArea(out[1]) < 0); - // The exterior must be the 100x100 ring, the hole the 20x20 one. - try std.testing.expect(@abs(ringSignedArea(out[0])) > @abs(ringSignedArea(out[1]))); -} - -test "orientAreaRings keeps disjoint parts as separate exteriors (multipolygon)" { - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - const a = arena.allocator(); - - // Two disjoint squares (CTNARE-style multi-part area): both are exteriors, - // both wound positive, neither becomes a hole of the other. - const r0 = [_]mvt.Point{ - .{ .x = 0, .y = 0 }, .{ .x = 0, .y = 10 }, .{ .x = 10, .y = 10 }, .{ .x = 10, .y = 0 }, - }; - const r1 = [_]mvt.Point{ - .{ .x = 50, .y = 50 }, .{ .x = 50, .y = 60 }, .{ .x = 60, .y = 60 }, .{ .x = 60, .y = 50 }, - }; - const rings = [_][]const mvt.Point{ r0[0..], r1[0..] }; - const out = try orientAreaRings(a, &rings); - try std.testing.expectEqual(@as(usize, 2), out.len); - try std.testing.expect(ringSignedArea(out[0]) > 0); - try std.testing.expect(ringSignedArea(out[1]) > 0); -} fn findProp(props: []const mvt.Prop, key: []const u8) ?mvt.Value { for (props) |pr| if (std.mem.eql(u8, pr.key, key)) return pr.value; diff --git a/src/tiles/band.zig b/src/tiles/band.zig new file mode 100644 index 0000000..ec723f6 --- /dev/null +++ b/src/tiles/band.zig @@ -0,0 +1,70 @@ +//! Navigational-purpose bands: the map from an S-57 cell's compilation scale to +//! the Web-Mercator zoom range it serves. Pure integer math, shared by the baker +//! (which bakes each cell over its band's zooms) and the compositor (which reads +//! the band to decide overscale fill-up), so neither owns the mapping. + +/// Native [minzoom, maxzoom] Web-Mercator span for a navigational-purpose band. +pub const ZoomRange = struct { min: u8, max: u8 }; + +/// Navigational-purpose bands, finest -> coarsest (the order bands must be baked +/// in for best-band dedup). +pub const Band = enum(u8) { berthing = 0, harbor, approach, coastal, general, overview }; + +/// All bands finest -> coarsest (the bake call order). +pub const bands_fine_to_coarse = [_]Band{ .berthing, .harbor, .approach, .coastal, .general, .overview }; + +/// Map a compilation-scale denominator (CSCL, 1:N) to its band. +pub fn bandOf(cscl: i32) Band { + const n: i64 = if (cscl <= 0) 50_000 else cscl; + if (n <= 8_000) return .berthing; + if (n <= 32_000) return .harbor; + if (n <= 130_000) return .approach; + if (n <= 500_000) return .coastal; + if (n <= 2_300_000) return .general; + return .overview; +} + +/// Overscale fill-up depth DEFAULT: how many zooms past its native max a band's +/// own cells keep baking (only where nothing finer already emitted). Every +/// extension zoom ~4x that band's tile count over its uncovered footprint +/// (measured: +2 turned a 5.6k-tile approach pass into 41k), so the default is +/// ONE crisp overscale zoom; TILE57_FILLUP_DZ=0..2 overrides per bake +/// (Baker.fillup_dz). 0 never blanks — the client camera stops at the probed +/// data depth and MapLibre stretches one level past it. +pub const FILLUP_DZ: u8 = 1; + +/// Absolute fill-up ceiling: extension zooms never exceed this. The fill-up +/// serves the MID-ZOOM seam where a coarse chart is the finest coverage (the +/// blank bay at z12-15); letting fine bands extend too (harbor->z17-18, +/// berthing->z19-20) quadruples the tile count per extra zoom across every +/// harbor footprint for content nobody needs — a district pack ballooned from +/// ~800k to 13M+ planned tiles. A band's NATIVE window is never clamped by this; +/// past its data the camera stops at the probed depth instead. +pub const FILLUP_CEIL: u8 = 15; + +/// A band's native zoom span. Adjacent bands overlap by one zoom; best-band dedup +/// resolves the overlap to the finer band. +pub fn bandZooms(band: Band) ZoomRange { + return switch (band) { + .berthing => .{ .min = 16, .max = 18 }, + .harbor => .{ .min = 13, .max = 16 }, + .approach => .{ .min = 11, .max = 13 }, + .coastal => .{ .min = 9, .max = 11 }, + .general => .{ .min = 7, .max = 9 }, + .overview => .{ .min = 0, .max = 7 }, + }; +} + +test "bandOf maps compilation scale to band" { + const std = @import("std"); + try std.testing.expectEqual(Band.harbor, bandOf(20_000)); + try std.testing.expectEqual(Band.approach, bandOf(50_000)); + try std.testing.expectEqual(Band.overview, bandOf(3_000_000)); + try std.testing.expectEqual(Band.approach, bandOf(0)); // unknown -> 50k default +} + +test "bandZooms is finest-to-coarsest with one-zoom overlap" { + const std = @import("std"); + try std.testing.expectEqual(ZoomRange{ .min = 11, .max = 13 }, bandZooms(.approach)); + try std.testing.expectEqual(ZoomRange{ .min = 9, .max = 11 }, bandZooms(.coastal)); +} diff --git a/src/tiles/mvt.zig b/src/tiles/mvt.zig index e715162..707d860 100644 --- a/src/tiles/mvt.zig +++ b/src/tiles/mvt.zig @@ -572,3 +572,152 @@ test "zigzag" { try std.testing.expectEqual(@as(i64, -1), unzig(zigzag(-1))); try std.testing.expectEqual(@as(i64, 12345), unzig(zigzag(12345))); } + +// ---- tile schema + polygon ring winding ------------------------------------ + +/// The MVT source-layer set of the tile57/2 schema, in emit order. The scene +/// emitter fills these layers and the compositor stitches them by name. +pub const VECTOR_LAYERS = [_][]const u8{ + "areas", "area_patterns", "lines", "point_symbols", "soundings", "text", +}; + +/// Shoelace signed area (x2) of a ring in tile space; only its sign is used. +/// y is down, so a positive value is a clockwise (exterior) ring per the MVT spec. +fn ringSignedArea(ring: []const Point) i64 { + if (ring.len < 3) return 0; + var area: i64 = 0; + var j: usize = ring.len - 1; + for (ring, 0..) |p, i| { + const q = ring[j]; + area += @as(i64, q.x) * @as(i64, p.y) - @as(i64, p.x) * @as(i64, q.y); + j = i; + } + return area; +} + +/// Even-odd ray test: is tile-space point `pt` inside `ring`? +fn ringContains(ring: []const Point, pt: Point) bool { + if (ring.len < 3) return false; + var inside = false; + const px: f64 = @floatFromInt(pt.x); + const py: f64 = @floatFromInt(pt.y); + var j: usize = ring.len - 1; + for (ring, 0..) |p, i| { + const q = ring[j]; + const ax: f64 = @floatFromInt(p.x); + const ay: f64 = @floatFromInt(p.y); + const bx: f64 = @floatFromInt(q.x); + const by: f64 = @floatFromInt(q.y); + if ((ay > py) != (by > py) and + px < (bx - ax) * (py - ay) / (by - ay) + ax) + { + inside = !inside; + } + j = i; + } + return inside; +} + +/// Orient + order a feature's clipped area rings into MVT multipolygon parts so +/// holes are SUBTRACTED instead of filled (e.g. an island inside a sea/depth +/// area). Classify each ring by geometric nesting depth (even = exterior, odd = +/// hole), force exteriors to a positive signed area (clockwise in y-down tile +/// space) and holes to negative, and emit each exterior immediately followed by +/// the holes it directly contains. This is independent of the FSPT USAG tags, and +/// keeps disjoint multi-part areas (multiple exteriors) working as a proper +/// multipolygon. `rings` are the clipped rings (open, >= 3 pts); returned parts +/// may reverse a ring into a fresh copy. The scene emitter and the ownership +/// partition both wind rings through this, so they agree. +pub fn orientAreaRings(a: std.mem.Allocator, rings: []const []const Point) ![]const []const Point { + const n = rings.len; + const depth = try a.alloc(usize, n); + for (rings, 0..) |ri, i| { + var d: usize = 0; + for (rings, 0..) |rj, j| { + if (i != j and ringContains(rj, ri[0])) d += 1; + } + depth[i] = d; + } + + const done = try a.alloc(bool, n); + @memset(done, false); + var out = std.ArrayList([]const Point).empty; + + const emit = struct { + fn one(al: std.mem.Allocator, list: *std.ArrayList([]const Point), ring: []const Point, d: usize) !void { + const want_pos = (d % 2) == 0; // even depth = exterior (positive), odd = hole + if ((ringSignedArea(ring) >= 0) == want_pos) { + try list.append(al, ring); + } else { + const rev = try al.alloc(Point, ring.len); + for (ring, 0..) |p, k| rev[ring.len - 1 - k] = p; + try list.append(al, rev); + } + } + }.one; + + // Each exterior (even depth) followed by the holes it directly contains, so a + // decoder attaches each hole to the right exterior (depth exactly +1, inside). + for (0..n) |i| { + if (done[i] or depth[i] % 2 != 0) continue; + done[i] = true; + try emit(a, &out, rings[i], depth[i]); + for (0..n) |k| { + if (done[k] or depth[k] != depth[i] + 1) continue; + if (ringContains(rings[i], rings[k][0])) { + done[k] = true; + try emit(a, &out, rings[k], depth[k]); + } + } + } + // Safety net: emit anything not placed (malformed nesting) on its own. + for (0..n) |i| { + if (done[i]) continue; + done[i] = true; + try emit(a, &out, rings[i], depth[i]); + } + return out.items; +} + +test "orientAreaRings subtracts a hole: exterior CW (+), interior CCW (-)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // A sea-area exterior square (CCW as authored) with a smaller island hole + // inside it (also CCW as authored). y is down in tile space. + const ext = [_]Point{ + .{ .x = 0, .y = 0 }, .{ .x = 0, .y = 100 }, + .{ .x = 100, .y = 100 }, .{ .x = 100, .y = 0 }, + }; + const hole = [_]Point{ + .{ .x = 40, .y = 40 }, .{ .x = 40, .y = 60 }, + .{ .x = 60, .y = 60 }, .{ .x = 60, .y = 40 }, + }; + // Pass the hole first to prove ordering is by geometry, not input order. + const rings = [_][]const Point{ hole[0..], ext[0..] }; + const out = try orientAreaRings(a, &rings); + + try std.testing.expectEqual(@as(usize, 2), out.len); + try std.testing.expect(ringSignedArea(out[0]) > 0); + try std.testing.expect(ringSignedArea(out[1]) < 0); + try std.testing.expect(@abs(ringSignedArea(out[0])) > @abs(ringSignedArea(out[1]))); +} + +test "orientAreaRings keeps disjoint parts as separate exteriors (multipolygon)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const r0 = [_]Point{ + .{ .x = 0, .y = 0 }, .{ .x = 0, .y = 10 }, .{ .x = 10, .y = 10 }, .{ .x = 10, .y = 0 }, + }; + const r1 = [_]Point{ + .{ .x = 50, .y = 50 }, .{ .x = 50, .y = 60 }, .{ .x = 60, .y = 60 }, .{ .x = 60, .y = 50 }, + }; + const rings = [_][]const Point{ r0[0..], r1[0..] }; + const out = try orientAreaRings(a, &rings); + try std.testing.expectEqual(@as(usize, 2), out.len); + try std.testing.expect(ringSignedArea(out[0]) > 0); + try std.testing.expect(ringSignedArea(out[1]) > 0); +} diff --git a/src/tiles/tiles.zig b/src/tiles/tiles.zig index c8202d4..a70081e 100644 --- a/src/tiles/tiles.zig +++ b/src/tiles/tiles.zig @@ -6,6 +6,7 @@ //! pmtiles — PMTiles v3 archive read/write //! tile — web-mercator tile math: projection, extent, clipping, //! simplification (the geometry side of tiling) +//! band — compilation-scale -> zoom-range mapping (navigational bands) //! //! Pure std; the leaf bundle everything tile-shaped builds on. @@ -14,6 +15,7 @@ pub const mlt = @import("mlt.zig"); pub const gzip = @import("gzip.zig"); pub const pmtiles = @import("pmtiles.zig"); pub const tile = @import("tile.zig"); +pub const band = @import("band.zig"); test { _ = mvt; @@ -21,4 +23,5 @@ test { _ = gzip; _ = pmtiles; _ = tile; + _ = band; } From f12acf2ca46336139df3ea4c3496cbb3192a875e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 19:11:51 -0400 Subject: [PATCH 080/140] refactor: promote coverage to a module; move ordersBeforeKeys to geometry Prep for lifting the compositor out of bundle.zig. The compositor reads per-cell coverage from baked archives and needs the ownership tiebreak, so relocate both out of the scene module: - src/scene/coverage.zig -> src/coverage/ (its own module; already depended only on s57, so registering it is a clean split) - ordersBeforeKeys (equal-scale clip-order tiebreak) -> geometry.partition, where the baker and the partition share it (bake_enc re-exports) Pure moves: composed tiles remain byte-identical to the baseline. Co-Authored-By: Claude Fable 5 --- build.zig | 12 +++++++++++- src/{scene => coverage}/coverage.zig | 0 src/geometry/partition.zig | 10 ++++++++++ src/scene/bake_enc.zig | 13 ++++--------- src/scene/scene.zig | 2 +- 5 files changed, 26 insertions(+), 11 deletions(-) rename src/{scene => coverage}/coverage.zig (100%) diff --git a/build.zig b/build.zig index 462f8f5..96ca274 100644 --- a/build.zig +++ b/build.zig @@ -212,6 +212,15 @@ pub fn build(b: *std.Build) void { // engine + baker use it for the cross-band composite. const geometry_mod = b.addModule("geometry", .{ .root_source_file = b.path("src/geometry/geometry.zig") }); + // Per-cell M_COVR coverage sidecar (src/coverage/): CellCoverage plus the + // fromCell / encodeJson / decodeFromMetadata round-trip carried in PMTiles + // metadata. Pure over s57 (the LonLat point type) + std.json; the baker writes + // it and the compositor reads it. + const coverage_mod = b.addModule("coverage", .{ + .root_source_file = b.path("src/coverage/coverage.zig"), + .imports = &.{.{ .name = "s57", .module = s57_mod }}, + }); + // The tile engine (src/scene/): S-57 -> tile-surface generation plus the // banded ENC_ROOT baker (bake_enc.zig, mirrors the Go oracle's // internal/engine/baker — folded in as the engine's batch driver). @@ -223,6 +232,7 @@ pub fn build(b: *std.Build) void { .{ .name = "tiles", .module = tiles_mod }, .{ .name = "render", .module = render_mod }, .{ .name = "geometry", .module = geometry_mod }, + .{ .name = "coverage", .module = coverage_mod }, }, }); @@ -600,7 +610,7 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = bundle_test_mod })).step); // Per-cell coverage sidecar (JSON round-trip carried in PMTiles metadata): pure // over s57 + std.json. Part of the main suite. - _ = addPkgTest(b, test_step, "src/scene/coverage.zig", target, optimize, &.{ + _ = addPkgTest(b, test_step, "src/coverage/coverage.zig", target, optimize, &.{ .{ .name = "s57", .module = s57_mod }, }); // The render module: Surface contract + noop lifecycle smoke test (pins diff --git a/src/scene/coverage.zig b/src/coverage/coverage.zig similarity index 100% rename from src/scene/coverage.zig rename to src/coverage/coverage.zig diff --git a/src/geometry/partition.zig b/src/geometry/partition.zig index 929ab24..0b596fc 100644 --- a/src/geometry/partition.zig +++ b/src/geometry/partition.zig @@ -16,6 +16,16 @@ const std = @import("std"); const plane = @import("plane.zig"); const boolean = @import("boolean.zig"); +/// Whether (date da, name na) orders strictly before (db, nb) in the equal-scale +/// clip order: newer DSID issue/update date first (YYYYMMDD compares lexically; a +/// dated cell orders before an undated one), then cell name ascending — total and +/// deterministic for distinct cells, so bake output is byte-stable. The baker and +/// this partition use the same tie-break so they pick the same ownership winner. +pub fn ordersBeforeKeys(da: []const u8, na: []const u8, db: []const u8, nb: []const u8) bool { + if (!std.mem.eql(u8, da, db)) return std.mem.lessThan(u8, db, da); // newer first + return std.mem.lessThan(u8, na, nb); +} + pub const BandMap = struct { /// The band floor (lowest zoom) this map is computed at. tier: u8, diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index 2b37a84..df4fdf2 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -160,15 +160,10 @@ fn coversAny(backends: []const Backend, idxs: []const u32, lon: f64, lat: f64) b return coversAnyCtx(BackendCov{ .backends = backends }, idxs, lon, lat); } -// Whether (date da, name na) orders strictly before (db, nb) in the equal-scale -// clip order: newer DSID issue/update date first (YYYYMMDD compares lexically; a -// dated cell orders before an undated one), then cell name ascending - total and -// deterministic for distinct cells, so bake output is byte-stable. Public so the -// ownership partition assigns the same tie-break winner as the bake. -pub fn ordersBeforeKeys(da: []const u8, na: []const u8, db: []const u8, nb: []const u8) bool { - if (!std.mem.eql(u8, da, db)) return std.mem.lessThan(u8, db, da); // newer first - return std.mem.lessThan(u8, na, nb); -} +// The equal-scale clip-order tiebreak (newer date, then name) lives in +// geometry.partition, so the baker and the ownership partition pick the same +// winner. Re-exported here for this module's callers. +pub const ordersBeforeKeys = geometry.partition.ordersBeforeKeys; fn ordersBefore(ctx: anytype, a_idx: u32, b_idx: u32) bool { return ordersBeforeKeys(ctx.date(a_idx), ctx.name(a_idx), ctx.date(b_idx), ctx.name(b_idx)); diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 58133f1..e94d5d4 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -24,7 +24,7 @@ const rs = render.surface; /// The banded multi-cell ENC_ROOT -> PMTiles baker (folded in: it is the /// batch driver of this engine). Re-exported for the CLI + lib root. pub const bake_enc = @import("bake_enc.zig"); -pub const coverage = @import("coverage.zig"); // per-cell coverage sidecar (in PMTiles metadata) +pub const coverage = @import("coverage"); // per-cell coverage sidecar (in PMTiles metadata) pub const compose = @import("compose.zig"); // per-cell-composite clip-to-owned-face + face projection /// Output tile encoding: classic Mapbox Vector Tile, or MapLibre Tile. From eca50c302fe233a6a8b6bf59484dc8595a553462 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 19:26:06 -0400 Subject: [PATCH 081/140] refactor(compose): lift the runtime compositor into its own module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-demand tile compositor was wedged inside bundle.zig (the asset- emitter/bake module). Move it to a first-class compose module that reads already-baked archives and depends only on the tile / geometry / coverage leaves (s57 rides in only for the LonLat point type) — never scene, portray, render, or Lua. Compositing is now a standalone capability, separate from baking, as intended. src/compose/compose.zig composeTile / ComposeSource / openComposeSourceFiles + the tile-serving helpers src/compose/clip.zig the per-face clip-to-owned-geometry core (was src/scene/compose.zig) bundle.zig keeps the S-101 asset emitters + the ownership-partition debug bake, and imports compose for the two shared partition-input helpers (LoadedCov, toPlaneCells). The C ABI, compose-tile CLI, and bake CLI now import compose directly. bundle.zig shrank 979 -> 522 lines. Verbatim move: 54 composed tiles across z9-14 are byte-identical to the pre-extraction baseline; tests + compose-test green; a fresh bake -> serve round-trips. Co-Authored-By: Claude Fable 5 --- build.zig | 31 +- src/bundle.zig | 493 +------------------- src/capi.zig | 15 +- src/{scene/compose.zig => compose/clip.zig} | 6 +- src/compose/compose.zig | 469 +++++++++++++++++++ src/scene/scene.zig | 1 - tools/bake.zig | 4 +- tools/compose_tile.zig | 4 +- 8 files changed, 527 insertions(+), 496 deletions(-) rename src/{scene/compose.zig => compose/clip.zig} (98%) create mode 100644 src/compose/compose.zig diff --git a/build.zig b/build.zig index 96ca274..846d5dd 100644 --- a/build.zig +++ b/build.zig @@ -221,6 +221,20 @@ pub fn build(b: *std.Build) void { .imports = &.{.{ .name = "s57", .module = s57_mod }}, }); + // The runtime tile compositor (src/compose/): serve any (z,x,y) on demand from + // N per-cell PMTiles archives + an ownership partition. Reads baked archives + // only — never parses S-57 or runs portrayal — so it depends solely on the + // tile / geometry / coverage leaves (s57 rides in for the LonLat point type). + const compose_mod = b.addModule("compose", .{ + .root_source_file = b.path("src/compose/compose.zig"), + .imports = &.{ + .{ .name = "tiles", .module = tiles_mod }, + .{ .name = "geometry", .module = geometry_mod }, + .{ .name = "coverage", .module = coverage_mod }, + .{ .name = "s57", .module = s57_mod }, + }, + }); + // The tile engine (src/scene/): S-57 -> tile-surface generation plus the // banded ENC_ROOT baker (bake_enc.zig, mirrors the Go oracle's // internal/engine/baker — folded in as the engine's batch driver). @@ -336,6 +350,7 @@ pub fn build(b: *std.Build) void { .{ .name = "style", .module = style_mod }, .{ .name = "sprite", .module = sprite_mod }, .{ .name = "catalog", .module = catalog_embed }, + .{ .name = "compose", .module = compose_mod }, // debug bake reuses LoadedCov/toPlaneCells }, }); @@ -377,7 +392,8 @@ pub fn build(b: *std.Build) void { addPkgs(lib_mod, &pure_pkgs); lib_mod.addImport("portray", portray_mod); lib_mod.addImport("sprite", sprite_mod); // C ABI: sprite/pattern atlas generation - lib_mod.addImport("bundle", bundle_mod); // C ABI: the whole chart-bundle pipeline (bake_bundle) + lib_mod.addImport("bundle", bundle_mod); // C ABI: portrayal-asset emitters + debug bake + lib_mod.addImport("compose", compose_mod); // C ABI: tile57_compose_* (the runtime compositor) // The full engine surface as a NAMED import (not a root.zig file-import), so the // single root.zig file isn't claimed by both lib_mod and engine_full (which bundle // pulls in) — Zig requires each file to belong to exactly one module per artifact. @@ -462,6 +478,7 @@ pub fn build(b: *std.Build) void { .{ .name = "sprite", .module = sprite_mod }, .{ .name = "catalog", .module = catalog_embed }, .{ .name = "bundle", .module = bundle_mod }, + .{ .name = "compose", .module = compose_mod }, // compose-tile CLI .{ .name = "render", .module = render_mod }, // renderpng pixel path .{ .name = "chart", .module = chart_mod }, // ENC_ROOT view renders }, @@ -580,15 +597,17 @@ pub fn build(b: *std.Build) void { _ = addPkgTest(b, test_step, "src/style/style.zig", target, optimize, &.{}); // Geometry core for the cross-band composition (pure, std-only). _ = addPkgTest(b, test_step, "src/geometry/geometry.zig", target, optimize, &.{}); - // Compose core (clip-to-face): pure over mvt + geometry. Its own step for fast iteration, - // and part of the main suite. + // The runtime compositor + its clip core: pure over tiles + geometry + coverage. + // Its own step for fast iteration, and part of the main suite. const compose_deps = [_]std.Build.Module.Import{ .{ .name = "tiles", .module = tiles_mod }, .{ .name = "geometry", .module = geometry_mod }, + .{ .name = "coverage", .module = coverage_mod }, + .{ .name = "s57", .module = s57_mod }, }; - const compose_step = b.step("compose-test", "Run the compose-core (clip-to-face) tests"); - _ = addPkgTest(b, compose_step, "src/scene/compose.zig", target, optimize, &compose_deps); - _ = addPkgTest(b, test_step, "src/scene/compose.zig", target, optimize, &compose_deps); + const compose_step = b.step("compose-test", "Run the runtime compositor + clip-core tests"); + _ = addPkgTest(b, compose_step, "src/compose/compose.zig", target, optimize, &compose_deps); + _ = addPkgTest(b, test_step, "src/compose/compose.zig", target, optimize, &compose_deps); // The chart-bundle module hosts the per-cell composite (composeTile / ComposeSource). Its full // dep set (engine + assets/sprite/catalog) needs libc, so create the test module directly diff --git a/src/bundle.zig b/src/bundle.zig index 8b7e66e..a63224e 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -1,11 +1,10 @@ -//! bundle — the chart-bundle pipeline as a LIBRARY module: S-101 portrayal asset -//! emission (colortables / linestyles / sprite / patterns / sprite-mln) built from -//! the embedded catalogue (or an on-disk PortrayalCatalog dir). Moved out of the -//! `tile57` CLI so the CLI is a thin wrapper and any consumer (the C ABI, a Go/JS -//! binding, another UI) can build the same style. Alongside the asset emitters it -//! holds the per-cell composite: the ownership partition + the on-demand tile -//! compositor (composeTile / ComposeSource) that serves a live map from per-cell -//! archives. Pure-Zig + libc (the sprite atlas builder needs nanosvg/stb). +//! bundle — S-101 portrayal asset emission as a LIBRARY module: colortables, +//! linestyles, sprite / pattern / sprite-mln atlases, built from the embedded +//! catalogue (or an on-disk PortrayalCatalog dir). Any consumer (the C ABI, a +//! Go/JS binding, another UI) builds the same assets. Also holds the ownership- +//! partition debug bake (`bakePartitionDebug`), which reuses the compositor's +//! partition-input helpers. Pure-Zig + libc (the sprite atlas builder needs +//! nanosvg/stb). The runtime compositor itself lives in the `compose` module. //! //! Each reader takes a `catalog_dir`: "" = the embedded catalogue (catalog module's //! @embedFile'd registries), else `/{Symbols,LineStyles,AreaFills, @@ -17,6 +16,7 @@ const std = @import("std"); const engine = @import("engine"); const style = @import("style"); const sprite = @import("sprite"); +const compose = @import("compose"); // the runtime compositor (LoadedCov/toPlaneCells reused by the debug bake) const embedded_assets = @import("catalog"); // S-101 portrayal assets embedded into the binary // PortrayalCatalog sub-paths (relative to an on-disk catalog dir). @@ -268,16 +268,6 @@ fn readParseCell(io: std.Io, dir: std.Io.Dir, bpath: []const u8) ?engine.s57.Cel return engine.s57.parseCellWithUpdates(gpa, base, ups.items) catch null; } -/// One ENC cell loaded for coverage-level work: its M_COVR(CATCOV=1) rings, bbox, -/// compilation scale, DSNM stem, and DSID date. The eager cell-loader's output; the -/// cross-pack context uses a subset, the partition debug bake widens it to points. -pub const LoadedCov = struct { - name: []const u8, // DSNM stem - date: []const u8, // DSID issue/update date (YYYYMMDD) - cscl: i32, // compilation scale (1:N) - coverage: []const []const []const engine.s57.LonLat, // M_COVR(CATCOV=1) rings - bounds: [4]f64, // [w,s,e,n] over the coverage -}; /// THE eager cell-coverage loader: walk an ENC_ROOT, parse each cell once (base + /// updates via readParseCell), and capture its M_COVR coverage + bbox + cscl + name @@ -285,11 +275,11 @@ pub const LoadedCov = struct { /// Coverage is copied into `a` and survives the cell's deinit. "" / a missing dir /// yields none; cells with no M_COVR are skipped. The single load path composed by /// the cross-pack context, the partition debug bake, and the compositor. -fn loadCells(io: std.Io, a: std.mem.Allocator, path: []const u8) []LoadedCov { +fn loadCells(io: std.Io, a: std.mem.Allocator, path: []const u8) []compose.LoadedCov { if (path.len == 0) return &.{}; var dir = std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }) catch return &.{}; defer dir.close(io); - var out = std.ArrayList(LoadedCov).empty; + var out = std.ArrayList(compose.LoadedCov).empty; var seen = std.StringHashMap(void).init(a); var walker = dir.walk(a) catch return &.{}; defer walker.deinit(); @@ -332,7 +322,7 @@ const DEBUG_PALETTE = [_][]const u8{ /// Bake a DEBUG PMTiles of the ownership PARTITION (which cell renders which ground), /// NO portrayed content, for eyeballing the composite quilt. `band` < 0 emits the band /// GOVERNING each zoom (the natural view); 0..5 (berthing..overview) emits only that -/// band's own map, at every zoom. Composes the single paths — loadCells + toPlaneCells +/// band's own map, at every zoom. Composes the single paths — loadCells + compose.toPlaneCells /// + geometry.plane partition + the mvt/tile/pmtiles primitives — and STREAMS tiles (one /// tier + one zoom resident) so it scales to the whole corpus. Returns the cell count; /// `out_path` is a single `.pmtiles` file. @@ -350,8 +340,8 @@ pub fn bakePartitionDebug(io: std.Io, gpa: std.mem.Allocator, root_path: []const // hypothetical full pool. const loaded = loadCells(io, a, root_path); if (loaded.len == 0) return error.NoGeometry; - const cells = try toPlaneCells(a, loaded); - for (cells) |*c| c.reach = bandReach(c.cscl); + const cells = try compose.toPlaneCells(a, loaded); + for (cells) |*c| c.reach = compose.bandReach(c.cscl); // Union bbox (E7) for the header, so a viewer opens on the data not the world. var ubox = [4]i32{ std.math.maxInt(i32), std.math.maxInt(i32), std.math.minInt(i32), std.math.minInt(i32) }; @@ -443,50 +433,11 @@ fn governingTier(floors_desc: []const u8, z: u8) u8 { return floors_desc[floors_desc.len - 1]; } -/// The s57-coverage → geometry.plane.Cell adapter: widen each cell's M_COVR to integer -/// points, set the band floor (bandOf), and assign the deterministic DSID order (same -/// tie-break as the bake) across the whole set. Arena-allocated in `a`. THE single -/// conversion path; the partition-debug bake and the compositor both use it. -fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]engine.geometry.plane.Cell { - const geometry = engine.geometry; - const n = loaded.len; - const rank = try a.alloc(usize, n); - for (rank, 0..) |*v, i| v.* = i; - std.mem.sort(usize, rank, loaded, struct { - fn lt(ls: []const LoadedCov, x: usize, y: usize) bool { - return engine.bake_enc.ordersBeforeKeys(ls[x].date, ls[x].name, ls[y].date, ls[y].name); - } - }.lt); - const order = try a.alloc(u64, n); - for (rank, 0..) |ci, r| order[ci] = r; - - const cells = try a.alloc(geometry.plane.Cell, n); - for (loaded, 0..) |lc, i| { - const out = try a.alloc(geometry.plane.Poly, lc.coverage.len); - for (lc.coverage, 0..) |feat, fi| { - const rings = try a.alloc([]const geometry.plane.Pt, feat.len); - for (feat, 0..) |ring, ri| { - const pts = try a.alloc(geometry.plane.Pt, ring.len); - for (ring, 0..) |p, pi| pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; - rings[ri] = pts; - } - out[fi] = rings; - } - cells[i] = .{ - .cscl = lc.cscl, - .band_floor = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(lc.cscl)).min, - .order = order[i], - .cov1 = out, - }; - } - return cells; -} - /// Project + clip one tier's faces into the tiles of zoom `z` and stream them to `sw` /// (layer "partition"). `sa` is a per-zoom arena the caller resets, so only one zoom's /// tiles are resident. Reuses tile.project/clipPolygon + scene.orientAreaRings (the MVT /// winding authority) + mvt.encode. -fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: []const engine.geometry.plane.OwnedCell, loaded: []const LoadedCov, z: u8, tier: u8) !void { +fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: []const engine.geometry.plane.OwnedCell, loaded: []const compose.LoadedCov, z: u8, tier: u8) !void { const mvt = engine.mvt; const tile = engine.tile; const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); @@ -509,10 +460,10 @@ fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: }; const w_tl = tile.lonLatToWorld(fb[0], fb[3]); const w_br = tile.lonLatToWorld(fb[2], fb[1]); - const tx0 = worldAxisToTile(w_tl[0], scale); - const tx1 = worldAxisToTile(w_br[0], scale); - const ty0 = worldAxisToTile(w_tl[1], scale); - const ty1 = worldAxisToTile(w_br[1], scale); + const tx0 = compose.worldAxisToTile(w_tl[0], scale); + const tx1 = compose.worldAxisToTile(w_br[0], scale); + const ty0 = compose.worldAxisToTile(w_tl[1], scale); + const ty1 = compose.worldAxisToTile(w_br[1], scale); const props = try sa.dupe(mvt.Prop, &.{ .{ .key = "cell", .value = .{ .string = lc.name } }, @@ -569,411 +520,3 @@ fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: try sw.add(z, kv.key_ptr.x, kv.key_ptr.y, enc); } } - -// A normalised web-mercator world axis coordinate ([0,1]) → tile index at `scale` -// (= 2^z), clamped to [0, scale-1]. -fn worldAxisToTile(w: f64, scale: f64) u32 { - const f = @floor(w * scale); - if (f < 0) return 0; - return @intFromFloat(@min(f, scale - 1)); -} - -// =========================================================================== -// Per-cell composite — the on-demand tile compositor -// =========================================================================== -// -// Combine N per-cell PMTiles (each native-band-scale, its M_COVR coverage embedded in the -// metadata) into ONE merged PMTiles driven by the ownership partition. At every output tile, -// each owning cell's decoded features are clipped to the ground it OWNS (partition.ownedFace, -// projected into the tile) and concatenated per layer. The faces are a disjoint partition, so -// there is no double-draw at a seam and no z-order re-sort — S-52 draw priority rides the -// per-feature `draw_prio` property, which the style sorts client-side (so feature order within -// a tile is cosmetic). This retires the streaming in-bake cross-cell combiner: the per-cell -// bakes stay dumb + cacheable, and all cross-cell logic is precomputed as the partition. - -const N_COMPOSE_LAYERS = engine.mvt.VECTOR_LAYERS.len; - -// The tile-index cover (nw..se) of an owner face's lon/lat bbox at zoom `scale = 1< bb.tx1 or ty < bb.ty0 or ty > bb.ty1) continue; - - var grid = try engine.geometry.plane.EdgeGrid.init(ta, face.owned, tileWidthE7(z)); - defer grid.deinit(); - switch (grid.classify(tileClassifyBox(z, tx, ty))) { - .full => continue, // owns none of this tile - .empty => { // owns the whole tile: verbatim blob if present, else fall through - owned = true; - if (gzip) { - if (try readers[ci].getCompressed(z, tx, ty)) |blob| return .{ .tile = try gpa.dupe(u8, blob), .owned = true }; - } else if (try readers[ci].getTile(ta, z, tx, ty)) |raw| return .{ .tile = try gpa.dupe(u8, raw), .owned = true }; - }, - .seam => owned = true, - } - if (!(try ownerHasTile(readers[ci], cscl, z, tx, ty))) continue; - const face_px = try compose.projectFace(ta, face.owned, z, tx, ty); - if (face_px.len == 0) continue; - try slots.append(ta, @intCast(slot)); - } - if (slots.items.len == 0) return .{ .tile = null, .owned = owned }; - - const enc = (try composeSeamTile(ta, part, map, readers, slots.items, z, tx, ty)) orelse return .{ .tile = null, .owned = owned }; - // gzip=true → match the archive's stored (gzipped) bytes; gzip=false → hand back the raw MLT. - const bytes = if (gzip) try engine.pmtiles.StreamWriter.gzipTile(gpa, enc) else try gpa.dupe(u8, enc); - return .{ .tile = bytes, .owned = true }; -} - -/// The outcome of composing one tile: its bytes (null if nothing rendered) and whether the ownership -/// partition says a cell SHOULD render here. `tile == null and owned` = expected-but-empty (a cell -/// owns the ground but produced nothing — transient during a bake, suspect once a bake is done); -/// `tile == null and !owned` = true empty (no cell owns this ground — open ocean, safe to cache). -pub const TileResult = struct { tile: ?[]u8, owned: bool }; - -/// A resident compositor: the per-cell archives held mmap'd and the ownership partition built once -/// (or loaded from a sidecar), so `serve` composes any tile without a whole-district pass. Open once, -/// serve many, deinit. Only coverage-carrying archives are kept; cell index == reader index. This is -/// the runtime backing for on-demand serving — the batch is for producing a full archive; this is for -/// a camera asking for tiles. -pub const ComposeSource = struct { - gpa: std.mem.Allocator, - arena: std.heap.ArenaAllocator, // owns the readers/maps arrays + adapted cells (borrowed by part) - maps: []const []align(std.heap.page_size_min) const u8, - readers: []const *engine.pmtiles.Reader, - part: engine.geometry.partition.Partition, - minz: u8, - maxz: u8, - loop_max: u8, // deepest zoom the sources can serve (native windows + one fill-up overscale zoom) - bounds: [4]f64, // union coverage [west, south, east, north] in degrees - - /// Compose one tile → raw (decompressed) MLT + the ownership flag (gpa-owned bytes; null when - /// nothing rendered — `owned` then says whether a cell SHOULD have). This is what a live tile - /// server hands its HTTP layer, which gzips on the wire. Byte-faithful to the batch. - pub fn serve(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !TileResult { - return composeTile(gpa, &self.part, self.readers, z, tx, ty, false); - } - /// Serialize the resident ownership partition to a sidecar blob (gpa-owned) a later open can - /// load to skip the owned-face build. - pub fn serializePartition(self: *ComposeSource, gpa: std.mem.Allocator) ![]u8 { - return engine.geometry.partition.serialize(gpa, &self.part); - } - pub fn deinit(self: *ComposeSource) void { - const gpa = self.gpa; - self.part.deinit(); - for (self.readers) |rp| rp.deinit(); - for (self.maps) |m| std.posix.munmap(m); - self.arena.deinit(); - gpa.destroy(self); - } -}; - -/// Open a resident ComposeSource over per-cell PMTiles at `paths` (mmap'd, so the cell set is never -/// fully resident). If `load_partition` is non-null and valid for this cell set the partition is -/// loaded (no build); else it is built. Returns null if no archive carries coverage. Free with -/// `ComposeSource.deinit`. -pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, load_partition: ?[]const u8) !?*ComposeSource { - const pmtiles = engine.pmtiles; - const scene = engine.scene; - const src = try gpa.create(ComposeSource); - src.* = .{ .gpa = gpa, .arena = std.heap.ArenaAllocator.init(gpa), .maps = &.{}, .readers = &.{}, .part = undefined, .minz = 0, .maxz = 0, .loop_max = 0, .bounds = .{ 0, 0, 0, 0 } }; - errdefer { - src.arena.deinit(); - gpa.destroy(src); - } - const a = src.arena.allocator(); - - // mmap + open each archive; keep only those carrying coverage, so readers/maps/shims stay - // aligned (cell index == reader index) for composeTile. - var readers = std.ArrayList(*pmtiles.Reader).empty; - var maps = std.ArrayList([]align(std.heap.page_size_min) const u8).empty; - var shims = std.ArrayList(LoadedCov).empty; - var built_part = false; - errdefer { - for (readers.items) |rp| rp.deinit(); - for (maps.items) |m| std.posix.munmap(m); - if (built_part) src.part.deinit(); - } - var minz: u8 = 255; - var maxz: u8 = 0; - var ubox = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; // union coverage [w, s, e, n] - for (paths) |path| { - var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch continue; - const st = f.stat(io) catch { - f.close(io); - continue; - }; - const len: usize = @intCast(st.size); - if (len == 0) { - f.close(io); - continue; - } - const map = std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, f.handle, 0) catch { - f.close(io); - continue; - }; - f.close(io); - const rp = a.create(pmtiles.Reader) catch { - std.posix.munmap(map); - continue; - }; - rp.* = pmtiles.Reader.init(gpa, map) catch { - std.posix.munmap(map); - continue; - }; - const meta = readMetaJson(a, rp) orelse { - rp.deinit(); - std.posix.munmap(map); - continue; - }; - const cc = (scene.coverage.decodeFromMetadata(a, meta) catch null) orelse { - rp.deinit(); - std.posix.munmap(map); - continue; - }; - const bz = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cc.cscl)); - minz = @min(minz, bz.min); - maxz = @max(maxz, bz.max); - const b = [4]f64{ - @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, - @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, - }; - ubox[0] = @min(ubox[0], b[0]); - ubox[1] = @min(ubox[1], b[1]); - ubox[2] = @max(ubox[2], b[2]); - ubox[3] = @max(ubox[3], b[3]); - try maps.append(a, map); - try readers.append(a, rp); - try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = b }); - } - if (readers.items.len == 0) { - src.arena.deinit(); - gpa.destroy(src); - return null; - } - - const cells = try toPlaneCells(a, shims.items); - for (cells, readers.items) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); - - var loaded = false; - if (load_partition) |bytes| { - if (engine.geometry.partition.deserialize(gpa, bytes, cells)) |p| { - src.part = p; - loaded = true; - } else |err| std.debug.print(" partition sidecar unusable ({s}); building\n", .{@errorName(err)}); - } - if (!loaded) src.part = try engine.geometry.partition.build(gpa, cells); - built_part = true; - - const fill_max = @min(maxz + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL); - src.maps = maps.items; - src.readers = readers.items; - src.minz = minz; - src.maxz = maxz; - src.loop_max = @max(maxz, fill_max); - src.bounds = ubox; - return src; -} - -// The output-layer slot for a decoded layer name (one of scene.VECTOR_LAYERS), or null to drop. -fn layerIndex(name: []const u8) ?usize { - for (engine.mvt.VECTOR_LAYERS, 0..) |ln, i| { - if (std.mem.eql(u8, ln, name)) return i; - } - return null; -} - -// Decode a per-cell tile by its stored type (.mlt for our bakes, .mvt otherwise). -fn decodeTile(a: std.mem.Allocator, tt: engine.pmtiles.TileType, raw: []const u8) ![]engine.mvt.DecodedLayer { - return switch (tt) { - .mlt => engine.mlt.decode(a, raw), - else => engine.mvt.decode(a, raw), - }; -} - -// The decoded layers cell `r` contributes at (z,tx,ty): its native tile if it has one, else — -// when z is within the fill-up window just past the cell's band native max — its deepest native -// ancestor tile with the features scaled up into this descendant (overscale). null = nothing -// reachable (below native, or a coarse-only zoom beyond the fill-up window, where the client -// camera + MapLibre overzoom take over). Everything is arena-allocated in `a`. -fn ownerTile(a: std.mem.Allocator, r: *engine.pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32) !?[]engine.mvt.DecodedLayer { - const tt = r.header.tile_type; - if (try r.getTile(a, z, tx, ty)) |raw| return try decodeTile(a, tt, raw); - - const nmax = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cscl)).max; - if (z <= nmax or z > nmax + engine.bake_enc.FILLUP_DZ or z > engine.bake_enc.FILLUP_CEIL) return null; - const shift: u5 = @intCast(z - nmax); - const anc = (try r.getTile(a, nmax, tx >> shift, ty >> shift)) orelse return null; - const layers = try decodeTile(a, tt, anc); - scaleUpTile(layers, shift, tx, ty); - return layers; -} - -// The deepest zoom a cell's band ladder can serve — its native window max, or the fill-up -// overscale window just past it (`ownerTile`'s window, which FILLUP_CEIL can pull BELOW the -// native max for the finest bands — hence the @max). The band terms of `plane.Cell.reach`. -fn bandReach(cscl: i32) u8 { - const nmax = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cscl)).max; - return @max(nmax, @min(nmax + engine.bake_enc.FILLUP_DZ, engine.bake_enc.FILLUP_CEIL)); -} - -// Cheap existence mirror of `ownerTile` (directory probes only — no decompress, no decode): -// would it return content for cell `r` at (z,tx,ty)? Must stay in lockstep with it — the -// tile-major compositor's discovery pass uses this to reproduce the compose predicate, and -// the two passes must agree on which tiles compose. -fn ownerHasTile(r: *engine.pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32) !bool { - if ((try r.getCompressed(z, tx, ty)) != null) return true; - const nmax = engine.bake_enc.bandZooms(engine.bake_enc.bandOf(cscl)).max; - if (z <= nmax or z > nmax + engine.bake_enc.FILLUP_DZ or z > engine.bake_enc.FILLUP_CEIL) return false; - const shift: u5 = @intCast(z - nmax); - return (try r.getCompressed(nmax, tx >> shift, ty >> shift)) != null; -} - -// Scale an ancestor tile's features up into descendant (tx,ty) — the sub-cell `shift` levels -// finer: pixel (px,py) → (px< raw, - .gzip => engine.gzip.decompress(a, raw) catch return null, - else => null, - }; -} - -// The "scamin" ladder from an archive's metadata JSON, or empty if absent/unparseable. -fn parseScamin(a: std.mem.Allocator, meta: []const u8) []const u32 { - const Dto = struct { scamin: []const u32 = &.{} }; - const v = std.json.parseFromSliceLeaky(Dto, a, meta, .{ .ignore_unknown_fields = true }) catch return &.{}; - return v.scamin; -} diff --git a/src/capi.zig b/src/capi.zig index d635404..6a6dca1 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -7,7 +7,8 @@ const std = @import("std"); const chart = @import("chart.zig"); const s57 = @import("s57"); -const bundle = @import("bundle"); // the whole chart-bundle pipeline (tiles + assets + manifest) +const bundle = @import("bundle"); // portrayal-asset emitters + the partition debug bake +const compose = @import("compose"); // the runtime tile compositor (tile57_compose_*) const mariner = @import("style").mariner; const style = @import("style"); // The S-52 ColorProfiles/colorProfile.xml baked into the library (build.zig), so @@ -163,7 +164,7 @@ export fn tile57_compose_open( paths: ?[*]const ?[*:0]const u8, n: usize, partition_path: ?[*:0]const u8, -) callconv(.c) ?*bundle.ComposeSource { +) callconv(.c) ?*compose.ComposeSource { const ps = paths orelse return null; if (n == 0) return null; const list = gpa.alloc([]const u8, n) catch return null; @@ -186,7 +187,7 @@ export fn tile57_compose_open( } else |_| {} } - return (bundle.openComposeSourceFiles(io, gpa, list, load_bytes) catch return null) orelse return null; + return (compose.openComposeSourceFiles(io, gpa, list, load_bytes) catch return null) orelse return null; } /// Compose the tile (z,x,y) on demand, returning the RAW (decompressed) MLT in *out / *out_len (free @@ -195,7 +196,7 @@ export fn tile57_compose_open( /// during a bake, an error state once bakes are done), 0 = not owned (true empty ocean, safe to /// cache), -1 = error. Byte-faithful to the batch compositor. See tile57.h. export fn tile57_compose_serve( - handle: ?*bundle.ComposeSource, + handle: ?*compose.ComposeSource, z: u8, x: u32, y: u32, @@ -213,7 +214,7 @@ export fn tile57_compose_serve( } /// Fill `out` with the compositor's zoom range + union coverage bounds. See tile57.h. -export fn tile57_compose_meta_get(handle: ?*bundle.ComposeSource, out: *CComposeMeta) callconv(.c) void { +export fn tile57_compose_meta_get(handle: ?*compose.ComposeSource, out: *CComposeMeta) callconv(.c) void { const src = handle orelse return; out.* = .{ .min_zoom = src.minz, @@ -228,7 +229,7 @@ export fn tile57_compose_meta_get(handle: ?*bundle.ComposeSource, out: *CCompose /// Serialize the compositor's ownership partition to the file `path` (a sidecar a later /// tile57_compose_open can load to skip the build). 1=ok, -1=error. See tile57.h. -export fn tile57_compose_save_partition(handle: ?*bundle.ComposeSource, path: ?[*:0]const u8) callconv(.c) c_int { +export fn tile57_compose_save_partition(handle: ?*compose.ComposeSource, path: ?[*:0]const u8) callconv(.c) c_int { const src = handle orelse return -1; const p = spanOpt(path) orelse return -1; const bytes = src.serializePartition(gpa) catch return -1; @@ -240,7 +241,7 @@ export fn tile57_compose_save_partition(handle: ?*bundle.ComposeSource, path: ?[ } /// Release a compositor opened by tile57_compose_open (munmaps the archives, frees the partition). -export fn tile57_compose_close(handle: ?*bundle.ComposeSource) callconv(.c) void { +export fn tile57_compose_close(handle: ?*compose.ComposeSource) callconv(.c) void { if (handle) |src| src.deinit(); } diff --git a/src/scene/compose.zig b/src/compose/clip.zig similarity index 98% rename from src/scene/compose.zig rename to src/compose/clip.zig index 90562ba..fb90a9f 100644 --- a/src/scene/compose.zig +++ b/src/compose/clip.zig @@ -1,6 +1,6 @@ -//! Compose core (Stage 2 of the per-cell-composite bake): clip ONE cell's decoded tile -//! features to the ground it OWNS, so the compositor can merge many cells' clipped tiles -//! into one composed tile with no double-draw at a seam. +//! The compositor's clip core: clip ONE cell's decoded tile features to the ground +//! it OWNS, so the compositor can merge many cells' clipped tiles into one composed +//! tile with no double-draw at a seam. //! //! `face` is the cell's owned rings (from `partition.ownedFace`) projected to THIS tile's //! pixel space, i64 — `projectFace` does that projection, mirroring EXACTLY the baker's diff --git a/src/compose/compose.zig b/src/compose/compose.zig new file mode 100644 index 0000000..f2678f5 --- /dev/null +++ b/src/compose/compose.zig @@ -0,0 +1,469 @@ +//! compose — the runtime tile compositor. Given N per-cell PMTiles archives plus +//! an ownership partition, it serves any (z, x, y) tile ON DEMAND by clipping each +//! owning cell's tile to the face it owns and stitching the result. Separate from +//! baking: it reads already-baked archives and never parses S-57 or runs +//! portrayal, so it depends only on the tile/geometry/coverage leaves. +//! +//! ComposeSource — resident compositor over mmap'd archives + a partition +//! composeTile — compose one tile (the stateless core ComposeSource uses) +//! openComposeSourceFiles — open archives from disk, load or build the partition +//! clip — the per-face clip-to-owned-geometry core (submodule) + +const std = @import("std"); +const pmtiles = @import("tiles").pmtiles; +const mvt = @import("tiles").mvt; +const mlt = @import("tiles").mlt; +const gzip = @import("tiles").gzip; +const tile = @import("tiles").tile; +const band = @import("tiles").band; +const geometry = @import("geometry"); +const coverage = @import("coverage"); +const s57 = @import("s57"); + +/// The per-face clip-to-owned-geometry core: project an owned face to tile space +/// and clip each feature to it. Pure over tiles + geometry. +pub const clip = @import("clip.zig"); + +pub const LoadedCov = struct { + name: []const u8, // DSNM stem + date: []const u8, // DSID issue/update date (YYYYMMDD) + cscl: i32, // compilation scale (1:N) + coverage: []const []const []const s57.LonLat, // M_COVR(CATCOV=1) rings + bounds: [4]f64, // [w,s,e,n] over the coverage +}; + +pub fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]geometry.plane.Cell { + const n = loaded.len; + const rank = try a.alloc(usize, n); + for (rank, 0..) |*v, i| v.* = i; + std.mem.sort(usize, rank, loaded, struct { + fn lt(ls: []const LoadedCov, x: usize, y: usize) bool { + return geometry.partition.ordersBeforeKeys(ls[x].date, ls[x].name, ls[y].date, ls[y].name); + } + }.lt); + const order = try a.alloc(u64, n); + for (rank, 0..) |ci, r| order[ci] = r; + + const cells = try a.alloc(geometry.plane.Cell, n); + for (loaded, 0..) |lc, i| { + const out = try a.alloc(geometry.plane.Poly, lc.coverage.len); + for (lc.coverage, 0..) |feat, fi| { + const rings = try a.alloc([]const geometry.plane.Pt, feat.len); + for (feat, 0..) |ring, ri| { + const pts = try a.alloc(geometry.plane.Pt, ring.len); + for (ring, 0..) |p, pi| pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; + rings[ri] = pts; + } + out[fi] = rings; + } + cells[i] = .{ + .cscl = lc.cscl, + .band_floor = band.bandZooms(band.bandOf(lc.cscl)).min, + .order = order[i], + .cov1 = out, + }; + } + return cells; +} + +// A normalised web-mercator world axis coordinate ([0,1]) -> tile index at `scale` +// (= 2^z), clamped to [0, scale-1]. +pub fn worldAxisToTile(w: f64, scale: f64) u32 { + const f = @floor(w * scale); + if (f < 0) return 0; + return @intFromFloat(@min(f, scale - 1)); +} + +// =========================================================================== +// Per-cell composite — the on-demand tile compositor +// =========================================================================== +// +// Combine N per-cell PMTiles (each native-band-scale, its M_COVR coverage embedded in the +// metadata) into ONE merged PMTiles driven by the ownership partition. At every output tile, +// each owning cell's decoded features are clipped to the ground it OWNS (partition.ownedFace, +// projected into the tile) and concatenated per layer. The faces are a disjoint partition, so +// there is no double-draw at a seam and no z-order re-sort — S-52 draw priority rides the +// per-feature `draw_prio` property, which the style sorts client-side (so feature order within +// a tile is cosmetic). This retires the streaming in-bake cross-cell combiner: the per-cell +// bakes stay dumb + cacheable, and all cross-cell logic is precomputed as the partition. + +const N_COMPOSE_LAYERS = mvt.VECTOR_LAYERS.len; + +// The tile-index cover (nw..se) of an owner face's lon/lat bbox at zoom `scale = 1< bb.tx1 or ty < bb.ty0 or ty > bb.ty1) continue; + + var grid = try geometry.plane.EdgeGrid.init(ta, face.owned, tileWidthE7(z)); + defer grid.deinit(); + switch (grid.classify(tileClassifyBox(z, tx, ty))) { + .full => continue, // owns none of this tile + .empty => { // owns the whole tile: verbatim blob if present, else fall through + owned = true; + if (want_gzip) { + if (try readers[ci].getCompressed(z, tx, ty)) |blob| return .{ .tile = try gpa.dupe(u8, blob), .owned = true }; + } else if (try readers[ci].getTile(ta, z, tx, ty)) |raw| return .{ .tile = try gpa.dupe(u8, raw), .owned = true }; + }, + .seam => owned = true, + } + if (!(try ownerHasTile(readers[ci], cscl, z, tx, ty))) continue; + const face_px = try compose.projectFace(ta, face.owned, z, tx, ty); + if (face_px.len == 0) continue; + try slots.append(ta, @intCast(slot)); + } + if (slots.items.len == 0) return .{ .tile = null, .owned = owned }; + + const enc = (try composeSeamTile(ta, part, map, readers, slots.items, z, tx, ty)) orelse return .{ .tile = null, .owned = owned }; + // want_gzip → match the archive's stored (gzipped) bytes; else hand back the raw MLT. + const bytes = if (want_gzip) try pmtiles.StreamWriter.gzipTile(gpa, enc) else try gpa.dupe(u8, enc); + return .{ .tile = bytes, .owned = true }; +} + +/// The outcome of composing one tile: its bytes (null if nothing rendered) and whether the ownership +/// partition says a cell SHOULD render here. `tile == null and owned` = expected-but-empty (a cell +/// owns the ground but produced nothing — transient during a bake, suspect once a bake is done); +/// `tile == null and !owned` = true empty (no cell owns this ground — open ocean, safe to cache). +pub const TileResult = struct { tile: ?[]u8, owned: bool }; + +/// A resident compositor: the per-cell archives held mmap'd and the ownership partition built once +/// (or loaded from a sidecar), so `serve` composes any tile without a whole-district pass. Open once, +/// serve many, deinit. Only coverage-carrying archives are kept; cell index == reader index. This is +/// the runtime backing for on-demand serving — the batch is for producing a full archive; this is for +/// a camera asking for tiles. +pub const ComposeSource = struct { + gpa: std.mem.Allocator, + arena: std.heap.ArenaAllocator, // owns the readers/maps arrays + adapted cells (borrowed by part) + maps: []const []align(std.heap.page_size_min) const u8, + readers: []const *pmtiles.Reader, + part: geometry.partition.Partition, + minz: u8, + maxz: u8, + loop_max: u8, // deepest zoom the sources can serve (native windows + one fill-up overscale zoom) + bounds: [4]f64, // union coverage [west, south, east, north] in degrees + + /// Compose one tile → raw (decompressed) MLT + the ownership flag (gpa-owned bytes; null when + /// nothing rendered — `owned` then says whether a cell SHOULD have). This is what a live tile + /// server hands its HTTP layer, which gzips on the wire. Byte-faithful to the batch. + pub fn serve(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !TileResult { + return composeTile(gpa, &self.part, self.readers, z, tx, ty, false); + } + /// Serialize the resident ownership partition to a sidecar blob (gpa-owned) a later open can + /// load to skip the owned-face build. + pub fn serializePartition(self: *ComposeSource, gpa: std.mem.Allocator) ![]u8 { + return geometry.partition.serialize(gpa, &self.part); + } + pub fn deinit(self: *ComposeSource) void { + const gpa = self.gpa; + self.part.deinit(); + for (self.readers) |rp| rp.deinit(); + for (self.maps) |m| std.posix.munmap(m); + self.arena.deinit(); + gpa.destroy(self); + } +}; + +/// Open a resident ComposeSource over per-cell PMTiles at `paths` (mmap'd, so the cell set is never +/// fully resident). If `load_partition` is non-null and valid for this cell set the partition is +/// loaded (no build); else it is built. Returns null if no archive carries coverage. Free with +/// `ComposeSource.deinit`. +pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, load_partition: ?[]const u8) !?*ComposeSource { + const src = try gpa.create(ComposeSource); + src.* = .{ .gpa = gpa, .arena = std.heap.ArenaAllocator.init(gpa), .maps = &.{}, .readers = &.{}, .part = undefined, .minz = 0, .maxz = 0, .loop_max = 0, .bounds = .{ 0, 0, 0, 0 } }; + errdefer { + src.arena.deinit(); + gpa.destroy(src); + } + const a = src.arena.allocator(); + + // mmap + open each archive; keep only those carrying coverage, so readers/maps/shims stay + // aligned (cell index == reader index) for composeTile. + var readers = std.ArrayList(*pmtiles.Reader).empty; + var maps = std.ArrayList([]align(std.heap.page_size_min) const u8).empty; + var shims = std.ArrayList(LoadedCov).empty; + var built_part = false; + errdefer { + for (readers.items) |rp| rp.deinit(); + for (maps.items) |m| std.posix.munmap(m); + if (built_part) src.part.deinit(); + } + var minz: u8 = 255; + var maxz: u8 = 0; + var ubox = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; // union coverage [w, s, e, n] + for (paths) |path| { + var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch continue; + const st = f.stat(io) catch { + f.close(io); + continue; + }; + const len: usize = @intCast(st.size); + if (len == 0) { + f.close(io); + continue; + } + const map = std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, f.handle, 0) catch { + f.close(io); + continue; + }; + f.close(io); + const rp = a.create(pmtiles.Reader) catch { + std.posix.munmap(map); + continue; + }; + rp.* = pmtiles.Reader.init(gpa, map) catch { + std.posix.munmap(map); + continue; + }; + const meta = readMetaJson(a, rp) orelse { + rp.deinit(); + std.posix.munmap(map); + continue; + }; + const cc = (coverage.decodeFromMetadata(a, meta) catch null) orelse { + rp.deinit(); + std.posix.munmap(map); + continue; + }; + const bz = band.bandZooms(band.bandOf(cc.cscl)); + minz = @min(minz, bz.min); + maxz = @max(maxz, bz.max); + const b = [4]f64{ + @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, + @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, + }; + ubox[0] = @min(ubox[0], b[0]); + ubox[1] = @min(ubox[1], b[1]); + ubox[2] = @max(ubox[2], b[2]); + ubox[3] = @max(ubox[3], b[3]); + try maps.append(a, map); + try readers.append(a, rp); + try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = b }); + } + if (readers.items.len == 0) { + src.arena.deinit(); + gpa.destroy(src); + return null; + } + + const cells = try toPlaneCells(a, shims.items); + for (cells, readers.items) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); + + var loaded = false; + if (load_partition) |bytes| { + if (geometry.partition.deserialize(gpa, bytes, cells)) |p| { + src.part = p; + loaded = true; + } else |err| std.debug.print(" partition sidecar unusable ({s}); building\n", .{@errorName(err)}); + } + if (!loaded) src.part = try geometry.partition.build(gpa, cells); + built_part = true; + + const fill_max = @min(maxz + band.FILLUP_DZ, band.FILLUP_CEIL); + src.maps = maps.items; + src.readers = readers.items; + src.minz = minz; + src.maxz = maxz; + src.loop_max = @max(maxz, fill_max); + src.bounds = ubox; + return src; +} + +// The output-layer slot for a decoded layer name (one of mvt.VECTOR_LAYERS), or null to drop. +fn layerIndex(name: []const u8) ?usize { + for (mvt.VECTOR_LAYERS, 0..) |ln, i| { + if (std.mem.eql(u8, ln, name)) return i; + } + return null; +} + +// Decode a per-cell tile by its stored type (.mlt for our bakes, .mvt otherwise). +fn decodeTile(a: std.mem.Allocator, tt: pmtiles.TileType, raw: []const u8) ![]mvt.DecodedLayer { + return switch (tt) { + .mlt => mlt.decode(a, raw), + else => mvt.decode(a, raw), + }; +} + +// The decoded layers cell `r` contributes at (z,tx,ty): its native tile if it has one, else — +// when z is within the fill-up window just past the cell's band native max — its deepest native +// ancestor tile with the features scaled up into this descendant (overscale). null = nothing +// reachable (below native, or a coarse-only zoom beyond the fill-up window, where the client +// camera + MapLibre overzoom take over). Everything is arena-allocated in `a`. +fn ownerTile(a: std.mem.Allocator, r: *pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32) !?[]mvt.DecodedLayer { + const tt = r.header.tile_type; + if (try r.getTile(a, z, tx, ty)) |raw| return try decodeTile(a, tt, raw); + + const nmax = band.bandZooms(band.bandOf(cscl)).max; + if (z <= nmax or z > nmax + band.FILLUP_DZ or z > band.FILLUP_CEIL) return null; + const shift: u5 = @intCast(z - nmax); + const anc = (try r.getTile(a, nmax, tx >> shift, ty >> shift)) orelse return null; + const layers = try decodeTile(a, tt, anc); + scaleUpTile(layers, shift, tx, ty); + return layers; +} + +// The deepest zoom a cell's band ladder can serve — its native window max, or the fill-up +// overscale window just past it (`ownerTile`'s window, which FILLUP_CEIL can pull BELOW the +// native max for the finest bands — hence the @max). The band terms of `plane.Cell.reach`. +pub fn bandReach(cscl: i32) u8 { + const nmax = band.bandZooms(band.bandOf(cscl)).max; + return @max(nmax, @min(nmax + band.FILLUP_DZ, band.FILLUP_CEIL)); +} + +// Cheap existence mirror of `ownerTile` (directory probes only — no decompress, no decode): +// would it return content for cell `r` at (z,tx,ty)? Must stay in lockstep with it — the +// tile-major compositor's discovery pass uses this to reproduce the compose predicate, and +// the two passes must agree on which tiles compose. +fn ownerHasTile(r: *pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32) !bool { + if ((try r.getCompressed(z, tx, ty)) != null) return true; + const nmax = band.bandZooms(band.bandOf(cscl)).max; + if (z <= nmax or z > nmax + band.FILLUP_DZ or z > band.FILLUP_CEIL) return false; + const shift: u5 = @intCast(z - nmax); + return (try r.getCompressed(nmax, tx >> shift, ty >> shift)) != null; +} + +// Scale an ancestor tile's features up into descendant (tx,ty) — the sub-cell `shift` levels +// finer: pixel (px,py) → (px< raw, + .gzip => gzip.decompress(a, raw) catch return null, + else => null, + }; +} + +// The "scamin" ladder from an archive's metadata JSON, or empty if absent/unparseable. +fn parseScamin(a: std.mem.Allocator, meta: []const u8) []const u32 { + const Dto = struct { scamin: []const u32 = &.{} }; + const v = std.json.parseFromSliceLeaky(Dto, a, meta, .{ .ignore_unknown_fields = true }) catch return &.{}; + return v.scamin; +} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index e94d5d4..0a3f987 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -25,7 +25,6 @@ const rs = render.surface; /// batch driver of this engine). Re-exported for the CLI + lib root. pub const bake_enc = @import("bake_enc.zig"); pub const coverage = @import("coverage"); // per-cell coverage sidecar (in PMTiles metadata) -pub const compose = @import("compose.zig"); // per-cell-composite clip-to-owned-face + face projection /// Output tile encoding: classic Mapbox Vector Tile, or MapLibre Tile. pub const TileFormat = enum { mvt, mlt }; diff --git a/tools/bake.zig b/tools/bake.zig index 008870f..8065844 100644 --- a/tools/bake.zig +++ b/tools/bake.zig @@ -13,7 +13,7 @@ const std = @import("std"); const chart = @import("chart"); // per-cell bake (bakeCellBytes) + freeBytes -const bundle = @import("bundle"); // openComposeSourceFiles + serializePartition (the resident compositor) +const compose = @import("compose"); // openComposeSourceFiles + serializePartition (the resident compositor) const common = @import("common.zig"); const Flags = common.Flags; const usageErr = common.usageErr; @@ -115,7 +115,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // Open the resident compositor over the per-cell archives (mmap'd) and serialize its ownership // partition to /partition.tpart — the sidecar a runtime open loads to skip the build. - const src = (bundle.openComposeSourceFiles(io, a, archive_paths.items, null) catch |err| { + const src = (compose.openComposeSourceFiles(io, a, archive_paths.items, null) catch |err| { std.debug.print("error: open compose source failed ({s})\n", .{@errorName(err)}); return; }) orelse return usageErr("no coverage-carrying archives (nothing to compose)"); diff --git a/tools/compose_tile.zig b/tools/compose_tile.zig index 6c587ac..c19923d 100644 --- a/tools/compose_tile.zig +++ b/tools/compose_tile.zig @@ -5,7 +5,7 @@ const std = @import("std"); const engine = @import("engine"); -const bundle = @import("bundle"); +const compose = @import("compose"); const common = @import("common.zig"); const Flags = common.Flags; const usageErr = common.usageErr; @@ -86,7 +86,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // Open the resident source (mmap archives + partition once) — the amortised cost. const open_t0 = nowNs(); - const src = (bundle.openComposeSourceFiles(io, a, paths.items, load_bytes) catch |err| { + const src = (compose.openComposeSourceFiles(io, a, paths.items, load_bytes) catch |err| { std.debug.print("error: open compose source failed ({s})\n", .{@errorName(err)}); return; }) orelse { From 9ba1c3560788db02339360f473fd7ac7ad8cbfac Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 19:31:48 -0400 Subject: [PATCH 082/140] feat(tile57): complete the public root with the full pipeline Rewrite src/tile57.zig so a Zig consumer can reach the whole pipeline without importing private modules: cells in -> per-cell PMTiles + partition -> compose out, plus render and style. New/expanded surface: - bake namespace (cellBytes / cellsToFiles / tree / archive / pmtilesMetadata) - compose (ComposeSource / composeTile / openComposeSourceFiles) - partition + geometry (the .tpart ownership sidecar) - render (the Surface/Canvas rendering path) - mlt, gzip, band, coverage - style is the style module directly (json / Options / diff / mariner / color tables); tile57.Mariner aliases the settings type The header now states the real pipeline and drops the unverifiable "high-performance and low-memory" claim, and notes each layer module is independently grabbable. Co-Authored-By: Claude Fable 5 --- build.zig | 3 ++ src/tile57.zig | 110 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 76 insertions(+), 37 deletions(-) diff --git a/build.zig b/build.zig index 846d5dd..dacb617 100644 --- a/build.zig +++ b/build.zig @@ -378,6 +378,8 @@ pub fn build(b: *std.Build) void { addPkgs(tile57_mod, &pure_pkgs); tile57_mod.addImport("portray", portray_mod); tile57_mod.addImport("sprite", sprite_mod); // sprite/pattern atlas generation + tile57_mod.addImport("coverage", coverage_mod); // per-cell coverage sidecar + tile57_mod.addImport("compose", compose_mod); // the runtime compositor // Static library (libtile57.a): C ABI + embedded Lua. Its own root so // the C sources / libc only land in the archive (linked by the C++ host), @@ -394,6 +396,7 @@ pub fn build(b: *std.Build) void { lib_mod.addImport("sprite", sprite_mod); // C ABI: sprite/pattern atlas generation lib_mod.addImport("bundle", bundle_mod); // C ABI: portrayal-asset emitters + debug bake lib_mod.addImport("compose", compose_mod); // C ABI: tile57_compose_* (the runtime compositor) + lib_mod.addImport("coverage", coverage_mod); // the tile57 public root re-exports it // The full engine surface as a NAMED import (not a root.zig file-import), so the // single root.zig file isn't claimed by both lib_mod and engine_full (which bundle // pulls in) — Zig requires each file to belong to exactly one module per artifact. diff --git a/src/tile57.zig b/src/tile57.zig index 5abd833..b8c7fd3 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -1,74 +1,110 @@ -//! tile57 — the public Zig API for the S-57 → MVT vector-tile + S-52 style engine. +//! tile57 — the public Zig API for turning S-57 ENC data into vector tiles. //! -//! tile57 turns IHO S-57 ENC cells into Mapbox Vector Tiles plus a MapLibre -//! S-52 style and its portrayal assets (colour tables, line styles, sprite + -//! pattern atlases). It is high-performance and low-memory by design: cells are -//! indexed cheaply and parsed/portrayed lazily per requested tile, multi-cell -//! bakes stream band-by-band, and the foundational format/encode packages are -//! pure Zig (no libc). +//! The pipeline: parse IHO S-57 cells, apply S-101 portrayal, and bake each cell +//! to its own MLT vector tiles in a PMTiles archive at its compilation scale. A +//! runtime compositor then stitches the per-cell archives into one tile for any +//! (z, x, y) on demand, using an ownership partition so cells never double-draw at +//! a seam. The same engine renders a chart to PNG or PDF, and generates the +//! MapLibre S-52 style plus its portrayal assets (color tables, line styles, +//! sprite and pattern atlases). //! //! This module is the curated public surface. Add it as a dependency and -//! `@import("tile57")`. The C ABI in include/tile57.h is a thin shim over the -//! same Zig API (see capi.zig). +//! `@import("tile57")`. The C ABI in include/tile57.h is a thin shim over the same +//! Zig API (see capi.zig). Consumers that want only one layer can depend on that +//! module directly — `iso8211`, `s57`, `s101`, `tiles`, `geometry`, `coverage`, +//! `compose`, `render`, and `style` are each standalone. //! //! Surface: -//! - High-level engine: `Chart` (open → render/inspect), `bakeArchive`, `style.build` -//! - Portrayal assets: `assets` (colortables, linestyles, sprite/pattern) -//! - Style patching: `mariner` -//! - Tiling: `mvt`, `tile`, `pmtiles`, `bake_enc`, `scene` -//! - Raw formats: `formats.{iso8211, s57, s101}` +//! - Chart: `Chart` — open, then render / query / inspect +//! - Bake: `bake` — a cell (or a tree of cells) to per-cell PMTiles +//! - Compose: `compose` — per-cell archives + `partition` -> tiles on demand +//! - Style: `style` — the MapLibre style; `sprite` — the atlases +//! - Tiling: `mvt`, `mlt`, `tile`, `pmtiles`, `gzip`, `band`, `scene` +//! - Render: `render` — the Surface/Canvas rendering path +//! - Formats: `formats.{iso8211, s57, s101}`, `coverage` const std = @import("std"); /// Library version (matches build.zig.zon and tile57_version()). pub const version = "0.1.0"; -// ---- high-level engine: open a Chart, render, bake, build a style ---- +// ---- Chart: open a chart, then render / query / inspect -------------------- const chart = @import("chart.zig"); -/// An embeddable chart source: `Chart.openBytes` / `Chart.openCells`, then -/// render / query / bake. See chart.zig. +/// An embeddable chart source: open from a path, from bytes, or as a multi-cell +/// ENC_ROOT (streaming), then render / query / inspect. See chart.zig. pub const Chart = chart.Chart; pub const Format = chart.Format; pub const CellInput = chart.CellInput; pub const Progress = chart.Progress; -// Streaming ENC_ROOT open (read cell bytes on demand, low memory): see -// Chart.openCellsStreaming. +/// Streaming ENC_ROOT open (read a cell's bytes on demand, low memory): see +/// Chart.openCellsStreaming. pub const CellMeta = chart.CellMeta; pub const CellBytes = chart.CellBytes; pub const CellReadFn = chart.CellReadFn; -/// Bake an ENC_ROOT into one band-streamed PMTiles archive. -pub const bakeArchive = chart.bakeArchive; /// Free bytes returned by the render / bake entry points. pub const freeBytes = chart.freeBytes; +/// Warm the embedded S-101 catalogue + Lua rules once, before serving. +pub const warmup = chart.warmup; -/// MapLibre style generation from a template + mariner S-52 display settings. -/// `build` is the single style builder (regenerates the full style with the mariner -/// baked in; the old template-patch pass is retired) — see assets.buildFromTemplate. -pub const style = struct { - pub const build = assets.buildFromTemplate; - pub const Mariner = mariner.Settings; +// ---- Bake: cells -> per-cell PMTiles archives ------------------------------ +/// Bake each cell to its own PMTiles at its compilation scale — the input the +/// compositor serves from. `archive` is the alternative offline path that merges +/// a slice of cells into one band-streamed archive. +pub const bake = struct { + pub const cellBytes = chart.bakeCellBytes; // one cell + updates -> PMTiles bytes + pub const cellsParallel = chart.bakeCellsParallel; // N cells -> N archives, threaded + pub const cellsToFiles = chart.bakeCellsToFiles; // N cells -> files under a dir + pub const tree = chart.bakeTree; // walk an ENC_ROOT, bake each cell to a mirrored path + pub const archive = chart.bakeArchive; // offline: merge a slice of cells into one archive + pub const pmtilesMetadata = chart.pmtilesMetadata; // read an archive's TileJSON metadata + pub const Progress = chart.BakeProgress; }; -// ---- portrayal asset + style generation ---------------------------------- -pub const assets = @import("style"); // colortables / line styles / style.json -pub const sprite = @import("sprite"); // S-101 sprite + area-fill pattern atlases -pub const mariner = @import("style").mariner; // mariner-driven MapLibre style patching +// ---- Compose: per-cell archives + a partition -> tiles on demand ----------- +/// The runtime compositor: `ComposeSource` over mmap'd archives + a partition, +/// `composeTile` for one tile, `openComposeSourceFiles` to open from disk. +pub const compose = @import("compose"); +/// The ownership partition and its `.tpart` sidecar (serialize / deserialize). +pub const partition = @import("geometry").partition; +/// The integer computational geometry the compositor and baker share. +pub const geometry = @import("geometry"); -// ---- tiling / encoding --------------------------------------------------- +// ---- Style + portrayal assets ---------------------------------------------- +/// MapLibre style generation: `style.json` builds a style.json, `style.Options` +/// its inputs, `style.mariner` the S-52 display settings, `style.diff` the minimal +/// mutation to retint/refilter, plus the color tables and line styles. +pub const style = @import("style"); +/// The S-52 mariner display settings model (`style.mariner.Settings`). +pub const Mariner = style.mariner.Settings; +/// S-101 sprite + area-fill pattern atlases (SVG raster). +pub const sprite = @import("sprite"); + +// ---- Tiling / encoding ----------------------------------------------------- pub const mvt = @import("tiles").mvt; // Mapbox Vector Tile encode/decode +pub const mlt = @import("tiles").mlt; // MapLibre Tile encode/decode (the bake default) pub const tile = @import("tiles").tile; // web-mercator tiling + clipping pub const pmtiles = @import("tiles").pmtiles; // PMTiles read/write -pub const bake_enc = @import("scene").bake_enc; // banded multi-cell ENC_ROOT → PMTiles -pub const scene = @import("scene"); // S-57 feature → MVT tile +pub const gzip = @import("tiles").gzip; // gzip (tile payloads, PMTiles internals) +pub const band = @import("tiles").band; // compilation-scale -> zoom-range mapping +pub const scene = @import("scene"); // S-57 + portrayal -> tile surface +pub const bake_enc = @import("scene").bake_enc; // the banded multi-cell baker + +// ---- Render surfaces ------------------------------------------------------- +/// The Surface/Canvas rendering path: PNG raster, vector PDF, ASCII, and the +/// callback surfaces a GPU host drives. +pub const render = @import("render"); -// ---- raw S-57 / S-100 formats (advanced) --------------------------------- +// ---- Raw formats (advanced) ------------------------------------------------ pub const formats = struct { - pub const iso8211 = @import("s57").iso8211; // ISO/IEC 8211 records + pub const iso8211 = @import("s57").iso8211; // ISO/IEC 8211 container records pub const s57 = @import("s57"); // S-57 ENC cell parser + geometry - pub const s101 = @import("s101"); // S-101 catalogue + adaptation + instructions + pub const s101 = @import("s101"); // S-101 catalogue + adapter + instructions }; +/// Per-cell M_COVR coverage sidecar (carried in an archive's PMTiles metadata). +pub const coverage = @import("coverage"); test { std.testing.refAllDecls(@This()); std.testing.refAllDecls(formats); + std.testing.refAllDecls(bake); } From 09d8b89f2cd1a8f475f9b9947f0a8b9fd3d745d5 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 19:41:25 -0400 Subject: [PATCH 083/140] refactor: delete dead code and trim the public surface Remove functions with no callers (orphans from retired features): - compose.parseScamin (metadata scamin ladder, never read) - portray.portrayCellSubset (retired scamin pre-pass) - geometry.partition.facesForBand - style.Manifest + manifestJson (retired chart-bundle manifest) De-pub the batch pmtiles.write + InputTile: no production caller (the live baker streams via StreamWriter), so they drop out of the public surface but stay as the reader tests round-trip fixture. Co-Authored-By: Claude Fable 5 --- src/compose/compose.zig | 7 ---- src/geometry/partition.zig | 6 --- src/portray/portray.zig | 23 ------------ src/style/style.zig | 75 -------------------------------------- src/tiles/pmtiles.zig | 9 +++-- 5 files changed, 6 insertions(+), 114 deletions(-) diff --git a/src/compose/compose.zig b/src/compose/compose.zig index f2678f5..783bd2c 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -460,10 +460,3 @@ fn readMetaJson(a: std.mem.Allocator, r: *pmtiles.Reader) ?[]const u8 { else => null, }; } - -// The "scamin" ladder from an archive's metadata JSON, or empty if absent/unparseable. -fn parseScamin(a: std.mem.Allocator, meta: []const u8) []const u32 { - const Dto = struct { scamin: []const u32 = &.{} }; - const v = std.json.parseFromSliceLeaky(Dto, a, meta, .{ .ignore_unknown_fields = true }) catch return &.{}; - return v.scamin; -} diff --git a/src/geometry/partition.zig b/src/geometry/partition.zig index 0b596fc..33c2848 100644 --- a/src/geometry/partition.zig +++ b/src/geometry/partition.zig @@ -66,12 +66,6 @@ pub const Partition = struct { return null; } - /// Faces owned at band index `i` (0 = finest band) — for the renderer/compositor - /// to iterate every owner at a band. - pub fn facesForBand(self: *const Partition, i: usize) []const plane.OwnedCell { - return self.maps[i].faces; - } - /// The cell index that owns (x,y) at zoom `z`, or null — a true gap in this /// band's map (the ground is owned by a coarser band). Coordinates are integers /// (degrees × 10⁷). On-border results are even-odd-ambiguous; sample off edges. diff --git a/src/portray/portray.zig b/src/portray/portray.zig index f783464..77484ba 100644 --- a/src/portray/portray.zig +++ b/src/portray/portray.zig @@ -345,29 +345,6 @@ pub const CellPortrayal = struct { simplified: ?[]const ?[]const u8 = null, }; -/// Portray only the features whose index is set in `mask` (indexed by -/// cell.features index): the default pass plus the SimplifiedSymbols variant -/// over the masked POINT features. Used by the scamin-standalone bake pre-pass -/// to portray just the deduped cross-cell winners — a small fraction of the -/// cell — without paying for a whole-cell portrayal twice. The whole cell is -/// still ADAPTED first (cheap, pure Zig) so cross-feature context (topmark -/// platforms etc.) matches the full-cell pass exactly; only the rule -/// evaluation is subset. Returned arrays are indexed by cell.features index. -pub fn portrayCellSubset(arena: std.mem.Allocator, cell: *const s57.Cell, rules_dir: []const u8, mask: []const bool) !CellPortrayal { - const adapted = try adapter.adaptCell(arena, cell); - var subset = std.ArrayList(adapter.Adapted).empty; - var points = std.ArrayList(adapter.Adapted).empty; - for (adapted) |ad| { - if (ad.feature_index >= mask.len or !mask[ad.feature_index]) continue; - try subset.append(arena, ad); - if (std.mem.eql(u8, ad.primitive, "Point")) try points.append(arena, ad); - } - var cp = CellPortrayal{ .base = try runAdapted(arena, cell, subset.items, rules_dir, .{}) }; - if (points.items.len > 0) - cp.simplified = runAdapted(arena, cell, points.items, rules_dir, .{ .simplified_symbols = true }) catch null; - return cp; -} - /// Portray a cell three ways so the client can toggle boundary style (areas) and /// point-symbol style (points) live: the default pass, a PlainBoundaries pass over /// only the area features, and a SimplifiedSymbols pass over only the point diff --git a/src/style/style.zig b/src/style/style.zig index 4e92249..13599c3 100644 --- a/src/style/style.zig +++ b/src/style/style.zig @@ -406,53 +406,6 @@ pub fn linestylesJson(alloc: std.mem.Allocator, srcs: []const LineStyleSrc) ![]u return out.toOwnedSlice(alloc); } -// ---- manifest.json ------------------------------------------------------- - -/// Inputs for the bundle manifest. Relative paths only (the bundle is -/// relocatable). bbox is [west, south, east, north]; anchor is [lon, lat]. -pub const Manifest = struct { - generator: []const u8, - created: []const u8 = "", // ISO 8601; Zig has no wall clock, so passed in - catalogue_version: []const u8 = "", - tiles_rel: []const u8, - colortables_rel: []const u8, - minzoom: u8, - maxzoom: u8, - bbox: [4]f64, - anchor: [2]f64, - cells: []const []const u8, - styles: ?Styles = null, // per-palette style.json paths, if emitted - - pub const Styles = struct { day: []const u8, dusk: []const u8, night: []const u8 }; -}; - -/// Emit the bundle manifest.json. Loaded first by a renderer, which refuses a -/// bundle whose schema_version it doesn't speak — turning tile/style coupling -/// into a checked invariant. Returns allocator-owned bytes. -pub fn manifestJson(alloc: std.mem.Allocator, m: Manifest) ![]u8 { - // The manifest is plain data: describe it as a value and let std.json emit it. - // indent_2 keeps it human-readable; emit_null_optional_fields drops "styles" - // (the one optional) when absent. - return std.json.Stringify.valueAlloc(alloc, .{ - .bundle_version = 1, - .schema_version = SCHEMA_VERSION, - .generator = m.generator, - .created = m.created, - .catalogue_version = m.catalogue_version, - .data = .{ - .tiles = m.tiles_rel, - .minzoom = m.minzoom, - .maxzoom = m.maxzoom, - .bbox = m.bbox, - .anchor = m.anchor, - .cells = m.cells, - }, - .portrayal = .{ - .colortables = m.colortables_rel, - .styles = m.styles, - }, - }, .{ .whitespace = .indent_2, .emit_null_optional_fields = false }); -} // ---- tests --------------------------------------------------------------- @@ -515,34 +468,6 @@ test "linestylesJson: dash pattern + placed symbols, sorted ids, skips no-interv try std.testing.expectEqualStrings(expected, out); } -test "manifestJson: pins schema_version and couples tiles to portrayal" { - const out = try manifestJson(std.testing.allocator, .{ - .generator = "tile57 0.1.0", - .created = "2026-06-27T00:00:00Z", - .catalogue_version = "S-101 PC 1.4.0", - .tiles_rel = "tiles/chart.pmtiles", - .colortables_rel = "assets/colortables.json", - .minzoom = 8, - .maxzoom = 16, - .bbox = .{ -76.55, 38.90, -76.40, 39.02 }, - .anchor = .{ -76.475, 38.96 }, - .cells = &.{ "US5MD1MC", "US4MD81M" }, - }); - defer std.testing.allocator.free(out); - const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, out, .{}); - defer parsed.deinit(); - const o = parsed.value.object; - try std.testing.expectEqualStrings("tile57/2", o.get("schema_version").?.string); - const data = o.get("data").?.object; - try std.testing.expectEqualStrings("tiles/chart.pmtiles", data.get("tiles").?.string); - try std.testing.expectEqual(@as(usize, 2), data.get("cells").?.array.items.len); - try std.testing.expectEqualStrings("US5MD1MC", data.get("cells").?.array.items[0].string); - const portrayal = o.get("portrayal").?.object; - try std.testing.expectEqualStrings("assets/colortables.json", portrayal.get("colortables").?.string); - // "styles" is omitted when not provided (emit_null_optional_fields = false) - try std.testing.expect(portrayal.get("styles") == null); -} - test { _ = mariner; _ = @import("maplibre.zig"); // run the style.json builder tests too diff --git a/src/tiles/pmtiles.zig b/src/tiles/pmtiles.zig index b8953de..9b8915d 100644 --- a/src/tiles/pmtiles.zig +++ b/src/tiles/pmtiles.zig @@ -367,7 +367,8 @@ fn findEntry(dir: []const Entry, tid: u64) ?usize { // ---- writer ------------------------------------------------------------- -pub const InputTile = struct { z: u8, x: u32, y: u32, mvt: []const u8 }; +// A whole-tile input for the batch `write` below (test-only; the live path is StreamWriter). +const InputTile = struct { z: u8, x: u32, y: u32, mvt: []const u8 }; pub const WriteOptions = struct { metadata_json: []const u8 = "{}", @@ -546,8 +547,10 @@ pub const StreamWriter = struct { } }; -/// Build a PMTiles archive from MVT tiles (gzipped + deduped). Caller owns it. -pub fn write(gpa: Allocator, tiles: []const InputTile, opts: WriteOptions) ![]u8 { +// Build a PMTiles archive from a whole set of MVT tiles in one call (gzipped + +// deduped). The live baker streams via StreamWriter instead; this stays as a +// self-contained round-trip fixture for the reader tests below. +fn write(gpa: Allocator, tiles: []const InputTile, opts: WriteOptions) ![]u8 { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); From 971c285dcd550b659a7c77379b1b0e1a9fa56eeb Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 19:45:27 -0400 Subject: [PATCH 084/140] docs: fix the Go README, drop hyperbole, document bake + compose - Rewrite the Go README usage + surface against the real API (the old example called removed functions: BakeBundle, Source.Tile, Colortables, ...). It now shows BakeTree -> OpenCompose -> Serve and the current Source/ComposeSource/bake/style surface. Verified it compiles. - Drop the unverifiable "high-performance, low-memory" / "easy-to-use" / "crowd-pleaser" claims from README, intro, getting-started. - Fix the intro pipeline note: per-cell bakes + the runtime compositor, not a single band-streamed archive. - Expand zig-api.md with the bake + compose surface and correct the style member names (style.buildFromTemplate, tile57.Mariner). Co-Authored-By: Claude Fable 5 --- README.md | 13 ++++++------ bindings/go/README.md | 35 ++++++++++++++++++++------------- docs/docs/getting-started.md | 2 +- docs/docs/intro.md | 17 ++++++++-------- docs/docs/zig-api.md | 38 ++++++++++++++++++++++++------------ 5 files changed, 62 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index bdbc404..edb1688 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

tile57

- ⚓ A high-performance, low-memory S-57/S-101 chart engine: vector tiles, S-52 styles, PNG and PDF.
+ ⚓ An S-57/S-101 chart engine: vector tiles, S-52 styles, PNG and PDF.
tile57 turns IHO S-57 ENC cells into Mapbox Vector Tiles with a matching MapLibre S-52 style, or renders finished charts directly to PNG and PDF — one Zig library with a C ABI, compiled natively or to WASM. @@ -48,11 +48,10 @@ entirely with AI assistance. A few specific goals shape its design: - **Language-agnostic embedding.** A thin C ABI (`libtile57.a`) bridges the Zig core to any language with C FFI. Go bindings ship in the repo; others are straightforward additions. -- **An engine anyone can build on.** The end goal is an easy-to-use S-57/S-100 chart - rendering engine that anyone can use to build their marine app ideas — without first - becoming an IHO spec expert. Open a chart, get tiles, PNGs, or PDFs; the S-52 rules, - portrayal catalogue, and mariner settings are the engine's problem. Ideas it should make - easy: +- **An engine to build on.** The goal is an S-57/S-100 chart engine you can use to build + a marine app without first becoming an IHO spec expert. Open a chart, get tiles, PNGs, + or PDFs; the S-52 rules, portrayal catalogue, and mariner settings are the engine's + problem. It aims to support: - an **anchor alarm** that draws your swing circle over a real chart, - a **Windy plugin** overlaying forecast weather on ENC charts, - a **native cross-platform Qt6 C++ chartplotter**, @@ -69,7 +68,7 @@ portrayal **assets** it references — colour tables, line styles, and the sprit + area-fill pattern atlases — so a renderer like [MapLibre](https://github.com/maplibre/maplibre-native) can draw a chart directly. -It is **high-performance and low-memory** by design: +It holds only its working set: - **Lazy, per-cell work.** A multi-cell ENC_ROOT is indexed cheaply (band + bbox); cells are parsed and portrayed only when a requested tile needs them, then held diff --git a/bindings/go/README.md b/bindings/go/README.md index 8bdbd59..379680e 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -34,26 +34,33 @@ replace github.com/beetlebugorg/tile57/bindings/go => /path/to/chartplotter-nati ```go import tile57 "github.com/beetlebugorg/tile57/bindings/go" -// Bake a single cell.000 or a whole ENC_ROOT dir into a self-contained bundle. -cells, bbox, err := tile57.BakeBundle("/enc/ENC_ROOT", "/out/bundle", "", "", "", 0, 16, nil) +// Bake an ENC_ROOT: each cell becomes its own PMTiles under /tiles/, plus +// an ownership partition at /partition.tpart. +n, err := tile57.BakeTree("/enc/ENC_ROOT", "/out", 4, nil) -// Or serve tiles live, and publish the SCAMIN manifest for the style. -src, _ := tile57.Open("/enc/ENC_ROOT") +// Open the compositor over the baked archives + partition, and serve tiles. +src, _ := tile57.OpenCompose([]string{"/out/tiles/US5MD1MC.pmtiles"}, "/out/partition.tpart") defer src.Close() -mvt, _ := src.Tile(13, 2359, 3139) -manifest := src.Scamin() // []uint32, ascending +body, owned, _ := src.Serve(13, 2359, 3139) // owned=false, body=nil => open ocean + +// Or open one cell/archive for metadata (cells, features, SCAMIN, bounds). +chart, _ := tile57.Open("/enc/ENC_ROOT") +defer chart.Close() +cells, _ := chart.Cells() +scamin := chart.Scamin() // []uint32, ascending ``` ## Surface -- **Charts** — `Open` (path, streaming), `OpenChartBytes` (one in-memory cell), - `OpenPMTiles` (baked bundle); `Source.Tile`, `Info`, `Meta`, `Scamin`, - `ClearCache`, `Close`. -- **Bake** — `BakeCells` (→ one PMTiles archive in memory), `BakeBundle` (→ a full - on-disk bundle: tiles + assets + per-scheme styles + manifest). -- **Assets / style** — `ColortablesDefault`, `Colortables`, `Linestyles`, - `SpriteAtlas`, `PatternAtlas`, `StyleTemplate`, `BuildStyle`, `Style`, - `MarinerDefaults`. +- **Charts (metadata + query)** — `Open` (path, streaming), `OpenChartBytes` (one + in-memory cell), `OpenPMTiles` (a baked archive); `Source.Info`, `Meta`, `Cells`, + `Features`, `Scamin`, `Close`. +- **Bake** — `BakeCell` (one cell → PMTiles bytes), `BakeTree` (an ENC_ROOT → per-cell + archives + `partition.tpart`), `BakeAssets` (portrayal assets in memory). +- **Compose** — `OpenCompose` (archives + partition); `ComposeSource.Serve` (a tile, + with an ownership flag), `Meta`, `SavePartition`, `Close`. +- **Style** — `ColortablesDefault`, `Style`, `BuildStyle`, `StyleDiff`, + `MarinerDefaults`, `CatalogEntries`. `libtile57` is not internally synchronized; every `Source` method is mutex-guarded, so a `Source` is safe for concurrent use. diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 596d231..f1358da 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -54,7 +54,7 @@ MLT natively — the generated styles carry the source `encoding` hint). The engine can also encode Mapbox Vector Tiles for consumers without an MLT decoder. ::: -And a crowd-pleaser — the chart in your terminal, as a Unicode grid with +The chart also renders in your terminal, as a Unicode grid with ANSI colour, with `--tui` for an interactive pan/zoom loop (`--kitty` paints real S-52 pixels inline on kitty-graphics terminals like Ghostty or Kitty): diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 82dac1a..45009ba 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -16,9 +16,8 @@ navigation.** See [Known limitations](./limitations.md). ::: -**tile57** is a high-performance, low-memory **S-57/S-101 → vector-tile + -S-52 style engine**, embeddable from **Zig**, **C**, or **Go**, targeting native -and WASM. It decodes IHO/NOAA **S-57** ENC cells and generates **vector tiles** +**tile57** is an **S-57/S-101 → vector-tile + S-52 style engine**, embeddable +from **Zig**, **C**, or **Go**, targeting native and WASM. It decodes IHO/NOAA **S-57** ENC cells and generates **vector tiles** by `(z, x, y)` — [MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) (MLT, the default) or Mapbox Vector Tiles (MVT) — running the official IHO **S-101 Portrayal Catalogue** in embedded Lua to produce S-52 nautical @@ -81,18 +80,18 @@ render Surface ──► MVT / MLT tiles + PMTiles (src/tiles/) + MapLibre sty └───► PNG raster / vector PDF / terminal text (src/render/) ``` -## Why tile57 is fast and small +## The memory model -The engine is **high-performance and low-memory by design**: +The engine holds only its working set: - **Lazy, per-cell work.** A multi-cell ENC_ROOT is indexed cheaply (band + bbox); cells are parsed and portrayed only when a requested tile needs them, then held under an LRU bound. A **streaming** open reads a cell's bytes on demand (and frees them on eviction), so a host holds only the working set — not the whole catalogue. -- **Band-streamed bakes.** Baking an ENC_ROOT to one PMTiles archive streams - band-by-band (finest → coarsest, best-band dedup), so peak memory tracks the - largest single band. +- **Per-cell bakes.** Each cell bakes to its own PMTiles at its compilation scale, + so a bake holds one cell at a time; the runtime compositor stitches the cells by + `(z, x, y)` on demand through an ownership partition. - **Pure-Zig core.** The foundational format/encode modules (`iso8211`, `s57`, `s101`, `tiles`, `render`, `scene`, `style`) have no libc; only the Lua portrayal (`portray`) and the sprite rasterizer (`sprite`) pull in C. @@ -107,7 +106,7 @@ hex, so a renderer can switch palette without regenerating tiles. ## Where to go next - [**Installation**](./installation.md) — Zig 0.16, submodules, `zig build`. -- [**Getting Started**](./getting-started.md) — bake a bundle and fetch a tile +- [**Getting Started**](./getting-started.md) — bake cells and fetch a tile from Zig or C. - [**Zig API**](./zig-api.md) — the `@import("tile57")` surface. - [**C API**](./c-api.md) — the `tile57_*` C ABI (`include/tile57.h`). diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index 215fddd..ffdd022 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -73,32 +73,46 @@ The streaming open uses the extern types `tile57.CellMeta` (bbox + `cscl`), library), and `tile57.CellReadFn` (the reader callback). Multi-cell input for `openCells` is `tile57.CellInput`. -## Bake an ENC_ROOT +## Bake + compose + +Baking and compositing are separate steps. `tile57.bake` writes each cell to its +own PMTiles at its compilation scale; `tile57.compose` stitches those archives +into one tile for any `(z, x, y)` on demand, using an ownership partition so cells +never double-draw at a seam. ```zig -// Band-streamed: peak memory tracks the largest single band, not the whole archive. -const pmtiles_bytes = try tile57.bakeArchive(/* … */); -defer tile57.freeBytes(pmtiles_bytes); +// Bake an ENC_ROOT: each cell -> /tiles/.pmtiles + /partition.tpart. +const n = tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null); + +// Open the compositor over the archives + partition, then serve tiles. +var src = (try tile57.compose.openComposeSourceFiles(io, gpa, paths, "/out/partition.tpart")).?; +defer src.deinit(); +const result = try src.serve(gpa, 13, 2359, 3139); // result.tile: ?[]u8, result.owned: bool ``` -`tile57.bakeArchive` bakes a whole ENC_ROOT into one PMTiles archive, zoom-banded -per cell by compilation scale; `tile57.Progress` is the optional progress -callback type. +| Surface | What it does | +|---------|--------------| +| `tile57.bake.cellBytes(path, rules)` | bake one cell (+ updates) to PMTiles bytes. | +| `tile57.bake.cellsToFiles(...)` / `bake.tree(...)` | bake many cells / a whole ENC_ROOT to files. | +| `tile57.bake.archive(...)` | the offline path: merge a slice of cells into one archive. | +| `tile57.bake.Progress` | the optional progress-callback type. | +| `tile57.compose.openComposeSourceFiles(...)` | open a `ComposeSource` over on-disk archives + a partition. | +| `tile57.compose.composeTile(...)` | compose one tile (the stateless core `serve` uses). | +| `tile57.partition` | the ownership partition and its `.tpart` sidecar (serialize / deserialize). | ## Style + portrayal assets ```zig // MapLibre style from a template + mariner S-52 display settings + colortables. -const json = try tile57.style.build(/* … */); // tile57.style.Mariner settings +const json = try tile57.style.buildFromTemplate(/* … */); // tile57.Mariner settings ``` | Surface | What it does | |---------|--------------| -| `tile57.style.build` (`style.buildFromTemplate`) | build a MapLibre style from a template + mariner settings + colortables. | -| `tile57.style.Mariner` | the S-52 mariner display options struct (`style.mariner.Settings`). | -| `tile57.style` | color tables, line styles, and style.json generation. | -| `tile57.sprite` | S-101 sprite + area-fill pattern atlases (SVG raster). | +| `tile57.style` | the MapLibre style: `json`, `Options`, `diff`, `buildFromTemplate`, color tables, line styles. | | `tile57.style.mariner` | the S-52 mariner settings model and expression builders. | +| `tile57.Mariner` | the S-52 mariner display options struct (`style.mariner.Settings`). | +| `tile57.sprite` | S-101 sprite + area-fill pattern atlases (SVG raster). | ## Tiling + encoding From ddf6fb0296faa023e945085c6d9f4ebe1d76b5b7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 20:00:30 -0400 Subject: [PATCH 085/140] refactor(scene): extract the SYMINS mini-parser into scene/symins.zig First cut at splitting the scene god-file. The self-contained SYMINS02 native-fallback parser (S-57 NEWOBJ producer draw ops -> portrayal instructions) moves to its own file, depending only on s57 / s101 / style. scene.zig calls symins.buildSyminsPortrayal. Verbatim move: scene 3579 -> 3261 lines; tests green; the 10-cell subset re-bakes byte-identical. Co-Authored-By: Claude Fable 5 --- src/scene/scene.zig | 323 +----------------------------------------- src/scene/symins.zig | 324 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 320 deletions(-) create mode 100644 src/scene/symins.zig diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 0a3f987..c871bb3 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -25,6 +25,7 @@ const rs = render.surface; /// batch driver of this engine). Re-exported for the CLI + lib root. pub const bake_enc = @import("bake_enc.zig"); pub const coverage = @import("coverage"); // per-cell coverage sidecar (in PMTiles metadata) +const symins = @import("symins.zig"); // SYMINS02 native fallback (NEWOBJ producer draw ops) /// Output tile encoding: classic Mapbox Vector Tile, or MapLibre Tile. pub const TileFormat = enum { mvt, mlt }; @@ -1777,280 +1778,6 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, } } -// === SYMINS02 native fallback (S-52 PresLib §13.2.18 / §10.3.3.8) =========== -// Portray an S-57 NEWOBJ from its producer SYMINS attribute (code 192): a -// ';'-separated list of S-52 draw ops — SY()/TX()/TE()/LS()/LC()/AC()/AP() — -// rendered verbatim instead of the S-101 V-AIS alias the FeatureCatalogue maps -// NEWOBJ to. This is how the S-52 PresLib "ECDIS Chart 1" labels/boundaries/fills -// are drawn. Mirrors Go parseSYMINS (internal/engine/portrayal/symins.go). - -const SYMINS_ATTR: u16 = 192; - -fn syminsTrimQuotes(s: []const u8) []const u8 { - return std.mem.trim(u8, s, "'\""); -} - -fn syminsArgAt(args: []const []const u8, i: usize) []const u8 { - return if (i < args.len) args[i] else ""; -} - -/// The S-57 attribute value referenced by acronym (e.g. "OBJNAM"), trimmed, or null -/// when absent/blank. Mirrors Go lookupAttributeText over the feature's attrs. -fn syminsFeatAttr(f: s57.Feature, acr: []const u8) ?[]const u8 { - for (f.attrs) |at| { - const a2 = catalogue.attrAcronym(at.code) orelse continue; - if (std.ascii.eqlIgnoreCase(a2, acr)) { - const v = std.mem.trim(u8, at.value, " "); - return if (v.len == 0) null else v; - } - } - return null; -} - -/// Split a SYMINS string on ';', honouring quotes and nested parens (so a ';' inside -/// TX('a;b',…) or between parens isn't a split). Returns slices into `s`. -fn syminsSplitInstructions(a: Allocator, s: []const u8) ![]const []const u8 { - var out = std.ArrayList([]const u8).empty; - var depth: i32 = 0; - var in_quote = false; - var start: usize = 0; - var i: usize = 0; - while (i < s.len) : (i += 1) { - switch (s[i]) { - '\'', '"' => in_quote = !in_quote, - '(' => if (!in_quote) { - depth += 1; - }, - ')' => if (!in_quote) { - depth -= 1; - }, - ';' => if (!in_quote and depth == 0) { - try out.append(a, s[start..i]); - start = i + 1; - }, - else => {}, - } - } - if (start < s.len) try out.append(a, s[start..]); - return out.items; -} - -/// Split "OP(params)" into the op and inner params, or null when malformed. -fn syminsSplitOp(instr0: []const u8) ?struct { op: []const u8, params: []const u8 } { - const instr = std.mem.trim(u8, instr0, " \t"); - const open = std.mem.indexOfScalar(u8, instr, '(') orelse return null; - const close = std.mem.lastIndexOfScalar(u8, instr, ')') orelse return null; - if (open == 0 or close < open) return null; - return .{ .op = std.mem.trim(u8, instr[0..open], " \t"), .params = instr[open + 1 .. close] }; -} - -/// Split an instruction's params on ',', honouring quotes. Returns trimmed slices. -fn syminsSplitArgs(a: Allocator, params: []const u8) ![]const []const u8 { - var out = std.ArrayList([]const u8).empty; - var in_quote = false; - var start: usize = 0; - var i: usize = 0; - while (i < params.len) : (i += 1) { - const c = params[i]; - if (c == '\'' or c == '"') { - in_quote = !in_quote; - } else if (c == ',' and !in_quote) { - try out.append(a, std.mem.trim(u8, params[start..i], " \t")); - start = i + 1; - } - } - try out.append(a, std.mem.trim(u8, params[start..], " \t")); - return out.items; -} - -/// printf-style format substitution for a SYMINS TE() instruction (S-52 §3.2.3.2): -/// each %-spec consumes the next attribute name and formats its value (floats honour -/// .precision; integer convs round; the '0' flag + width zero-pad). Mirrors Go -/// formatSubstitute + appendConverted + zeroPad. Returns null when an attribute is -/// missing (the whole label is then dropped, as in Go). -fn syminsZeroPad(a: Allocator, out: *std.ArrayList(u8), s: []const u8, width: usize, flags: []const u8) !void { - const has0 = std.mem.indexOfScalar(u8, flags, '0') != null; - const has_minus = std.mem.indexOfScalar(u8, flags, '-') != null; - if (width <= s.len or !has0 or has_minus) { - try out.appendSlice(a, s); - return; - } - const pad = width - s.len; - const signed = s.len > 0 and (s[0] == '-' or s[0] == '+' or s[0] == ' '); - if (signed) try out.append(a, s[0]); - var k: usize = 0; - while (k < pad) : (k += 1) try out.append(a, '0'); - try out.appendSlice(a, if (signed) s[1..] else s); -} - -fn syminsAppendConverted(a: Allocator, out: *std.ArrayList(u8), val: []const u8, conv: u8, precision: i32, width: usize, flags: []const u8) !void { - var buf: [512]u8 = undefined; - var s: []const u8 = val; - switch (conv) { - 'f', 'e', 'g' => { - if (std.fmt.parseFloat(f64, std.mem.trim(u8, val, " \t"))) |x| { - s = std.fmt.float.render(&buf, x, .{ - .mode = .decimal, - .precision = if (precision >= 0) @as(usize, @intCast(precision)) else null, - }) catch val; - } else |_| {} - }, - 'd', 'i', 'u', 'x' => { - if (std.fmt.parseFloat(f64, std.mem.trim(u8, val, " \t"))) |x| { - const r: i64 = @intFromFloat(@round(x)); - s = std.fmt.bufPrint(&buf, "{d}", .{r}) catch val; - } else |_| {} - }, - else => {}, - } - try syminsZeroPad(a, out, s, width, flags); -} - -fn syminsFormatSubstitute(a: Allocator, f: s57.Feature, format: []const u8, names: []const []const u8) !?[]const u8 { - var out = std.ArrayList(u8).empty; - var attr_idx: usize = 0; - var i: usize = 0; - while (i < format.len) { - if (format[i] != '%' or i + 1 >= format.len) { - try out.append(a, format[i]); - i += 1; - continue; - } - if (format[i + 1] == '%') { - try out.append(a, '%'); - i += 2; - continue; - } - var j = i + 1; - const flags_start = j; - while (j < format.len and std.mem.indexOfScalar(u8, "-+ #0", format[j]) != null) j += 1; - const flags = format[flags_start..j]; - var width: usize = 0; - while (j < format.len and format[j] >= '0' and format[j] <= '9') : (j += 1) width = width * 10 + (format[j] - '0'); - var precision: i32 = -1; - if (j < format.len and format[j] == '.') { - j += 1; - var p: i32 = 0; - while (j < format.len and format[j] >= '0' and format[j] <= '9') : (j += 1) p = p * 10 + @as(i32, format[j] - '0'); - precision = p; - } - while (j < format.len and (format[j] == 'l' or format[j] == 'h' or format[j] == 'L')) j += 1; - if (j >= format.len) { - try out.appendSlice(a, format[i..]); // malformed trailing spec -> literal - break; - } - const conv = format[j]; - switch (conv) { - 's', 'c', 'd', 'i', 'u', 'x', 'f', 'e', 'g' => { - if (attr_idx >= names.len) return null; - const acr = names[attr_idx]; - attr_idx += 1; - const val = syminsFeatAttr(f, acr) orelse return null; - try syminsAppendConverted(a, &out, val, conv, precision, width, flags); - }, - else => try out.appendSlice(a, format[i .. j + 1]), // unknown conversion -> literal - } - i = j + 1; - } - return out.items; -} - -/// Parse a SYMINS TX()/TE() instruction into a Text. The Text model carries the -/// label string, colour and viewing group; the S-52 justification / offset / font / -/// halo fields are dropped (tracked OpText findings), matching the current text path. -fn syminsText(a: Allocator, f: s57.Feature, op: []const u8, params: []const u8) !?instructions.Text { - const args = try syminsSplitArgs(a, params); - var text: []const u8 = ""; - var color_idx: usize = undefined; - var display_idx: usize = undefined; - if (std.mem.eql(u8, op, "TE")) { - if (args.len < 10) return null; - var names = std.ArrayList([]const u8).empty; - var it = std.mem.splitScalar(u8, syminsTrimQuotes(args[1]), ','); - while (it.next()) |nm| { - const t = std.mem.trim(u8, nm, " \t"); - if (t.len > 0) try names.append(a, t); - } - text = (try syminsFormatSubstitute(a, f, syminsTrimQuotes(args[0]), names.items)) orelse return null; - color_idx = 8; - display_idx = 9; - } else { // TX - if (args.len < 9) return null; - const raw = args[0]; - if (raw.len > 0 and (raw[0] == '\'' or raw[0] == '"')) { - text = syminsTrimQuotes(raw); // literal - } else { - text = syminsFeatAttr(f, std.mem.trim(u8, raw, " \t")) orelse return null; - } - color_idx = 7; - display_idx = 8; - } - if (text.len == 0) return null; - var color = std.mem.trim(u8, syminsArgAt(args, color_idx), " \t"); - if (color.len == 0) color = "CHBLK"; - const group = std.fmt.parseInt(i64, std.mem.trim(u8, syminsArgAt(args, display_idx), " \t"), 10) catch 0; - return instructions.Text{ .text = text, .color = color, .group = group }; -} - -/// Build an S-101 Portrayal from a NEWOBJ's SYMINS attribute, or null when there is -/// no usable SYMINS (caller then falls back to the default new-object symbology). -/// Geometry/anchoring/clipping is handled by processFeatureParsed exactly like a rule stream. -fn buildSyminsPortrayal(a: Allocator, f: s57.Feature) !?instructions.Portrayal { - const raw0 = f.attr(SYMINS_ATTR) orelse return null; - const raw = std.mem.trim(u8, raw0, " "); - if (raw.len == 0) return null; - - var points = std.ArrayList(instructions.Point).empty; - var texts = std.ArrayList(instructions.Text).empty; - var lines = std.ArrayList(instructions.Line).empty; - var patterns = std.ArrayList([]const u8).empty; - var fill_token: ?[]const u8 = null; - - for (try syminsSplitInstructions(a, raw)) |instr| { - const opp = syminsSplitOp(instr) orelse continue; - if (std.mem.eql(u8, opp.op, "SY")) { // SY(NAME[,rot]) - const args = try syminsSplitArgs(a, opp.params); - const name = std.mem.trim(u8, syminsArgAt(args, 0), " \t"); - if (name.len == 0) continue; - const rot: f64 = if (args.len > 1) (std.fmt.parseFloat(f64, std.mem.trim(u8, args[1], " \t")) catch 0) else 0; - try points.append(a, .{ .symbol = name, .rotation = rot, .offset_x = 0, .offset_y = 0 }); - } else if (std.mem.eql(u8, opp.op, "TX") or std.mem.eql(u8, opp.op, "TE")) { - if (try syminsText(a, f, opp.op, opp.params)) |t| try texts.append(a, t); - } else if (std.mem.eql(u8, opp.op, "LS")) { // LS(style,width,colour) - const args = try syminsSplitArgs(a, opp.params); - if (args.len < 3) continue; - var w = std.fmt.parseInt(i64, std.mem.trim(u8, args[1], " \t"), 10) catch 0; - if (w <= 0) w = 1; - const st = std.mem.trim(u8, args[0], " \t"); - const dashed = std.ascii.eqlIgnoreCase(st, "DASH") or std.ascii.eqlIgnoreCase(st, "DOTT"); - try lines.append(a, .{ - .style = if (dashed) "dash" else "solid", - .width = @floatFromInt(w), - .color = std.mem.trim(u8, args[2], " \t"), - }); - } else if (std.mem.eql(u8, opp.op, "LC")) { // LC(LINESTYLE) — approximated as dashed - const name = std.mem.trim(u8, syminsArgAt(try syminsSplitArgs(a, opp.params), 0), " \t"); - if (name.len == 0) continue; - try lines.append(a, .{ .style = name, .width = 1, .color = "CHBLK" }); - } else if (std.mem.eql(u8, opp.op, "AC")) { // AC(COLOUR[,transp]) - const color = std.mem.trim(u8, syminsArgAt(try syminsSplitArgs(a, opp.params), 0), " \t"); - if (color.len > 0) fill_token = color; - } else if (std.mem.eql(u8, opp.op, "AP")) { // AP(PATTERN) - const name = std.mem.trim(u8, syminsArgAt(try syminsSplitArgs(a, opp.params), 0), " \t"); - if (name.len > 0) try patterns.append(a, name); - } - } - if (points.items.len == 0 and texts.items.len == 0 and lines.items.len == 0 and - patterns.items.len == 0 and fill_token == null) return null; - return instructions.Portrayal{ - .fill_token = fill_token, - .patterns = patterns.items, - .lines = lines.items, - .points = points.items, - .texts = texts.items, - }; -} - // === Complex (symbolised) line tessellation (S-101 LineStyles) ============= // A named linestyle (LC / a LineInstruction whose style is not "_simple_") is // tessellated per zoom: walk the line by arc length and emit, per period, the dash @@ -2778,7 +2505,7 @@ fn appendCellFeatures( continue; } if (f.objl == 163) { - if (try buildSyminsPortrayal(a, f)) |sp| { + if (try symins.buildSyminsPortrayal(a, f)) |sp| { try processFeatureParsed(a, cell.*, f, fi, geo, geo_world, sp, 2, 2, z, x, y, tb, box, fopts, surf); continue; } @@ -3285,51 +3012,6 @@ test "generate a tile from a cell is well-formed MVT" { try std.testing.expectEqual(@as(usize, 0), out.len); } -test "buildSyminsPortrayal parses SY/TX/LS/LC/AC/AP" { - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - const a = arena.allocator(); - - const attrs = [_]s57.Attr{.{ - .code = SYMINS_ATTR, - .value = "SY(BOYSPP01,45);TX('Hello',1,2,0,'15110',0,0,CHRED,28);" ++ - "LS(DASH,3,CHGRD);LS(SOLD,2,CHBLK);LC(NAVARE51);AC(DEPVS);AP(DIAMOND1)", - }}; - const f = s57.Feature{ .rcnm = 100, .rcid = 1, .prim = 3, .objl = 163, .attrs = &attrs }; - - const p = (try buildSyminsPortrayal(a, f)) orelse return error.NoPortrayal; - - try std.testing.expectEqual(@as(usize, 1), p.points.len); - try std.testing.expectEqualStrings("BOYSPP01", p.points[0].symbol); - try std.testing.expectEqual(@as(f64, 45), p.points[0].rotation); - - try std.testing.expectEqual(@as(usize, 1), p.texts.len); - try std.testing.expectEqualStrings("Hello", p.texts[0].text); - try std.testing.expectEqualStrings("CHRED", p.texts[0].color); - try std.testing.expectEqual(@as(i64, 28), p.texts[0].group); - - try std.testing.expectEqual(@as(usize, 3), p.lines.len); // 2x LS + 1x LC - try std.testing.expectEqualStrings("dash", p.lines[0].style); // DASH -> dashed - try std.testing.expectEqual(@as(f64, 3), p.lines[0].width); - try std.testing.expectEqualStrings("CHGRD", p.lines[0].color); - try std.testing.expectEqualStrings("solid", p.lines[1].style); // SOLD -> solid - try std.testing.expectEqualStrings("NAVARE51", p.lines[2].style); // LC name verbatim - - try std.testing.expectEqualStrings("DEPVS", p.fill_token.?); - try std.testing.expectEqual(@as(usize, 1), p.patterns.len); - try std.testing.expectEqualStrings("DIAMOND1", p.patterns[0]); - - // A blank / absent SYMINS yields no portrayal. - const f_empty = s57.Feature{ .rcnm = 100, .rcid = 2, .prim = 3, .objl = 163, .attrs = &[_]s57.Attr{.{ .code = SYMINS_ATTR, .value = " " }} }; - try std.testing.expect((try buildSyminsPortrayal(a, f_empty)) == null); - - // Instruction-splitting honours a ';' inside a quoted TX string. - const f_semi = s57.Feature{ .rcnm = 100, .rcid = 3, .prim = 1, .objl = 163, .attrs = &[_]s57.Attr{.{ .code = SYMINS_ATTR, .value = "TX('a;b',1,2,0,'15110',0,0,CHBLK,28)" }} }; - const ps = (try buildSyminsPortrayal(a, f_semi)) orelse return error.NoPortrayal; - try std.testing.expectEqual(@as(usize, 1), ps.texts.len); - try std.testing.expectEqualStrings("a;b", ps.texts[0].text); -} - test "QUASOU=5 no-bottom sounding draws the low-accuracy ring (S-65 DepthNoBottomFound)" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -3423,6 +3105,7 @@ test "DANGER01/02 on a VALSOU danger normalizes + tags danger_depth/sym_deep for test { _ = bake_enc; + _ = symins; } // ---- bundle-sourced replay (baked tile -> Surface calls) -------------------- diff --git a/src/scene/symins.zig b/src/scene/symins.zig new file mode 100644 index 0000000..37acfd7 --- /dev/null +++ b/src/scene/symins.zig @@ -0,0 +1,324 @@ +//! SYMINS02 native fallback (S-52 PresLib §13.2.18 / §10.3.3.8): portray an S-57 +//! NEWOBJ from its producer SYMINS attribute (code 192) — a ';'-separated list of +//! S-52 draw ops SY()/TX()/TE()/LS()/LC()/AC()/AP() rendered verbatim, instead of +//! the S-101 V-AIS alias the FeatureCatalogue maps NEWOBJ to. This is how the S-52 +//! PresLib "ECDIS Chart 1" labels / boundaries / fills are drawn. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const s57 = @import("s57"); +const instructions = @import("s101").instructions; +const catalogue = @import("s101").catalogue; +const style = @import("style"); + +pub const SYMINS_ATTR: u16 = 192; + +fn syminsTrimQuotes(s: []const u8) []const u8 { + return std.mem.trim(u8, s, "'\""); +} + +fn syminsArgAt(args: []const []const u8, i: usize) []const u8 { + return if (i < args.len) args[i] else ""; +} + +/// The S-57 attribute value referenced by acronym (e.g. "OBJNAM"), trimmed, or null +/// when absent/blank. Mirrors Go lookupAttributeText over the feature's attrs. +fn syminsFeatAttr(f: s57.Feature, acr: []const u8) ?[]const u8 { + for (f.attrs) |at| { + const a2 = catalogue.attrAcronym(at.code) orelse continue; + if (std.ascii.eqlIgnoreCase(a2, acr)) { + const v = std.mem.trim(u8, at.value, " "); + return if (v.len == 0) null else v; + } + } + return null; +} + +/// Split a SYMINS string on ';', honouring quotes and nested parens (so a ';' inside +/// TX('a;b',…) or between parens isn't a split). Returns slices into `s`. +fn syminsSplitInstructions(a: Allocator, s: []const u8) ![]const []const u8 { + var out = std.ArrayList([]const u8).empty; + var depth: i32 = 0; + var in_quote = false; + var start: usize = 0; + var i: usize = 0; + while (i < s.len) : (i += 1) { + switch (s[i]) { + '\'', '"' => in_quote = !in_quote, + '(' => if (!in_quote) { + depth += 1; + }, + ')' => if (!in_quote) { + depth -= 1; + }, + ';' => if (!in_quote and depth == 0) { + try out.append(a, s[start..i]); + start = i + 1; + }, + else => {}, + } + } + if (start < s.len) try out.append(a, s[start..]); + return out.items; +} + +/// Split "OP(params)" into the op and inner params, or null when malformed. +fn syminsSplitOp(instr0: []const u8) ?struct { op: []const u8, params: []const u8 } { + const instr = std.mem.trim(u8, instr0, " \t"); + const open = std.mem.indexOfScalar(u8, instr, '(') orelse return null; + const close = std.mem.lastIndexOfScalar(u8, instr, ')') orelse return null; + if (open == 0 or close < open) return null; + return .{ .op = std.mem.trim(u8, instr[0..open], " \t"), .params = instr[open + 1 .. close] }; +} + +/// Split an instruction's params on ',', honouring quotes. Returns trimmed slices. +fn syminsSplitArgs(a: Allocator, params: []const u8) ![]const []const u8 { + var out = std.ArrayList([]const u8).empty; + var in_quote = false; + var start: usize = 0; + var i: usize = 0; + while (i < params.len) : (i += 1) { + const c = params[i]; + if (c == '\'' or c == '"') { + in_quote = !in_quote; + } else if (c == ',' and !in_quote) { + try out.append(a, std.mem.trim(u8, params[start..i], " \t")); + start = i + 1; + } + } + try out.append(a, std.mem.trim(u8, params[start..], " \t")); + return out.items; +} + +/// printf-style format substitution for a SYMINS TE() instruction (S-52 §3.2.3.2): +/// each %-spec consumes the next attribute name and formats its value (floats honour +/// .precision; integer convs round; the '0' flag + width zero-pad). Mirrors Go +/// formatSubstitute + appendConverted + zeroPad. Returns null when an attribute is +/// missing (the whole label is then dropped, as in Go). +fn syminsZeroPad(a: Allocator, out: *std.ArrayList(u8), s: []const u8, width: usize, flags: []const u8) !void { + const has0 = std.mem.indexOfScalar(u8, flags, '0') != null; + const has_minus = std.mem.indexOfScalar(u8, flags, '-') != null; + if (width <= s.len or !has0 or has_minus) { + try out.appendSlice(a, s); + return; + } + const pad = width - s.len; + const signed = s.len > 0 and (s[0] == '-' or s[0] == '+' or s[0] == ' '); + if (signed) try out.append(a, s[0]); + var k: usize = 0; + while (k < pad) : (k += 1) try out.append(a, '0'); + try out.appendSlice(a, if (signed) s[1..] else s); +} + +fn syminsAppendConverted(a: Allocator, out: *std.ArrayList(u8), val: []const u8, conv: u8, precision: i32, width: usize, flags: []const u8) !void { + var buf: [512]u8 = undefined; + var s: []const u8 = val; + switch (conv) { + 'f', 'e', 'g' => { + if (std.fmt.parseFloat(f64, std.mem.trim(u8, val, " \t"))) |x| { + s = std.fmt.float.render(&buf, x, .{ + .mode = .decimal, + .precision = if (precision >= 0) @as(usize, @intCast(precision)) else null, + }) catch val; + } else |_| {} + }, + 'd', 'i', 'u', 'x' => { + if (std.fmt.parseFloat(f64, std.mem.trim(u8, val, " \t"))) |x| { + const r: i64 = @intFromFloat(@round(x)); + s = std.fmt.bufPrint(&buf, "{d}", .{r}) catch val; + } else |_| {} + }, + else => {}, + } + try syminsZeroPad(a, out, s, width, flags); +} + +fn syminsFormatSubstitute(a: Allocator, f: s57.Feature, format: []const u8, names: []const []const u8) !?[]const u8 { + var out = std.ArrayList(u8).empty; + var attr_idx: usize = 0; + var i: usize = 0; + while (i < format.len) { + if (format[i] != '%' or i + 1 >= format.len) { + try out.append(a, format[i]); + i += 1; + continue; + } + if (format[i + 1] == '%') { + try out.append(a, '%'); + i += 2; + continue; + } + var j = i + 1; + const flags_start = j; + while (j < format.len and std.mem.indexOfScalar(u8, "-+ #0", format[j]) != null) j += 1; + const flags = format[flags_start..j]; + var width: usize = 0; + while (j < format.len and format[j] >= '0' and format[j] <= '9') : (j += 1) width = width * 10 + (format[j] - '0'); + var precision: i32 = -1; + if (j < format.len and format[j] == '.') { + j += 1; + var p: i32 = 0; + while (j < format.len and format[j] >= '0' and format[j] <= '9') : (j += 1) p = p * 10 + @as(i32, format[j] - '0'); + precision = p; + } + while (j < format.len and (format[j] == 'l' or format[j] == 'h' or format[j] == 'L')) j += 1; + if (j >= format.len) { + try out.appendSlice(a, format[i..]); // malformed trailing spec -> literal + break; + } + const conv = format[j]; + switch (conv) { + 's', 'c', 'd', 'i', 'u', 'x', 'f', 'e', 'g' => { + if (attr_idx >= names.len) return null; + const acr = names[attr_idx]; + attr_idx += 1; + const val = syminsFeatAttr(f, acr) orelse return null; + try syminsAppendConverted(a, &out, val, conv, precision, width, flags); + }, + else => try out.appendSlice(a, format[i .. j + 1]), // unknown conversion -> literal + } + i = j + 1; + } + return out.items; +} + +/// Parse a SYMINS TX()/TE() instruction into a Text. The Text model carries the +/// label string, colour and viewing group; the S-52 justification / offset / font / +/// halo fields are dropped (tracked OpText findings), matching the current text path. +fn syminsText(a: Allocator, f: s57.Feature, op: []const u8, params: []const u8) !?instructions.Text { + const args = try syminsSplitArgs(a, params); + var text: []const u8 = ""; + var color_idx: usize = undefined; + var display_idx: usize = undefined; + if (std.mem.eql(u8, op, "TE")) { + if (args.len < 10) return null; + var names = std.ArrayList([]const u8).empty; + var it = std.mem.splitScalar(u8, syminsTrimQuotes(args[1]), ','); + while (it.next()) |nm| { + const t = std.mem.trim(u8, nm, " \t"); + if (t.len > 0) try names.append(a, t); + } + text = (try syminsFormatSubstitute(a, f, syminsTrimQuotes(args[0]), names.items)) orelse return null; + color_idx = 8; + display_idx = 9; + } else { // TX + if (args.len < 9) return null; + const raw = args[0]; + if (raw.len > 0 and (raw[0] == '\'' or raw[0] == '"')) { + text = syminsTrimQuotes(raw); // literal + } else { + text = syminsFeatAttr(f, std.mem.trim(u8, raw, " \t")) orelse return null; + } + color_idx = 7; + display_idx = 8; + } + if (text.len == 0) return null; + var color = std.mem.trim(u8, syminsArgAt(args, color_idx), " \t"); + if (color.len == 0) color = "CHBLK"; + const group = std.fmt.parseInt(i64, std.mem.trim(u8, syminsArgAt(args, display_idx), " \t"), 10) catch 0; + return instructions.Text{ .text = text, .color = color, .group = group }; +} + +/// Build an S-101 Portrayal from a NEWOBJ's SYMINS attribute, or null when there is +/// no usable SYMINS (caller then falls back to the default new-object symbology). +/// Geometry/anchoring/clipping is handled by processFeatureParsed exactly like a rule stream. +pub fn buildSyminsPortrayal(a: Allocator, f: s57.Feature) !?instructions.Portrayal { + const raw0 = f.attr(SYMINS_ATTR) orelse return null; + const raw = std.mem.trim(u8, raw0, " "); + if (raw.len == 0) return null; + + var points = std.ArrayList(instructions.Point).empty; + var texts = std.ArrayList(instructions.Text).empty; + var lines = std.ArrayList(instructions.Line).empty; + var patterns = std.ArrayList([]const u8).empty; + var fill_token: ?[]const u8 = null; + + for (try syminsSplitInstructions(a, raw)) |instr| { + const opp = syminsSplitOp(instr) orelse continue; + if (std.mem.eql(u8, opp.op, "SY")) { // SY(NAME[,rot]) + const args = try syminsSplitArgs(a, opp.params); + const name = std.mem.trim(u8, syminsArgAt(args, 0), " \t"); + if (name.len == 0) continue; + const rot: f64 = if (args.len > 1) (std.fmt.parseFloat(f64, std.mem.trim(u8, args[1], " \t")) catch 0) else 0; + try points.append(a, .{ .symbol = name, .rotation = rot, .offset_x = 0, .offset_y = 0 }); + } else if (std.mem.eql(u8, opp.op, "TX") or std.mem.eql(u8, opp.op, "TE")) { + if (try syminsText(a, f, opp.op, opp.params)) |t| try texts.append(a, t); + } else if (std.mem.eql(u8, opp.op, "LS")) { // LS(style,width,colour) + const args = try syminsSplitArgs(a, opp.params); + if (args.len < 3) continue; + var w = std.fmt.parseInt(i64, std.mem.trim(u8, args[1], " \t"), 10) catch 0; + if (w <= 0) w = 1; + const st = std.mem.trim(u8, args[0], " \t"); + const dashed = std.ascii.eqlIgnoreCase(st, "DASH") or std.ascii.eqlIgnoreCase(st, "DOTT"); + try lines.append(a, .{ + .style = if (dashed) "dash" else "solid", + .width = @floatFromInt(w), + .color = std.mem.trim(u8, args[2], " \t"), + }); + } else if (std.mem.eql(u8, opp.op, "LC")) { // LC(LINESTYLE) — approximated as dashed + const name = std.mem.trim(u8, syminsArgAt(try syminsSplitArgs(a, opp.params), 0), " \t"); + if (name.len == 0) continue; + try lines.append(a, .{ .style = name, .width = 1, .color = "CHBLK" }); + } else if (std.mem.eql(u8, opp.op, "AC")) { // AC(COLOUR[,transp]) + const color = std.mem.trim(u8, syminsArgAt(try syminsSplitArgs(a, opp.params), 0), " \t"); + if (color.len > 0) fill_token = color; + } else if (std.mem.eql(u8, opp.op, "AP")) { // AP(PATTERN) + const name = std.mem.trim(u8, syminsArgAt(try syminsSplitArgs(a, opp.params), 0), " \t"); + if (name.len > 0) try patterns.append(a, name); + } + } + if (points.items.len == 0 and texts.items.len == 0 and lines.items.len == 0 and + patterns.items.len == 0 and fill_token == null) return null; + return instructions.Portrayal{ + .fill_token = fill_token, + .patterns = patterns.items, + .lines = lines.items, + .points = points.items, + .texts = texts.items, + }; +} + +test "buildSyminsPortrayal parses SY/TX/LS/LC/AC/AP" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const attrs = [_]s57.Attr{.{ + .code = SYMINS_ATTR, + .value = "SY(BOYSPP01,45);TX('Hello',1,2,0,'15110',0,0,CHRED,28);" ++ + "LS(DASH,3,CHGRD);LS(SOLD,2,CHBLK);LC(NAVARE51);AC(DEPVS);AP(DIAMOND1)", + }}; + const f = s57.Feature{ .rcnm = 100, .rcid = 1, .prim = 3, .objl = 163, .attrs = &attrs }; + + const p = (try buildSyminsPortrayal(a, f)) orelse return error.NoPortrayal; + + try std.testing.expectEqual(@as(usize, 1), p.points.len); + try std.testing.expectEqualStrings("BOYSPP01", p.points[0].symbol); + try std.testing.expectEqual(@as(f64, 45), p.points[0].rotation); + + try std.testing.expectEqual(@as(usize, 1), p.texts.len); + try std.testing.expectEqualStrings("Hello", p.texts[0].text); + try std.testing.expectEqualStrings("CHRED", p.texts[0].color); + try std.testing.expectEqual(@as(i64, 28), p.texts[0].group); + + try std.testing.expectEqual(@as(usize, 3), p.lines.len); // 2x LS + 1x LC + try std.testing.expectEqualStrings("dash", p.lines[0].style); // DASH -> dashed + try std.testing.expectEqual(@as(f64, 3), p.lines[0].width); + try std.testing.expectEqualStrings("CHGRD", p.lines[0].color); + try std.testing.expectEqualStrings("solid", p.lines[1].style); // SOLD -> solid + try std.testing.expectEqualStrings("NAVARE51", p.lines[2].style); // LC name verbatim + + try std.testing.expectEqualStrings("DEPVS", p.fill_token.?); + try std.testing.expectEqual(@as(usize, 1), p.patterns.len); + try std.testing.expectEqualStrings("DIAMOND1", p.patterns[0]); + + // A blank / absent SYMINS yields no portrayal. + const f_empty = s57.Feature{ .rcnm = 100, .rcid = 2, .prim = 3, .objl = 163, .attrs = &[_]s57.Attr{.{ .code = SYMINS_ATTR, .value = " " }} }; + try std.testing.expect((try buildSyminsPortrayal(a, f_empty)) == null); + + // Instruction-splitting honours a ';' inside a quoted TX string. + const f_semi = s57.Feature{ .rcnm = 100, .rcid = 3, .prim = 1, .objl = 163, .attrs = &[_]s57.Attr{.{ .code = SYMINS_ATTR, .value = "TX('a;b',1,2,0,'15110',0,0,CHBLK,28)" }} }; + const ps = (try buildSyminsPortrayal(a, f_semi)) orelse return error.NoPortrayal; + try std.testing.expectEqual(@as(usize, 1), ps.texts.len); + try std.testing.expectEqualStrings("a;b", ps.texts[0].text); +} From 0cbf6c3fca984f9b07afa26badc35fc97e1b729b Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 20:07:43 -0400 Subject: [PATCH 086/140] refactor(scene): extract light-sector reach; delete dead scanLightReachAttrs Move the light-reach math (LightReach / lightReachTiles / collectLightReach, pure over s57) to scene/lightreach.zig; scene re-exports it so chart and bake_enc keep reaching it as scene.X. Delete scanLightReachAttrs (no production caller, only its own test). scene 3261 -> 3054 lines; tests green; 10-cell subset re-bakes byte-identical. Co-Authored-By: Claude Fable 5 --- src/scene/lightreach.zig | 158 ++++++++++++++++++++++++++++ src/scene/scene.zig | 218 ++------------------------------------- 2 files changed, 167 insertions(+), 209 deletions(-) create mode 100644 src/scene/lightreach.zig diff --git a/src/scene/lightreach.zig b/src/scene/lightreach.zig new file mode 100644 index 0000000..7c83710 --- /dev/null +++ b/src/scene/lightreach.zig @@ -0,0 +1,158 @@ +//! Light-sector reach: how far a LIGHTS feature's sector legs/arcs reach, so the +//! baker and the emitter widen the LIGHTS spatial-cull margin enough that a +//! directional light's ground legs are not dropped on the tiles they cross. +//! Pure over s57. + +const std = @import("std"); +const s57 = @import("s57"); + +const EARTH_CIRCUM_M: f64 = 40075016.686; + +// Worst-case reach of a light's sector legs/arcs (emitAugFigures) as a fraction of a +// tile — these are drawn at a fixed DISPLAY size (radius/length in mm), so the reach +// is ~constant in tile units at every zoom (offset_tiles = mm * PX_PER_MM / 256). +// Used to widen the LIGHTS spatial-cull margin so an arc isn't dropped on the tiles it +// crosses (S-52 legs ~25 mm / arcs ~20 mm ≈ 0.8 tile; 1.0 leaves headroom). +// GROUND-length legs (directional lights: nmi2metres(nominal range), LightSectored.lua) +// exceed this by far at fine zooms — lightReachTiles is the honest per-zoom bound. +pub const LIGHT_AUG_REACH_TILES: f64 = 1.0; + +/// How far a cell's constructed sector figures (emitAugFigures) can reach beyond +/// their feature anchors, summarised per cell so tile addressing (bake_enc +/// buildTileMap / the live tileRefs) can include the cell in neighbouring tiles +/// its raw bbox never touches — otherwise legs/arcs clip exactly at the boundary. +pub const LightReach = struct { + /// Union bbox [w,s,e,n] of the aug-figure-bearing features' anchors (feature + /// nodes + explicit AugmentedPoint anchors); null = no sector figures at all. + bbox: ?[4]f64 = null, + /// Max ground-distance leg length in metres (AugmentedRay with a GeographicCRS + /// length — directional-light legs); 0 = display-mm figures only. + range_m: f64 = 0, +}; + +/// Worst-case tile-unit reach of sector figures at zoom z, latitude `lat`: +/// display-mm figures reach a ~constant LIGHT_AUG_REACH_TILES, ground-length legs +/// reach range_m metres = range_m·2^z/(cosφ·C) tiles (the emitAugFigures +/// projection: len_px = range_m/(cosφ·EARTH_CIRCUM_M)·worldPx). Never below the +/// mm bound — a cell with figures always reaches at least the display-sized ones. +pub fn lightReachTiles(range_m: f64, z: u8, lat: f64) f64 { + if (range_m <= 0) return LIGHT_AUG_REACH_TILES; + const cos_lat = @max(@cos(lat * std.math.pi / 180.0), 1e-6); + const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + return @max(LIGHT_AUG_REACH_TILES, range_m * scale / (cos_lat * EARTH_CIRCUM_M)); +} + +// The nth comma-separated field of an instruction argument ("" past the end). +fn instrCsv(s: []const u8, n: usize) []const u8 { + var it = std.mem.splitScalar(u8, s, ','); + var i: usize = 0; + while (it.next()) |part| : (i += 1) if (i == n) return std.mem.trim(u8, part, " "); + return ""; +} + +// Fold one aug-figure-bearing feature's anchors + ground length into `r`: +// the feature node plus any explicit AugmentedPoint anchors in the stream. +fn foldLightReach(r: *LightReach, cell: *const s57.Cell, f: s57.Feature, stream: []const u8) void { + var b: [4]f64 = if (r.bbox) |bb| bb else .{ 1e9, 1e9, -1e9, -1e9 }; + if (cell.pointGeometry(f)) |pg| { + b[0] = @min(b[0], pg.lon()); + b[1] = @min(b[1], pg.lat()); + b[2] = @max(b[2], pg.lon()); + b[3] = @max(b[3], pg.lat()); + } + var it = std.mem.splitScalar(u8, stream, ';'); + while (it.next()) |item| { + const colon = std.mem.indexOfScalar(u8, item, ':') orelse continue; + const key = item[0..colon]; + const val = item[colon + 1 ..]; + if (std.mem.eql(u8, key, "AugmentedRay")) { + // "AugmentedRay:,,," — a GeographicCRS + // length is ground metres (the directional-light leg); else display mm. + if (std.mem.eql(u8, instrCsv(val, 2), "GeographicCRS")) { + const len = std.fmt.parseFloat(f64, instrCsv(val, 3)) catch 0; + r.range_m = @max(r.range_m, len); + } + } else if (std.mem.eql(u8, key, "AugmentedPoint")) { + // "AugmentedPoint:,," — an explicit figure anchor. + const lon = std.fmt.parseFloat(f64, instrCsv(val, 1)) catch continue; + const lat = std.fmt.parseFloat(f64, instrCsv(val, 2)) catch continue; + b[0] = @min(b[0], lon); + b[1] = @min(b[1], lat); + b[2] = @max(b[2], lon); + b[3] = @max(b[3], lat); + } + } + if (b[0] <= b[2]) r.bbox = b; +} + +/// EXACT per-cell sector-figure reach, from the portrayal instruction streams: +/// a feature constructs figures iff its stream carries AugmentedRay/ArcByRadius +/// (LightSectored legs/arcs), so this can't drift from what emitAugFigures will +/// actually draw (including context-parameter effects like FullLightLines). +/// Only prim==1 features can emit figures (processFeatureParsed). Allocation-free. +pub fn collectLightReach(cell: *const s57.Cell, portrayal: ?[]const ?[]const u8) LightReach { + var r = LightReach{}; + const streams = portrayal orelse return r; + for (cell.features, 0..) |f, fi| { + if (f.prim != 1 or fi >= streams.len) continue; + const stream = streams[fi] orelse continue; + if (std.mem.indexOf(u8, stream, "AugmentedRay:") == null and + std.mem.indexOf(u8, stream, "ArcByRadius:") == null) continue; + foldLightReach(&r, cell, f, stream); + } + return r; +} + +test "collectLightReach: AugmentedRay ground legs + anchors from the streams" { + const gpa = std.testing.allocator; + const feats = [_]s57.Feature{ + // A directional light: GeographicCRS leg 16668 m + a sector arc. + .{ .rcnm = 100, .rcid = 1, .prim = 1, .objl = 75, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }} }, + // A plain sectored light: LocalCRS (display-mm) legs only. + .{ .rcnm = 100, .rcid = 2, .prim = 1, .objl = 75, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 2 }, .ornt = 255 }} }, + // A buoy with no figures at all. + .{ .rcnm = 100, .rcid = 3, .prim = 1, .objl = 14, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 3 }, .ornt = 255 }} }, + }; + var cell = s57.Cell{ + .params = .{ .cscl = 80_000 }, + .vectors = &.{}, + .features = &feats, + .nodes = std.AutoHashMap(u64, s57.LonLat).init(gpa), + .edges = std.AutoHashMap(u32, usize).init(gpa), + .sounding_vecs = std.AutoHashMap(u64, usize).init(gpa), + .arena = std.heap.ArenaAllocator.init(gpa), + }; + defer cell.deinit(); + try cell.nodes.put((@as(u64, s57.RCNM_VI) << 32) | 1, s57.LonLat.init(-76.52, 39.20)); + try cell.nodes.put((@as(u64, s57.RCNM_VI) << 32) | 2, s57.LonLat.init(-76.40, 39.30)); + try cell.nodes.put((@as(u64, s57.RCNM_VI) << 32) | 3, s57.LonLat.init(-76.10, 39.10)); + + const streams = [_]?[]const u8{ + "ViewingGroup:27070;DrawingPriority:8;AugmentedRay:GeographicCRS,45.0,GeographicCRS,16668;LineStyle:dash,3.51,0.32,CHBLK;LineInstruction:_simple_;ClearGeometry", + "DrawingPriority:8;AugmentedRay:GeographicCRS,120.0,LocalCRS,25.0;LineInstruction:_simple_;ArcByRadius:0,0,20,120,90;LineInstruction:_simple_;ClearGeometry", + "DrawingPriority:7;PointInstruction:BOYLAT01", + }; + const r = collectLightReach(&cell, &streams); + // Ground range from the directional leg only; mm-only figures add no range. + try std.testing.expectEqual(@as(f64, 16668), r.range_m); + // The bbox unions BOTH figure-bearing lights, not the figuresless buoy. + const bb = r.bbox orelse return error.TestUnexpectedResult; + try std.testing.expectApproxEqAbs(@as(f64, -76.52), bb[0], 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 39.20), bb[1], 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, -76.40), bb[2], 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 39.30), bb[3], 1e-9); + + // No streams at all -> no reach. + try std.testing.expectEqual(@as(?[4]f64, null), collectLightReach(&cell, null).bbox); + + // An explicit AugmentedPoint anchor extends the bbox beyond the node. + const anchored = [_]?[]const u8{ + "AugmentedPoint:GeographicCRS,-76.60,39.10;ArcByRadius:0,0,25,0,360;LineInstruction:_simple_;ClearGeometry", + null, + null, + }; + const ra = collectLightReach(&cell, &anchored); + const ab = ra.bbox orelse return error.TestUnexpectedResult; + try std.testing.expectApproxEqAbs(@as(f64, -76.60), ab[0], 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 39.10), ab[1], 1e-9); +} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index c871bb3..c79c3f9 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -27,6 +27,14 @@ pub const bake_enc = @import("bake_enc.zig"); pub const coverage = @import("coverage"); // per-cell coverage sidecar (in PMTiles metadata) const symins = @import("symins.zig"); // SYMINS02 native fallback (NEWOBJ producer draw ops) +// Light-sector reach (scene/lightreach.zig), re-exported so chart + bake_enc keep +// reaching it through the scene module. +const lightreach = @import("lightreach.zig"); +pub const LightReach = lightreach.LightReach; +pub const collectLightReach = lightreach.collectLightReach; +pub const lightReachTiles = lightreach.lightReachTiles; +pub const LIGHT_AUG_REACH_TILES = lightreach.LIGHT_AUG_REACH_TILES; + /// Output tile encoding: classic Mapbox Vector Tile, or MapLibre Tile. pub const TileFormat = enum { mvt, mlt }; const instructions = @import("s101").instructions; @@ -48,136 +56,6 @@ const SYMBOL_SCALE: f64 = @import("render").sndfrm.SYMBOL_SCALE; // rounds to whole feet — contour valdco values are whole metres). const M_TO_FT: f64 = @import("render").sndfrm.M_TO_FT; -// Worst-case reach of a light's sector legs/arcs (emitAugFigures) as a fraction of a -// tile — these are drawn at a fixed DISPLAY size (radius/length in mm), so the reach -// is ~constant in tile units at every zoom (offset_tiles = mm * PX_PER_MM / 256). -// Used to widen the LIGHTS spatial-cull margin so an arc isn't dropped on the tiles it -// crosses (S-52 legs ~25 mm / arcs ~20 mm ≈ 0.8 tile; 1.0 leaves headroom). -// GROUND-length legs (directional lights: nmi2metres(nominal range), LightSectored.lua) -// exceed this by far at fine zooms — lightReachTiles is the honest per-zoom bound. -pub const LIGHT_AUG_REACH_TILES: f64 = 1.0; - -/// How far a cell's constructed sector figures (emitAugFigures) can reach beyond -/// their feature anchors, summarised per cell so tile addressing (bake_enc -/// buildTileMap / the live tileRefs) can include the cell in neighbouring tiles -/// its raw bbox never touches — otherwise legs/arcs clip exactly at the boundary. -pub const LightReach = struct { - /// Union bbox [w,s,e,n] of the aug-figure-bearing features' anchors (feature - /// nodes + explicit AugmentedPoint anchors); null = no sector figures at all. - bbox: ?[4]f64 = null, - /// Max ground-distance leg length in metres (AugmentedRay with a GeographicCRS - /// length — directional-light legs); 0 = display-mm figures only. - range_m: f64 = 0, -}; - -/// Worst-case tile-unit reach of sector figures at zoom z, latitude `lat`: -/// display-mm figures reach a ~constant LIGHT_AUG_REACH_TILES, ground-length legs -/// reach range_m metres = range_m·2^z/(cosφ·C) tiles (the emitAugFigures -/// projection: len_px = range_m/(cosφ·EARTH_CIRCUM_M)·worldPx). Never below the -/// mm bound — a cell with figures always reaches at least the display-sized ones. -pub fn lightReachTiles(range_m: f64, z: u8, lat: f64) f64 { - if (range_m <= 0) return LIGHT_AUG_REACH_TILES; - const cos_lat = @max(@cos(lat * std.math.pi / 180.0), 1e-6); - const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); - return @max(LIGHT_AUG_REACH_TILES, range_m * scale / (cos_lat * EARTH_CIRCUM_M)); -} - -// The nth comma-separated field of an instruction argument ("" past the end). -fn instrCsv(s: []const u8, n: usize) []const u8 { - var it = std.mem.splitScalar(u8, s, ','); - var i: usize = 0; - while (it.next()) |part| : (i += 1) if (i == n) return std.mem.trim(u8, part, " "); - return ""; -} - -// Fold one aug-figure-bearing feature's anchors + ground length into `r`: -// the feature node plus any explicit AugmentedPoint anchors in the stream. -fn foldLightReach(r: *LightReach, cell: *const s57.Cell, f: s57.Feature, stream: []const u8) void { - var b: [4]f64 = if (r.bbox) |bb| bb else .{ 1e9, 1e9, -1e9, -1e9 }; - if (cell.pointGeometry(f)) |pg| { - b[0] = @min(b[0], pg.lon()); - b[1] = @min(b[1], pg.lat()); - b[2] = @max(b[2], pg.lon()); - b[3] = @max(b[3], pg.lat()); - } - var it = std.mem.splitScalar(u8, stream, ';'); - while (it.next()) |item| { - const colon = std.mem.indexOfScalar(u8, item, ':') orelse continue; - const key = item[0..colon]; - const val = item[colon + 1 ..]; - if (std.mem.eql(u8, key, "AugmentedRay")) { - // "AugmentedRay:,,," — a GeographicCRS - // length is ground metres (the directional-light leg); else display mm. - if (std.mem.eql(u8, instrCsv(val, 2), "GeographicCRS")) { - const len = std.fmt.parseFloat(f64, instrCsv(val, 3)) catch 0; - r.range_m = @max(r.range_m, len); - } - } else if (std.mem.eql(u8, key, "AugmentedPoint")) { - // "AugmentedPoint:,," — an explicit figure anchor. - const lon = std.fmt.parseFloat(f64, instrCsv(val, 1)) catch continue; - const lat = std.fmt.parseFloat(f64, instrCsv(val, 2)) catch continue; - b[0] = @min(b[0], lon); - b[1] = @min(b[1], lat); - b[2] = @max(b[2], lon); - b[3] = @max(b[3], lat); - } - } - if (b[0] <= b[2]) r.bbox = b; -} - -/// EXACT per-cell sector-figure reach, from the portrayal instruction streams: -/// a feature constructs figures iff its stream carries AugmentedRay/ArcByRadius -/// (LightSectored legs/arcs), so this can't drift from what emitAugFigures will -/// actually draw (including context-parameter effects like FullLightLines). -/// Only prim==1 features can emit figures (processFeatureParsed). Allocation-free. -pub fn collectLightReach(cell: *const s57.Cell, portrayal: ?[]const ?[]const u8) LightReach { - var r = LightReach{}; - const streams = portrayal orelse return r; - for (cell.features, 0..) |f, fi| { - if (f.prim != 1 or fi >= streams.len) continue; - const stream = streams[fi] orelse continue; - if (std.mem.indexOf(u8, stream, "AugmentedRay:") == null and - std.mem.indexOf(u8, stream, "ArcByRadius:") == null) continue; - foldLightReach(&r, cell, f, stream); - } - return r; -} - -/// CONSERVATIVE per-cell sector-figure reach from the raw S-57 attributes, for -/// paths that have a parsed cell but no portrayal yet (the bundle baker's -/// pre-pass super-tile index + planned-tile estimate). Must be a SUPERSET of -/// collectLightReach under the default portrayal context: any LIGHTS point is a -/// figure candidate (sector legs/arcs, all-round major-light rings), and a -/// directional light (CATLIT 1 + ORIENT) draws a nmi2metres(VALNMR || 9) ground -/// leg (LightSectored.lua). A non-default FullLightLines context could still -/// exceed this for plain sectored lights — the bake default keeps it off. -pub fn scanLightReachAttrs(cell: *const s57.Cell) LightReach { - var r = LightReach{}; - for (cell.features) |f| { - if (f.objl != 75 or f.prim != 1) continue; // LIGHTS points only - var b: [4]f64 = if (r.bbox) |bb| bb else .{ 1e9, 1e9, -1e9, -1e9 }; - const pg = cell.pointGeometry(f) orelse continue; - b[0] = @min(b[0], pg.lon()); - b[1] = @min(b[1], pg.lat()); - b[2] = @max(b[2], pg.lon()); - b[3] = @max(b[3], pg.lat()); - r.bbox = b; - // Directional (CATLIT list contains 1) with an orientation: the rule - // always draws the full nominal-range ground leg. - const catlit = f.attr(s57.ATTR_CATLIT) orelse ""; - var directional = false; - var it = std.mem.splitScalar(u8, catlit, ','); - while (it.next()) |v| { - if (std.mem.eql(u8, std.mem.trim(u8, v, " "), "1")) directional = true; - } - const orient: []const u8 = f.attr(s57.ATTR_ORIENT) orelse ""; - if (directional and orient.len > 0) { - const nmi = f.attrFloat(s57.ATTR_VALNMR) orelse 9.0; - r.range_m = @max(r.range_m, nmi * 1852.0); - } - } - return r; -} // S-57 attribute code for SCAMIN (the minimum display scale 1:N, S-57 Appendix A // attr 133 / S-52 §8.4). Features carrying it are routed to a dedicated *_scamin @@ -2749,86 +2627,7 @@ test "featureScamin reads s57 attr 133" { try std.testing.expectEqual(@as(?i64, null), featureScamin(without)); } -test "collectLightReach: AugmentedRay ground legs + anchors from the streams" { - const gpa = std.testing.allocator; - const feats = [_]s57.Feature{ - // A directional light: GeographicCRS leg 16668 m + a sector arc. - .{ .rcnm = 100, .rcid = 1, .prim = 1, .objl = 75, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }} }, - // A plain sectored light: LocalCRS (display-mm) legs only. - .{ .rcnm = 100, .rcid = 2, .prim = 1, .objl = 75, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 2 }, .ornt = 255 }} }, - // A buoy with no figures at all. - .{ .rcnm = 100, .rcid = 3, .prim = 1, .objl = 14, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 3 }, .ornt = 255 }} }, - }; - var cell = s57.Cell{ - .params = .{ .cscl = 80_000 }, - .vectors = &.{}, - .features = &feats, - .nodes = std.AutoHashMap(u64, s57.LonLat).init(gpa), - .edges = std.AutoHashMap(u32, usize).init(gpa), - .sounding_vecs = std.AutoHashMap(u64, usize).init(gpa), - .arena = std.heap.ArenaAllocator.init(gpa), - }; - defer cell.deinit(); - try cell.nodes.put((@as(u64, s57.RCNM_VI) << 32) | 1, s57.LonLat.init(-76.52, 39.20)); - try cell.nodes.put((@as(u64, s57.RCNM_VI) << 32) | 2, s57.LonLat.init(-76.40, 39.30)); - try cell.nodes.put((@as(u64, s57.RCNM_VI) << 32) | 3, s57.LonLat.init(-76.10, 39.10)); - - const streams = [_]?[]const u8{ - "ViewingGroup:27070;DrawingPriority:8;AugmentedRay:GeographicCRS,45.0,GeographicCRS,16668;LineStyle:dash,3.51,0.32,CHBLK;LineInstruction:_simple_;ClearGeometry", - "DrawingPriority:8;AugmentedRay:GeographicCRS,120.0,LocalCRS,25.0;LineInstruction:_simple_;ArcByRadius:0,0,20,120,90;LineInstruction:_simple_;ClearGeometry", - "DrawingPriority:7;PointInstruction:BOYLAT01", - }; - const r = collectLightReach(&cell, &streams); - // Ground range from the directional leg only; mm-only figures add no range. - try std.testing.expectEqual(@as(f64, 16668), r.range_m); - // The bbox unions BOTH figure-bearing lights, not the figuresless buoy. - const bb = r.bbox orelse return error.TestUnexpectedResult; - try std.testing.expectApproxEqAbs(@as(f64, -76.52), bb[0], 1e-9); - try std.testing.expectApproxEqAbs(@as(f64, 39.20), bb[1], 1e-9); - try std.testing.expectApproxEqAbs(@as(f64, -76.40), bb[2], 1e-9); - try std.testing.expectApproxEqAbs(@as(f64, 39.30), bb[3], 1e-9); - - // No streams at all -> no reach. - try std.testing.expectEqual(@as(?[4]f64, null), collectLightReach(&cell, null).bbox); - - // An explicit AugmentedPoint anchor extends the bbox beyond the node. - const anchored = [_]?[]const u8{ - "AugmentedPoint:GeographicCRS,-76.60,39.10;ArcByRadius:0,0,25,0,360;LineInstruction:_simple_;ClearGeometry", - null, - null, - }; - const ra = collectLightReach(&cell, &anchored); - const ab = ra.bbox orelse return error.TestUnexpectedResult; - try std.testing.expectApproxEqAbs(@as(f64, -76.60), ab[0], 1e-9); - try std.testing.expectApproxEqAbs(@as(f64, 39.10), ab[1], 1e-9); -} -test "scanLightReachAttrs: sectored/directional attrs drive the conservative reach" { - const gpa = std.testing.allocator; - const sect_attrs = [_]s57.Attr{ .{ .code = s57.ATTR_SECTR1, .value = "45" }, .{ .code = s57.ATTR_SECTR2, .value = "90" } }; - const dir_attrs = [_]s57.Attr{ .{ .code = s57.ATTR_CATLIT, .value = "1" }, .{ .code = s57.ATTR_ORIENT, .value = "195" }, .{ .code = s57.ATTR_VALNMR, .value = "12" } }; - const feats = [_]s57.Feature{ - .{ .rcnm = 100, .rcid = 1, .prim = 1, .objl = 75, .attrs = §_attrs, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }} }, - .{ .rcnm = 100, .rcid = 2, .prim = 1, .objl = 75, .attrs = &dir_attrs, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 2 }, .ornt = 255 }} }, - }; - var cell = s57.Cell{ - .params = .{ .cscl = 80_000 }, - .vectors = &.{}, - .features = &feats, - .nodes = std.AutoHashMap(u64, s57.LonLat).init(gpa), - .edges = std.AutoHashMap(u32, usize).init(gpa), - .sounding_vecs = std.AutoHashMap(u64, usize).init(gpa), - .arena = std.heap.ArenaAllocator.init(gpa), - }; - defer cell.deinit(); - try cell.nodes.put((@as(u64, s57.RCNM_VI) << 32) | 1, s57.LonLat.init(-76.52, 39.20)); - try cell.nodes.put((@as(u64, s57.RCNM_VI) << 32) | 2, s57.LonLat.init(-76.40, 39.30)); - - const r = scanLightReachAttrs(&cell); - try std.testing.expect(r.bbox != null); - // Directional CATLIT 1 + ORIENT: nmi2metres(VALNMR 12) ground leg. - try std.testing.expectEqual(@as(f64, 12.0 * 1852.0), r.range_m); -} test "processFeatureInstr routes SCAMIN point to the bucket + carries draw_prio/scamin" { const gpa = std.testing.allocator; @@ -3106,6 +2905,7 @@ test "DANGER01/02 on a VALSOU danger normalizes + tags danger_depth/sym_deep for test { _ = bake_enc; _ = symins; + _ = lightreach; } // ---- bundle-sourced replay (baked tile -> Surface calls) -------------------- From 8b60c81a75f0f4d45ce554eb86535383d6e01421 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 20:23:49 -0400 Subject: [PATCH 087/140] refactor(scene): extract complex-linestyle tessellation into scene/linestyle.zig Move the S-101 LineStyles registry + tessellation out of the oversized scene.zig. The registry global (g_linestyles) moves with it, gated behind a lookup() accessor; clearLinestyles (dead) is dropped. Exposed as scene.linestyle so chart/bundle register + look up styles through it. Spell out and de-stutter the API now that it lives in a linestyle module: LsInfo -> linestyle.Info (an analysed complex linestyle) LsSym -> linestyle.Symbol (a symbol placed along it) emitComplexLine -> drawComplexLine walkComplexRun -> drawComplexRun scene 3054 -> 2863 lines; tests green; 10-cell subset re-bakes byte-identical. (Follow-up: a linestyle.drawPlain counterpart would fold the inline plain-stroke sites into the module.) Co-Authored-By: Claude Fable 5 --- src/bundle.zig | 8 +- src/chart.zig | 10 +- src/scene/linestyle.zig | 219 ++++++++++++++++++++++++++++++++++++++ src/scene/scene.zig | 225 +++------------------------------------- tools/render.zig | 2 +- 5 files changed, 242 insertions(+), 222 deletions(-) create mode 100644 src/scene/linestyle.zig diff --git a/src/bundle.zig b/src/bundle.zig index a63224e..fa886cc 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -49,13 +49,13 @@ pub fn embeddedLinestyles(a: std.mem.Allocator) ![]style.LineStyleSrc { return out; } -// Build the complex-line tessellation table (id -> LsInfo) from `srcs` and register +// Build the complex-line tessellation table (id -> linestyle.Info) from `srcs` and register // it with scene before baking, so a named (symbolised) linestyle is drawn as its // real dash runs + embedded symbols instead of a generic dashed stroke. Mirrors Go // lsInfoFromCatalog; mm geometry is converted at the PresLib feature scale. Best // effort — a malformed/short style is simply skipped. `gpa` outlives the bake. pub fn registerLinestyles(gpa: std.mem.Allocator, srcs: []const style.LineStyleSrc) void { - const px = engine.scene.LINESTYLE_PX_PER_MM; + const px = engine.scene.linestyle.LINESTYLE_PX_PER_MM; for (srcs) |s| { const parsed = style.parseLineStyle(gpa, s.xml) catch continue; const period = parsed.interval_length * px; @@ -66,11 +66,11 @@ pub fn registerLinestyles(gpa: std.mem.Allocator, srcs: []const style.LineStyleS const hi = (d.start + d.length) * px; if (hi - lo > 1e-6) runs.append(gpa, .{ lo, hi }) catch {}; } - var syms = std.ArrayList(engine.scene.LsSym).empty; + var syms = std.ArrayList(engine.scene.linestyle.Symbol).empty; for (parsed.symbols) |sym| syms.append(gpa, .{ .name = sym.reference, .offset_px = sym.position * px }) catch {}; var width = parsed.pen_width * px; if (width < 0.6) width = 0.9; // S-52 minimum pen - engine.scene.registerLinestyle(gpa, s.id, .{ + engine.scene.linestyle.registerLinestyle(gpa, s.id, .{ .period_px = period, .on_runs = runs.items, .symbols = syms.items, diff --git a/src/chart.zig b/src/chart.zig index f22e6f4..93460ce 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -863,7 +863,7 @@ pub fn warmup() void { var ls_srcs = std.ArrayList(style.LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; - scene.registerLinestylesXml(gpa, ls_srcs.items); + scene.linestyle.registerLinestylesXml(gpa, ls_srcs.items); } // Parallel open worker: peek each cell's band + bbox and copy its bytes. @@ -1286,7 +1286,7 @@ pub const Chart = struct { var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; - scene.registerLinestylesXml(gpa, ls_srcs.items); + scene.linestyle.registerLinestylesXml(gpa, ls_srcs.items); // Continuous scaling between integer zooms; the host applies physical // calibration / @2x via settings.size_scale. @@ -1361,7 +1361,7 @@ pub const Chart = struct { var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; - scene.registerLinestylesXml(gpa, ls_srcs.items); + scene.linestyle.registerLinestylesXml(gpa, ls_srcs.items); const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); var vs = render.vector.VectorSurface.init(a, &colors, palette, settings, cb); @@ -1730,7 +1730,7 @@ pub fn renderFeature( var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; - scene.registerLinestylesXml(gpa, ls_srcs.items); + scene.linestyle.registerLinestylesXml(gpa, ls_srcs.items); // Always show the previewed feature regardless of its SCAMIN at the framing zoom. var s = settings.*; @@ -1807,7 +1807,7 @@ pub fn renderCellView( var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; defer ls_srcs.deinit(gpa); for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; - scene.registerLinestylesXml(gpa, ls_srcs.items); + scene.linestyle.registerLinestylesXml(gpa, ls_srcs.items); const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); var ps = render.pixel.PixelSurface.initView(a, &colors, palette, settings, zoom, w, h, pt, tile.EXTENT); diff --git a/src/scene/linestyle.zig b/src/scene/linestyle.zig new file mode 100644 index 0000000..4763ebd --- /dev/null +++ b/src/scene/linestyle.zig @@ -0,0 +1,219 @@ +//! Complex (symbolised) line tessellation (S-101 LineStyles): a named linestyle +//! (LC, or a LineInstruction whose style is not "_simple_") is walked by arc length +//! and emitted per period as dash "on" runs plus each embedded symbol as a point +//! rotated to the local tangent. The registry (id -> Info) is set once by the +//! baker before tile generation, then read-only during the parallel bake. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const s57 = @import("s57"); +const tile = @import("tiles").tile; +const mvt = @import("tiles").mvt; +const style = @import("style"); +const rs = @import("render").surface; +const SYMBOL_SCALE: f64 = @import("render").sndfrm.SYMBOL_SCALE; + +// === Complex (symbolised) line tessellation (S-101 LineStyles) ============= +// A named linestyle (LC / a LineInstruction whose style is not "_simple_") is +// tessellated per zoom: walk the line by arc length and emit, per period, the dash +// "on" runs as line segments + each embedded symbol as a point rotated to the local +// tangent. Mirrors Go bake/complexline.go + linestyle_catalog.go. The mm geometry is +// parsed by style.parseLineStyle; the baker converts it to Info at the PresLib +// FEATURE scale (ls_px_per_mm) and registers it before baking. + +const ls_feature_scale: f64 = 0.01 / 0.35278; // px per 0.01-mm PresLib unit (= SYMBOL_SCALE) +const ls_px_per_mm: f64 = 100.0 * ls_feature_scale; // mm -> screen px +/// The mm->px feature scale the baker must apply when building an Info from the raw +/// millimetre LineStyles geometry (style.parseLineStyle), so the tessellator and the +/// table agree. Differs from the symbol-scale style.analysePattern uses for the +/// client linestyles.json. +pub const LINESTYLE_PX_PER_MM = ls_px_per_mm; + +pub const Symbol = struct { name: []const u8, offset_px: f64 }; +pub const Info = struct { + period_px: f64, + on_runs: []const [2]f64, // [lo,hi] screen px from period start + symbols: []const Symbol, + color_token: []const u8, + width_px: f64, +}; + +// Set once by the baker before tile generation; read-only during the parallel bake +// (encodeTile only reads), so it needs no lock. Absent => named lines fall +// back to the generic dashed stroke (live/host path, no regression). +var g_linestyles: std.StringHashMapUnmanaged(Info) = .{}; + +/// Register one analysed complex linestyle (id = LineStyles file stem). `id` and the +/// Info slices must outlive the bake (embedded XML / the bake's long-lived alloc). +pub fn registerLinestyle(gpa: Allocator, id: []const u8, info: Info) void { + g_linestyles.put(gpa, id, info) catch {}; +} + +/// Look up a registered complex linestyle by id (LineStyles file stem); null if +/// unregistered, so the caller falls back to a generic dashed stroke. +pub fn lookup(id: []const u8) ?Info { + return g_linestyles.get(id); +} + + +/// Populate the complex-linestyle table from S-101 LineStyles XML sources +/// (id = file stem). IDEMPOTENT — a populated table is left untouched, so +/// every scene entry point (bake, lib renderView, CLI render) can call it +/// unconditionally; forgetting it silently degrades named linestyles +/// (MARSYS51, cables, pipelines, …) to generic dashed strokes. Mirrors the +/// Go lsInfoFromCatalog: mm geometry at the PresLib feature scale, S-52 +/// minimum pen width. `gpa` + `srcs` must outlive all tile generation. +pub fn registerLinestylesXml(gpa: Allocator, srcs: []const style.LineStyleSrc) void { + if (g_linestyles.count() > 0) return; + const px = LINESTYLE_PX_PER_MM; + for (srcs) |s| { + const parsed = style.parseLineStyle(gpa, s.xml) catch continue; + const period = parsed.interval_length * px; + if (period < 0.5) continue; // no interval to tile (pure-symbol style) + var runs = std.ArrayList([2]f64).empty; + for (parsed.dashes) |d| { + const lo = d.start * px; + const hi = (d.start + d.length) * px; + if (hi - lo > 1e-6) runs.append(gpa, .{ lo, hi }) catch {}; + } + var syms = std.ArrayList(Symbol).empty; + for (parsed.symbols) |sym| syms.append(gpa, .{ .name = sym.reference, .offset_px = sym.position * px }) catch {}; + var width = parsed.pen_width * px; + if (width < 0.6) width = 0.9; // S-52 minimum pen + registerLinestyle(gpa, s.id, .{ + .period_px = period, + .on_runs = runs.items, + .symbols = syms.items, + .color_token = parsed.pen_color, + .width_px = width, + }); + } +} + +const LsTangent = struct { p: tile.FPoint, dx: f64, dy: f64 }; + +/// Point at local arc `d` along rp plus the (un-normalised) tangent of its segment. +fn lsPointAndTangent(rp: []const tile.FPoint, rarc: []const f64, d_in: f64) ?LsTangent { + const total = rarc[rarc.len - 1]; + const d = std.math.clamp(d_in, 0, total); + var i: usize = 0; + while (i + 1 < rp.len) : (i += 1) { + if (d <= rarc[i + 1] or i + 2 == rp.len) { + const seg = rarc[i + 1] - rarc[i]; + const t: f64 = if (seg > 1e-12) (d - rarc[i]) / seg else 0; + return .{ + .p = .{ .x = rp[i].x + t * (rp[i + 1].x - rp[i].x), .y = rp[i].y + t * (rp[i + 1].y - rp[i].y) }, + .dx = rp[i + 1].x - rp[i].x, + .dy = rp[i + 1].y - rp[i].y, + }; + } + } + return null; +} + +fn lsLerpArc(rp: []const tile.FPoint, rarc: []const f64, d: f64) tile.FPoint { + return (lsPointAndTangent(rp, rarc, d) orelse LsTangent{ .p = rp[0], .dx = 0, .dy = 0 }).p; +} + +/// Sub-polyline of rp between local arc distances d0..d1 (endpoints interpolated). +fn lsSubPathByArc(a: Allocator, rp: []const tile.FPoint, rarc: []const f64, d0_in: f64, d1_in: f64) ![]tile.FPoint { + const total = rarc[rarc.len - 1]; + const d0 = std.math.clamp(d0_in, 0, total); + const d1 = std.math.clamp(d1_in, 0, total); + if (d1 - d0 < 1e-9) return &.{}; + var out = std.ArrayList(tile.FPoint).empty; + try out.append(a, lsLerpArc(rp, rarc, d0)); + for (rp, 0..) |p, i| { + if (rarc[i] > d0 and rarc[i] < d1) try out.append(a, p); + } + try out.append(a, lsLerpArc(rp, rarc, d1)); + return out.items; +} + +/// Tessellate a complex linestyle along a feature's geometry parts into this tile. +/// `emit_symbols` is false when best-band suppression drops the coarse cell's points. +/// Stage B: walk ONE clipped tile-local run by the complex-linestyle period, +/// emitting dash "on" segments and tangent-rotated embedded symbols. Runs are +/// already tile-local (no z/x/y needed). At RENDER the period AND the px offsets +/// are multiplied by `size_scale`, so spacing and brick size both scale with the +/// display (vector.zig scales the brick to match) and the BAKED tiles stay +/// display-independent. `size_scale <= 0` (bake / CLI) collapses to 1.0. +pub fn drawComplexRun(a: Allocator, rp: []const tile.FPoint, arc0: f64, info: Info, color: []const u8, size_scale: f64, emit_symbols: bool, surf: rs.Surface) !void { + if (rp.len < 2) return; + const ext: f64 = @floatFromInt(tile.EXTENT); + const px_scale = ext / 256.0; // figures are laid out in 256-px-per-tile space + const ss = if (size_scale > 0) size_scale else 1.0; + const period = info.period_px * px_scale * ss; + if (period < 1e-6) return; + const rarc = try a.alloc(f64, rp.len); + rarc[0] = 0; + for (1..rp.len) |i| rarc[i] = rarc[i - 1] + std.math.hypot(rp[i].x - rp[i - 1].x, rp[i].y - rp[i - 1].y); + const g0 = arc0; + const run_end = g0 + rarc[rp.len - 1]; + var k: i64 = @intFromFloat(@floor(g0 / period)); + while (@as(f64, @floatFromInt(k)) * period < run_end) : (k += 1) { + const base = @as(f64, @floatFromInt(k)) * period; + for (info.on_runs) |on| { // dash on-runs -> line segments + const lo = @max(base + on[0] * px_scale * ss, g0); + const hi = @min(base + on[1] * px_scale * ss, run_end); + if (hi - lo < 1e-6) continue; + const sub = try lsSubPathByArc(a, rp, rarc, lo - g0, hi - g0); + if (sub.len < 2) continue; + const seg = try a.alloc(mvt.Point, sub.len); + for (sub, 0..) |spt, i| seg[i] = tile.quantizeF(spt); + const segparts = try a.alloc([]const mvt.Point, 1); + segparts[0] = seg; + try surf.strokeLine(color, info.width_px, .solid, segparts, null); + } + if (!emit_symbols) continue; + for (info.symbols) |sym| { // embedded symbols -> tangent-rotated points + if (sym.name.len == 0) continue; + const gp = base + sym.offset_px * px_scale * ss; + if (gp < g0 or gp > run_end) continue; + const tp = lsPointAndTangent(rp, rarc, gp - g0) orelse continue; + const qp = tile.quantizeF(tp.p); + // Own each embedded symbol by exactly ONE tile: emit it only when its + // position lands inside the RAW tile [0,EXTENT). The dash-run lines + // keep the buffered clip (seamless strokes across the seam), but a + // symbol in the buffer zone would otherwise be tessellated by BOTH + // this tile and its neighbour -> the same symbol drawn twice at every + // tile seam (user-reported "double symbols"). Half-open so a symbol on + // the seam belongs to exactly one side (no gap, no double). + if (qp.x < 0 or qp.x >= tile.EXTENT or qp.y < 0 or qp.y >= tile.EXTENT) continue; + const rot = std.math.atan2(tp.dy, tp.dx) * 180.0 / std.math.pi; + try surf.drawSymbol(sym.name, qp, rot, SYMBOL_SCALE, true, .line, null); + } + } +} + +/// Tessellate a complex linestyle along a feature's geometry parts into this tile. +/// `emit_symbols` is false when best-band suppression drops the coarse cell's points. +/// `style` is the linestyle id: at BAKE we store each clipped run un-tessellated +/// (tagged with the id) so replay can re-walk the period display-scaled; on a live +/// render surface we walk it here at that surface's size_scale. +pub fn drawComplexLine(a: Allocator, parts: []const []s57.LonLat, info: Info, line_style: []const u8, color: []const u8, emit_symbols: bool, z: u8, x: u32, y: u32, box: tile.Box, fmeta: *const rs.FeatureMeta, surf: rs.Surface) !void { + const store = surf.canStoreComplexRun(); + const ss = surf.sizeScale(); + try surf.beginFeature(fmeta); + for (parts) |part| { + if (part.len < 2) continue; + const fpts = try a.alloc(tile.FPoint, part.len); + for (part, 0..) |pt, i| fpts[i] = tile.worldToTileF(tile.lonLatToWorld(pt.lon(), pt.lat()), z, x, y, tile.EXTENT); + const arc = try a.alloc(f64, part.len); + arc[0] = 0; + for (1..part.len) |i| arc[i] = arc[i - 1] + std.math.hypot(fpts[i].x - fpts[i - 1].x, fpts[i].y - fpts[i - 1].y); + for (try tile.clipLinePhased(a, fpts, arc, box)) |run| { + if (run.points.len < 2) continue; + if (store) { + // BAKE: store the clipped run un-tessellated (display-independent). + const qpts = try a.alloc(mvt.Point, run.points.len); + for (run.points, 0..) |p, i| qpts[i] = tile.quantizeF(p); + try surf.storeComplexRun(line_style, color, info.width_px, run.arc0, qpts); + } else { + // LIVE / CLI render: walk the period at this surface's display scale. + try drawComplexRun(a, run.points, run.arc0, info, color, ss, emit_symbols, surf); + } + } + } + try surf.endFeature(); +} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index c79c3f9..94c50b3 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -35,6 +35,10 @@ pub const collectLightReach = lightreach.collectLightReach; pub const lightReachTiles = lightreach.lightReachTiles; pub const LIGHT_AUG_REACH_TILES = lightreach.LIGHT_AUG_REACH_TILES; +// Complex-linestyle tessellation + registry (scene/linestyle.zig). Exposed so +// chart + bundle register and look up styles through `scene.linestyle`. +pub const linestyle = @import("linestyle.zig"); + /// Output tile encoding: classic Mapbox Vector Tile, or MapLibre Tile. pub const TileFormat = enum { mvt, mlt }; const instructions = @import("s101").instructions; @@ -560,7 +564,7 @@ pub const TileSurface = struct { /// Store one clipped complex-linestyle run un-tessellated (display-independent /// bake): a `lines` feature tagged with `ls_style`/`ls_arc0` so replayTile - /// re-looks-up the LsInfo and re-walks the period display-scaled at render time. + /// re-looks-up the linestyle.Info and re-walks the period display-scaled at render time. fn storeComplexRun(ctx: *anyopaque, line_style: []const u8, color: rs.ColorToken, width_px: f64, arc0: f64, run: []const rs.TilePoint) anyerror!void { const s = sp(ctx); var props = std.ArrayList(mvt.Prop).empty; @@ -1567,7 +1571,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, var want_geo = false; var want_proj = false; if (!opts.suppress_lines) for (p.lines) |ln| { - if (!std.mem.eql(u8, ln.style, "solid") and g_linestyles.get(ln.style) != null) want_geo = true else want_proj = true; + if (!std.mem.eql(u8, ln.style, "solid") and linestyle.lookup(ln.style) != null) want_geo = true else want_proj = true; }; if (want_geo) stroke_geo = clipGeoPartsOutsideCover(a, stroke_geo, cc, z, x, y); if (want_proj) stroke_proj = clipRunsOutsideCover(a, stroke_proj, cc); @@ -1577,9 +1581,9 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, if (!opts.suppress_lines) for (p.lines) |ln| { if (!std.mem.eql(u8, ln.style, "solid")) { - if (g_linestyles.get(ln.style)) |info| { + if (linestyle.lookup(ln.style)) |info| { // Complex linestyle: tessellated dash runs + tangent-rotated symbols. - try emitComplexLine(a, stroke_geo, info, ln.style, ln.color, !opts.suppress_points, z, x, y, box, &fmeta, surf); + try linestyle.drawComplexLine(a, stroke_geo, info, ln.style, ln.color, !opts.suppress_points, z, x, y, box, &fmeta, surf); continue; } } @@ -1656,210 +1660,6 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, } } -// === Complex (symbolised) line tessellation (S-101 LineStyles) ============= -// A named linestyle (LC / a LineInstruction whose style is not "_simple_") is -// tessellated per zoom: walk the line by arc length and emit, per period, the dash -// "on" runs as line segments + each embedded symbol as a point rotated to the local -// tangent. Mirrors Go bake/complexline.go + linestyle_catalog.go. The mm geometry is -// parsed by style.parseLineStyle; the baker converts it to LsInfo at the PresLib -// FEATURE scale (ls_px_per_mm) and registers it before baking. - -const ls_feature_scale: f64 = 0.01 / 0.35278; // px per 0.01-mm PresLib unit (= SYMBOL_SCALE) -const ls_px_per_mm: f64 = 100.0 * ls_feature_scale; // mm -> screen px -/// The mm->px feature scale the baker must apply when building an LsInfo from the raw -/// millimetre LineStyles geometry (style.parseLineStyle), so the tessellator and the -/// table agree. Differs from the symbol-scale style.analysePattern uses for the -/// client linestyles.json. -pub const LINESTYLE_PX_PER_MM = ls_px_per_mm; - -pub const LsSym = struct { name: []const u8, offset_px: f64 }; -pub const LsInfo = struct { - period_px: f64, - on_runs: []const [2]f64, // [lo,hi] screen px from period start - symbols: []const LsSym, - color_token: []const u8, - width_px: f64, -}; - -// Set once by the baker before tile generation; read-only during the parallel bake -// (encodeTile only reads), so it needs no lock. Absent => named lines fall -// back to the generic dashed stroke (live/host path, no regression). -var g_linestyles: std.StringHashMapUnmanaged(LsInfo) = .{}; - -/// Register one analysed complex linestyle (id = LineStyles file stem). `id` and the -/// LsInfo slices must outlive the bake (embedded XML / the bake's long-lived alloc). -pub fn registerLinestyle(gpa: Allocator, id: []const u8, info: LsInfo) void { - g_linestyles.put(gpa, id, info) catch {}; -} - -/// Drop all registered linestyles (host/test reset). -pub fn clearLinestyles(gpa: Allocator) void { - g_linestyles.deinit(gpa); - g_linestyles = .{}; -} - -/// Populate the complex-linestyle table from S-101 LineStyles XML sources -/// (id = file stem). IDEMPOTENT — a populated table is left untouched, so -/// every scene entry point (bake, lib renderView, CLI render) can call it -/// unconditionally; forgetting it silently degrades named linestyles -/// (MARSYS51, cables, pipelines, …) to generic dashed strokes. Mirrors the -/// Go lsInfoFromCatalog: mm geometry at the PresLib feature scale, S-52 -/// minimum pen width. `gpa` + `srcs` must outlive all tile generation. -pub fn registerLinestylesXml(gpa: Allocator, srcs: []const style.LineStyleSrc) void { - if (g_linestyles.count() > 0) return; - const px = LINESTYLE_PX_PER_MM; - for (srcs) |s| { - const parsed = style.parseLineStyle(gpa, s.xml) catch continue; - const period = parsed.interval_length * px; - if (period < 0.5) continue; // no interval to tile (pure-symbol style) - var runs = std.ArrayList([2]f64).empty; - for (parsed.dashes) |d| { - const lo = d.start * px; - const hi = (d.start + d.length) * px; - if (hi - lo > 1e-6) runs.append(gpa, .{ lo, hi }) catch {}; - } - var syms = std.ArrayList(LsSym).empty; - for (parsed.symbols) |sym| syms.append(gpa, .{ .name = sym.reference, .offset_px = sym.position * px }) catch {}; - var width = parsed.pen_width * px; - if (width < 0.6) width = 0.9; // S-52 minimum pen - registerLinestyle(gpa, s.id, .{ - .period_px = period, - .on_runs = runs.items, - .symbols = syms.items, - .color_token = parsed.pen_color, - .width_px = width, - }); - } -} - -const LsTangent = struct { p: tile.FPoint, dx: f64, dy: f64 }; - -/// Point at local arc `d` along rp plus the (un-normalised) tangent of its segment. -fn lsPointAndTangent(rp: []const tile.FPoint, rarc: []const f64, d_in: f64) ?LsTangent { - const total = rarc[rarc.len - 1]; - const d = std.math.clamp(d_in, 0, total); - var i: usize = 0; - while (i + 1 < rp.len) : (i += 1) { - if (d <= rarc[i + 1] or i + 2 == rp.len) { - const seg = rarc[i + 1] - rarc[i]; - const t: f64 = if (seg > 1e-12) (d - rarc[i]) / seg else 0; - return .{ - .p = .{ .x = rp[i].x + t * (rp[i + 1].x - rp[i].x), .y = rp[i].y + t * (rp[i + 1].y - rp[i].y) }, - .dx = rp[i + 1].x - rp[i].x, - .dy = rp[i + 1].y - rp[i].y, - }; - } - } - return null; -} - -fn lsLerpArc(rp: []const tile.FPoint, rarc: []const f64, d: f64) tile.FPoint { - return (lsPointAndTangent(rp, rarc, d) orelse LsTangent{ .p = rp[0], .dx = 0, .dy = 0 }).p; -} - -/// Sub-polyline of rp between local arc distances d0..d1 (endpoints interpolated). -fn lsSubPathByArc(a: Allocator, rp: []const tile.FPoint, rarc: []const f64, d0_in: f64, d1_in: f64) ![]tile.FPoint { - const total = rarc[rarc.len - 1]; - const d0 = std.math.clamp(d0_in, 0, total); - const d1 = std.math.clamp(d1_in, 0, total); - if (d1 - d0 < 1e-9) return &.{}; - var out = std.ArrayList(tile.FPoint).empty; - try out.append(a, lsLerpArc(rp, rarc, d0)); - for (rp, 0..) |p, i| { - if (rarc[i] > d0 and rarc[i] < d1) try out.append(a, p); - } - try out.append(a, lsLerpArc(rp, rarc, d1)); - return out.items; -} - -/// Tessellate a complex linestyle along a feature's geometry parts into this tile. -/// `emit_symbols` is false when best-band suppression drops the coarse cell's points. -/// Stage B: walk ONE clipped tile-local run by the complex-linestyle period, -/// emitting dash "on" segments and tangent-rotated embedded symbols. Runs are -/// already tile-local (no z/x/y needed). At RENDER the period AND the px offsets -/// are multiplied by `size_scale`, so spacing and brick size both scale with the -/// display (vector.zig scales the brick to match) and the BAKED tiles stay -/// display-independent. `size_scale <= 0` (bake / CLI) collapses to 1.0. -fn walkComplexRun(a: Allocator, rp: []const tile.FPoint, arc0: f64, info: LsInfo, color: []const u8, size_scale: f64, emit_symbols: bool, surf: rs.Surface) !void { - if (rp.len < 2) return; - const ext: f64 = @floatFromInt(tile.EXTENT); - const px_scale = ext / 256.0; // figures are laid out in 256-px-per-tile space - const ss = if (size_scale > 0) size_scale else 1.0; - const period = info.period_px * px_scale * ss; - if (period < 1e-6) return; - const rarc = try a.alloc(f64, rp.len); - rarc[0] = 0; - for (1..rp.len) |i| rarc[i] = rarc[i - 1] + std.math.hypot(rp[i].x - rp[i - 1].x, rp[i].y - rp[i - 1].y); - const g0 = arc0; - const run_end = g0 + rarc[rp.len - 1]; - var k: i64 = @intFromFloat(@floor(g0 / period)); - while (@as(f64, @floatFromInt(k)) * period < run_end) : (k += 1) { - const base = @as(f64, @floatFromInt(k)) * period; - for (info.on_runs) |on| { // dash on-runs -> line segments - const lo = @max(base + on[0] * px_scale * ss, g0); - const hi = @min(base + on[1] * px_scale * ss, run_end); - if (hi - lo < 1e-6) continue; - const sub = try lsSubPathByArc(a, rp, rarc, lo - g0, hi - g0); - if (sub.len < 2) continue; - const seg = try a.alloc(mvt.Point, sub.len); - for (sub, 0..) |spt, i| seg[i] = tile.quantizeF(spt); - const segparts = try a.alloc([]const mvt.Point, 1); - segparts[0] = seg; - try surf.strokeLine(color, info.width_px, .solid, segparts, null); - } - if (!emit_symbols) continue; - for (info.symbols) |sym| { // embedded symbols -> tangent-rotated points - if (sym.name.len == 0) continue; - const gp = base + sym.offset_px * px_scale * ss; - if (gp < g0 or gp > run_end) continue; - const tp = lsPointAndTangent(rp, rarc, gp - g0) orelse continue; - const qp = tile.quantizeF(tp.p); - // Own each embedded symbol by exactly ONE tile: emit it only when its - // position lands inside the RAW tile [0,EXTENT). The dash-run lines - // keep the buffered clip (seamless strokes across the seam), but a - // symbol in the buffer zone would otherwise be tessellated by BOTH - // this tile and its neighbour -> the same symbol drawn twice at every - // tile seam (user-reported "double symbols"). Half-open so a symbol on - // the seam belongs to exactly one side (no gap, no double). - if (qp.x < 0 or qp.x >= tile.EXTENT or qp.y < 0 or qp.y >= tile.EXTENT) continue; - const rot = std.math.atan2(tp.dy, tp.dx) * 180.0 / std.math.pi; - try surf.drawSymbol(sym.name, qp, rot, SYMBOL_SCALE, true, .line, null); - } - } -} - -/// Tessellate a complex linestyle along a feature's geometry parts into this tile. -/// `emit_symbols` is false when best-band suppression drops the coarse cell's points. -/// `style` is the linestyle id: at BAKE we store each clipped run un-tessellated -/// (tagged with the id) so replay can re-walk the period display-scaled; on a live -/// render surface we walk it here at that surface's size_scale. -fn emitComplexLine(a: Allocator, parts: []const []s57.LonLat, info: LsInfo, line_style: []const u8, color: []const u8, emit_symbols: bool, z: u8, x: u32, y: u32, box: tile.Box, fmeta: *const rs.FeatureMeta, surf: rs.Surface) !void { - const store = surf.canStoreComplexRun(); - const ss = surf.sizeScale(); - try surf.beginFeature(fmeta); - for (parts) |part| { - if (part.len < 2) continue; - const fpts = try a.alloc(tile.FPoint, part.len); - for (part, 0..) |pt, i| fpts[i] = tile.worldToTileF(tile.lonLatToWorld(pt.lon(), pt.lat()), z, x, y, tile.EXTENT); - const arc = try a.alloc(f64, part.len); - arc[0] = 0; - for (1..part.len) |i| arc[i] = arc[i - 1] + std.math.hypot(fpts[i].x - fpts[i - 1].x, fpts[i].y - fpts[i - 1].y); - for (try tile.clipLinePhased(a, fpts, arc, box)) |run| { - if (run.points.len < 2) continue; - if (store) { - // BAKE: store the clipped run un-tessellated (display-independent). - const qpts = try a.alloc(mvt.Point, run.points.len); - for (run.points, 0..) |p, i| qpts[i] = tile.quantizeF(p); - try surf.storeComplexRun(line_style, color, info.width_px, run.arc0, qpts); - } else { - // LIVE / CLI render: walk the period at this surface's display scale. - try walkComplexRun(a, run.points, run.arc0, info, color, ss, emit_symbols, surf); - } - } - } - try surf.endFeature(); -} - /// Native S-52 fallback for SweptArea (SWPARE, objl 134). The S-101 Portrayal /// Catalogue ships no SweptArea rule (an IHO gap), so the Lua engine emits /// nothing for it. Mirror the Go reference's sweptAreaBuild: a dashed CHGRD @@ -1942,8 +1742,8 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize const nav_parts = if (opts.cover_clip) |cc| clipGeoPartsOutsideCover(a, geo_parts, cc, z, x, y) else geo_parts; // Tessellate the registered complex linestyle (dashes + the A/B letter symbols). - if (g_linestyles.get(boundary)) |info| { - try emitComplexLine(a, nav_parts, info, boundary, "CHGRD", !opts.suppress_points, z, x, y, box, &fmeta, surf); + if (linestyle.lookup(boundary)) |info| { + try linestyle.drawComplexLine(a, nav_parts, info, boundary, "CHGRD", !opts.suppress_points, z, x, y, box, &fmeta, surf); return; } // No registered linestyle (live/host path, no table): a plain dashed CHGRD ring. @@ -2906,6 +2706,7 @@ test { _ = bake_enc; _ = symins; _ = lightreach; + _ = linestyle; } // ---- bundle-sourced replay (baked tile -> Surface calls) -------------------- @@ -2996,14 +2797,14 @@ pub fn replayTile(a: Allocator, surf: rs.Surface, layers: []const mvt.DecodedLay // surface's display scale so spacing + brick size track the display. const ls_style = propStr(f.properties, "ls_style"); if (ls_style.len > 0) { - if (g_linestyles.get(ls_style)) |info| { + if (linestyle.lookup(ls_style)) |info| { const color = propStr(f.properties, "color_token"); const arc0 = propF64(f.properties, "ls_arc0") orelse 0; for (f.parts) |part| { if (part.len < 2) continue; const fpts = try a.alloc(tile.FPoint, part.len); for (part, 0..) |p, i| fpts[i] = .{ .x = @floatFromInt(p.x), .y = @floatFromInt(p.y) }; - try walkComplexRun(a, fpts, arc0, info, color, surf.sizeScale(), true, surf); + try linestyle.drawComplexRun(a, fpts, arc0, info, color, surf.sizeScale(), true, surf); } continue; } diff --git a/tools/render.zig b/tools/render.zig index 9bf5106..428a755 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -184,7 +184,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: // Complex-linestyle table (idempotent; arena-backed — this run only). const ls_srcs = try a.alloc(style.LineStyleSrc, catalog_embed.linestyles.len); for (catalog_embed.linestyles, 0..) |e, li| ls_srcs[li] = .{ .id = e.name, .xml = e.bytes }; - engine.scene.registerLinestylesXml(a, ls_srcs); + engine.scene.linestyle.registerLinestylesXml(a, ls_srcs); const bytes = if (from_bundle) blk: { // Bundle-sourced replay: decode each covering baked tile and re-emit From 2574b4c301e8b187678cb3be10512026803c12b4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 20:32:36 -0400 Subject: [PATCH 088/140] refactor(scene): extract baked-tile replay into scene/replay.zig Move the bundle-sourced replay (decoded tile -> Surface draw calls) and its private prop-readers out of scene.zig into scene/replay.zig, pure over tiles + render + linestyle. scene re-exports replayTile so chart + the render CLI reach it unchanged. scene 2863 -> 2710 lines; tests green; the 10-cell subset re-bakes byte-identical and a baked archive still replays to content. Co-Authored-By: Claude Fable 5 --- src/scene/replay.zig | 165 +++++++++++++++++++++++++++++++++++++++++++ src/scene/scene.zig | 158 ++--------------------------------------- 2 files changed, 170 insertions(+), 153 deletions(-) create mode 100644 src/scene/replay.zig diff --git a/src/scene/replay.zig b/src/scene/replay.zig new file mode 100644 index 0000000..a8393a0 --- /dev/null +++ b/src/scene/replay.zig @@ -0,0 +1,165 @@ +//! Bundle-sourced replay: turn a decoded baked tile (mvt layers) back into +//! Surface draw calls, so the native pixel/PDF/ASCII path renders a pre-baked +//! archive the same way it renders a live cell. Reads the tile properties the +//! scene emitter wrote; re-walks complex linestyles at the surface's display +//! scale through the linestyle module. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const mvt = @import("tiles").mvt; +const tile = @import("tiles").tile; +const rs = @import("render").surface; +const linestyle = @import("linestyle.zig"); +const SYMBOL_SCALE: f64 = @import("render").sndfrm.SYMBOL_SCALE; + +// +// The tile schema is a serialized Surface-call stream, so a baked tile can be +// replayed onto any Surface — the substrate for rendering PMTiles bundles to +// pixels with no source cells. LOSSY by design: the bake-time portrayal +// context is frozen (SafetyContour/Depth 30); the live-swappable props the +// tile path bakes (danger_depth/sym_deep, sym_s/sym_g depth + quality-ring +// prefixes) are re-expanded here, so the mariner's danger swap, sounding +// bold/faint split, and display unit still evaluate LIVE. + +fn propOf(props: []const mvt.Prop, key: []const u8) ?mvt.Value { + for (props) |p| if (std.mem.eql(u8, p.key, key)) return p.value; + return null; +} + +fn propInt(props: []const mvt.Prop, key: []const u8, default: i64) i64 { + const v = propOf(props, key) orelse return default; + return switch (v) { + .int => |i| i, + .uint => |u| @intCast(u), + .double => |d| @intFromFloat(d), + .float => |f| @intFromFloat(f), + else => default, + }; +} + +fn propF64(props: []const mvt.Prop, key: []const u8) ?f64 { + const v = propOf(props, key) orelse return null; + return switch (v) { + .double => |d| d, + .float => |f| f, + .int => |i| @floatFromInt(i), + .uint => |u| @floatFromInt(u), + else => null, + }; +} + +fn propStr(props: []const mvt.Prop, key: []const u8) []const u8 { + const v = propOf(props, key) orelse return ""; + return switch (v) { + .string => |s| s, + else => "", + }; +} + +fn metaFromProps(props: []const mvt.Prop) rs.FeatureMeta { + return .{ + .draw_prio = propInt(props, "draw_prio", 0), + .cat = propInt(props, "cat", 1), + .vg = propInt(props, "vg", 0), + .scamin = if (propOf(props, "scamin")) |_| propInt(props, "scamin", 0) else null, + .class = propStr(props, "class"), + .s57_json = propStr(props, "s57"), // cursor-pick attribute blob (baked) + .cell_name = propStr(props, "cell"), // source cell badge (baked) + .band = @intCast(std.math.clamp(propInt(props, "band", 0), 0, 255)), + .bnd = propInt(props, "bnd", 2), + .pts = propInt(props, "pts", 2), + .date_start = propStr(props, "date_start"), + .date_end = propStr(props, "date_end"), + }; +} + +/// Replay one decoded tile's layers as Surface calls (between the caller's +/// begin/endScene). Layer names route exactly as TileSurface emitted them. +pub fn replayTile(a: Allocator, surf: rs.Surface, layers: []const mvt.DecodedLayer) !void { + for (layers) |layer| { + const is_areas = std.mem.startsWith(u8, layer.name, "areas"); + const is_patterns = std.mem.startsWith(u8, layer.name, "area_patterns"); + const is_lines = std.mem.startsWith(u8, layer.name, "lines"); + const is_points = std.mem.startsWith(u8, layer.name, "point_symbols"); + const is_soundings = std.mem.eql(u8, layer.name, "soundings"); + const is_text = std.mem.startsWith(u8, layer.name, "text"); + for (layer.features) |f| { + const meta = metaFromProps(f.properties); + try surf.beginFeature(&meta); + defer surf.endFeature() catch {}; + if (is_patterns) { + try surf.fillPattern(propStr(f.properties, "pattern_name"), f.parts); + } else if (is_areas) { + const d1 = propF64(f.properties, "drval1"); + const dr: ?rs.DepthRange = if (d1) |v| .{ .d1 = @floatCast(v), .d2 = @floatCast(propF64(f.properties, "drval2") orelse v) } else null; + try surf.fillArea(propStr(f.properties, "color_token"), f.parts, dr); + } else if (is_lines) { + // Complex (symbolised) linestyle: the bake stored the clipped run + // un-tessellated (tagged ls_style). Re-walk the period at the render + // surface's display scale so spacing + brick size track the display. + const ls_style = propStr(f.properties, "ls_style"); + if (ls_style.len > 0) { + if (linestyle.lookup(ls_style)) |info| { + const color = propStr(f.properties, "color_token"); + const arc0 = propF64(f.properties, "ls_arc0") orelse 0; + for (f.parts) |part| { + if (part.len < 2) continue; + const fpts = try a.alloc(tile.FPoint, part.len); + for (part, 0..) |p, i| fpts[i] = .{ .x = @floatFromInt(p.x), .y = @floatFromInt(p.y) }; + try linestyle.drawComplexRun(a, fpts, arc0, info, color, surf.sizeScale(), true, surf); + } + continue; + } + // Style not registered (host without the table): fall back to a + // plain dashed stroke so the line never disappears. + try surf.strokeLine(propStr(f.properties, "color_token"), propF64(f.properties, "width_px") orelse 1, .dashed, f.parts, null); + continue; + } + const dash: rs.Dash = if (std.mem.eql(u8, propStr(f.properties, "dash"), "solid")) .solid else .dashed; + try surf.strokeLine(propStr(f.properties, "color_token"), propF64(f.properties, "width_px") orelse 1, dash, f.parts, propF64(f.properties, "valdco")); + } else if (is_points) { + if (f.parts.len == 0 or f.parts[0].len == 0) continue; + try surf.drawSymbol( + propStr(f.properties, "symbol_name"), + f.parts[0][0], + propF64(f.properties, "rotation_deg") orelse 0, + propF64(f.properties, "scale") orelse SYMBOL_SCALE, + propInt(f.properties, "rot_north", 0) != 0, + .point, + propF64(f.properties, "danger_depth"), // live swap re-evaluates + ); + } else if (is_soundings) { + if (f.parts.len == 0 or f.parts[0].len == 0) continue; + const depth = propF64(f.properties, "depth") orelse continue; + // The quality-ring flags are encoded in the baked glyph list's + // leading tokens (SNDFRM04 B1 swept / C2/C3 low-accuracy) — + // recover them so the live recomposition keeps the rings. + const sym_s = propStr(f.properties, "sym_s"); + const swept = std.mem.indexOf(u8, sym_s, "SB1") != null; + const low_acc = std.mem.indexOf(u8, sym_s, "SC3") != null; + try surf.drawSounding(depth, swept, low_acc, f.parts[0][0]); + } else if (is_text) { + if (f.parts.len == 0 or f.parts[0].len == 0) continue; + var ox: f64 = 0; + var oy: f64 = 0; + const loff = propStr(f.properties, "loff"); + if (loff.len > 0) { + var it = std.mem.splitScalar(u8, loff, ','); + const TEXT_BODY_MM = 3.51; + ox = (std.fmt.parseFloat(f64, it.next() orelse "0") catch 0) * TEXT_BODY_MM; + oy = (std.fmt.parseFloat(f64, it.next() orelse "0") catch 0) * TEXT_BODY_MM; + } + const ts = rs.TextStyle{ + .color = propStr(f.properties, "color_token"), + .font_size = propF64(f.properties, "font_size_px") orelse 12, + .halign = propStr(f.properties, "halign"), + .valign = propStr(f.properties, "valign"), + .offset_x = ox, + .offset_y = oy, + .group = propInt(f.properties, "tgrp", 0), + }; + try surf.drawText(propStr(f.properties, "text"), &ts, f.parts[0][0]); + } + } + } +} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 94c50b3..20d57c5 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -39,6 +39,10 @@ pub const LIGHT_AUG_REACH_TILES = lightreach.LIGHT_AUG_REACH_TILES; // chart + bundle register and look up styles through `scene.linestyle`. pub const linestyle = @import("linestyle.zig"); +// Baked-tile replay (scene/replay.zig): a decoded tile -> Surface draw calls. +const replay = @import("replay.zig"); +pub const replayTile = replay.replayTile; + /// Output tile encoding: classic Mapbox Vector Tile, or MapLibre Tile. pub const TileFormat = enum { mvt, mlt }; const instructions = @import("s101").instructions; @@ -2707,157 +2711,5 @@ test { _ = symins; _ = lightreach; _ = linestyle; -} - -// ---- bundle-sourced replay (baked tile -> Surface calls) -------------------- -// -// The tile schema is a serialized Surface-call stream, so a baked tile can be -// replayed onto any Surface — the substrate for rendering PMTiles bundles to -// pixels with no source cells. LOSSY by design: the bake-time portrayal -// context is frozen (SafetyContour/Depth 30); the live-swappable props the -// tile path bakes (danger_depth/sym_deep, sym_s/sym_g depth + quality-ring -// prefixes) are re-expanded here, so the mariner's danger swap, sounding -// bold/faint split, and display unit still evaluate LIVE. - -fn propOf(props: []const mvt.Prop, key: []const u8) ?mvt.Value { - for (props) |p| if (std.mem.eql(u8, p.key, key)) return p.value; - return null; -} - -fn propInt(props: []const mvt.Prop, key: []const u8, default: i64) i64 { - const v = propOf(props, key) orelse return default; - return switch (v) { - .int => |i| i, - .uint => |u| @intCast(u), - .double => |d| @intFromFloat(d), - .float => |f| @intFromFloat(f), - else => default, - }; -} - -fn propF64(props: []const mvt.Prop, key: []const u8) ?f64 { - const v = propOf(props, key) orelse return null; - return switch (v) { - .double => |d| d, - .float => |f| f, - .int => |i| @floatFromInt(i), - .uint => |u| @floatFromInt(u), - else => null, - }; -} - -fn propStr(props: []const mvt.Prop, key: []const u8) []const u8 { - const v = propOf(props, key) orelse return ""; - return switch (v) { - .string => |s| s, - else => "", - }; -} - -fn metaFromProps(props: []const mvt.Prop) rs.FeatureMeta { - return .{ - .draw_prio = propInt(props, "draw_prio", 0), - .cat = propInt(props, "cat", 1), - .vg = propInt(props, "vg", 0), - .scamin = if (propOf(props, "scamin")) |_| propInt(props, "scamin", 0) else null, - .class = propStr(props, "class"), - .s57_json = propStr(props, "s57"), // cursor-pick attribute blob (baked) - .cell_name = propStr(props, "cell"), // source cell badge (baked) - .band = @intCast(std.math.clamp(propInt(props, "band", 0), 0, 255)), - .bnd = propInt(props, "bnd", 2), - .pts = propInt(props, "pts", 2), - .date_start = propStr(props, "date_start"), - .date_end = propStr(props, "date_end"), - }; -} - -/// Replay one decoded tile's layers as Surface calls (between the caller's -/// begin/endScene). Layer names route exactly as TileSurface emitted them. -pub fn replayTile(a: Allocator, surf: rs.Surface, layers: []const mvt.DecodedLayer) !void { - for (layers) |layer| { - const is_areas = std.mem.startsWith(u8, layer.name, "areas"); - const is_patterns = std.mem.startsWith(u8, layer.name, "area_patterns"); - const is_lines = std.mem.startsWith(u8, layer.name, "lines"); - const is_points = std.mem.startsWith(u8, layer.name, "point_symbols"); - const is_soundings = std.mem.eql(u8, layer.name, "soundings"); - const is_text = std.mem.startsWith(u8, layer.name, "text"); - for (layer.features) |f| { - const meta = metaFromProps(f.properties); - try surf.beginFeature(&meta); - defer surf.endFeature() catch {}; - if (is_patterns) { - try surf.fillPattern(propStr(f.properties, "pattern_name"), f.parts); - } else if (is_areas) { - const d1 = propF64(f.properties, "drval1"); - const dr: ?rs.DepthRange = if (d1) |v| .{ .d1 = @floatCast(v), .d2 = @floatCast(propF64(f.properties, "drval2") orelse v) } else null; - try surf.fillArea(propStr(f.properties, "color_token"), f.parts, dr); - } else if (is_lines) { - // Complex (symbolised) linestyle: the bake stored the clipped run - // un-tessellated (tagged ls_style). Re-walk the period at the render - // surface's display scale so spacing + brick size track the display. - const ls_style = propStr(f.properties, "ls_style"); - if (ls_style.len > 0) { - if (linestyle.lookup(ls_style)) |info| { - const color = propStr(f.properties, "color_token"); - const arc0 = propF64(f.properties, "ls_arc0") orelse 0; - for (f.parts) |part| { - if (part.len < 2) continue; - const fpts = try a.alloc(tile.FPoint, part.len); - for (part, 0..) |p, i| fpts[i] = .{ .x = @floatFromInt(p.x), .y = @floatFromInt(p.y) }; - try linestyle.drawComplexRun(a, fpts, arc0, info, color, surf.sizeScale(), true, surf); - } - continue; - } - // Style not registered (host without the table): fall back to a - // plain dashed stroke so the line never disappears. - try surf.strokeLine(propStr(f.properties, "color_token"), propF64(f.properties, "width_px") orelse 1, .dashed, f.parts, null); - continue; - } - const dash: rs.Dash = if (std.mem.eql(u8, propStr(f.properties, "dash"), "solid")) .solid else .dashed; - try surf.strokeLine(propStr(f.properties, "color_token"), propF64(f.properties, "width_px") orelse 1, dash, f.parts, propF64(f.properties, "valdco")); - } else if (is_points) { - if (f.parts.len == 0 or f.parts[0].len == 0) continue; - try surf.drawSymbol( - propStr(f.properties, "symbol_name"), - f.parts[0][0], - propF64(f.properties, "rotation_deg") orelse 0, - propF64(f.properties, "scale") orelse SYMBOL_SCALE, - propInt(f.properties, "rot_north", 0) != 0, - .point, - propF64(f.properties, "danger_depth"), // live swap re-evaluates - ); - } else if (is_soundings) { - if (f.parts.len == 0 or f.parts[0].len == 0) continue; - const depth = propF64(f.properties, "depth") orelse continue; - // The quality-ring flags are encoded in the baked glyph list's - // leading tokens (SNDFRM04 B1 swept / C2/C3 low-accuracy) — - // recover them so the live recomposition keeps the rings. - const sym_s = propStr(f.properties, "sym_s"); - const swept = std.mem.indexOf(u8, sym_s, "SB1") != null; - const low_acc = std.mem.indexOf(u8, sym_s, "SC3") != null; - try surf.drawSounding(depth, swept, low_acc, f.parts[0][0]); - } else if (is_text) { - if (f.parts.len == 0 or f.parts[0].len == 0) continue; - var ox: f64 = 0; - var oy: f64 = 0; - const loff = propStr(f.properties, "loff"); - if (loff.len > 0) { - var it = std.mem.splitScalar(u8, loff, ','); - const TEXT_BODY_MM = 3.51; - ox = (std.fmt.parseFloat(f64, it.next() orelse "0") catch 0) * TEXT_BODY_MM; - oy = (std.fmt.parseFloat(f64, it.next() orelse "0") catch 0) * TEXT_BODY_MM; - } - const ts = rs.TextStyle{ - .color = propStr(f.properties, "color_token"), - .font_size = propF64(f.properties, "font_size_px") orelse 12, - .halign = propStr(f.properties, "halign"), - .valign = propStr(f.properties, "valign"), - .offset_x = ox, - .offset_y = oy, - .group = propInt(f.properties, "tgrp", 0), - }; - try surf.drawText(propStr(f.properties, "text"), &ts, f.parts[0][0]); - } - } - } + _ = replay; } From 81c5bb421a7d7c01d7211a00fe937876c865eaa7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 20:44:08 -0400 Subject: [PATCH 089/140] refactor(linestyle): add drawPlainLine to pair with drawComplexLine The plain (solid/dashed) line-feature draw was an inline clip+stroke loop in scene.zig; fold it into linestyle.drawPlainLine so the line-emit path dispatches uniformly: symbolised -> linestyle.drawComplexLine, plain -> linestyle.drawPlainLine. Generalize the shared clip helper into tile.clipSimplifyLine (clipLine + simplifyRing; 5 scene call sites). Verbatim behavior: the 10-cell subset re-bakes byte-identical and the pixel golden test passes. Co-Authored-By: Claude Fable 5 --- src/scene/linestyle.zig | 12 ++++++++++++ src/scene/scene.zig | 27 +++++---------------------- src/tiles/tile.zig | 12 ++++++++++++ 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/scene/linestyle.zig b/src/scene/linestyle.zig index 4763ebd..c5b9879 100644 --- a/src/scene/linestyle.zig +++ b/src/scene/linestyle.zig @@ -217,3 +217,15 @@ pub fn drawComplexLine(a: Allocator, parts: []const []s57.LonLat, info: Info, li } try surf.endFeature(); } + +/// Draw a plain (solid or dashed) line: clip + simplify each projected run for +/// this tile and stroke it. The symbolised-line counterpart is drawComplexLine. +pub fn drawPlainLine(a: Allocator, stroke_proj: []const []const mvt.Point, color: []const u8, width: f64, dash: rs.Dash, box: tile.Box, fmeta: *const rs.FeatureMeta, valdco: ?f64, surf: rs.Surface) !void { + for (stroke_proj) |proj| { + const sub = try tile.clipSimplifyLine(a, proj, box); + if (sub.len == 0) continue; + try surf.beginFeature(fmeta); + try surf.strokeLine(color, width, dash, sub, valdco); + try surf.endFeature(); + } +} diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 20d57c5..7074672 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -330,15 +330,6 @@ fn coveredByFiner(cover_clip: []const []const mvt.Point, lon: f64, lat: f64, z: } // Clip a line + simplify each kept run (drop runs that collapse below 2 vertices). -fn clipSimplifyLine(a: Allocator, proj: []const mvt.Point, box: tile.Box) ![]const []const mvt.Point { - const sub = try tile.clipLine(a, proj, box); - var out = std.ArrayList([]const mvt.Point).empty; - for (sub) |run| { - const s = try tile.simplifyRing(a, run); - if (s.len >= 2) try out.append(a, s); - } - return out.items; -} fn geomBounds(g: []const s57.LonLat) [4]f64 { @@ -1596,15 +1587,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, else "dashed"; const dash: rs.Dash = if (std.mem.eql(u8, dash_str, "solid")) .solid else .dashed; - for (stroke_proj) |proj| { - const sub = try clipSimplifyLine(a, proj, box); - if (sub.len == 0) continue; - const seg_parts = try a.alloc([]const mvt.Point, sub.len); - for (sub, 0..) |seg, i| seg_parts[i] = seg; - try surf.beginFeature(&fmeta); - try surf.strokeLine(ln.color, ln.width, dash, seg_parts, valdco); - try surf.endFeature(); - } + try linestyle.drawPlainLine(a, stroke_proj, ln.color, ln.width, dash, box, &fmeta, valdco, surf); }; if (!opts.suppress_points and p.texts.len > 0) { @@ -1690,7 +1673,7 @@ fn emitSweptAreaFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize if (!overlaps(geomBounds(gp), tb)) continue; const proj = try a.alloc(mvt.Point, gp.len); for (gp, 0..) |pt, i| proj[i] = tile.project(pt.lon(), pt.lat(), z, x, y, tile.EXTENT); - var sub = try clipSimplifyLine(a, proj, box); + var sub = try tile.clipSimplifyLine(a, proj, box); if (opts.cover_clip) |cc| sub = clipRunsOutsideCover(a, sub, cc); if (sub.len == 0) continue; const parts = try a.alloc([]const mvt.Point, sub.len); @@ -1757,7 +1740,7 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize if (!overlaps(geomBounds(gp), tb)) continue; const proj = try a.alloc(mvt.Point, gp.len); for (gp, 0..) |pt, i| proj[i] = tile.project(pt.lon(), pt.lat(), z, x, y, tile.EXTENT); - const sub = try clipSimplifyLine(a, proj, box); + const sub = try tile.clipSimplifyLine(a, proj, box); if (sub.len == 0) continue; const parts = try a.alloc([]const mvt.Point, sub.len); for (sub, 0..) |s, i| parts[i] = s; @@ -1794,7 +1777,7 @@ fn emitDashedBoundary(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, g if (!overlaps(geomBounds(gp), tb)) continue; const proj = try a.alloc(mvt.Point, gp.len); for (gp, 0..) |pt, i| proj[i] = tile.project(pt.lon(), pt.lat(), z, x, y, tile.EXTENT); - var sub = try clipSimplifyLine(a, proj, box); + var sub = try tile.clipSimplifyLine(a, proj, box); if (opts.cover_clip) |cc| sub = clipRunsOutsideCover(a, sub, cc); if (sub.len == 0) continue; const parts = try a.alloc([]const mvt.Point, sub.len); @@ -2257,7 +2240,7 @@ fn appendCellFeatures( if (!overlaps(geomBounds(gp), tb)) continue; const proj = try a.alloc(mvt.Point, gp.len); for (gp, 0..) |p, i| proj[i] = tile.project(p.lon(), p.lat(), z, x, y, tile.EXTENT); - var sub = try clipSimplifyLine(a, proj, box); + var sub = try tile.clipSimplifyLine(a, proj, box); if (fopts.cover_clip) |cc| sub = clipRunsOutsideCover(a, sub, cc); if (sub.len == 0) continue; const parts = try a.alloc([]const mvt.Point, sub.len); diff --git a/src/tiles/tile.zig b/src/tiles/tile.zig index 3210165..7aa1058 100644 --- a/src/tiles/tile.zig +++ b/src/tiles/tile.zig @@ -211,6 +211,18 @@ pub fn clipLine(a: Allocator, line: []const mvt.Point, b: Box) ![][]mvt.Point { return parts.items; } +/// Clip a projected line to a tile box and simplify each surviving run (dropping +/// runs shorter than 2 points). The common "clip a line for this tile" composite. +pub fn clipSimplifyLine(a: Allocator, proj: []const mvt.Point, box: Box) ![]const []const mvt.Point { + const sub = try clipLine(a, proj, box); + var out = std.ArrayList([]const mvt.Point).empty; + for (sub) |run| { + const s = try simplifyRing(a, run); + if (s.len >= 2) try out.append(a, s); + } + return out.items; +} + fn eqPt(a: mvt.Point, b: mvt.Point) bool { return a.x == b.x and a.y == b.y; } From 0167e13a3995852967cace6ca62c9ff83c76c441 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 20:49:24 -0400 Subject: [PATCH 090/140] fix(capi): guard the scamin @intCast; correct the chart_open docs - scaminBuf did @intCast(i32 -> u32) on a host-supplied array; a negative value tripped a safety-checked cast and aborted the whole process across the ABI. Clamp negatives to 0 ("no scale gate"), keeping the buffer full-length so tile57_free stays correct. - tile57_chart_open opens via the streaming path (metadata up front, tiles baked lazily per request) and accepts a whole ENC_ROOT, but the header and c-api.md still described an upfront ~1-2s in-memory bake. Fix both to the streaming reality. Co-Authored-By: Claude Fable 5 --- docs/docs/c-api.md | 7 ++++--- include/tile57.h | 11 ++++++----- src/capi.zig | 5 ++++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index b26f580..75329e6 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -43,9 +43,10 @@ const char *tile57_version(void); /* "0.1.0" */ typedef struct tile57_chart tile57_chart; /* Open an on-disk ENC_ROOT directory (or a single .000 file, with its .001.. - * update chain). The cell is baked to an in-memory PMTiles and served through the - * reader render path, with real M_COVR coverage + compilation scale attached. - * Rules are the library's embedded catalogue. NULL on failure. */ + * update chain) via the streaming path: each cell's metadata (name, compilation + * scale, M_COVR coverage) is read up front and tiles are baked lazily per request, + * with no upfront full-cell bake. Rules are the library's embedded catalogue. + * NULL on failure. */ tile57_chart *tile57_chart_open(const char *path); /* Open a cell for METADATA ONLY — bbox, native_scale, M_COVR coverage — via a diff --git a/include/tile57.h b/include/tile57.h index bc8bdb1..2d59bc9 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -76,11 +76,12 @@ const char *tile57_version(void); typedef struct tile57_chart tile57_chart; /* Open ONE S-57 cell (a .000 file, with its .001.. update chain auto-read from the - * same directory) by BAKING it to an in-memory PMTiles and serving the fast reader - * render path (tile57_chart_render_surface_cb), with the cell's real M_COVR coverage - * (tile57_chart_coverage) and compilation scale (chart_info.native_scale) attached. - * The bake costs ~1-2s; a host rendering per view should run it off-thread. See the - * header/zoom variants for a cheap scan pass + progressive load. NULL/fail -> NULL. */ + * same directory) OR a whole ENC_ROOT directory, via the STREAMING path: each cell's + * metadata (name, compilation scale, M_COVR coverage) is enumerated up front and + * tiles are baked lazily, per requested tile — there is no upfront full-cell bake. + * This backend exposes the per-cell list (tile57_chart_cells) and the render/query + * surface. See the header/zoom variants for a metadata-only scan or a progressive + * narrow-band open. NULL/fail -> NULL. */ tile57_chart *tile57_chart_open(const char *path); /* Open ONE cell for METADATA ONLY — bbox, native_scale, and M_COVR coverage — via a diff --git a/src/capi.zig b/src/capi.zig index 6a6dca1..fd39e7e 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -908,7 +908,10 @@ fn scaminBuf(scamin: ?[*]const i32, scamin_count: usize) ![]u32 { const p = scamin orelse return &.{}; if (scamin_count == 0) return &.{}; const buf = try gpa.alloc(u32, scamin_count); - for (p[0..scamin_count], 0..) |v, i| buf[i] = @intCast(v); + // SCAMIN is a 1:N denominator (> 0); a negative is garbage from the host. Clamp + // to 0 ("no scale gate") rather than let a safety-checked @intCast abort the + // whole process across the ABI. + for (p[0..scamin_count], 0..) |v, i| buf[i] = if (v < 0) 0 else @intCast(v); return buf; } From 14303e834c298e0f99e717d8c5b646ca8f94aa25 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 20:50:14 -0400 Subject: [PATCH 091/140] docs(capi): fix the stale draw_sprite "LAST field" comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draw_sprite was labelled "Must be the LAST field", but draw_pattern and draw_text_str were appended after it — only draw_text_str is actually last. Note draw_sprite as ABI-appended without the false ordering claim. Co-Authored-By: Claude Fable 5 --- include/tile57.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/tile57.h b/include/tile57.h index 2d59bc9..6d6fb1d 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -413,7 +413,7 @@ typedef struct { * (tile57_bake_assets sprite_png/json), world anchor, rotation (deg), and the * symbol's un-rotated half-extent in reference px. Draw the atlas cell as a * quad of that half-size, centred on the anchor. NULL => symbols tessellate - * via draw_symbol instead. Must be the LAST field (ABI-appended). */ + * via draw_symbol instead. (ABI-appended after the original vtable.) */ void (*draw_sprite)(void *ctx, const tile57_feature *f, const char *name, size_t name_len, tile57_world_point anchor, float rot_deg, float half_w_px, float half_h_px); /* Area fill pattern: pattern name (ptr,len) to look up in the atlas ("pat:" * prefix) + the fill rings (world). Tile the cell across the polygon at a From 1a8752fe7ac9f58a5ec909252c8f271cda05cc3c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 9 Jul 2026 21:24:48 -0400 Subject: [PATCH 092/140] =?UTF-8?q?feat(capi):=20error=20model=20=E2=80=94?= =?UTF-8?q?=20tile57=5Fstatus,=20tile57=5Ferror,=20taxonomy;=20convert=20o?= =?UTF-8?q?pens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the C ABI error model (the strerror + optional-GError patterns, no global state): - tile57_status enum + tile57_status_str() (static message) - tile57_error { status; char message[256]; } — caller-owned, opt-in (NULL to ignore), no allocation - src/errors.zig: the engine error taxonomy (NotFound / IoFailed / InvalidCell / InvalidArchive / InvalidPartition / Unsupported / RenderFailed / OutOfMemory) + describe() Convert the opens (chart_open/_header/_zoom/_bytes/_pmtiles, compose_open) to return tile57_status with an out-handle and optional tile57_error. chart.zig now returns SPECIFIC errors (OOM, NotFound, InvalidCell/Archive) instead of the catch-all OpenFailed, and openCellHeader PROPAGATES the exact S-57/ISO-8211 parse reason (BadLeader, UnknownRUIN, …). capi carries the FILENAME into the message ("US5MD1MC.000: malformed ISO 8211 leader"). Go binding: statusError() maps status -> category sentinels (ErrParse/ErrIO/ …) while keeping the specific message; opens updated. Lib + go build + tests green. This is an in-progress ABI migration: the remaining exports (bake, serve, render, style, getters) still use the old int conventions and convert next. Co-Authored-By: Claude Fable 5 --- bindings/go/compose.go | 12 +++- bindings/go/errors.go | 10 +++ bindings/go/tile57.go | 54 +++++++++++--- build.zig | 7 ++ include/tile57.h | 67 +++++++++++++---- src/capi.zig | 159 ++++++++++++++++++++++++++++++++++------- src/chart.zig | 30 ++++---- src/errors.zig | 52 ++++++++++++++ 8 files changed, 326 insertions(+), 65 deletions(-) create mode 100644 src/errors.zig diff --git a/bindings/go/compose.go b/bindings/go/compose.go index 7a04ead..e68c136 100644 --- a/bindings/go/compose.go +++ b/bindings/go/compose.go @@ -52,9 +52,15 @@ func OpenCompose(paths []string, partitionPath string) (*ComposeSource, error) { if partitionPath != "" { cpart = ar.str(partitionPath) } - ptr := C.tile57_compose_open(cpaths, C.size_t(len(paths)), cpart) - if ptr == nil { - return nil, fmt.Errorf("tile57: OpenCompose found no coverage or failed to open: %w", ErrNoCoverage) + var ptr *C.tile57_compose_source + var cerr C.tile57_error + if st := C.tile57_compose_open(cpaths, C.size_t(len(paths)), cpart, &ptr, &cerr); st != C.TILE57_OK { + // No coverage-carrying archive is reported as TILE57_ERR_PARSE; surface it + // as ErrNoCoverage so a host can branch, keeping the specific message. + if st == C.TILE57_ERR_PARSE { + return nil, fmt.Errorf("%s: %w", C.GoString(&cerr.message[0]), ErrNoCoverage) + } + return nil, statusError(st, &cerr) } return &ComposeSource{ptr: ptr}, nil } diff --git a/bindings/go/errors.go b/bindings/go/errors.go index b0ad0c6..2e15c44 100644 --- a/bindings/go/errors.go +++ b/bindings/go/errors.go @@ -19,4 +19,14 @@ var ( // ErrEmptyInput is returned when a call is handed no usable input — no cells, // empty bytes, an empty style template, or empty asset inputs. ErrEmptyInput = errors.New("tile57: empty input") + + // Category sentinels matching the C tile57_status codes. A call that fails + // wraps the matching sentinel with %w (the message stays specific — often + // "path: reason"), so a host can branch with errors.Is(err, ErrParse) etc. + ErrBadArg = errors.New("tile57: invalid argument") + ErrIO = errors.New("tile57: I/O error") + ErrParse = errors.New("tile57: malformed input") + ErrNoMem = errors.New("tile57: out of memory") + ErrUnsupported = errors.New("tile57: unsupported input") + ErrRender = errors.New("tile57: render failed") ) diff --git a/bindings/go/tile57.go b/bindings/go/tile57.go index a6edd3e..64b84a6 100644 --- a/bindings/go/tile57.go +++ b/bindings/go/tile57.go @@ -33,6 +33,39 @@ import ( // Version returns the libtile57 version string (e.g. "0.1.0"). func Version() string { return C.GoString(C.tile57_version()) } +// statusError turns a non-OK tile57_status (with the optional tile57_error the +// call filled) into a Go error that wraps the matching category sentinel, so a +// host can branch with errors.Is while the message stays specific (often +// "path: reason"). Returns nil for TILE57_OK. +func statusError(st C.tile57_status, cerr *C.tile57_error) error { + if st == C.TILE57_OK { + return nil + } + msg := "" + if cerr != nil { + msg = C.GoString(&cerr.message[0]) + } + if msg == "" { + msg = C.GoString(C.tile57_status_str(st)) + } + var sentinel error + switch st { + case C.TILE57_ERR_BADARG: + sentinel = ErrBadArg + case C.TILE57_ERR_PARSE: + sentinel = ErrParse + case C.TILE57_ERR_NOMEM: + sentinel = ErrNoMem + case C.TILE57_ERR_UNSUPPORTED: + sentinel = ErrUnsupported + case C.TILE57_ERR_RENDER: + sentinel = ErrRender + default: + sentinel = ErrIO + } + return fmt.Errorf("%s (%w)", msg, sentinel) +} + // TileFormat is a tile encoding (tile57_tile_type / tile57_bake_opts.format). // The zero value means "the engine default" (MLT) in bake options. type TileFormat uint8 @@ -99,9 +132,10 @@ func Open(path string) (*Source, error) { } cPath := C.CString(path) defer C.free(unsafe.Pointer(cPath)) - ptr := C.tile57_chart_open(cPath) - if ptr == nil { - return nil, fmt.Errorf("tile57: failed to open chart at %q", path) + var ptr *C.tile57_chart + var cerr C.tile57_error + if st := C.tile57_chart_open(cPath, &ptr, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) } return &Source{ptr: ptr}, nil } @@ -112,9 +146,10 @@ func OpenChartBytes(base []byte) (*Source, error) { if len(base) == 0 { return nil, fmt.Errorf("tile57: empty cell bytes: %w", ErrEmptyInput) } - ptr := C.tile57_chart_open_bytes((*C.uint8_t)(unsafe.Pointer(&base[0])), C.size_t(len(base))) - if ptr == nil { - return nil, fmt.Errorf("tile57: failed to open cell (%d bytes)", len(base)) + var ptr *C.tile57_chart + var cerr C.tile57_error + if st := C.tile57_chart_open_bytes((*C.uint8_t)(unsafe.Pointer(&base[0])), C.size_t(len(base)), &ptr, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) } return &Source{ptr: ptr}, nil } @@ -126,9 +161,10 @@ func OpenPMTiles(path string) (*Source, error) { } cPath := C.CString(path) defer C.free(unsafe.Pointer(cPath)) - ptr := C.tile57_chart_open_pmtiles(cPath) - if ptr == nil { - return nil, fmt.Errorf("tile57: failed to open pmtiles at %q", path) + var ptr *C.tile57_chart + var cerr C.tile57_error + if st := C.tile57_chart_open_pmtiles(cPath, &ptr, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) } return &Source{ptr: ptr}, nil } diff --git a/build.zig b/build.zig index dacb617..9c8531c 100644 --- a/build.zig +++ b/build.zig @@ -221,6 +221,10 @@ pub fn build(b: *std.Build) void { .imports = &.{.{ .name = "s57", .module = s57_mod }}, }); + // The engine error taxonomy (src/errors.zig): the error set the C ABI maps to + // tile57_status, plus describe() for the per-call message. Pure std-only leaf. + const errors_mod = b.addModule("errors", .{ .root_source_file = b.path("src/errors.zig") }); + // The runtime tile compositor (src/compose/): serve any (z,x,y) on demand from // N per-cell PMTiles archives + an ownership partition. Reads baked archives // only — never parses S-57 or runs portrayal — so it depends solely on the @@ -380,6 +384,7 @@ pub fn build(b: *std.Build) void { tile57_mod.addImport("sprite", sprite_mod); // sprite/pattern atlas generation tile57_mod.addImport("coverage", coverage_mod); // per-cell coverage sidecar tile57_mod.addImport("compose", compose_mod); // the runtime compositor + tile57_mod.addImport("errors", errors_mod); // the error taxonomy // Static library (libtile57.a): C ABI + embedded Lua. Its own root so // the C sources / libc only land in the archive (linked by the C++ host), @@ -397,6 +402,7 @@ pub fn build(b: *std.Build) void { lib_mod.addImport("bundle", bundle_mod); // C ABI: portrayal-asset emitters + debug bake lib_mod.addImport("compose", compose_mod); // C ABI: tile57_compose_* (the runtime compositor) lib_mod.addImport("coverage", coverage_mod); // the tile57 public root re-exports it + lib_mod.addImport("errors", errors_mod); // C ABI: error taxonomy -> tile57_status // The full engine surface as a NAMED import (not a root.zig file-import), so the // single root.zig file isn't claimed by both lib_mod and engine_full (which bundle // pulls in) — Zig requires each file to belong to exactly one module per artifact. @@ -598,6 +604,7 @@ pub fn build(b: *std.Build) void { .{ .name = "geometry", .module = geometry_mod }, }); _ = addPkgTest(b, test_step, "src/style/style.zig", target, optimize, &.{}); + _ = addPkgTest(b, test_step, "src/errors.zig", target, optimize, &.{}); // Geometry core for the cross-band composition (pure, std-only). _ = addPkgTest(b, test_step, "src/geometry/geometry.zig", target, optimize, &.{}); // The runtime compositor + its clip core: pure over tiles + geometry + coverage. diff --git a/include/tile57.h b/include/tile57.h index 6d6fb1d..3bd6e18 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -63,6 +63,42 @@ extern "C" { #define TILE57_VERSION_PATCH 0 const char *tile57_version(void); +/* ======================================================================== * + * Errors + * + * A fallible call returns a tile57_status; TILE57_OK (0) is success and every + * other value is a failure with a coarse cause. Results (handles, byte buffers) + * come back through out-parameters. "Nothing produced" is NOT a failure — the + * call returns TILE57_OK with a NULL/zero out (e.g. a cell that bakes no tiles, + * or composing open ocean). + * + * tile57_status_str() gives a static, human-readable string for a status (the + * strerror pattern). Where the specific cause is worth carrying — which path, + * which errno — the call also takes an optional tile57_error*: pass NULL to + * ignore it, or a caller-owned struct (a stack local is fine) the call fills with + * the status + a message on failure and leaves untouched on success. There is no + * allocation and nothing to free. + * ======================================================================== */ + +typedef enum { + TILE57_OK = 0, /* success */ + TILE57_ERR_BADARG, /* a NULL or out-of-range argument */ + TILE57_ERR_IO, /* a file/directory could not be opened, read, or written */ + TILE57_ERR_PARSE, /* malformed input (S-57 cell, PMTiles, partition, JSON) */ + TILE57_ERR_NOMEM, /* an allocation failed */ + TILE57_ERR_UNSUPPORTED, /* valid but unsupported input */ + TILE57_ERR_RENDER, /* tile generation or rendering failed */ +} tile57_status; + +/* A static, human-readable string for a status. Never NULL, never freed. */ +const char *tile57_status_str(tile57_status status); + +#define TILE57_ERROR_MSG_MAX 256 +typedef struct { + tile57_status status; + char message[TILE57_ERROR_MSG_MAX]; /* NUL-terminated; "" when no detail */ +} tile57_error; + /* ======================================================================== * * 2. Chart: open + metadata * @@ -81,25 +117,25 @@ typedef struct tile57_chart tile57_chart; * tiles are baked lazily, per requested tile — there is no upfront full-cell bake. * This backend exposes the per-cell list (tile57_chart_cells) and the render/query * surface. See the header/zoom variants for a metadata-only scan or a progressive - * narrow-band open. NULL/fail -> NULL. */ -tile57_chart *tile57_chart_open(const char *path); + * narrow-band open. TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ +tile57_status tile57_chart_open(const char *path, tile57_chart **out, tile57_error *err); /* Open ONE cell for METADATA ONLY — bbox, native_scale, and M_COVR coverage — via a * cheap parse with NO tile bake, for a host's chart-database/header scan. Do NOT - * render_surface this handle (it has no portrayal). NULL/failure -> NULL. */ -tile57_chart *tile57_chart_open_header(const char *path); + * render_surface this handle (it has no portrayal). TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ +tile57_status tile57_chart_open_header(const char *path, tile57_chart **out, tile57_error *err); /* Open ONE cell baking only [minzoom, maxzoom] to an in-memory PMTiles: bake a narrow * native band fast for first paint, then re-open the full range in the background - * (progressive load). Renders via the fast reader path. NULL/failure -> NULL. */ -tile57_chart *tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t maxzoom); + * (progressive load). Renders via the fast reader path. TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ +tile57_status tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t maxzoom, tile57_chart **out, tile57_error *err); /* Open one in-memory ENC cell (base .000 bytes) as a resident chart. Bytes are copied. - * NULL/failure -> NULL. */ -tile57_chart *tile57_chart_open_bytes(const uint8_t *base, size_t len); + * TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ +tile57_status tile57_chart_open_bytes(const uint8_t *base, size_t len, tile57_chart **out, tile57_error *err); -/* Open a baked PMTiles bundle from a file path. NULL/failure -> NULL. */ -tile57_chart *tile57_chart_open_pmtiles(const char *path); +/* Open a baked PMTiles bundle from a file path. TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ +tile57_status tile57_chart_open_pmtiles(const char *path, tile57_chart **out, tile57_error *err); /* Vector-tile encodings the engine produces (reported in tile57_chart_info.tile_type; * the compositor serves MLT). */ @@ -265,11 +301,12 @@ typedef struct { * tile57_bake_cell_bytes, on disk), mmap'd so the cell set is never fully resident. * `partition_path` (NULL to skip) names a partition sidecar — written by * tile57_compose_save_partition (the `tile57 bake` CLI emits one as partition.tpart) — - * to load and skip the build; a missing/stale one falls back to building. Returns an - * opaque handle (free with tile57_compose_close), or NULL on error / no - * coverage-carrying archive. */ -tile57_compose_source *tile57_compose_open(const char *const *paths, size_t n, - const char *partition_path); + * to load and skip the build; a missing/stale one falls back to building. On + * TILE57_OK *out is the handle (free with tile57_compose_close); no + * coverage-carrying archive is TILE57_ERR_PARSE. */ +tile57_status tile57_compose_open(const char *const *paths, size_t n, + const char *partition_path, + tile57_compose_source **out, tile57_error *err); /* Compose the tile (z,x,y) on demand into RAW (decompressed) MLT in *out / *out_len (free with * tile57_free) — what a live tile server hands its HTTP layer (which gzips on the wire). Returns: diff --git a/src/capi.zig b/src/capi.zig index fd39e7e..91e6274 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -11,6 +11,7 @@ const bundle = @import("bundle"); // portrayal-asset emitters + the partition de const compose = @import("compose"); // the runtime tile compositor (tile57_compose_*) const mariner = @import("style").mariner; const style = @import("style"); +const errors = @import("errors"); // the engine error taxonomy + describe() // The S-52 ColorProfiles/colorProfile.xml baked into the library (build.zig), so // the style C ABI generates colortables + a base style template with no on-disk // catalogue. Symbols/linestyles are NOT embedded here (only the bake exe needs them). @@ -33,6 +34,100 @@ fn spanOpt(s: ?[*:0]const u8) ?[]const u8 { return if (s) |p| std.mem.span(p) else null; } +// ---- error model (mirrors tile57_status / tile57_error in tile57.h) --------- + +// Keep in sync with the tile57_status enum in tile57.h. +const Status = enum(c_int) { ok = 0, badarg, io, parse, nomem, unsupported, render }; + +// Mirrors tile57_error in tile57.h: a caller-owned status + fixed message buffer. +const ERROR_MSG_MAX = 256; +const CError = extern struct { status: c_int, message: [ERROR_MSG_MAX]u8 }; + +const OK: c_int = @intFromEnum(Status.ok); + +// Map an engine error (errors.Error) to a tile57_status. std IO errors that can +// surface directly from file ops map to .io; anything unrecognised is .io. +fn statusOf(e: anyerror) Status { + return switch (e) { + error.OutOfMemory => .nomem, + error.Unsupported => .unsupported, + error.RenderFailed => .render, + error.InvalidCell, error.InvalidArchive, error.InvalidPartition => .parse, + // Specific S-57 / ISO 8211 parse failures, propagated so the message + // carries the exact reason (e.g. "BadLeader", "UnknownRUIN"). + error.ShortLeader, + error.BadLeader, + error.BadAsciiInt, + error.BadAsciiDigit, + error.MissingFieldTerminator, + error.FieldOutOfBounds, + error.ModifyMissingSpatial, + error.ModifyMissingFeature, + error.UnknownRUIN, + error.BadFeatureRecord, + => .parse, + error.NotFound, error.IoFailed => .io, + error.FileNotFound, error.AccessDenied, error.NotDir, error.IsDir => .io, + else => .io, + }; +} + +// Fill an optional caller-provided tile57_error (NULL to ignore) with a status + +// message; the message is copied, truncated to fit, and NUL-terminated. +fn setError(err: ?*CError, status: Status, msg: []const u8) void { + const dst = err orelse return; + dst.status = @intFromEnum(status); + const n = @min(msg.len, ERROR_MSG_MAX - 1); + @memcpy(dst.message[0..n], msg[0..n]); + dst.message[n] = 0; +} + +// Report a Zig error: set `err` (if any) with the mapped status + describe() +// message, and return the status code. +fn fail(err: ?*CError, e: anyerror) c_int { + const s = statusOf(e); + setError(err, s, errors.describe(e)); + return @intFromEnum(s); +} + +// Like fail, but prefix the message with a context string (e.g. the file path): +// "US5MD1MC.000: malformed ISO 8211 leader". Truncated to fit. +fn failCtx(err: ?*CError, e: anyerror, context: []const u8) c_int { + const s = statusOf(e); + if (err) |dst| { + dst.status = @intFromEnum(s); + const msg = std.fmt.bufPrint(dst.message[0 .. ERROR_MSG_MAX - 1], "{s}: {s}", .{ context, errors.describe(e) }) catch blk: { + // Context + reason overflowed the buffer; keep the reason alone. + const r = errors.describe(e); + const n = @min(r.len, ERROR_MSG_MAX - 1); + @memcpy(dst.message[0..n], r[0..n]); + break :blk dst.message[0..n]; + }; + dst.message[msg.len] = 0; + } + return @intFromEnum(s); +} + +// Report a specific status with a literal message. +fn failWith(err: ?*CError, status: Status, msg: []const u8) c_int { + setError(err, status, msg); + return @intFromEnum(status); +} + +/// Return a static, human-readable string for a tile57_status. +export fn tile57_status_str(status: c_int) callconv(.c) [*:0]const u8 { + return switch (status) { + @intFromEnum(Status.ok) => "ok", + @intFromEnum(Status.badarg) => "invalid argument", + @intFromEnum(Status.io) => "I/O error", + @intFromEnum(Status.parse) => "malformed input", + @intFromEnum(Status.nomem) => "out of memory", + @intFromEnum(Status.unsupported) => "unsupported input", + @intFromEnum(Status.render) => "render failed", + else => "unknown error", + }; +} + /// Return the library version string ("0.1.0"). export fn tile57_version() callconv(.c) [*:0]const u8 { return version_string; @@ -43,25 +138,31 @@ export fn tile57_version() callconv(.c) [*:0]const u8 { /// metadata (name/scale/M_COVR) is enumerated up front and tiles are baked lazily per /// request. Unlike a bake-to-reader open, this backend exposes the per-cell list /// (tile57_chart_cells) — a header/metadata scan needs no tile bake. See tile57.h. -export fn tile57_chart_open(path: ?[*:0]const u8) callconv(.c) ?*Chart { - const p = spanOpt(path) orelse return null; - return chart.Chart.openPath(p, null, true) catch null; +export fn tile57_chart_open(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); + o.* = chart.Chart.openPath(p, null, true) catch |e| return failCtx(err, e, p); + return OK; } /// Open ONE cell for METADATA ONLY (bbox + native scale + M_COVR coverage): a cheap /// parse, no tile bake — for a host's header/scan pass. Do NOT render_surface this /// handle (no portrayal). See tile57.h. -export fn tile57_chart_open_header(path: ?[*:0]const u8) callconv(.c) ?*Chart { - const p = spanOpt(path) orelse return null; - return chart.openCellHeader(p, null, true) catch null; +export fn tile57_chart_open_header(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); + o.* = chart.openCellHeader(p, null, true) catch |e| return failCtx(err, e, p); + return OK; } /// Open ONE cell by baking only [minzoom, maxzoom] to an in-memory PMTiles — a host /// bakes a narrow band fast for first paint, then re-opens the full range in the /// background (progressive load). Renders via the fast reader path. See tile57.h. -export fn tile57_chart_open_zoom(path: ?[*:0]const u8, minzoom: u8, maxzoom: u8) callconv(.c) ?*Chart { - const p = spanOpt(path) orelse return null; - return chart.openCellBaked(p, null, true, minzoom, maxzoom) catch null; +export fn tile57_chart_open_zoom(path: ?[*:0]const u8, minzoom: u8, maxzoom: u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); + o.* = chart.openCellBaked(p, null, true, minzoom, maxzoom) catch |e| return failCtx(err, e, p); + return OK; } /// Bake ONE cell (+ its updates, read from disk) to PMTiles bytes over its NATIVE band @@ -164,12 +265,15 @@ export fn tile57_compose_open( paths: ?[*]const ?[*:0]const u8, n: usize, partition_path: ?[*:0]const u8, -) callconv(.c) ?*compose.ComposeSource { - const ps = paths orelse return null; - if (n == 0) return null; - const list = gpa.alloc([]const u8, n) catch return null; + out: ?*?*compose.ComposeSource, + err: ?*CError, +) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + const ps = paths orelse return failWith(err, .badarg, "paths must not be null"); + if (n == 0) return failWith(err, .badarg, "n must not be zero"); + const list = gpa.alloc([]const u8, n) catch |e| return fail(err, e); defer gpa.free(list); - for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return null; + for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return failWith(err, .badarg, "a path in paths is null"); // The lib has no std.process.Init; stand up a threaded std.Io for the open-time file I/O // (mmap survives it, and serve/close need no io). @@ -187,7 +291,9 @@ export fn tile57_compose_open( } else |_| {} } - return (compose.openComposeSourceFiles(io, gpa, list, load_bytes) catch return null) orelse return null; + o.* = (compose.openComposeSourceFiles(io, gpa, list, load_bytes) catch |e| return fail(err, e)) orelse + return failWith(err, .parse, "no coverage-carrying archive in paths"); + return OK; } /// Compose the tile (z,x,y) on demand, returning the RAW (decompressed) MLT in *out / *out_len (free @@ -267,24 +373,29 @@ export fn tile57_warmup() callconv(.c) void { } /// Open one in-memory ENC cell (base .000 bytes) as a resident chart. See tile57.h. -export fn tile57_chart_open_bytes(base: [*]const u8, len: usize) callconv(.c) ?*Chart { - if (len == 0) return null; - const cells = [_]chart.CellInput{.{ .base = base[0..len] }}; - return Chart.openCells(&cells, null, true) catch null; +export fn tile57_chart_open_bytes(base: ?[*]const u8, len: usize, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + const b = base orelse return failWith(err, .badarg, "base must not be null"); + if (len == 0) return failWith(err, .badarg, "len must not be zero"); + const cells = [_]chart.CellInput{.{ .base = b[0..len] }}; + o.* = Chart.openCells(&cells, null, true) catch |e| return fail(err, e); + return OK; } /// Open a baked PMTiles bundle from a file path. See tile57.h. -export fn tile57_chart_open_pmtiles(path: ?[*:0]const u8) callconv(.c) ?*Chart { - const p = spanOpt(path) orelse return null; +export fn tile57_chart_open_pmtiles(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); var threaded: std.Io.Threaded = .init(gpa, .{}); defer threaded.deinit(); const io = threaded.io(); const dir_path = std.fs.path.dirname(p) orelse "."; - var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{}) catch return null; + var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{}) catch |e| return failCtx(err, e, p); defer dir.close(io); - const bytes = dir.readFileAlloc(io, std.fs.path.basename(p), gpa, .unlimited) catch return null; + const bytes = dir.readFileAlloc(io, std.fs.path.basename(p), gpa, .unlimited) catch |e| return failCtx(err, e, p); defer gpa.free(bytes); - return Chart.openBytes(bytes, .pmtiles, null) catch null; + o.* = Chart.openBytes(bytes, .pmtiles, null) catch |e| return failCtx(err, e, p); + return OK; } // Fixed-size chart metadata (mirrors tile57_chart_info in tile57.h): folds the old diff --git a/src/chart.zig b/src/chart.zig index 93460ce..fa194fe 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -532,18 +532,20 @@ pub fn openCellHeader(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool _ = rules_dir; var cf = try readCellFiles(path); defer cf.deinit(); - const cell = s57.parseCellWithUpdates(gpa, cf.base, cf.updates) catch return error.OpenFailed; + // Propagate the specific parse error (BadLeader / UnknownRUIN / …) so the C + // caller reads the exact reason the cell is invalid, not a generic code. + const cell = try s57.parseCellWithUpdates(gpa, cf.base, cf.updates); var cb = CellBackend{ .cell = cell, .cscl = s57.peekScale(gpa, cf.base) orelse 0 }; const pa = gpa.create(std.heap.ArenaAllocator) catch { freeCellBackend(&cb); - return error.OpenFailed; + return error.OutOfMemory; }; pa.* = std.heap.ArenaAllocator.init(gpa); cb.portray_arena = pa; cb.coverage = cb.cell.mcovrCoverage(pa.allocator()); const src = gpa.create(Chart) catch { freeCellBackend(&cb); - return error.OpenFailed; + return error.OutOfMemory; }; src.* = .{ .backend = .{ .cell = cb }, .cache = std.AutoHashMap(u64, []u8).init(gpa), .pick_attrs = pick_attrs }; return src; @@ -573,7 +575,7 @@ pub fn openCellBaked(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool, } else |_| {} const cell_in = [_]CellInput{.{ .base = cf.base, .updates = cf.updates }}; - const archive = (bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, pick_attrs, null, null, null) catch null) orelse return error.OpenFailed; + const archive = (bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, pick_attrs, null, null, null) catch null) orelse return error.InvalidCell; defer gpa.free(archive); const src = try Chart.openBytes(archive, .pmtiles, rules_dir); src.cscl_override = cscl; @@ -932,23 +934,23 @@ pub const Chart = struct { if (fmt == .pmtiles or fmt == .auto) { const copy = try gpa.dupe(u8, bytes); if (openPmtiles(copy)) |src| return src; // openPmtiles freed `copy` on failure - if (fmt == .pmtiles) return error.OpenFailed; + if (fmt == .pmtiles) return error.InvalidArchive; } - return openCell(bytes, rules_dir) orelse error.OpenFailed; + return openCell(bytes, rules_dir) orelse error.InvalidCell; } /// Open an ENC_ROOT as a multi-cell source: cells are indexed cheaply (band + /// bbox) in parallel and parsed/portrayed lazily per tile. All bytes are /// copied. Errors if no cell's header parses. pub fn openCells(cells_in: []const CellInput, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart { - if (cells_in.len == 0) return error.OpenFailed; + if (cells_in.len == 0) return error.NotFound; const dir = resolveRulesDir(rules_dir); const dir_copy = try gpa.dupe(u8, dir); errdefer gpa.free(dir_copy); - const tmp = gpa.alloc(LazyCell, cells_in.len) catch return error.OpenFailed; + const tmp = try gpa.alloc(LazyCell, cells_in.len); defer gpa.free(tmp); - const ok = gpa.alloc(bool, cells_in.len) catch return error.OpenFailed; + const ok = try gpa.alloc(bool, cells_in.len); defer gpa.free(ok); @memset(ok, false); @@ -959,11 +961,11 @@ pub const Chart = struct { for (ok) |k| { if (k) valid += 1; } - if (valid == 0) return error.OpenFailed; + if (valid == 0) return error.InvalidCell; // cells provided, but none parsed const cells = gpa.alloc(LazyCell, valid) catch { for (tmp, ok) |*lc, k| if (k) lazyFreeCell(lc); - return error.OpenFailed; + return error.OutOfMemory; }; var j: usize = 0; for (tmp, ok) |lc, k| if (k) { @@ -974,7 +976,7 @@ pub const Chart = struct { const src = gpa.create(Chart) catch { for (cells) |*lc| lazyFreeCell(lc); gpa.free(cells); - return error.OpenFailed; + return error.OutOfMemory; }; src.* = .{ .backend = .{ .cells = .{ .cells = cells, .rules_dir = dir_copy } }, @@ -990,11 +992,11 @@ pub const Chart = struct { /// needs them and freed on LRU eviction, so the host holds the working set's /// bytes — not the whole ENC_ROOT. No bytes are read at open. Errors if empty. pub fn openCellsStreaming(metas: []const CellMeta, reader: CellReadFn, user: ?*anyopaque, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart { - if (metas.len == 0) return error.OpenFailed; + if (metas.len == 0) return error.NotFound; const dir = resolveRulesDir(rules_dir); const dir_copy = try gpa.dupe(u8, dir); errdefer gpa.free(dir_copy); - const cells = gpa.alloc(LazyCell, metas.len) catch return error.OpenFailed; + const cells = try gpa.alloc(LazyCell, metas.len); for (metas, 0..) |m, i| { cells[i] = .{ .base = &.{}, diff --git a/src/errors.zig b/src/errors.zig new file mode 100644 index 0000000..3c74277 --- /dev/null +++ b/src/errors.zig @@ -0,0 +1,52 @@ +//! The engine's error taxonomy. Every fallible engine entry point returns one of +//! these; the C ABI maps each to a tile57_status (see capi.zig) and carries +//! describe() as the per-call tile57_error.message. Keep this set, the C +//! tile57_status enum, and capi's statusOf in sync. + +pub const Error = error{ + NotFound, // a path or cell was not found + IoFailed, // a file/directory could not be opened, read, or written + InvalidCell, // malformed S-57 ENC cell + InvalidArchive, // malformed PMTiles archive + InvalidPartition, // malformed or stale ownership partition + Unsupported, // valid but unsupported input + RenderFailed, // tile generation or rendering failed + OutOfMemory, // an allocation failed +}; + +/// A human-readable description of an engine error — the text a C caller reads +/// from tile57_error.message. Accepts any error (falls back to the error name) so +/// a caller can pass a raw `anyerror` from a catch. +pub fn describe(e: anyerror) []const u8 { + return switch (e) { + error.NotFound => "not found", + error.IoFailed => "I/O error", + error.InvalidCell => "malformed S-57 cell", + error.InvalidArchive => "malformed PMTiles archive", + error.InvalidPartition => "malformed or stale partition", + error.Unsupported => "unsupported input", + error.RenderFailed => "render failed", + error.OutOfMemory => "out of memory", + // Specific S-57 / ISO 8211 parse failures, propagated as the reason a cell + // was invalid so a C caller reads the exact cause. + error.ShortLeader => "ISO 8211 record shorter than its leader", + error.BadLeader => "malformed ISO 8211 leader", + error.BadAsciiInt => "malformed ASCII integer field", + error.BadAsciiDigit => "malformed ASCII digit", + error.MissingFieldTerminator => "missing ISO 8211 field terminator", + error.FieldOutOfBounds => "ISO 8211 field runs past the record", + error.ModifyMissingSpatial => "update references a missing spatial record", + error.ModifyMissingFeature => "update references a missing feature record", + error.UnknownRUIN => "unknown record update instruction (RUIN)", + error.BadFeatureRecord => "malformed feature record", + else => @errorName(e), + }; +} + +test "describe covers the taxonomy" { + const std = @import("std"); + try std.testing.expectEqualStrings("malformed S-57 cell", describe(error.InvalidCell)); + try std.testing.expectEqualStrings("I/O error", describe(error.IoFailed)); + // unknown errors fall back to the name + try std.testing.expectEqualStrings("SomethingElse", describe(error.SomethingElse)); +} From 3706ce8f79998083a386b55cde456a5377f98033 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 04:45:57 -0400 Subject: [PATCH 093/140] feat(chart): surface embedded coverage/cscl on PMTiles opens; bakeTree errors on unreadable in_dir A per-cell bake embeds the source cell's M_COVR coverage + compilation scale in the archive metadata; opening the archive now attaches them, so coverage() and nativeScale() answer for a baked archive exactly as for the live cell. bakeTree previously swallowed an unreadable in_dir into a 0 count. Co-Authored-By: Claude Fable 5 --- build.zig | 1 + src/capi.zig | 2 +- src/chart.zig | 58 +++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/build.zig b/build.zig index 9c8531c..95063d2 100644 --- a/build.zig +++ b/build.zig @@ -468,6 +468,7 @@ pub fn build(b: *std.Build) void { }, }); chart_mod.addImport("style", style_mod); // linestyle XML analysis + chart_mod.addImport("coverage", coverage_mod); // embedded-coverage attach on PMTiles opens const bake_target = if (target.result.os.tag == .linux and target.result.abi != .musl) b.resolveTargetQuery(.{ .cpu_arch = target.result.cpu.arch, .os_tag = .linux, .abi = .musl }) diff --git a/src/capi.zig b/src/capi.zig index 91e6274..08fd90f 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -230,7 +230,7 @@ export fn tile57_bake_tree( // Stand up a threaded std.Io for the tree walk + the workers' file writes. var threaded: std.Io.Threaded = .init(gpa, .{}); defer threaded.deinit(); - return @intCast(chart.bakeTree(threaded.io(), in_d, out_d, null, workers, progress, progress_ctx)); + return @intCast(chart.bakeTree(threaded.io(), in_d, out_d, null, workers, progress, progress_ctx) catch return -1); } /// Coverage/zoom summary of a resident compositor, filled by tile57_compose_meta. diff --git a/src/chart.zig b/src/chart.zig index fa194fe..e0c1efb 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -29,6 +29,7 @@ const render = @import("render"); const sprite = @import("sprite"); const embedded_assets = @import("catalog"); // S-101 portrayal assets (renderView store) const style = @import("style"); // displayDenomZ (the physical display-scale formula) +const cell_coverage = @import("coverage"); // per-cell M_COVR coverage embedded in archive metadata // smp_allocator (Zig's fast thread-safe GPA), not page_allocator: the engine // makes many small, short-lived allocations (tile cache, cell dupes, index @@ -429,9 +430,40 @@ fn openPmtiles(copy: []u8) ?*Chart { return null; }; src.* = .{ .backend = .{ .reader = reader }, .data = copy, .cache = std.AutoHashMap(u64, []u8).init(gpa) }; + attachEmbeddedCoverage(src); return src; } +// A per-cell bake embeds the source cell's M_COVR coverage + compilation scale in +// the archive's metadata JSON; surface them on the opened chart so coverage() and +// nativeScale() answer for a baked archive exactly as they did for the live cell. +// Best-effort: an archive without (or with unparseable) coverage attaches nothing. +fn attachEmbeddedCoverage(src: *Chart) void { + const rd = &src.backend.reader; + const h = rd.header; + if (h.metadata_length == 0) return; + const raw = rd.bytes[@intCast(h.metadata_offset)..][0..@intCast(h.metadata_length)]; + const cov_arena = gpa.create(std.heap.ArenaAllocator) catch return; + cov_arena.* = std.heap.ArenaAllocator.init(gpa); + const a = cov_arena.allocator(); + const drop = struct { + fn f(ar: *std.heap.ArenaAllocator) void { + ar.deinit(); + gpa.destroy(ar); + } + }.f; + const json: []const u8 = switch (h.internal_compression) { + .none => raw, + .gzip => gzip.decompress(a, raw) catch return drop(cov_arena), + else => return drop(cov_arena), + }; + const cov = (cell_coverage.decodeFromMetadata(a, json) catch null) orelse return drop(cov_arena); + if (cov.cscl == 0 and cov.cov1.len == 0) return drop(cov_arena); + src.cscl_override = cov.cscl; + src.coverage_override = cov.cov1; + src.coverage_arena = cov_arena; +} + // Parse (+ apply updates) + portray one cell into a CellBackend. Reads the bytes // but does not take ownership. Portrayal failure is non-fatal (classify() fallback). fn buildCellBackend(base: []const u8, updates: []const []const u8, dir: []const u8) ?CellBackend { @@ -578,6 +610,11 @@ pub fn openCellBaked(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool, const archive = (bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, pick_attrs, null, null, null) catch null) orelse return error.InvalidCell; defer gpa.free(archive); const src = try Chart.openBytes(archive, .pmtiles, rules_dir); + // Replace any coverage the archive metadata attached (this parse is fresher). + if (src.coverage_arena) |ca| { + ca.deinit(); + gpa.destroy(ca); + } src.cscl_override = cscl; src.coverage_override = coverage; src.coverage_arena = cov_arena; @@ -763,8 +800,10 @@ pub fn bakeCellsToFiles(io: std.Io, in_paths: []const []const u8, out_paths: []c /// plus an .sha sidecar. Output subdirs are created as needed. `in_dir` is the source ENC data; /// `out_dir` is the caller's own cache (it owns the location + names, so consumers don't clash). The /// engine writes + frees each archive, so the host never holds N in memory. `progress` fires per -/// cell (serialised) for an import progress bar. Returns the count baked. -pub fn bakeTree(io: std.Io, in_dir: []const u8, out_dir: []const u8, rules_dir: ?[]const u8, workers: usize, progress: BakeProgress, progress_ctx: ?*anyopaque) usize { +/// cell (serialised) for an import progress bar. INCREMENTAL: a cell whose mirrored archive is +/// already at least as new as its whole input (.000 + update chain) is skipped, so a re-run over an +/// unchanged tree bakes nothing. Returns the count baked THIS run; errors if `in_dir` is unreadable. +pub fn bakeTree(io: std.Io, in_dir: []const u8, out_dir: []const u8, rules_dir: ?[]const u8, workers: usize, progress: BakeProgress, progress_ctx: ?*anyopaque) !usize { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); @@ -772,9 +811,9 @@ pub fn bakeTree(io: std.Io, in_dir: []const u8, out_dir: []const u8, rules_dir: var in_paths = std.ArrayList([]const u8).empty; var out_paths = std.ArrayList([]const u8).empty; - var dir = std.Io.Dir.cwd().openDir(io, in_dir, .{ .iterate = true }) catch return 0; + var dir = try std.Io.Dir.cwd().openDir(io, in_dir, .{ .iterate = true }); defer dir.close(io); - var walker = dir.walk(a) catch return 0; + var walker = try dir.walk(a); defer walker.deinit(); while (walker.next(io) catch null) |entry| { if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; @@ -1113,8 +1152,8 @@ pub const Chart = struct { /// The cell's M_COVR(CATCOV=1) data-coverage polygons (polygon -> rings -> /// lon/lat points), for the host to report as chart coverage so OpenCPN quilts - /// gaps to coarser cells. Only the live-cell backend has it (a baked PMTiles - /// carries no coverage polygon); null otherwise. + /// gaps to coarser cells. A live cell parses it; a per-cell baked PMTiles + /// surfaces the copy embedded in its archive metadata. Null when absent. pub fn coverage(self: *const Chart) ?[]const []const []const s57.LonLat { if (self.coverage_override.len > 0) return self.coverage_override; return switch (self.backend) { @@ -1123,9 +1162,10 @@ pub const Chart = struct { }; } - /// The cell's compilation scale (DSPM CSCL, 1:N) — the live-cell chart's native - /// scale, so the host doesn't derive an over-detailed one from the 0..18 zoom - /// range. 0 (unknown) for a PMTiles chart (derive from the zoom band instead). + /// The cell's compilation scale (DSPM CSCL, 1:N), so the host doesn't derive an + /// over-detailed one from the 0..18 zoom range. A live cell parses it; a per-cell + /// baked PMTiles reads the copy in its archive metadata. 0 = unknown (derive from + /// the zoom band instead). pub fn nativeScale(self: *const Chart) i32 { if (self.cscl_override != 0) return self.cscl_override; return switch (self.backend) { From 5c8ad5ddc96e29674a3a260dd147fb68824a394e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 04:53:30 -0400 Subject: [PATCH 094/140] feat(engine): mmap-backed PMTiles path-open; compose over borrowed chart archives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openPmtilesPath maps an archive instead of copying it, and stores the decoded per-cell coverage on the chart. openComposeSourceCharts builds a compositor that borrows already-open charts' readers + coverage (owns_archives=false), so a host opens its library once and composes over the same handles — the never-fully-resident property moves from compose-internal mmaps to the charts. Co-Authored-By: Claude Fable 5 --- src/chart.zig | 58 +++++++++++++++++++- src/compose/compose.zig | 119 ++++++++++++++++++++++++++++++---------- 2 files changed, 147 insertions(+), 30 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index e0c1efb..32124a9 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -459,11 +459,32 @@ fn attachEmbeddedCoverage(src: *Chart) void { }; const cov = (cell_coverage.decodeFromMetadata(a, json) catch null) orelse return drop(cov_arena); if (cov.cscl == 0 and cov.cov1.len == 0) return drop(cov_arena); - src.cscl_override = cov.cscl; - src.coverage_override = cov.cov1; + src.cell_cov = cov; src.coverage_arena = cov_arena; } +/// Open a baked PMTiles archive from a file path, mmap'd rather than copied — a +/// whole chart library can be open without being resident (the page cache holds the +/// working set). The mapping is released in deinit; the file must stay in place for +/// the chart's lifetime. +pub fn openPmtilesPath(io: std.Io, path: []const u8) !*Chart { + var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch return error.NotFound; + defer f.close(io); + const st = f.stat(io) catch return error.IoFailed; + const len: usize = @intCast(st.size); + if (len == 0) return error.InvalidArchive; + const map = std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, f.handle, 0) catch return error.IoFailed; + errdefer std.posix.munmap(map); + var reader = pmtiles.Reader.init(gpa, map) catch return error.InvalidArchive; + const src = gpa.create(Chart) catch { + reader.deinit(); + return error.OutOfMemory; + }; + src.* = .{ .backend = .{ .reader = reader }, .data_map = map, .cache = std.AutoHashMap(u64, []u8).init(gpa) }; + attachEmbeddedCoverage(src); + return src; +} + // Parse (+ apply updates) + portray one cell into a CellBackend. Reads the bytes // but does not take ownership. Portrayal failure is non-fatal (classify() fallback). fn buildCellBackend(base: []const u8, updates: []const []const u8, dir: []const u8) ?CellBackend { @@ -614,6 +635,7 @@ pub fn openCellBaked(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool, if (src.coverage_arena) |ca| { ca.deinit(); gpa.destroy(ca); + src.cell_cov = null; } src.cscl_override = cscl; src.coverage_override = coverage; @@ -965,6 +987,13 @@ pub const Chart = struct { coverage_override: []const []const []const s57.LonLat = &.{}, coverage_arena: ?*std.heap.ArenaAllocator = null, cscl_override: i32 = 0, + // The per-cell coverage decoded from the opened archive's metadata (name, date, + // cscl, bbox, M_COVR rings) — what a compositor borrows to place this chart in + // the ownership partition. Storage lives in coverage_arena. + cell_cov: ?cell_coverage.CellCoverage = null, + // A path-opened archive is mmap'd rather than copied (never fully resident); + // released in deinit. Bytes-opened archives use `data` instead. + data_map: ?[]align(std.heap.page_size_min) const u8 = null, /// Open a source from in-memory bytes. `fmt` selects the backend (`.auto` /// sniffs PMTiles then S-57); `rules_dir` is the S-101 rules dir for cells @@ -1135,6 +1164,7 @@ pub const Chart = struct { while (it.next()) |v| gpa.free(v.*); self.cache.deinit(); if (self.data) |d| gpa.free(d); + if (self.data_map) |m| std.posix.munmap(m); if (self.coverage_arena) |ca| { ca.deinit(); gpa.destroy(ca); @@ -1156,6 +1186,9 @@ pub const Chart = struct { /// surfaces the copy embedded in its archive metadata. Null when absent. pub fn coverage(self: *const Chart) ?[]const []const []const s57.LonLat { if (self.coverage_override.len > 0) return self.coverage_override; + if (self.cell_cov) |cc| { + if (cc.cov1.len > 0) return cc.cov1; + } return switch (self.backend) { .cell => |*c| if (c.coverage.len > 0) c.coverage else null, else => null, @@ -1168,12 +1201,33 @@ pub const Chart = struct { /// the zoom band instead). pub fn nativeScale(self: *const Chart) i32 { if (self.cscl_override != 0) return self.cscl_override; + if (self.cell_cov) |cc| { + if (cc.cscl != 0) return cc.cscl; + } return switch (self.backend) { .cell => |*c| c.cscl, else => 0, }; } + /// The chart's PMTiles reader, for a compositor to borrow (null unless this is an + /// archive-backed chart). The reader lives inside the chart: the chart must outlive + /// every borrower, and a borrower's reads must not run concurrently with this + /// chart's own render/query calls (no internal lock). + pub fn pmtilesReader(self: *Chart) ?*pmtiles.Reader { + return switch (self.backend) { + .reader => |*r| r, + else => null, + }; + } + + /// The per-cell coverage embedded in the opened archive's metadata (name, date, + /// cscl, bbox, M_COVR rings), or null if the archive carries none. Borrows the + /// chart's storage. + pub fn cellCoverage(self: *const Chart) ?cell_coverage.CellCoverage { + return self.cell_cov; + } + /// The tile encoding this chart's tiles carry: a PMTiles backend reports its /// archive's stored tile type (tiles serve verbatim); a cell backend reports its /// live generation format (`tile_format`). Non-vector archive types (png/…) are diff --git a/src/compose/compose.zig b/src/compose/compose.zig index 783bd2c..66cb8be 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -4,10 +4,11 @@ //! baking: it reads already-baked archives and never parses S-57 or runs //! portrayal, so it depends only on the tile/geometry/coverage leaves. //! -//! ComposeSource — resident compositor over mmap'd archives + a partition -//! composeTile — compose one tile (the stateless core ComposeSource uses) -//! openComposeSourceFiles — open archives from disk, load or build the partition -//! clip — the per-face clip-to-owned-geometry core (submodule) +//! ComposeSource — resident compositor over mmap'd archives + a partition +//! composeTile — compose one tile (the stateless core ComposeSource uses) +//! openComposeSourceFiles — open archives from disk, load or build the partition +//! openComposeSourceCharts — same, borrowing already-open charts' readers + coverage +//! clip — the per-face clip-to-owned-geometry core (submodule) const std = @import("std"); const pmtiles = @import("tiles").pmtiles; @@ -232,6 +233,9 @@ pub const ComposeSource = struct { arena: std.heap.ArenaAllocator, // owns the readers/maps arrays + adapted cells (borrowed by part) maps: []const []align(std.heap.page_size_min) const u8, readers: []const *pmtiles.Reader, + // A files-open owns its readers + mmaps (deinit closes them); a charts-open + // borrows them from the charts, which must outlive this source. + owns_archives: bool = true, part: geometry.partition.Partition, minz: u8, maxz: u8, @@ -252,8 +256,10 @@ pub const ComposeSource = struct { pub fn deinit(self: *ComposeSource) void { const gpa = self.gpa; self.part.deinit(); - for (self.readers) |rp| rp.deinit(); - for (self.maps) |m| std.posix.munmap(m); + if (self.owns_archives) { + for (self.readers) |rp| rp.deinit(); + for (self.maps) |m| std.posix.munmap(m); + } self.arena.deinit(); gpa.destroy(self); } @@ -277,15 +283,10 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const var readers = std.ArrayList(*pmtiles.Reader).empty; var maps = std.ArrayList([]align(std.heap.page_size_min) const u8).empty; var shims = std.ArrayList(LoadedCov).empty; - var built_part = false; errdefer { for (readers.items) |rp| rp.deinit(); for (maps.items) |m| std.posix.munmap(m); - if (built_part) src.part.deinit(); } - var minz: u8 = 255; - var maxz: u8 = 0; - var ubox = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; // union coverage [w, s, e, n] for (paths) |path| { var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch continue; const st = f.stat(io) catch { @@ -320,29 +321,91 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const std.posix.munmap(map); continue; }; - const bz = band.bandZooms(band.bandOf(cc.cscl)); - minz = @min(minz, bz.min); - maxz = @max(maxz, bz.max); - const b = [4]f64{ - @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, - @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, - }; - ubox[0] = @min(ubox[0], b[0]); - ubox[1] = @min(ubox[1], b[1]); - ubox[2] = @max(ubox[2], b[2]); - ubox[3] = @max(ubox[3], b[3]); try maps.append(a, map); try readers.append(a, rp); - try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = b }); + try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = covDegBounds(cc) }); + } + if (readers.items.len == 0) { + src.arena.deinit(); + gpa.destroy(src); + return null; + } + return try finishOpen(gpa, src, readers.items, maps.items, shims.items, load_partition, true); +} + +/// One already-open per-cell archive for a compositor to compose over: the archive's +/// PMTiles reader plus the per-cell coverage embedded in its metadata. The compositor +/// BORROWS both — whatever owns them (a chart handle) must outlive the source. +pub const ChartArchive = struct { + reader: *pmtiles.Reader, + cov: coverage.CellCoverage, +}; + +/// Open a resident ComposeSource over already-open charts' archives. Nothing is +/// opened or mmap'd here and deinit closes none of it: every archive is borrowed, +/// so the charts must OUTLIVE this source. Archives without coverage rings are +/// skipped (they can own no ground); returns null if none carries coverage. +/// `load_partition` as in openComposeSourceFiles. +pub fn openComposeSourceCharts(gpa: std.mem.Allocator, archives: []const ChartArchive, load_partition: ?[]const u8) !?*ComposeSource { + const src = try gpa.create(ComposeSource); + src.* = .{ .gpa = gpa, .arena = std.heap.ArenaAllocator.init(gpa), .maps = &.{}, .readers = &.{}, .part = undefined, .minz = 0, .maxz = 0, .loop_max = 0, .bounds = .{ 0, 0, 0, 0 } }; + errdefer { + src.arena.deinit(); + gpa.destroy(src); + } + const a = src.arena.allocator(); + var readers = std.ArrayList(*pmtiles.Reader).empty; + var shims = std.ArrayList(LoadedCov).empty; + for (archives) |ar| { + if (ar.cov.cov1.len == 0) continue; + try readers.append(a, ar.reader); + try shims.append(a, .{ .name = ar.cov.name, .date = ar.cov.date, .cscl = ar.cov.cscl, .coverage = ar.cov.cov1, .bounds = covDegBounds(ar.cov) }); } if (readers.items.len == 0) { src.arena.deinit(); gpa.destroy(src); return null; } + return try finishOpen(gpa, src, readers.items, &.{}, shims.items, load_partition, false); +} + +// The coverage bbox (integer lon/lat e7) as degree bounds [w, s, e, n]. +fn covDegBounds(cc: coverage.CellCoverage) [4]f64 { + return .{ + @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, + @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, + }; +} + +// Shared open tail over aligned (readers, shims): build (or load) the ownership +// partition, derive the zoom range + union bounds, and finish `src`. The arrays +// already live in src.arena; on error the caller's errdefer tears down whatever +// it owns per `owns_archives`. +fn finishOpen( + gpa: std.mem.Allocator, + src: *ComposeSource, + readers: []const *pmtiles.Reader, + maps: []const []align(std.heap.page_size_min) const u8, + shims: []const LoadedCov, + load_partition: ?[]const u8, + owns_archives: bool, +) !*ComposeSource { + const a = src.arena.allocator(); + var minz: u8 = 255; + var maxz: u8 = 0; + var ubox = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; // union coverage [w, s, e, n] + for (shims) |sh| { + const bz = band.bandZooms(band.bandOf(sh.cscl)); + minz = @min(minz, bz.min); + maxz = @max(maxz, bz.max); + ubox[0] = @min(ubox[0], sh.bounds[0]); + ubox[1] = @min(ubox[1], sh.bounds[1]); + ubox[2] = @max(ubox[2], sh.bounds[2]); + ubox[3] = @max(ubox[3], sh.bounds[3]); + } - const cells = try toPlaneCells(a, shims.items); - for (cells, readers.items) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); + const cells = try toPlaneCells(a, shims); + for (cells, readers) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); var loaded = false; if (load_partition) |bytes| { @@ -352,11 +415,11 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const } else |err| std.debug.print(" partition sidecar unusable ({s}); building\n", .{@errorName(err)}); } if (!loaded) src.part = try geometry.partition.build(gpa, cells); - built_part = true; const fill_max = @min(maxz + band.FILLUP_DZ, band.FILLUP_CEIL); - src.maps = maps.items; - src.readers = readers.items; + src.maps = maps; + src.readers = readers; + src.owns_archives = owns_archives; src.minz = minz; src.maxz = maxz; src.loop_max = @max(maxz, fill_max); From 2b0bc7627707e307afa22d6385fb9493007a5500 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 05:11:10 -0400 Subject: [PATCH 095/140] =?UTF-8?q?feat(capi)!:=20rework=20the=20C=20ABI?= =?UTF-8?q?=20=E2=80=94=20bake/render/compose=20sections,=20tile57=20handl?= =?UTF-8?q?e,=20status=20model=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart handle is now `tile57` (sqlite3-style) and opens baked PMTiles ONLY — mmap'd from a path or copied from bytes — carrying the embedded coverage + compilation scale, the S-52 pick, and the four view renders. Raw S-57 reading moves to handle-free tile57_s57_* calls (cells inventory, GeoJSON features from path or bytes, CATALOG.031 decode). The compositor opens over charts, borrowing their archives (charts outlive it). Every fallible export returns tile57_status with an optional tile57_error* and NULL/0 outs on failure; TILE57_ERR_INTERNAL added for unclassified engine errors. Removed: tile57_chart_open/_header/_zoom/_pmtiles/_bytes (S-57), chart_cells, chart_features, and the int/inverted return conventions. Engine drops the orphaned openCellHeader/openCellBaked/openCellPath and the coverage overrides (cell_cov from archive metadata replaces them). Verified end-to-end by a C smoke test: bake -> open -> info/coverage/render -> compose -> serve -> partition sidecar round-trip on real ENC cells. Co-Authored-By: Claude Fable 5 --- include/tile57.h | 870 +++++++++++++++++---------------- src/capi.zig | 1192 +++++++++++++++++++++++----------------------- src/chart.zig | 90 +--- src/errors.zig | 1 + 4 files changed, 1066 insertions(+), 1087 deletions(-) diff --git a/include/tile57.h b/include/tile57.h index 3bd6e18..c3b85c8 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -1,40 +1,53 @@ /* tile57.h — public C ABI for libtile57. * - * libtile57 is an embeddable nautical-chart engine. It turns IHO S-57 ENC cells - * into vector tiles + a matching S-52 style, and renders finished charts to - * pixels / PDF / a callback surface. + * libtile57 is an embeddable nautical-chart engine. It reads IHO S-57 ENC cells, + * bakes them to per-cell PMTiles archives, serves composed vector tiles from + * those archives on demand, renders finished charts to pixels / PDF / a callback + * surface, and generates the matching S-52 MapLibre style + portrayal assets. * - * Tiles are made ONE way: bake each ENC cell to its own PMTiles - * (tile57_bake_cell_bytes), then compose them ON DEMAND through the ownership - * partition (tile57_compose_open / tile57_compose_serve). The composed bytes are - * decompressed vector tiles — MapLibre Tiles (MLT, the default) or Mapbox Vector - * Tiles (MVT) — consumed by any matching renderer (maplibre-gl >= 5.12 decodes - * both natively via the vector source `encoding` option); the ABI is - * renderer-agnostic. + * The pipeline is three stages, and the header is organised the same way: * - * A tile57_chart is a SEPARATE handle used for metadata (open + get_info / cells / - * features / coverage / query / scamin) and for rendering a view to pixels / PDF / - * a callback surface. It does not itself serve (z, x, y) tiles — that is the - * compositor's job. + * 3. BAKE ENC source data in, per-cell PMTiles out. Each cell bakes to its + * own archive at its own compilation scale, with its M_COVR + * coverage + scale embedded in the archive metadata. This is the + * import step; the section also carries the raw-source readers + * (cell inventory, feature extraction, exchange-set catalogue). * - * The sections below: - * 1. Version - * 2. Chart: open + metadata - * 3. Cell baking (the per-cell tiles the compositor stitches) - * 4. Live composing (the runtime compositor) - * 5. Render surface (PNG / PDF / callback canvas + world-space surface) - * 6. Style + portrayal assets - * 7. Util / catalogue / debug + * 4. RENDER a `tile57` chart handle opens ONE baked archive (mmap'd from a + * path, or copied from bytes) and answers for it: metadata + * (info / scamin / coverage), the cursor pick (query), and full + * view renders (PNG / PDF / callback canvas / world-space + * surface). * - * Lifetime: a tile57_chart / tile57_compose_source must OUTLIVE every renderer or - * adapter still holding it. In the MapLibre hosts the handle is captured by a - * long-lived source and intentionally never closed before process exit (closing - * first would be a use-after-free during teardown). Call tile57_chart_close / - * tile57_compose_close only once nothing can still call into it. + * 5. COMPOSE a `tile57_compose` handle stitches MANY open charts into one + * seamless tile pyramid on demand, via the cell-ownership + * partition — what a live tile server hands its HTTP layer. * - * Threading: neither handle is internally synchronized — do not call into the SAME - * handle from multiple threads concurrently (caches are mutated without a lock). - * Distinct handles are independent. + * Section 6 (style + portrayal assets) turns the mariner's S-52 display options + * into a concrete MapLibre style JSON plus the colortables / sprite / pattern / + * glyph atlases it references. The composed tiles are MapLibre Tiles (MLT); + * maplibre-gl >= 5.12 decodes them natively via the vector source `encoding` + * option. The ABI is renderer-agnostic. + * + * Errors: every fallible call returns a tile57_status (TILE57_OK = 0) and takes + * an optional caller-owned tile57_error* it fills on failure — see section 2. + * Out-parameters are always defined on return: the result on TILE57_OK, + * NULL/0 otherwise. "Nothing produced" is NOT a failure — a call that finds + * nothing (a cell that bakes no tiles, a tile nobody owns, a chart with no + * SCAMIN values) returns TILE57_OK with a NULL/zero out. + * + * Lifetime: a handle must OUTLIVE every borrower still holding it: a compositor + * borrows its charts (close the compositor first, then the charts), and in + * the MapLibre hosts a long-lived source captures the handle and intentionally + * never closes it before process exit (closing first would be a use-after-free + * during teardown). A path-opened chart mmaps its file — the file must stay in + * place while the chart is open. + * + * Threading: no handle is internally synchronized — do not call into the SAME + * handle from multiple threads concurrently (caches are mutated without a + * lock). A compositor reads through its charts, so while it serves, do not + * call those charts' own methods from other threads. Distinct handles are + * independent. * * Memory: calls that return bytes allocate *out; release it with tile57_free, * passing the same length. All pointers are POD across the seam. @@ -57,27 +70,25 @@ extern "C" { * 1. Version * ======================================================================== */ -/* Library version. tile57_version() returns the string form, e.g. "0.1.0". */ +/* Library version. tile57_version() returns the string form, e.g. "0.2.0". */ #define TILE57_VERSION_MAJOR 0 -#define TILE57_VERSION_MINOR 1 +#define TILE57_VERSION_MINOR 2 #define TILE57_VERSION_PATCH 0 const char *tile57_version(void); /* ======================================================================== * - * Errors + * 2. Errors * * A fallible call returns a tile57_status; TILE57_OK (0) is success and every - * other value is a failure with a coarse cause. Results (handles, byte buffers) - * come back through out-parameters. "Nothing produced" is NOT a failure — the - * call returns TILE57_OK with a NULL/zero out (e.g. a cell that bakes no tiles, - * or composing open ocean). + * other value is a failure with a coarse cause. Results come back through + * out-parameters, which are always defined on return (NULL/0 on failure). * * tile57_status_str() gives a static, human-readable string for a status (the * strerror pattern). Where the specific cause is worth carrying — which path, - * which errno — the call also takes an optional tile57_error*: pass NULL to - * ignore it, or a caller-owned struct (a stack local is fine) the call fills with - * the status + a message on failure and leaves untouched on success. There is no - * allocation and nothing to free. + * which parse failure — the call also takes an optional tile57_error*: pass + * NULL to ignore it, or a caller-owned struct (a stack local is fine) the call + * fills with the status + a message on failure and leaves untouched on + * success. There is no allocation and nothing to free. * ======================================================================== */ typedef enum { @@ -86,8 +97,9 @@ typedef enum { TILE57_ERR_IO, /* a file/directory could not be opened, read, or written */ TILE57_ERR_PARSE, /* malformed input (S-57 cell, PMTiles, partition, JSON) */ TILE57_ERR_NOMEM, /* an allocation failed */ - TILE57_ERR_UNSUPPORTED, /* valid but unsupported input */ + TILE57_ERR_UNSUPPORTED, /* valid but unusable input (e.g. no coverage-carrying chart) */ TILE57_ERR_RENDER, /* tile generation or rendering failed */ + TILE57_ERR_INTERNAL, /* an unexpected engine failure (please report) */ } tile57_status; /* A static, human-readable string for a status. Never NULL, never freed. */ @@ -100,273 +112,313 @@ typedef struct { } tile57_error; /* ======================================================================== * - * 2. Chart: open + metadata + * 3. Bake + * + * ENC source data in, per-cell PMTiles archives out. The composite model bakes + * each cell to its own archive over its NATIVE band zoom range at its own + * compilation scale; the compositor (section 5) stitches them on demand, so + * baking never composes and re-importing one cell never re-bakes the rest. + * + * A per-cell archive's metadata embeds the cell's M_COVR coverage, compilation + * scale, and identity — everything a chart handle or compositor needs to place + * it, with no .000 re-parse (read it back with tile57_pmtiles_metadata). + * + * Every emitted vector-tile feature carries the per-feature pick/inspector + * properties used by the S-52 §10.8 pick report: `class` (object-class + * acronym), `cell` (source cell name), and `s57` (a JSON object of the + * feature's full S-57 attribute set, acronym -> value). tile57_query and a + * host inspector read these back. + * + * The section also carries the raw-source readers a host's import UI needs: + * the cell inventory of a path, GeoJSON feature extraction, and the + * exchange-set catalogue decode. + * ======================================================================== */ + +/* ---- reading ENC source data --------------------------------------------- */ + +/* The per-cell metadata of the S-57 data at `path` — ONE cell (a .000 file, + * with its .001.. update chain auto-read from the same directory) or a whole + * ENC_ROOT directory — as a JSON array, one object per cell: + * [{"name":"US5MD1MC","scale":12000,"edition":"13","update":"3", + * "issueDate":"20240105","agency":550,"bbox":[west,south,east,north]}, ...] + * `name` is the DSNM stem; `scale` is DSPM CSCL; edition/update/issueDate/ + * agency are DSID EDTN/UPDN/ISDT/AGEN after the update chain is applied; + * `bbox` is omitted when none parses. For a host's chart-database scan. + * TILE57_OK with the JSON in *out / *out_len (free with tile57_free). */ +tile57_status tile57_s57_cells(const char *path, uint8_t **out, size_t *out_len, + tile57_error *err); + +/* The features of the S-57 data at `path` (one cell or a whole ENC_ROOT) for + * the given object classes (comma-separated acronyms, e.g. "DEPARE,DRGARE") as + * a GeoJSON FeatureCollection: geometry in lon/lat (Polygon rings + * largest-first, MultiPoint with depths for soundings, LineString/Point as + * encoded), properties = {"class":"DEPARE", ...the feature's full S-57 + * acronym->value attribute map}. Parsed without portrayal; a whole-ENC_ROOT + * extraction walks every cell — the caller owns that cost. TILE57_OK with the + * JSON in *out / *out_len (free with tile57_free); NULL/0 when nothing + * matched. */ +tile57_status tile57_s57_features(const char *path, const char *classes, + uint8_t **out, size_t *out_len, + tile57_error *err); + +/* tile57_s57_features over in-memory base-cell bytes (a .000 read from a zip + * member, say) instead of a path. No update chain is applied. */ +tile57_status tile57_s57_features_bytes(const uint8_t *base, size_t len, + const char *classes, + uint8_t **out, size_t *out_len, + tile57_error *err); + +/* Decode a CATALOG.031 exchange-set catalogue (raw bytes) into a JSON array of + * its CATD entries: + * [{"file":"US5MD1MC/US5MD1MC.000","longName":"Annapolis Harbor", + * "impl":"BIN","bbox":[west,south,east,north]}, ...] + * `file` is the recorded path with separators normalised to '/'; `longName` is + * LFIL (the human chart title; empty when absent); `impl` is BIN/ASC/TXT; + * `bbox` is omitted when SLAT/WLON/NLAT/ELON are not all present (aux files). + * TILE57_OK with the JSON in *out / *out_len (free with tile57_free); NULL/0 + * when the file holds no CATD records. */ +tile57_status tile57_s57_catalog(const uint8_t *catalog_031, size_t len, + uint8_t **out, size_t *out_len, + tile57_error *err); + +/* ---- baking cells to per-cell archives ------------------------------------ */ + +/* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over + * its NATIVE band zoom range and nothing else. Returned in *out / *out_len + * (free with tile57_free); NULL/0 when the cell produced no tiles. For a host + * persisting a per-cell tile cache to disk — open the archives as charts + * (section 4) and compose them (section 5). */ +tile57_status tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len, + tile57_error *err); + +/* Bake `n` cells (each a .000 path; updates auto-read) to per-cell PMTiles + * bytes IN PARALLEL across up to `workers` threads. The engine returns BYTES + * only — it never writes an output directory; the host writes each archive + * into the cache it manages. out_bytes[i] / out_lens[i] receive cell i's + * archive (free each with tile57_free) or NULL/0 when that cell produced + * nothing; both arrays are caller-allocated, length n. *out_baked (NULL to + * ignore) receives the number of cells that produced bytes. `workers` is a + * MEMORY bound — each concurrent bake holds a whole cell's + * parse+portray+raster working set, so pass a small count (not a core count). + * Warms up the process globals internally, so concurrent baking is race-free. */ +tile57_status tile57_bake_cells(const char *const *paths, size_t n, uint32_t workers, + uint8_t **out_bytes, size_t *out_lens, + size_t *out_baked, tile57_error *err); + +/* Progress callback for tile57_bake_tree: fires after each cell with (done, + * total). May be called CONCURRENTLY from worker threads — make it + * thread-safe. */ +typedef void (*tile57_bake_progress)(void *ctx, uint32_t done, uint32_t total); + +/* Walk `in_dir` for S-57 base cells (*.000) and bake each IN PARALLEL to the + * SAME relative path under `out_dir` with a .pmtiles extension + * (in_dir/d1/US4CT1AA.000 -> out_dir/d1/US4CT1AA.pmtiles), plus an .sha + * content-hash sidecar; output subdirs are created as needed. The engine + * writes and frees each archive as it goes, so the host never holds N archives + * in memory (peak ~ workers). INCREMENTAL: a cell whose mirrored archive is + * already at least as new as its whole input (.000 + update chain) is skipped, + * so a re-run over an unchanged tree bakes nothing — *out_baked (NULL to + * ignore) counts the cells baked THIS run, and 0 over a warm cache is success, + * not failure. `in_dir` is the source ENC data; `out_dir` is the caller's OWN + * cache — it owns the location + layout, so distinct library consumers each + * keep their own chart library without clashing. `workers` is a MEMORY bound — + * pass a small count. An unreadable `in_dir` is TILE57_ERR_IO. */ +tile57_status tile57_bake_tree(const char *in_dir, const char *out_dir, uint32_t workers, + tile57_bake_progress progress, void *progress_ctx, + uint32_t *out_baked, tile57_error *err); + +/* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / + * *out_len (free with tile57_free); NULL/0 when the archive carries none. A + * per-cell bake embeds the cell's M_COVR coverage + cscl + date/name under a + * "coverage" key. */ +tile57_status tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, + uint8_t **out, size_t *out_len, + tile57_error *err); + +/* Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) + * into a single PMTiles at out_path: the composited ownership faces (which + * cell renders which ground at each band), one polygon per owning cell tagged + * with the properties cell/cscl/band/tier/oi/color, and NO portrayed chart + * content — for building a partition-debug UI. band < 0 emits the band + * GOVERNING each zoom (the natural view); 0..5 (berthing..overview) emits one + * band's own map at every zoom. minzoom/maxzoom bound the tiles (harbor-level + * detail needs maxzoom >= 13; coarser bands are much cheaper). TILE57_OK with + * *out_cell_count (NULL to ignore) = the covered-cell count; 0 with no file + * written when nothing is covered. */ +tile57_status 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, tile57_error *err); + +/* ======================================================================== * + * 4. Render * - * A tile57_chart is the metadata + render handle: open a cell (or a baked - * PMTiles) and read its bounds / scale / coverage / features, query the feature - * under a point, or render a view (section 5). Tile production is separate — - * see sections 3 (bake) and 4 (compose). + * A `tile57` is a chart: ONE baked PMTiles archive, opened for metadata and + * rendering. Open it from a path (mmap'd — a whole chart library can be open + * without being resident) or from bytes (copied). It reports the archive's + * zoom range / bounds / tile encoding, the coverage + compilation scale the + * bake embedded, and the live SCAMIN manifest; it answers the S-52 cursor + * pick; and it renders any VIEW (centre + fractional zoom + pixel size) as a + * finished PNG, a vector PDF, resolved pixel-space draw calls, or a + * world-space semantically-tagged stream. + * + * Rendering REPLAYS the archive's baked tiles through the native S-52 pixel + * path: one whole scene across every covering tile, labels decluttered over + * the full canvas (no tile seams), symbols replayed as vectors from the + * catalogue. The mariner's live-swappable settings — colour scheme, safety + * contour danger/sounding swaps, category/SCAMIN/text gates, size scale — + * re-evaluate at render time; the rest of the portrayal context was fixed at + * bake time. * ======================================================================== */ -/* Opaque chart handle. */ -typedef struct tile57_chart tile57_chart; - -/* Open ONE S-57 cell (a .000 file, with its .001.. update chain auto-read from the - * same directory) OR a whole ENC_ROOT directory, via the STREAMING path: each cell's - * metadata (name, compilation scale, M_COVR coverage) is enumerated up front and - * tiles are baked lazily, per requested tile — there is no upfront full-cell bake. - * This backend exposes the per-cell list (tile57_chart_cells) and the render/query - * surface. See the header/zoom variants for a metadata-only scan or a progressive - * narrow-band open. TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ -tile57_status tile57_chart_open(const char *path, tile57_chart **out, tile57_error *err); - -/* Open ONE cell for METADATA ONLY — bbox, native_scale, and M_COVR coverage — via a - * cheap parse with NO tile bake, for a host's chart-database/header scan. Do NOT - * render_surface this handle (it has no portrayal). TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ -tile57_status tile57_chart_open_header(const char *path, tile57_chart **out, tile57_error *err); - -/* Open ONE cell baking only [minzoom, maxzoom] to an in-memory PMTiles: bake a narrow - * native band fast for first paint, then re-open the full range in the background - * (progressive load). Renders via the fast reader path. TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ -tile57_status tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t maxzoom, tile57_chart **out, tile57_error *err); - -/* Open one in-memory ENC cell (base .000 bytes) as a resident chart. Bytes are copied. - * TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ -tile57_status tile57_chart_open_bytes(const uint8_t *base, size_t len, tile57_chart **out, tile57_error *err); - -/* Open a baked PMTiles bundle from a file path. TILE57_OK with *out set; else a tile57_status (err filled if non-NULL). */ -tile57_status tile57_chart_open_pmtiles(const char *path, tile57_chart **out, tile57_error *err); - -/* Vector-tile encodings the engine produces (reported in tile57_chart_info.tile_type; - * the compositor serves MLT). */ +/* Opaque chart handle: one open baked archive. */ +typedef struct tile57 tile57; + +/* Open a baked PMTiles archive from a file path, mmap'd (never fully + * resident). The file must stay in place while the chart is open. TILE57_OK + * with *out set (close with tile57_close). */ +tile57_status tile57_open(const char *path, tile57 **out, tile57_error *err); + +/* Open a baked PMTiles archive from in-memory bytes (e.g. straight from + * tile57_bake_cell_bytes, before any file exists). Bytes are copied. */ +tile57_status tile57_open_bytes(const uint8_t *pmtiles, size_t len, + tile57 **out, tile57_error *err); + +/* Vector-tile encodings an archive can store (reported in + * tile57_info.tile_type; the engine bakes MLT). */ typedef enum { TILE57_TILE_TYPE_MVT = 1, /* Mapbox Vector Tile */ - TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the default) */ + TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the bake default) */ } tile57_tile_type; -/* Fixed chart metadata — folds zoom_range/bands/bounds/anchor into one - * getter. Bounds/anchor validity are flagged (false -> those fields are 0). - * tile_type is the vector-tile encoding for this chart's tiles (a - * tile57_tile_type): a PMTiles-backed chart reports its archive's stored type; a - * cell-backed chart reports the engine's default bake encoding. A host passes it - * to tile57_style_template so the renderer decodes the tiles correctly. */ +/* Fixed chart metadata. Bounds/anchor validity are flagged (false -> those + * fields are 0). tile_type is the archive's stored encoding (a + * tile57_tile_type); a host passes it to tile57_style_template so the renderer + * decodes the tiles correctly. native_scale is the compilation scale (1:N) the + * bake embedded in the archive metadata; 0 = unknown (a composed/foreign + * archive — derive the scale from the zoom band instead). */ typedef struct { uint8_t min_zoom, max_zoom; uint32_t bands; /* bitmask of navigational bands present */ bool has_bounds; double west, south, east, north; bool has_anchor; double anchor_lat, anchor_lon, anchor_zoom; uint8_t tile_type; /* tile57_tile_type */ - int32_t native_scale; /* compilation scale (1:N) for a live cell; 0 = unknown - * (PMTiles: derive the scale from the zoom band instead) */ -} tile57_chart_info; -void tile57_chart_get_info(tile57_chart *chart, tile57_chart_info *out); - -/* The distinct SCAMIN denominators present in the chart (the live SCAMIN manifest), - * ascending — the host publishes these so its style builds one native fractional- - * minzoom bucket layer per value (so features honour their 1:N min-display-scale at - * zero per-zoom cost). A PMTiles chart reads them from the archive metadata; a cell - * / ENC_ROOT chart scans every cell's features (parsed without portrayal; streamed - * cells are read transiently). On success returns 1 with *out pointing at *out_len - * int32 values; 0 if there are none; -1 on error. Free *out with + int32_t native_scale; +} tile57_info; +void tile57_get_info(tile57 *chart, tile57_info *out); + +/* The distinct SCAMIN denominators present in the chart (read from the archive + * metadata), ascending — the host publishes these so its style builds one + * native fractional-minzoom bucket layer per value (features honour their 1:N + * min-display-scale at zero per-zoom cost). TILE57_OK with *out pointing at + * *out_len int32 values, or NULL/0 when the chart has none. Free *out with * tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ -int tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len); - -/* The chart's per-cell metadata as a JSON array, one object per cell: - * [{"name":"US5MD1MC","scale":12000,"edition":"13","update":"3", - * "issueDate":"20240105","agency":550,"bbox":[west,south,east,north]}, ...] - * `name` is the DSNM stem; `scale` is DSPM CSCL; edition/update/issueDate/ - * agency are DSID EDTN/UPDN/ISDT/AGEN after the cell's update chain is - * applied; `bbox` is the cell's geographic extent, omitted when none parses. - * Returns 1 with *out / *out_len holding the JSON (free with tile57_free); - * 0 if the chart has no cells (e.g. a PMTiles chart — its bundle manifest - * carries the cell inventory); -1 on error. */ -int tile57_chart_cells(tile57_chart *chart, uint8_t **out, size_t *out_len); - -/* The chart's features for the given object classes (comma-separated - * acronyms, e.g. "DEPARE,DRGARE") as a GeoJSON FeatureCollection: geometry - * in lon/lat (Polygon rings largest-first, MultiPoint with depths for - * soundings, LineString/Point as encoded), properties = {"class":"DEPARE", - * ...the feature's full S-57 acronym->value attribute map}. Parsed without - * portrayal; a whole-ENC_ROOT query walks every cell — the caller owns that - * cost. Returns 1 with *out / *out_len holding the JSON (free with - * tile57_free); 0 if no features matched; -1 on error. */ -int tile57_chart_features(tile57_chart *chart, const char *classes, - uint8_t **out, size_t *out_len); - -/* The chart's M_COVR(CATCOV=1) data-coverage polygons — the real coverage a host - * reports so a quilt fills gaps to coarser cells (vs. the bounding box). ring() is - * called once per polygon with its exterior ring as `npts` interleaved lon,lat - * doubles (valid only during the call). Only the live-cell backend (an opened - * .000) carries this; a baked PMTiles returns 0 with no calls. 0 ok, -1 bad args. */ +tile57_status tile57_scamin(tile57 *chart, int32_t **out, size_t *out_len, + tile57_error *err); + +/* The chart's M_COVR(CATCOV=1) data-coverage polygons, from the coverage the + * bake embedded in the archive metadata — the real coverage a host reports so + * a quilt fills gaps to coarser cells (vs. the bounding box). ring() is called + * once per polygon with its exterior ring as `npts` interleaved lon,lat + * doubles (valid only during the call). A chart whose archive embeds no + * coverage (a composed/foreign archive) is TILE57_OK with no calls. */ typedef struct { void *ctx; void (*ring)(void *ctx, const double *lonlat, size_t npts); } tile57_coverage_cb; -int tile57_chart_coverage(tile57_chart *chart, const tile57_coverage_cb *cb); - -/* Cursor object-query (S-52 pick): feature() is invoked once per feature the - * point (lon,lat) falls in — area point-in-polygon, line/point within a small - * radius — with the S-57 object-class acronym, the attribute JSON (acronym->value), - * and the source cell name. Pointers are valid only for the duration of the call. */ +tile57_status tile57_coverage(tile57 *chart, const tile57_coverage_cb *cb, + tile57_error *err); + +/* Cursor object-query (S-52 §10.8 pick): feature() is invoked once per feature + * the point (lon,lat) falls in — area point-in-polygon, line/point within a + * small radius — with the S-57 object-class acronym, the attribute JSON + * (acronym -> value), and the source cell name. Pointers are valid only for + * the duration of the call. */ typedef struct { void *ctx; void (*feature)(void *ctx, const char *cls, size_t cls_len, const char *s57, size_t s57_len, const char *cell, size_t cell_len); } tile57_query_cb; -/* `zoom` is the current view's web-mercator zoom: the query uses the tile at that - * zoom, so it reports the features actually DISPLAYED there (SCAMIN-bucketed) and - * the pick tolerance tracks on-screen distance. Returns 0 ok, -1 bad args. */ -int tile57_chart_query(tile57_chart *chart, double lon, double lat, double zoom, - const tile57_query_cb *cb); - -/* Release a chart and all cached tiles. Must not be called while any - * renderer/adapter may still render from it (see the lifetime note above). */ -void tile57_chart_close(tile57_chart *chart); +/* `zoom` is the current view's web-mercator zoom: the query reads the tile at + * that zoom, so it reports the features actually DISPLAYED there + * (SCAMIN-bucketed) and the pick tolerance tracks on-screen distance. */ +tile57_status tile57_query(tile57 *chart, double lon, double lat, double zoom, + const tile57_query_cb *cb, tile57_error *err); -/* ======================================================================== * - * 3. Cell baking +/* ---- mariner settings ------------------------------------------------------ * - * The composite model bakes each ENC cell to its own PMTiles at that cell's - * compilation scale; the runtime compositor (section 4) stitches them on demand. - * ======================================================================== */ - -/* Pick-report attributes. Every emitted vector-tile feature carries the per-feature - * cursor-pick / inspector properties used by the S-52 §10.8 pick report: `class` - * (object-class acronym), `cell` (source cell name, the file stem), and `s57` (a - * JSON object string of the feature's full S-57 attribute set, acronym -> value). - * These are baked into the per-cell tiles and are what tile57_chart_query and a - * host inspector read back. */ - -/* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over its - * NATIVE band zoom range and nothing else — the composite model bakes each cell at its - * own compilation scale; the compositor combines them and handles any cross-band zoom. - * Returned in *out / *out_len (free with tile57_free). For a host to persist a per-cell - * tile cache to disk — then feed the archives to tile57_compose_open. The metadata - * embeds the cell's coverage (read via tile57_pmtiles_metadata). 1=ok, 0=nothing baked, - * -1=error. */ -int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); - -/* Bake `n` cells (each a .000 path; its .001.. updates auto-read) to per-cell PMTiles bytes IN - * PARALLEL across up to `workers` threads. The engine returns BYTES only — it never writes an - * output directory; the host writes each archive into the cache it manages. out_bytes[i] / - * out_lens[i] receive cell i's archive (free each with tile57_free) or NULL/0 when that cell - * produced nothing. Both arrays are caller-allocated, length n. `workers` is a MEMORY bound — - * each concurrent bake holds a whole cell's parse+portray+raster working set, so pass a small - * count (not a core count). Warms up the process globals internally, so concurrent baking is - * race-free. Returns the number of cells that produced bytes, or -1 on bad args. */ -int tile57_bake_cells(const char *const *paths, size_t n, uint32_t workers, - uint8_t **out_bytes, size_t *out_lens); - -/* Walk `in_dir` for S-57 base cells (*.000) and bake each IN PARALLEL to the SAME relative path - * under `out_dir` with a .pmtiles extension (in_dir/d1/US4CT1AA.000 -> out_dir/d1/US4CT1AA.pmtiles), - * plus an .sha content-hash sidecar; output subdirs are created as needed. The engine writes - * and frees each archive as it goes, so the host never holds N archives in memory (peak ~ workers). - * `in_dir` is the source ENC data; `out_dir` is the caller's OWN cache — it owns the location + the - * layout, so distinct library consumers each keep their own chart library without clashing. - * `workers` is a MEMORY bound — pass a small count. `progress(progress_ctx, done, total)` (or NULL) - * fires after each cell for an import progress bar; it may be called CONCURRENTLY from worker - * threads, so it must be thread-safe. Returns the number of cells baked, or -1. */ -typedef void (*tile57_bake_progress)(void *ctx, uint32_t done, uint32_t total); -int tile57_bake_tree(const char *in_dir, const char *out_dir, uint32_t workers, - tile57_bake_progress progress, void *progress_ctx); + * The mariner's S-52 display options: shared by the view renderers below + * (evaluated live per render) and the style builders in section 6 (baked into + * the style JSON). Fill it from your UI, or start from + * tile57_mariner_defaults. */ -/* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / *out_len - * (free with tile57_free). A single-cell bake embeds that cell's M_COVR coverage + - * cscl + date/name under a "coverage" key, so the composite stitcher rebuilds the - * ownership partition without re-parsing the .000. 1=ok, 0=no metadata, -1=error. */ -int tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, - uint8_t **out, size_t *out_len); - -/* ======================================================================== * - * 4. Live composing (the runtime compositor) - * - * Holds the per-cell PMTiles (section 3) mmap'd and the ownership partition - * resident, so any tile is composed on demand for the cost of a classify + one - * decode/clip or a decompress. Open once, serve many, close. - * ======================================================================== */ - -/* Opaque runtime-compositor handle. */ -typedef struct tile57_compose_source tile57_compose_source; - -/* Coverage/zoom summary filled by tile57_compose_meta_get. */ -typedef struct { - uint8_t min_zoom; - uint8_t max_zoom; /* deepest zoom served (native windows + one fill-up overscale zoom) */ - uint32_t cells; /* coverage-carrying archives held */ - double west, south, east, north; /* union coverage bounds, degrees */ -} tile57_compose_meta; - -/* Open a resident compositor over the `n` per-cell PMTiles at `paths` (each from - * tile57_bake_cell_bytes, on disk), mmap'd so the cell set is never fully resident. - * `partition_path` (NULL to skip) names a partition sidecar — written by - * tile57_compose_save_partition (the `tile57 bake` CLI emits one as partition.tpart) — - * to load and skip the build; a missing/stale one falls back to building. On - * TILE57_OK *out is the handle (free with tile57_compose_close); no - * coverage-carrying archive is TILE57_ERR_PARSE. */ -tile57_status tile57_compose_open(const char *const *paths, size_t n, - const char *partition_path, - tile57_compose_source **out, tile57_error *err); - -/* Compose the tile (z,x,y) on demand into RAW (decompressed) MLT in *out / *out_len (free with - * tile57_free) — what a live tile server hands its HTTP layer (which gzips on the wire). Returns: - * 1 served (bytes in *out / *out_len), - * 2 OWNED but empty — a cell owns this ground per the ownership partition but produced nothing - * (transient while its per-cell bake is still running; an error state once bakes are done), - * 0 not owned — true empty ocean (no cell owns this ground; safe to cache), - * -1 error. */ -int tile57_compose_serve(tile57_compose_source *src, uint8_t z, uint32_t x, uint32_t y, - uint8_t **out, size_t *out_len); - -/* Fill *out with the compositor's zoom range + union coverage bounds. */ -void tile57_compose_meta_get(tile57_compose_source *src, tile57_compose_meta *out); +typedef enum { TILE57_SCHEME_DAY=0, TILE57_SCHEME_DUSK=1, TILE57_SCHEME_NIGHT=2 } tile57_scheme; +typedef enum { TILE57_DEPTH_METERS=0, TILE57_DEPTH_FEET=1 } tile57_depth_unit; +typedef enum { TILE57_BOUNDARY_SYMBOLIZED=0, TILE57_BOUNDARY_PLAIN=1 } tile57_boundary_style; -/* Serialize the compositor's ownership partition to the file `path` (a sidecar a later - * tile57_compose_open can load to skip the build). Returns 1 ok, -1 on error. */ -int tile57_compose_save_partition(tile57_compose_source *src, const char *path); +typedef struct tile57_mariner { + tile57_scheme scheme; + double shallow_contour, safety_contour, deep_contour, safety_depth; + bool four_shade_water; + tile57_depth_unit depth_unit; + bool display_base, display_standard, display_other; + bool data_quality, show_inform_callouts, show_meta_bounds, show_isolated_dangers_shallow; + tile57_boundary_style boundary_style; + bool simplified_points, show_full_sector_lines; + bool text_names, show_light_descriptions, text_other; + bool date_dependent, highlight_date_dependent; + char date_view[9]; /* "YYYYMMDD" or "" (empty -> today) */ + bool ignore_scamin; /* host debug toggle: drop SCAMIN scale-gating so every + * feature shows in-band (the *_scamin layers become a + * single ungated layer). NOT an S-52 setting. Default false. */ + double size_scale; /* physical-scale multiplier applied to icon-size / + * line-width / text-size (@2x, physical calibration). + * NOT an S-52 setting. 1.0 = catalogue sizes verbatim. */ + const int32_t *viewing_groups_off; /* S-52 §14.5 fine-grained viewing-group control: + * a DENY-LIST of the raw `vg` ids the mariner turned OFF. The + * pointee must outlive the call it is passed to. NULL/len 0 -> + * every viewing group shown. */ + uint32_t viewing_groups_off_len; + bool scamin_filter_gate; /* gate SCAMIN with a live client-driven filter instead of + * per-value bucket layers — one *_scamin layer per render-type + * (no minzoom buckets); the client rewrites the SCAMIN clause on + * boundary crossings. NOT an S-52 setting. Default false. */ + bool show_overscale; /* S-52 §10.1.10 overscale indication: the AP(OVERSC01) + * vertical-line hatch over regions displayed finer than their + * compilation scale (drives the `overscale` layer's + * visibility). Default true. */ +} tile57_mariner; -/* Release a compositor opened by tile57_compose_open (munmaps the archives, frees the partition). */ -void tile57_compose_close(tile57_compose_source *src); +/* Fill *m with the canonical default mariner settings (so a host needn't + * hardcode them). date_view is set to "" (today). */ +void tile57_mariner_defaults(tile57_mariner *m); -/* ======================================================================== * - * 5. Render surface +/* ---- view renders ---------------------------------------------------------- * - * A general render-surface primitive: portray a VIEW of a chart (centre + - * fractional zoom + pixel size) once and emit it as a finished PNG, a vector PDF, - * resolved pixel-space draw calls, or a world-space semantically-tagged stream. - * Make a surface, render it anywhere — a PNG file, a GPU chart app, a PDF printer. - * ======================================================================== */ - -struct tile57_mariner; /* fwd; defined in section 6 (style) */ - -/* Render a VIEW of the chart — centre + fractional zoom + pixel size — to a - * PNG through the native S-52 pixel path: the mariner settings evaluate LIVE - * (real safety contour + category/SCAMIN/text-group gates + day/dusk/night - * palette), catalogue symbols replay as vectors, and labels declutter over - * the whole canvas (no tile seams). `m` NULL = canonical defaults (see - * tile57_mariner_defaults; declared below). Physical calibration / @2x is - * m->size_scale. Returns 0 with *out and *out_len set (free with tile57_free); - * -1 bad handle, -2 render failure, -3 unsupported source (a baked PMTiles - * chart carries no portrayal to render from). */ -int tile57_chart_render_view(tile57_chart *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const struct tile57_mariner *m, - uint8_t **out, size_t *out_len); - -/* tile57_chart_render_view's vector twin: the SAME scene as a deterministic - * single-page PDF (1 px = 1 pt, 72 dpi; vector fills + native strokes + - * glyph-outline text). Same parameters, returns, and ownership. */ -int tile57_chart_render_pdf(tile57_chart *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const struct tile57_mariner *m, - uint8_t **out, size_t *out_len); - -/* ---- callback Canvas: tile57_chart_render_view's GPU/vector twin ---------- - * Run the SAME view portrayal as tile57_chart_render_view, but paint every - * resolved, flattened primitive through a table of C function pointers instead - * of rasterising to PNG. The embedder (e.g. a GPU chart app) feeds these to - * its own renderer. Geometry is emitted in canvas PIXEL space (y down), in - * final paint order; colours are fully resolved for the active palette. */ + * All four render the SAME scene for a VIEW — centre (lon, lat) + fractional + * zoom + pixel size — and differ only in what they emit. `m` NULL = canonical + * defaults. width/height must be 1..16384; larger or zero is + * TILE57_ERR_BADARG. */ + +/* The view as a PNG, in *out / *out_len (free with tile57_free). */ +tile57_status tile57_render_view(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); + +/* The view as a deterministic single-page vector PDF (1 px = 1 pt, 72 dpi; + * vector fills + native strokes + glyph-outline text), in *out / *out_len + * (free with tile57_free). */ +tile57_status tile57_render_pdf(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); + +/* ---- callback Canvas: the pixel-space callback twin ------------------------ + * The same view painted through a table of C function pointers instead of + * rasterising to PNG — the embedder (e.g. a GPU chart app) feeds these to its + * own renderer. Geometry is emitted in canvas PIXEL space (y down), in final + * paint order; colours are fully resolved for the active palette. */ typedef struct { float x, y; } tile57_point; /* canvas pixels */ typedef struct { uint8_t r, g, b, a; } tile57_rgba; /* resolved straight-alpha */ /* A multi-ring path: flat vertex array `pts`; ring k spans @@ -392,24 +444,19 @@ typedef struct { void (*draw_glyphs) (void *ctx, const tile57_rings *outline, tile57_rgba color, tile57_rgba halo, float halo_px); } tile57_canvas_cb; -/* Returns (INVERTED, matches tile57_chart_render_view): 0 ok / -1 bad handle / - * -2 render failure / -3 unsupported source. Same threading rules as the rest - * of a tile57_chart (serialise per handle). */ -int tile57_chart_render_view_cb(tile57_chart *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const struct tile57_mariner *m, - const tile57_canvas_cb *canvas); - -/* ---- world-space Surface callback: the GPU vector twin ---------------------- - * tile57_chart_render_surface_cb runs the SAME view portrayal as - * tile57_chart_render_view_cb but emits a WORLD-SPACE, semantically TAGGED - * stream rather than resolved pixels: area/line geometry in web-mercator [0,1] - * (y down); point symbols and text as a WORLD anchor + a LOCAL outline in - * reference px (a constant screen size); every draw call tagged with its - * feature's S-57 class and SCAMIN. A GPU host applies its own view transform, - * pins symbols/text at the anchor, and culls by SCAMIN per frame — so pan and - * zoom re-portray NOTHING. Works for a baked bundle (tile replay) or a live - * cell (full S-52 portrayal). */ +tile57_status tile57_render_view_cb(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_canvas_cb *canvas, tile57_error *err); + +/* ---- world-space Surface callback: the GPU vector twin --------------------- + * The same view emitted as a WORLD-SPACE, semantically TAGGED stream rather + * than resolved pixels: area/line geometry in web-mercator [0,1] (y down); + * point symbols and text as a WORLD anchor + a LOCAL outline in reference px + * (a constant screen size); every draw call tagged with its feature's S-57 + * class and SCAMIN. A GPU host applies its own view transform, pins + * symbols/text at the anchor, and culls by SCAMIN per frame — so pan and zoom + * re-portray NOTHING. */ typedef struct { double x, y; } tile57_world_point; /* web-mercator [0,1], y down */ typedef struct { float x, y; } tile57_local_point; /* anchor-relative reference px */ @@ -464,11 +511,74 @@ typedef struct { void (*draw_text_str)(void *ctx, const tile57_feature *f, tile57_world_point anchor, float ox_px, float oy_px, const char *text, size_t text_len, float size_px, tile57_rgba color, tile57_rgba halo); } tile57_surface_cb; -/* Returns 0 ok / -1 bad handle / -2 render failure / -3 unsupported source. */ -int tile57_chart_render_surface_cb(tile57_chart *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const struct tile57_mariner *m, - const tile57_surface_cb *surface); +tile57_status tile57_render_surface_cb(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); + +/* Release a chart and all cached tiles. Must not be called while any borrower + * (a compositor, a renderer thread) may still read from it. */ +void tile57_close(tile57 *chart); + +/* ======================================================================== * + * 5. Compose + * + * The runtime compositor: MANY open charts in, one seamless tile pyramid out. + * It builds (or loads) the cell-ownership partition over the charts' embedded + * coverage, then composes any (z, x, y) on demand for the cost of a classify + * plus one decompress or one decode/clip — what a live tile server hands its + * HTTP layer. Open once, serve many, close. + * + * The compositor BORROWS its charts (their mmap'd archives and decoded + * coverage): every chart must outlive the compositor, and while it serves, do + * not call those charts' own methods from other threads. + * ======================================================================== */ + +/* Opaque runtime-compositor handle. */ +typedef struct tile57_compose tile57_compose; + +/* Coverage/zoom summary filled by tile57_compose_meta_get. */ +typedef struct { + uint8_t min_zoom; + uint8_t max_zoom; /* deepest zoom served (native windows + one fill-up overscale zoom) */ + uint32_t cells; /* coverage-carrying charts held */ + double west, south, east, north; /* union coverage bounds, degrees */ +} tile57_compose_meta; + +/* Open a compositor over `n` open charts (each a per-cell archive from the + * bake, opened with tile57_open). Charts whose archives embed no coverage are + * skipped — they can own no ground; if none carries coverage the open is + * TILE57_ERR_UNSUPPORTED. `partition_path` (NULL to skip) names a partition + * sidecar — written by tile57_compose_save_partition (the `tile57 bake` CLI + * emits one as partition.tpart) — to load and skip the build; a missing/stale + * one falls back to building. TILE57_OK with *out set (close with + * tile57_compose_close — BEFORE closing the charts). */ +tile57_status tile57_compose_open(tile57 *const *charts, size_t n, + const char *partition_path, + tile57_compose **out, tile57_error *err); + +/* Compose the tile (z,x,y) on demand into RAW (decompressed) MLT in *out / + * *out_len (free with tile57_free) — the HTTP layer gzips on the wire. NULL/0 + * out with TILE57_OK means no bytes for this tile; *out_owned (NULL to ignore) + * then distinguishes the two empties: + * owned = false: no cell owns this ground — true empty ocean, safe to cache. + * owned = true: a cell owns this ground but produced nothing — transient + * while its per-cell bake is still running; suspect once + * bakes are done. */ +tile57_status tile57_compose_serve(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, + uint8_t **out, size_t *out_len, bool *out_owned, + tile57_error *err); + +/* Fill *out with the compositor's zoom range + union coverage bounds. */ +void tile57_compose_meta_get(tile57_compose *c, tile57_compose_meta *out); + +/* Serialize the compositor's ownership partition to the file `path` (a sidecar + * a later tile57_compose_open can load to skip the build). */ +tile57_status tile57_compose_save_partition(tile57_compose *c, const char *path, + tile57_error *err); + +/* Release a compositor. Its charts stay open (and stay yours to close). */ +void tile57_compose_close(tile57_compose *c); /* ======================================================================== * * 6. Style + portrayal assets @@ -484,25 +594,27 @@ int tile57_chart_render_surface_cb(tile57_chart *chart, double lon, double lat, /* All portrayal assets in memory, from the library's embedded catalogue * (catalog_dir NULL/"") or an on-disk one. Pairs with tile57_build_style + the - * composed tiles for a complete renderable chart. Returns 1 with *out filled (free - * with tile57_assets_free), 0 on error. */ + * composed tiles for a complete renderable chart. Free with + * tile57_assets_free. */ typedef struct { uint8_t *colortables; size_t colortables_len; uint8_t *linestyles; size_t linestyles_len; uint8_t *sprite_json; size_t sprite_json_len; uint8_t *sprite_png; size_t sprite_png_len; uint8_t *pattern_json; size_t pattern_json_len; uint8_t *pattern_png; size_t pattern_png_len; } tile57_assets; -int tile57_bake_assets(const char *catalog_dir, tile57_assets *out); +tile57_status tile57_bake_assets(const char *catalog_dir, tile57_assets *out, + tile57_error *err); /* Like tile57_bake_assets but sprite_json/sprite_png carry the MapLibre sprite-mln * atlas (pivot-centred cells + {name:{x,y,width,height,pixelRatio}} JSON); other - * fields are NULL. Free with tile57_assets_free. 1=ok, 0=error. */ -int tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out); + * fields are NULL. Free with tile57_assets_free. */ +tile57_status tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out, + tile57_error *err); /* SDF glyph atlas for GPU text: sprite_png is the RGBA signed-distance-field atlas * of the label font; sprite_json is {"em_px","pad","glyphs":{codepoint:[u0,v0,u1, * v1,off_x,off_y,w,h,advance]}} with the quad geometry in EM units (multiply by the * text pixel size). A host draws each glyph as a textured quad sampling the SDF. - * Only sprite_* filled. Free with tile57_assets_free. 1=ok, 0=error. */ -int tile57_bake_glyph_sdf(tile57_assets *out); + * Only sprite_* filled. Free with tile57_assets_free. */ +tile57_status tile57_bake_glyph_sdf(tile57_assets *out, tile57_error *err); void tile57_assets_free(tile57_assets *out); /* ---- chart-style generation --------------------------------------------- @@ -512,65 +624,33 @@ void tile57_assets_free(tile57_assets *out); * recolour) and AND-s the display filters (category, band, boundary/point style, * date validity, text groups, …) onto every source:"chart" layer. The template + * colortables are produced by the engine's asset generator; the host fills - * tile57_mariner from its UI. */ - -typedef enum { TILE57_SCHEME_DAY=0, TILE57_SCHEME_DUSK=1, TILE57_SCHEME_NIGHT=2 } tile57_scheme; -typedef enum { TILE57_DEPTH_METERS=0, TILE57_DEPTH_FEET=1 } tile57_depth_unit; -typedef enum { TILE57_BOUNDARY_SYMBOLIZED=0, TILE57_BOUNDARY_PLAIN=1 } tile57_boundary_style; - -typedef struct tile57_mariner { - tile57_scheme scheme; - double shallow_contour, safety_contour, deep_contour, safety_depth; - bool four_shade_water; - tile57_depth_unit depth_unit; - bool display_base, display_standard, display_other; - bool data_quality, show_inform_callouts, show_meta_bounds, show_isolated_dangers_shallow; - tile57_boundary_style boundary_style; - bool simplified_points, show_full_sector_lines; - bool text_names, show_light_descriptions, text_other; - bool date_dependent, highlight_date_dependent; - char date_view[9]; /* "YYYYMMDD" or "" (empty -> today) */ - bool ignore_scamin; /* host ?ignoreScamin debug toggle: drop SCAMIN scale-gating - * so every feature shows in-band (the *_scamin layers become - * a single ungated layer). NOT an S-52 setting. Default false. */ - double size_scale; /* physical-scale multiplier (host _featureSizeScale) applied to - * icon-size / line-width / text-size. NOT an S-52 setting. - * 1.0 = catalogue sizes verbatim. tile57_mariner_defaults sets 1.0. */ - const int32_t *viewing_groups_off; /* S-52 §14.5 fine-grained viewing-group control: - * a DENY-LIST of the raw `vg` ids the mariner turned OFF. The - * pointee must outlive the tile57_build_style call. NULL/len 0 -> - * every viewing group shown. tile57_mariner_defaults sets NULL/0. */ - uint32_t viewing_groups_off_len; - bool scamin_filter_gate; /* gate SCAMIN with a live client-driven filter instead of - * per-value bucket layers — one *_scamin layer per render-type - * (no minzoom buckets). The client rewrites the SCAMIN clause on - * boundary crossings. NOT an S-52 setting. - * tile57_mariner_defaults sets false (per-value buckets). */ - bool show_overscale; /* S-52 §10.1.10 overscale indication: the AP(OVERSC01) - * vertical-line hatch over regions displayed finer than their - * compilation scale (drives the `overscale` layer's visibility). - * tile57_mariner_defaults sets true. */ -} tile57_mariner; + * tile57_mariner (section 4) from its UI. + * + * The S-52 colortables and base style template are baked into the library, so a + * host can generate a complete style with no on-disk catalogue or template file: + * tile57_colortables_default(&ct,&ctn,NULL); + * tile57_style_template(scheme, "http://host/{z}/{x}/{y}", NULL,NULL,0,0,0, &t,&tn,NULL); + * tile57_build_style(t,tn, &m, ct,ctn, bands,nb, scamin,nsm,lat, &style,&sn,NULL); + * Free each buffer with tile57_free. */ /* Build a MapLibre style JSON from a template + mariner settings + S-52 colortables. * enabled_bands: NULL = no band filter (show all); else only features whose `band` * rank is in the array (count entries) are shown. * scamin: the distinct SCAMIN denominators present in the source (e.g. from - * tile57_chart_scamin / the TileJSON). When non-NULL with scamin_count>0 the - * `_scamin` source-layers are split into one per-value bucket layer with a native + * tile57_scamin / the TileJSON). When non-NULL with scamin_count>0 the `_scamin` + * source-layers are split into one per-value bucket layer with a native * fractional minzoom = scaminDisplayZoom(value, scamin_lat). NULL / count 0 -> the * `_scamin` layers stay a single ungated layer (features render, but SCAMIN does * not gate by value). * scamin_lat: representative latitude (degrees) for the bucket minzooms (the SCAMIN * display cutoff is latitude-dependent); use the source's center latitude. - * On success returns 1 with the style JSON in *out / *out_len (free with - * tile57_free); 0 on error. */ -int tile57_build_style(const char *template_json, size_t template_len, - const tile57_mariner *m, - const char *colortables_json, size_t colortables_len, - const int32_t *enabled_bands, size_t enabled_band_count, - const int32_t *scamin, size_t scamin_count, double scamin_lat, - uint8_t **out, size_t *out_len); + * TILE57_OK with the style JSON in *out / *out_len (free with tile57_free). */ +tile57_status tile57_build_style(const char *template_json, size_t template_len, + const tile57_mariner *m, + const char *colortables_json, size_t colortables_len, + const int32_t *enabled_bands, size_t enabled_band_count, + const int32_t *scamin, size_t scamin_count, double scamin_lat, + uint8_t **out, size_t *out_len, tile57_error *err); /* Compute the minimal MapLibre style-mutation ops to turn the style for `old_m` * into the style for `new_m` — same template/colortables/bands/scamin inputs as @@ -584,26 +664,21 @@ int tile57_build_style(const char *template_json, size_t template_len, * Only layers whose filter / a paint prop / a layout prop differ appear; an * unchanged toggle yields "[]". If the two mariners would produce a DIFFERENT SET * of layers (not expected for any current mariner field — a safety valve), the - * result is [{"op":"rebuild"}], signalling the host to fall back to a full setStyle. - * On success returns 1 with the op array in *out / *out_len (free with - * tile57_free); 0 on error. */ -int tile57_style_diff(const char *template_json, size_t template_len, - const tile57_mariner *old_m, const tile57_mariner *new_m, - const char *colortables_json, size_t colortables_len, - const int32_t *enabled_bands, size_t enabled_band_count, - const int32_t *scamin, size_t scamin_count, double scamin_lat, - uint8_t **out, size_t *out_len); - -/* The S-52 colortables and base style template are baked into the library, so a - * host can generate a complete style with no on-disk catalogue or template file: - * tile57_colortables_default(&ct,&ctn); - * tile57_style_template(scheme, "http://host/{z}/{x}/{y}", NULL,NULL,0,0,0, &t,&tn); - * tile57_build_style(t,tn, &m, ct,ctn, bands,nb, scamin,nsm,lat, &style,&sn); - * Free each buffer with tile57_free. */ + * result is [{"op":"rebuild"}], signalling the host to fall back to a full + * setStyle. TILE57_OK with the op array in *out / *out_len (free with + * tile57_free). */ +tile57_status tile57_style_diff(const char *template_json, size_t template_len, + const tile57_mariner *old_m, const tile57_mariner *new_m, + const char *colortables_json, size_t colortables_len, + const int32_t *enabled_bands, size_t enabled_band_count, + const int32_t *scamin, size_t scamin_count, double scamin_lat, + uint8_t **out, size_t *out_len, tile57_error *err); /* S-52 colortables.json (token -> hex per day/dusk/night) from the colour profile - * baked into the library. Returns 1 with out/out_len set, 0 on error. */ -int tile57_colortables_default(uint8_t **out, size_t *out_len); + * baked into the library. TILE57_OK with *out / *out_len set (free with + * tile57_free). */ +tile57_status tile57_colortables_default(uint8_t **out, size_t *out_len, + tile57_error *err); /* Base MapLibre style template (layers + chart `sources` + sprite/glyph URLs) from * the catalogue baked into the library — no template file needed. The source lives @@ -616,23 +691,19 @@ int tile57_colortables_default(uint8_t **out, size_t *out_len); * a source's minzoom, so an inflated floor blanks every lower zoom). * maxzoom: 0 -> engine default. * tile_encoding: the chart source's tile encoding (a tile57_tile_type, from - * chart_info.tile_type). TILE57_TILE_TYPE_MLT emits "encoding":"mlt" on + * tile57_info.tile_type). TILE57_TILE_TYPE_MLT emits "encoding":"mlt" on * the source so maplibre-gl >= 5.12 decodes MLT natively; 0 / * TILE57_TILE_TYPE_MVT emits nothing (the MapLibre default). The hint * survives tile57_build_style / tile57_style_diff. - * Returns 1 with out/out_len set (free with tile57_free), 0 on error. */ -int tile57_style_template(tile57_scheme scheme, const char *source_tiles, - const char *sprite, const char *glyphs, - uint32_t minzoom, uint32_t maxzoom, - uint8_t tile_encoding, - uint8_t **out, size_t *out_len); - -/* Fill *m with the canonical default mariner settings (so a host needn't hardcode - * them). date_view is set to "" (today). */ -void tile57_mariner_defaults(tile57_mariner *m); + * TILE57_OK with *out / *out_len set (free with tile57_free). */ +tile57_status tile57_style_template(tile57_scheme scheme, const char *source_tiles, + const char *sprite, const char *glyphs, + uint32_t minzoom, uint32_t maxzoom, + uint8_t tile_encoding, + uint8_t **out, size_t *out_len, tile57_error *err); /* ======================================================================== * - * 7. Util / catalogue / debug + * 7. Util * ======================================================================== */ /* Populate the process-global read-only registries (the S-100 feature catalogue and @@ -647,31 +718,6 @@ void tile57_warmup(void); * colortables, atlases, …), passing the same length. The universal free. */ void tile57_free(void *ptr, size_t len); -/* Decode a CATALOG.031 exchange-set catalogue (raw bytes) into a JSON array - * of its CATD entries: - * [{"file":"US5MD1MC/US5MD1MC.000","longName":"Annapolis Harbor", - * "impl":"BIN","bbox":[west,south,east,north]}, ...] - * `file` is the recorded path with separators normalised to '/'; `longName` - * is LFIL (the human chart title; empty when absent); `impl` is BIN/ASC/TXT; - * `bbox` is omitted when SLAT/WLON/NLAT/ELON are not all present (aux files). - * Not chart-scoped: the catalogue describes an exchange set, not an open - * chart. Returns 1 with *out / *out_len holding the JSON (free with - * tile57_free); 0 if the file holds no CATD records; -1 on parse error. */ -int tile57_catalog_entries(const uint8_t *catalog_031, size_t len, - uint8_t **out, size_t *out_len); - -/* Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) into a - * single PMTiles at out_path: the composited ownership faces (which cell renders which - * ground at each band), one polygon per owning cell tagged with the properties - * cell/cscl/band/tier/oi/color, and NO portrayed chart content — for building a - * partition-debug UI. band < 0 emits the band GOVERNING each zoom (the natural view); - * 0..5 (berthing..overview) emits one band's own map at every zoom. minzoom/maxzoom - * bound the tiles (harbor-level detail needs maxzoom >= 13; coarser bands are much - * cheaper). out_cell_count is optional. Returns 1=ok, 0=nothing covered, -1=error. */ -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); - #ifdef __cplusplus } #endif diff --git a/src/capi.zig b/src/capi.zig index 08fd90f..3227c08 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -1,8 +1,11 @@ //! C ABI for libtile57.a — a thin shim over the Zig engine API (chart.zig). //! -//! Contract: POD across the seam (ptr/len + status codes); Zig errors, slices -//! and optionals stay inside chart.zig. Public header: ../../include/tile57.h. -//! The opaque `tile57_chart` is a `*chart.Chart`. +//! Contract: POD across the seam. Every fallible export returns a tile57_status +//! (0 = OK), takes an optional caller-owned tile57_error* it fills on failure, +//! and defines its out-parameters on every return (result on OK, NULL/0 +//! otherwise). Zig errors, slices and optionals stay inside chart.zig. Public +//! header: ../../include/tile57.h. The opaque `tile57` is a `*chart.Chart`; +//! the opaque `tile57_compose` is a `*compose.ComposeSource`. const std = @import("std"); const chart = @import("chart.zig"); @@ -28,7 +31,7 @@ const Chart = chart.Chart; extern fn time(tloc: ?*c_long) callconv(.c) c_long; // Keep in sync with the TILE57_VERSION_* macros in tile57.h. -const version_string = "0.1.0"; +const version_string = "0.2.0"; fn spanOpt(s: ?[*:0]const u8) ?[]const u8 { return if (s) |p| std.mem.span(p) else null; @@ -37,7 +40,7 @@ fn spanOpt(s: ?[*:0]const u8) ?[]const u8 { // ---- error model (mirrors tile57_status / tile57_error in tile57.h) --------- // Keep in sync with the tile57_status enum in tile57.h. -const Status = enum(c_int) { ok = 0, badarg, io, parse, nomem, unsupported, render }; +const Status = enum(c_int) { ok = 0, badarg, io, parse, nomem, unsupported, render, internal }; // Mirrors tile57_error in tile57.h: a caller-owned status + fixed message buffer. const ERROR_MSG_MAX = 256; @@ -46,12 +49,12 @@ const CError = extern struct { status: c_int, message: [ERROR_MSG_MAX]u8 }; const OK: c_int = @intFromEnum(Status.ok); // Map an engine error (errors.Error) to a tile57_status. std IO errors that can -// surface directly from file ops map to .io; anything unrecognised is .io. +// surface directly from file ops map to .io; anything unrecognised is .internal. fn statusOf(e: anyerror) Status { return switch (e) { error.OutOfMemory => .nomem, error.Unsupported => .unsupported, - error.RenderFailed => .render, + error.RenderFailed, error.TileGen => .render, error.InvalidCell, error.InvalidArchive, error.InvalidPartition => .parse, // Specific S-57 / ISO 8211 parse failures, propagated so the message // carries the exact reason (e.g. "BadLeader", "UnknownRUIN"). @@ -67,8 +70,8 @@ fn statusOf(e: anyerror) Status { error.BadFeatureRecord, => .parse, error.NotFound, error.IoFailed => .io, - error.FileNotFound, error.AccessDenied, error.NotDir, error.IsDir => .io, - else => .io, + error.FileNotFound, error.AccessDenied, error.NotDir, error.IsDir, error.OpenFailed => .io, + else => .internal, }; } @@ -114,6 +117,23 @@ fn failWith(err: ?*CError, status: Status, msg: []const u8) c_int { return @intFromEnum(status); } +// Validate + zero a (bytes, len) out-parameter pair. Every buffer-returning +// export runs this first, so outs are defined (NULL/0) on every return path. +fn bytesOut(out: ?*?[*]u8, out_len: ?*usize) error{BadArg}!struct { *?[*]u8, *usize } { + const o = out orelse return error.BadArg; + const n = out_len orelse return error.BadArg; + o.* = null; + n.* = 0; + return .{ o, n }; +} + +fn setBytes(o: *?[*]u8, n: *usize, bytes: []u8) void { + o.* = bytes.ptr; + n.* = bytes.len; +} + +const bad_out = "out/out_len must not be null"; + /// Return a static, human-readable string for a tile57_status. export fn tile57_status_str(status: c_int) callconv(.c) [*:0]const u8 { return switch (status) { @@ -124,283 +144,254 @@ export fn tile57_status_str(status: c_int) callconv(.c) [*:0]const u8 { @intFromEnum(Status.nomem) => "out of memory", @intFromEnum(Status.unsupported) => "unsupported input", @intFromEnum(Status.render) => "render failed", + @intFromEnum(Status.internal) => "internal error", else => "unknown error", }; } -/// Return the library version string ("0.1.0"). +/// Return the library version string ("0.2.0"). export fn tile57_version() callconv(.c) [*:0]const u8 { return version_string; } -/// Open ONE S-57 cell (a .000 file, with its .001.. update chain auto-read from the -/// same directory) — or a whole ENC_ROOT directory — via the STREAMING path-open: cell -/// metadata (name/scale/M_COVR) is enumerated up front and tiles are baked lazily per -/// request. Unlike a bake-to-reader open, this backend exposes the per-cell list -/// (tile57_chart_cells) — a header/metadata scan needs no tile bake. See tile57.h. -export fn tile57_chart_open(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { - const o = out orelse return failWith(err, .badarg, "out must not be null"); +// =========================================================================== +// 3. Bake — ENC source data in, per-cell PMTiles out (see tile57.h) +// =========================================================================== + +/// The per-cell metadata of the S-57 data at `path` (one .000 or a whole ENC_ROOT) +/// as a JSON array — name/scale/edition/update/issueDate/agency/bbox per cell, +/// DSID fields reflecting the applied update chain. See tile57.h. +export fn tile57_s57_cells(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); - o.* = chart.Chart.openPath(p, null, true) catch |e| return failCtx(err, e, p); + const c = Chart.openPath(p, null, false) catch |e| return failCtx(err, e, p); + defer c.deinit(); + const bytes = (c.cellsJson() catch |e| return fail(err, e)) orelse return OK; + setBytes(o, n, bytes); return OK; } -/// Open ONE cell for METADATA ONLY (bbox + native scale + M_COVR coverage): a cheap -/// parse, no tile bake — for a host's header/scan pass. Do NOT render_surface this -/// handle (no portrayal). See tile57.h. -export fn tile57_chart_open_header(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { - const o = out orelse return failWith(err, .badarg, "out must not be null"); +/// The features of the S-57 data at `path` (one cell or a whole ENC_ROOT) for the +/// comma-separated object-class acronyms `classes`, as a GeoJSON FeatureCollection +/// (lon/lat geometry; properties = {"class", ...full S-57 attribute map}). Parsed +/// without portrayal. NULL/0 out when nothing matched. See tile57.h. +export fn tile57_s57_features(path: ?[*:0]const u8, classes: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); - o.* = chart.openCellHeader(p, null, true) catch |e| return failCtx(err, e, p); + const cls = spanOpt(classes) orelse return failWith(err, .badarg, "classes must not be null"); + const c = Chart.openPath(p, null, false) catch |e| return failCtx(err, e, p); + defer c.deinit(); + const bytes = (c.featuresJson(cls) catch |e| return fail(err, e)) orelse return OK; + setBytes(o, n, bytes); return OK; } -/// Open ONE cell by baking only [minzoom, maxzoom] to an in-memory PMTiles — a host -/// bakes a narrow band fast for first paint, then re-opens the full range in the -/// background (progressive load). Renders via the fast reader path. See tile57.h. -export fn tile57_chart_open_zoom(path: ?[*:0]const u8, minzoom: u8, maxzoom: u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { - const o = out orelse return failWith(err, .badarg, "out must not be null"); - const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); - o.* = chart.openCellBaked(p, null, true, minzoom, maxzoom) catch |e| return failCtx(err, e, p); +/// tile57_s57_features over in-memory base-cell bytes (no update chain). See tile57.h. +export fn tile57_s57_features_bytes(base: ?[*]const u8, len: usize, classes: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const b = base orelse return failWith(err, .badarg, "base must not be null"); + if (len == 0) return failWith(err, .badarg, "len must not be zero"); + const cls = spanOpt(classes) orelse return failWith(err, .badarg, "classes must not be null"); + const cells = [_]chart.CellInput{.{ .base = b[0..len] }}; + const c = Chart.openCells(&cells, null, false) catch |e| return fail(err, e); + defer c.deinit(); + const bytes = (c.featuresJson(cls) catch |e| return fail(err, e)) orelse return OK; + setBytes(o, n, bytes); + return OK; +} + +/// Decode a CATALOG.031 exchange-set catalogue into a JSON array of its CATD +/// entries: [{"file","longName","impl","bbox"?}, ...]. NULL/0 out when the file +/// holds no CATD records. See tile57.h. +export fn tile57_s57_catalog(catalog_031: ?[*]const u8, len: usize, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const cat = catalog_031 orelse return failWith(err, .badarg, "catalog_031 must not be null"); + if (len == 0) return failWith(err, .badarg, "len must not be zero"); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const entries = s57.parseCatalog(a, cat[0..len]) orelse return failWith(err, .parse, "malformed CATALOG.031"); + if (entries.len == 0) return OK; + var buf = std.ArrayList(u8).empty; + catalogJson(a, &buf, entries) catch |e| return fail(err, e); + const bytes = gpa.dupe(u8, buf.items) catch |e| return fail(err, e); + setBytes(o, n, bytes); return OK; } -/// Bake ONE cell (+ its updates, read from disk) to PMTiles bytes over its NATIVE band -/// zoom range, into *out / *out_len (free with tile57_free). For persisting a per-cell -/// tile cache to disk. 1=ok, 0=nothing baked, -1=error. See tile57.h. -export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, out: *[*]u8, out_len: *usize) callconv(.c) c_int { - const p = spanOpt(path) orelse return -1; - const archive = chart.bakeCellBytes(p, null) catch return -1; - if (archive) |a| { - out.* = a.ptr; - out_len.* = a.len; - return 1; +fn catalogJson(a: std.mem.Allocator, buf: *std.ArrayList(u8), entries: []const s57.CatalogEntry) !void { + try buf.append(a, '['); + for (entries, 0..) |e, i| { + if (i > 0) try buf.append(a, ','); + try buf.appendSlice(a, "{\"file\":"); + try jsonStr(a, buf, e.path); + try buf.appendSlice(a, ",\"longName\":"); + try jsonStr(a, buf, e.long_name); + try buf.appendSlice(a, ",\"impl\":"); + try jsonStr(a, buf, e.impl); + if (e.bbox) |b| try buf.print(a, ",\"bbox\":[{d},{d},{d},{d}]", .{ b[0], b[1], b[2], b[3] }); + try buf.append(a, '}'); } - return 0; + try buf.append(a, ']'); +} + +fn jsonStr(a: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) !void { + try buf.append(a, '"'); + for (s) |ch| switch (ch) { + '"' => try buf.appendSlice(a, "\\\""), + '\\' => try buf.appendSlice(a, "\\\\"), + else => if (ch < 0x20) { + try buf.print(a, "\\u{x:0>4}", .{ch}); + } else try buf.append(a, ch), + }; + try buf.append(a, '"'); +} + +/// Bake ONE cell (+ its updates, read from disk) to PMTiles bytes over its NATIVE +/// band zoom range, into *out / *out_len (free with tile57_free); NULL/0 when the +/// cell produced no tiles. See tile57.h. +export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); + const archive = chart.bakeCellBytes(p, null) catch |e| return failCtx(err, e, p); + if (archive) |a| setBytes(o, n, a); + return OK; } -/// Bake `n` cells to per-cell PMTiles bytes IN PARALLEL across up to `workers` threads. The engine -/// returns BYTES only (never writes an output dir); out_bytes[i]/out_lens[i] get cell i's archive -/// (free each with tile57_free) or NULL/0 when it produced nothing. `workers` is a MEMORY bound — -/// keep it small. Returns the number of cells baked, or -1 on bad args. See tile57.h. +/// Bake `n` cells to per-cell PMTiles bytes IN PARALLEL across up to `workers` +/// threads; out_bytes[i]/out_lens[i] get cell i's archive (free each with +/// tile57_free) or NULL/0 when it produced nothing. *out_baked (NULL to ignore) = +/// the count that produced bytes. `workers` is a MEMORY bound. See tile57.h. export fn tile57_bake_cells( paths: ?[*]const ?[*:0]const u8, n: usize, workers: u32, - out_bytes: [*c][*c]u8, - out_lens: [*c]usize, + out_bytes: ?[*]?[*]u8, + out_lens: ?[*]usize, + out_baked: ?*usize, + err: ?*CError, ) callconv(.c) c_int { - const ps = paths orelse return -1; - if (out_bytes == null or out_lens == null) return -1; - if (n == 0) return 0; - const list = gpa.alloc([]const u8, n) catch return -1; + if (out_baked) |p| p.* = 0; + const ps = paths orelse return failWith(err, .badarg, "paths must not be null"); + const ob = out_bytes orelse return failWith(err, .badarg, "out_bytes must not be null"); + const ol = out_lens orelse return failWith(err, .badarg, "out_lens must not be null"); + if (n == 0) return OK; + for (0..n) |i| { + ob[i] = null; + ol[i] = 0; + } + const list = gpa.alloc([]const u8, n) catch |e| return fail(err, e); defer gpa.free(list); - for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return -1; - const results = gpa.alloc(?[]u8, n) catch return -1; + for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return failWith(err, .badarg, "a path in paths is null"); + const results = gpa.alloc(?[]u8, n) catch |e| return fail(err, e); defer gpa.free(results); chart.bakeCellsParallel(list, null, workers, results); - var baked: c_int = 0; + var baked: usize = 0; for (0..n) |i| { if (results[i]) |b| { - out_bytes[i] = b.ptr; - out_lens[i] = b.len; + ob[i] = b.ptr; + ol[i] = b.len; baked += 1; - } else { - out_bytes[i] = null; - out_lens[i] = 0; } } - return baked; + if (out_baked) |p| p.* = baked; + return OK; } -/// Walk `in_dir` for S-57 base cells (*.000) and bake each IN PARALLEL to the SAME relative path -/// under `out_dir` with a .pmtiles extension (+ an .sha sidecar), creating subdirs as needed. -/// The engine writes + frees each archive, so the host never holds N in memory. `in_dir` is the -/// source ENC data; `out_dir` is the caller's own cache. Returns the count baked, or -1 on bad -/// args. See tile57.h. +/// Walk `in_dir` for S-57 base cells (*.000) and bake each IN PARALLEL to the SAME +/// relative path under `out_dir` with a .pmtiles extension (+ an .sha sidecar), +/// creating subdirs as needed. INCREMENTAL: an archive already at least as new as +/// its whole input is skipped, so *out_baked (NULL to ignore) counts THIS run only. +/// An unreadable `in_dir` errors. See tile57.h. export fn tile57_bake_tree( in_dir: ?[*:0]const u8, out_dir: ?[*:0]const u8, workers: u32, progress: chart.BakeProgress, progress_ctx: ?*anyopaque, + out_baked: ?*u32, + err: ?*CError, ) callconv(.c) c_int { - const in_d = spanOpt(in_dir) orelse return -1; - const out_d = spanOpt(out_dir) orelse return -1; + if (out_baked) |p| p.* = 0; + const in_d = spanOpt(in_dir) orelse return failWith(err, .badarg, "in_dir must not be null"); + const out_d = spanOpt(out_dir) orelse return failWith(err, .badarg, "out_dir must not be null"); // Stand up a threaded std.Io for the tree walk + the workers' file writes. var threaded: std.Io.Threaded = .init(gpa, .{}); defer threaded.deinit(); - return @intCast(chart.bakeTree(threaded.io(), in_d, out_d, null, workers, progress, progress_ctx) catch return -1); + const baked = chart.bakeTree(threaded.io(), in_d, out_d, null, workers, progress, progress_ctx) catch |e| return failCtx(err, e, in_d); + if (out_baked) |p| p.* = @intCast(baked); + return OK; } -/// Coverage/zoom summary of a resident compositor, filled by tile57_compose_meta. -const CComposeMeta = extern struct { - min_zoom: u8, - max_zoom: u8, // deepest zoom that can be served (native windows + one fill-up overscale zoom) - cells: u32, // coverage-carrying archives held - west: f64, - south: f64, - east: f64, - north: f64, -}; - -/// Read a partition sidecar file into a fresh gpa-owned buffer (or error). Used only during open. -fn readSidecar(io: std.Io, path: []const u8) ![]u8 { - var f = try std.Io.Dir.cwd().openFile(io, path, .{}); - defer f.close(io); - const st = try f.stat(io); - const n: usize = @intCast(st.size); - const buf = try gpa.alloc(u8, n); - errdefer gpa.free(buf); - _ = try f.readPositionalAll(io, buf, 0); - return buf; +/// The metadata JSON blob of a PMTiles archive (decompressed) — e.g. the embedded +/// per-cell "coverage" a single-cell bake carries — into *out / *out_len (free with +/// tile57_free); NULL/0 when the archive carries none. See tile57.h. +export fn tile57_pmtiles_metadata(pmtiles_ptr: ?[*]const u8, len: usize, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const p = pmtiles_ptr orelse return failWith(err, .badarg, "pmtiles must not be null"); + if (len == 0) return failWith(err, .badarg, "len must not be zero"); + const meta = chart.pmtilesMetadata(gpa, p[0..len]) catch |e| return fail(err, e); + if (meta) |m| setBytes(o, n, m); + return OK; } -/// Open a resident runtime compositor over the `n` per-cell PMTiles at `paths` (each from -/// tile57_bake_cell_bytes, on disk), mmap'd so the cell set is never fully resident. `partition_path` -/// (or NULL) names a partition sidecar (from `tile57 compose --save-partition`) to load and skip the -/// build; a missing/stale one falls back to building. Returns an opaque handle (free with -/// tile57_compose_close), or NULL on error / no coverage-carrying archive. See tile57.h. -export fn tile57_compose_open( - paths: ?[*]const ?[*:0]const u8, - n: usize, - partition_path: ?[*:0]const u8, - out: ?*?*compose.ComposeSource, +/// Bake the ownership-partition DEBUG tiles from an ENC_ROOT into a single PMTiles +/// at out_path (composited ownership faces, no portrayed content — for a +/// partition-debug UI). OK with *out_cell_count = 0 and no file written when +/// nothing is covered. See tile57.h. +export fn tile57_bake_partition_debug( + enc_root: ?[*:0]const u8, + out_path: ?[*:0]const u8, + minzoom: u8, + maxzoom: u8, + band: i8, + out_cell_count: ?*u32, err: ?*CError, ) callconv(.c) c_int { - const o = out orelse return failWith(err, .badarg, "out must not be null"); - const ps = paths orelse return failWith(err, .badarg, "paths must not be null"); - if (n == 0) return failWith(err, .badarg, "n must not be zero"); - const list = gpa.alloc([]const u8, n) catch |e| return fail(err, e); - defer gpa.free(list); - for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return failWith(err, .badarg, "a path in paths is null"); - - // The lib has no std.process.Init; stand up a threaded std.Io for the open-time file I/O - // (mmap survives it, and serve/close need no io). + if (out_cell_count) |p| p.* = 0; + const root = spanOpt(enc_root) orelse return failWith(err, .badarg, "enc_root must not be null"); + const outp = spanOpt(out_path) orelse return failWith(err, .badarg, "out_path must not be null"); + // The debug bake does filesystem I/O (read ENC_ROOT, write the pmtiles); the lib + // has no std.process.Init, so stand up a threaded std.Io for the call. It streams + // internally (StreamWriter over gpa), so pass the real gpa, not a scratch arena. var threaded: std.Io.Threaded = .init(gpa, .{}); defer threaded.deinit(); - const io = threaded.io(); - - var load_bytes: ?[]const u8 = null; - var owned: ?[]u8 = null; - defer if (owned) |b| gpa.free(b); - if (spanOpt(partition_path)) |pp| { - if (readSidecar(io, pp)) |b| { - owned = b; - load_bytes = b; - } else |_| {} - } - - o.* = (compose.openComposeSourceFiles(io, gpa, list, load_bytes) catch |e| return fail(err, e)) orelse - return failWith(err, .parse, "no coverage-carrying archive in paths"); + const nc = bundle.bakePartitionDebug(threaded.io(), gpa, root, outp, minzoom, maxzoom, band) catch |e| { + if (e == error.NoGeometry) return OK; // nothing covered: count stays 0 + return failCtx(err, e, root); + }; + if (out_cell_count) |p| p.* = @intCast(nc); return OK; } -/// Compose the tile (z,x,y) on demand, returning the RAW (decompressed) MLT in *out / *out_len (free -/// with tile57_free) — what a live tile server hands its HTTP layer. Returns 1 = served (bytes set), -/// 2 = OWNED-but-empty (a cell owns this ground per the partition but produced nothing — transient -/// during a bake, an error state once bakes are done), 0 = not owned (true empty ocean, safe to -/// cache), -1 = error. Byte-faithful to the batch compositor. See tile57.h. -export fn tile57_compose_serve( - handle: ?*compose.ComposeSource, - z: u8, - x: u32, - y: u32, - out: *[*]u8, - out_len: *usize, -) callconv(.c) c_int { - const src = handle orelse return -1; - const res = src.serve(gpa, z, x, y) catch return -1; - if (res.tile) |t| { - out.* = t.ptr; - out_len.* = t.len; - return 1; - } - return if (res.owned) 2 else 0; -} +// =========================================================================== +// 4. Render — the `tile57` chart handle (see tile57.h) +// =========================================================================== -/// Fill `out` with the compositor's zoom range + union coverage bounds. See tile57.h. -export fn tile57_compose_meta_get(handle: ?*compose.ComposeSource, out: *CComposeMeta) callconv(.c) void { - const src = handle orelse return; - out.* = .{ - .min_zoom = src.minz, - .max_zoom = src.loop_max, - .cells = @intCast(src.readers.len), - .west = src.bounds[0], - .south = src.bounds[1], - .east = src.bounds[2], - .north = src.bounds[3], - }; -} - -/// Serialize the compositor's ownership partition to the file `path` (a sidecar a later -/// tile57_compose_open can load to skip the build). 1=ok, -1=error. See tile57.h. -export fn tile57_compose_save_partition(handle: ?*compose.ComposeSource, path: ?[*:0]const u8) callconv(.c) c_int { - const src = handle orelse return -1; - const p = spanOpt(path) orelse return -1; - const bytes = src.serializePartition(gpa) catch return -1; - defer gpa.free(bytes); +/// Open a baked PMTiles archive from a file path, mmap'd (never fully resident; +/// the file must stay in place while the chart is open). See tile57.h. +export fn tile57_open(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + o.* = null; + const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); var threaded: std.Io.Threaded = .init(gpa, .{}); defer threaded.deinit(); - std.Io.Dir.cwd().writeFile(threaded.io(), .{ .sub_path = p, .data = bytes }) catch return -1; - return 1; -} - -/// Release a compositor opened by tile57_compose_open (munmaps the archives, frees the partition). -export fn tile57_compose_close(handle: ?*compose.ComposeSource) callconv(.c) void { - if (handle) |src| src.deinit(); -} - -/// The metadata JSON blob of a PMTiles archive (e.g. the embedded per-cell "coverage" -/// a single-cell bake carries), into *out / *out_len (free with tile57_free). 1=ok, -/// 0=no metadata, -1=error. See tile57.h. -export fn tile57_pmtiles_metadata(pmtiles_ptr: ?[*]const u8, len: usize, out: *[*]u8, out_len: *usize) callconv(.c) c_int { - const p = pmtiles_ptr orelse return -1; - const meta = chart.pmtilesMetadata(gpa, p[0..len]) catch return -1; - if (meta) |m| { - out.* = m.ptr; - out_len.* = m.len; - return 1; - } - return 0; -} - -/// Populate the process-global read-only registries (S-100 catalogue + linestyles) on -/// the calling thread. Call ONCE on the main thread before opening/baking cells from -/// worker threads, so concurrent bake/render is race-free. See tile57.h. -export fn tile57_warmup() callconv(.c) void { - chart.warmup(); -} - -/// Open one in-memory ENC cell (base .000 bytes) as a resident chart. See tile57.h. -export fn tile57_chart_open_bytes(base: ?[*]const u8, len: usize, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { - const o = out orelse return failWith(err, .badarg, "out must not be null"); - const b = base orelse return failWith(err, .badarg, "base must not be null"); - if (len == 0) return failWith(err, .badarg, "len must not be zero"); - const cells = [_]chart.CellInput{.{ .base = b[0..len] }}; - o.* = Chart.openCells(&cells, null, true) catch |e| return fail(err, e); + o.* = chart.openPmtilesPath(threaded.io(), p) catch |e| return failCtx(err, e, p); return OK; } -/// Open a baked PMTiles bundle from a file path. See tile57.h. -export fn tile57_chart_open_pmtiles(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { +/// Open a baked PMTiles archive from in-memory bytes (copied). See tile57.h. +export fn tile57_open_bytes(pmtiles_ptr: ?[*]const u8, len: usize, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { const o = out orelse return failWith(err, .badarg, "out must not be null"); - const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); - var threaded: std.Io.Threaded = .init(gpa, .{}); - defer threaded.deinit(); - const io = threaded.io(); - const dir_path = std.fs.path.dirname(p) orelse "."; - var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{}) catch |e| return failCtx(err, e, p); - defer dir.close(io); - const bytes = dir.readFileAlloc(io, std.fs.path.basename(p), gpa, .unlimited) catch |e| return failCtx(err, e, p); - defer gpa.free(bytes); - o.* = Chart.openBytes(bytes, .pmtiles, null) catch |e| return failCtx(err, e, p); + o.* = null; + const b = pmtiles_ptr orelse return failWith(err, .badarg, "pmtiles must not be null"); + if (len == 0) return failWith(err, .badarg, "len must not be zero"); + o.* = Chart.openBytes(b[0..len], .pmtiles, null) catch |e| return fail(err, e); return OK; } -// Fixed-size chart metadata (mirrors tile57_chart_info in tile57.h): folds the old -// zoom_range / bounds / anchor / bands getters into one struct fill. -const CChartInfo = extern struct { +// Fixed-size chart metadata (mirrors tile57_info in tile57.h). +const CInfo = extern struct { min_zoom: u8, max_zoom: u8, bands: u32, @@ -413,11 +404,8 @@ const CChartInfo = extern struct { anchor_lat: f64, anchor_lon: f64, anchor_zoom: f64, - // The encoding tile57_chart_tile returns (TILE57_TILE_TYPE_*): a PMTiles - // backend reports its archive's stored type; a cell backend its live - // generation format. Appended for ABI-append-safety. - tile_type: u8, - native_scale: i32, // live cell compilation scale (1:N); 0 = derive from zoom + tile_type: u8, // the archive's stored encoding (TILE57_TILE_TYPE_*) + native_scale: i32, // embedded compilation scale (1:N); 0 = derive from zoom band }; // tile57_tile_type values (keep in sync with tile57.h). @@ -425,44 +413,52 @@ const TILE_TYPE_MVT: u8 = 1; const TILE_TYPE_MLT: u8 = 2; /// Fill *out with the chart's fixed metadata (zoom range, bands, bounds, anchor, -/// tile encoding). See tile57.h. -export fn tile57_chart_get_info(src: ?*Chart, out: *CChartInfo) callconv(.c) void { - out.* = std.mem.zeroes(CChartInfo); +/// tile encoding, embedded compilation scale). See tile57.h. +export fn tile57_get_info(src: ?*Chart, out: ?*CInfo) callconv(.c) void { + const o = out orelse return; + o.* = std.mem.zeroes(CInfo); const s = src orelse return; const zr = s.zoomRange(); - out.min_zoom = zr.min; - out.max_zoom = zr.max; - out.native_scale = s.nativeScale(); - out.bands = s.bands(); - out.tile_type = switch (s.tileType()) { + o.min_zoom = zr.min; + o.max_zoom = zr.max; + o.native_scale = s.nativeScale(); + o.bands = s.bands(); + o.tile_type = switch (s.tileType()) { .mlt => TILE_TYPE_MLT, else => TILE_TYPE_MVT, }; if (s.bounds()) |b| { - out.has_bounds = true; - out.west = b[0]; - out.south = b[1]; - out.east = b[2]; - out.north = b[3]; + o.has_bounds = true; + o.west = b[0]; + o.south = b[1]; + o.east = b[2]; + o.north = b[3]; } if (s.anchor()) |a| { - out.has_anchor = true; - out.anchor_lat = a.lat; - out.anchor_lon = a.lon; - out.anchor_zoom = a.zoom; + o.has_anchor = true; + o.anchor_lat = a.lat; + o.anchor_lon = a.lon; + o.anchor_zoom = a.zoom; } } -const CQueryCb = @import("render").query.QueryCb; - -/// Cursor object-query at (lon,lat) for the view `zoom` (web-mercator): invokes -/// cb->feature once per displayed feature the point falls in, with its S-57 class, -/// attribute JSON, and source cell. 0=ok, -1=bad args. See tile57.h. -export fn tile57_chart_query(handle: ?*Chart, lon: f64, lat: f64, zoom: f64, cb: ?*const CQueryCb) callconv(.c) c_int { - const self = handle orelse return -1; - const cbp = cb orelse return -1; - self.queryPoint(lon, lat, zoom, cbp) catch return -1; - return 0; +/// The distinct SCAMIN denominators present in the chart (ascending, from the +/// archive metadata); NULL/0 out when there are none. Free *out with +/// tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). See tile57.h. +export fn tile57_scamin(handle: ?*Chart, out: ?*?[*]i32, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, bad_out); + const n = out_len orelse return failWith(err, .badarg, bad_out); + o.* = null; + n.* = 0; + const s = handle orelse return failWith(err, .badarg, "chart must not be null"); + const vals = s.scamin() catch |e| return fail(err, e); + if (vals.len == 0) { + chart.freeBytes(@as([*]u8, @ptrCast(vals.ptr))[0 .. vals.len * @sizeOf(u32)]); + return OK; + } + o.* = @ptrCast(vals.ptr); // SCAMIN denominators fit in int32 (engine caps them) + n.* = vals.len; + return OK; } const CCoverageCb = extern struct { @@ -470,14 +466,14 @@ const CCoverageCb = extern struct { ring: *const fn (?*anyopaque, lonlat: [*]const f64, npts: usize) callconv(.c) void, }; -/// The chart's M_COVR data-coverage polygons (live-cell backend only): cb->ring is -/// called once per polygon with its exterior ring as interleaved lon,lat doubles. -/// A baked PMTiles chart carries no coverage polygon (returns 0, no calls). -/// 0 ok, -1 bad args. See tile57.h. -export fn tile57_chart_coverage(handle: ?*Chart, cb: ?*const CCoverageCb) callconv(.c) c_int { - const self = handle orelse return -1; - const cbp = cb orelse return -1; - const polys = self.coverage() orelse return 0; +/// The chart's M_COVR data-coverage polygons, from the coverage the bake embedded +/// in the archive metadata: cb->ring is called once per polygon with its exterior +/// ring as interleaved lon,lat doubles. OK with no calls when the archive embeds +/// none. See tile57.h. +export fn tile57_coverage(handle: ?*Chart, cb: ?*const CCoverageCb, err: ?*CError) callconv(.c) c_int { + const self = handle orelse return failWith(err, .badarg, "chart must not be null"); + const cbp = cb orelse return failWith(err, .badarg, "cb must not be null"); + const polys = self.coverage() orelse return OK; var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); @@ -485,71 +481,47 @@ export fn tile57_chart_coverage(handle: ?*Chart, cb: ?*const CCoverageCb) callco if (poly.len == 0) continue; const ring = poly[0]; // exterior ring if (ring.len < 3) continue; - const out = a.alloc(f64, ring.len * 2) catch continue; + const flat = a.alloc(f64, ring.len * 2) catch continue; for (ring, 0..) |p, i| { - out[2 * i] = @as(f64, @floatFromInt(p.lon_e7)) / 1e7; - out[2 * i + 1] = @as(f64, @floatFromInt(p.lat_e7)) / 1e7; + flat[2 * i] = @as(f64, @floatFromInt(p.lon_e7)) / 1e7; + flat[2 * i + 1] = @as(f64, @floatFromInt(p.lat_e7)) / 1e7; } - cbp.ring(cbp.ctx, out.ptr, ring.len); + cbp.ring(cbp.ctx, flat.ptr, ring.len); } - return 0; + return OK; } -/// Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) into a -/// single PMTiles at out_path: the composited faces (which cell renders which ground -/// at each band), tagged cell/cscl/band/tier/oi/color, NO portrayed content — for a -/// partition-debug UI. `band` < 0 = the band governing each zoom (the natural view); -/// 0..5 (berthing..overview) = one band's own map at every zoom. out_cell_count is -/// optional. 1=ok, 0=nothing covered (no M_COVR), -1=error. See tile57.h. -export fn tile57_bake_partition_debug( - enc_root: [*:0]const u8, - out_path: [*:0]const u8, - minzoom: u8, - maxzoom: u8, - band: i8, - out_cell_count: ?*u32, -) callconv(.c) c_int { - // The debug bake does filesystem I/O (read ENC_ROOT, write the pmtiles); the lib - // has no std.process.Init, so stand up a threaded std.Io for the call. It streams - // internally (StreamWriter over gpa), so pass the real gpa, not a scratch arena. - var threaded: std.Io.Threaded = .init(gpa, .{}); - defer threaded.deinit(); - const io = threaded.io(); - const nc = bundle.bakePartitionDebug(io, gpa, std.mem.span(enc_root), std.mem.span(out_path), minzoom, maxzoom, band) catch |err| return if (err == error.NoGeometry) 0 else -1; - if (out_cell_count) |p| p.* = @intCast(nc); - return 1; -} +const CQueryCb = @import("render").query.QueryCb; -/// Release a chart and all cached tiles. See tile57.h. -export fn tile57_chart_close(handle: ?*Chart) callconv(.c) void { - if (handle) |s| s.deinit(); +/// Cursor object-query at (lon,lat) for the view `zoom` (web-mercator): invokes +/// cb->feature once per displayed feature the point falls in, with its S-57 class, +/// attribute JSON, and source cell. See tile57.h. +export fn tile57_query(handle: ?*Chart, lon: f64, lat: f64, zoom: f64, cb: ?*const CQueryCb, err: ?*CError) callconv(.c) c_int { + const self = handle orelse return failWith(err, .badarg, "chart must not be null"); + const cbp = cb orelse return failWith(err, .badarg, "cb must not be null"); + self.queryPoint(lon, lat, zoom, cbp) catch |e| return fail(err, e); + return OK; } -/// The distinct SCAMIN denominators present in the chart (the live SCAMIN manifest; -/// see tile57.h). On success returns 1 with *out pointing at *out_len int32 values -/// (ascending), 0 if there are none; -1 on error. Free *out with tile57_free -/// ((uint8_t*)*out, *out_len * sizeof(int32_t)). -export fn tile57_chart_scamin(handle: ?*Chart, out: *[*]i32, out_len: *usize) callconv(.c) c_int { - const s = handle orelse return -1; - const vals = s.scamin() catch return -1; - if (vals.len == 0) { - chart.freeBytes(@as([*]u8, @ptrCast(vals.ptr))[0 .. vals.len * @sizeOf(u32)]); - out_len.* = 0; - return 0; - } - out.* = @ptrCast(vals.ptr); // SCAMIN denominators fit in int32 (max ~2^31) - out_len.* = vals.len; - return 1; +const RenderPalette = @import("render").resolve.PaletteId; + +fn paletteOf(settings: *const mariner.Settings) RenderPalette { + return switch (settings.scheme) { + .day => .day, + .dusk => .dusk, + .night => .night, + }; } -/// Render a VIEW of the chart (centre + fractional zoom + pixel size) to PNG -/// through the native S-52 pixel path: the mariner settings evaluate LIVE -/// (real safety contour, category/SCAMIN/text-group gates, palette), symbols -/// replay as vectors, labels declutter over the whole canvas. `m` NULL = -/// defaults. Returns 0 with *out/*out_len set (free with tile57_free); -/// -1 bad handle, -2 render failure, -3 unsupported source (a baked PMTiles -/// chart carries no portrayal to render from). -export fn tile57_chart_render_view( +// Shared render prologue: width/height must be 1..MAX_RENDER_PX per side. +const MAX_RENDER_PX = 16384; +const bad_size = "width/height must be 1..16384"; + +/// Render a VIEW of the chart (centre + fractional zoom + pixel size) to PNG: +/// the archive's baked tiles replayed through the native S-52 pixel path — one +/// scene across every covering tile, labels decluttered over the whole canvas. +/// `m` NULL = defaults. See tile57.h. +export fn tile57_render_view( handle: ?*Chart, lon: f64, lat: f64, @@ -557,103 +529,23 @@ export fn tile57_chart_render_view( width: u32, height: u32, m: ?*const CMariner, - out: *[*]u8, - out_len: *usize, + out: ?*?[*]u8, + out_len: ?*usize, + err: ?*CError, ) callconv(.c) c_int { - const c = handle orelse return -1; - if (width == 0 or height == 0 or width > 16384 or height > 16384) return -2; + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const c = handle orelse return failWith(err, .badarg, "chart must not be null"); + if (width == 0 or height == 0 or width > MAX_RENDER_PX or height > MAX_RENDER_PX) + return failWith(err, .badarg, bad_size); const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; - const palette: RenderPalette = switch (settings.scheme) { - .day => .day, - .dusk => .dusk, - .night => .night, - }; - const bytes = c.renderView(lon, lat, zoom, width, height, palette, &settings, .png, null) catch |e| switch (e) { - error.Unsupported => return -3, - else => return -2, - }; - out.* = bytes.ptr; - out_len.* = bytes.len; - return 0; -} - -const RenderPalette = @import("render").resolve.PaletteId; - -/// The chart's per-cell metadata as JSON: -/// [{"name","scale","edition","update","issueDate","agency","bbox"?}, …]. -/// DSID fields reflect the applied update chain. Returns 1 with *out/*out_len -/// set (free with tile57_free); 0 when the chart has no cells (e.g. a PMTiles -/// chart — its manifest is the sidecar); -1 on error/bad handle. -export fn tile57_chart_cells(handle: ?*Chart, out: *[*]u8, out_len: *usize) callconv(.c) c_int { - const c = handle orelse return -1; - const bytes = (c.cellsJson() catch return -1) orelse return 0; - out.* = bytes.ptr; - out_len.* = bytes.len; - return 1; -} - -/// Decode a CATALOG.031 exchange-set catalogue into a JSON array of its CATD -/// entries: -/// [{"file","longName","impl","bbox"?}, …]. Not chart-scoped. Returns 1 with -/// *out/*out_len set (free with tile57_free); 0 when no CATD records; -1 on -/// parse error. -export fn tile57_catalog_entries(catalog_031: [*]const u8, len: usize, out: *[*]u8, out_len: *usize) callconv(.c) c_int { - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const a = arena.allocator(); - const entries = s57.parseCatalog(a, catalog_031[0..len]) orelse return -1; - if (entries.len == 0) return 0; - var buf = std.ArrayList(u8).empty; - buf.append(a, '[') catch return -1; - for (entries, 0..) |e, i| { - if (i > 0) buf.append(a, ',') catch return -1; - buf.appendSlice(a, "{\"file\":") catch return -1; - jsonStr(a, &buf, e.path) catch return -1; - buf.appendSlice(a, ",\"longName\":") catch return -1; - jsonStr(a, &buf, e.long_name) catch return -1; - buf.appendSlice(a, ",\"impl\":") catch return -1; - jsonStr(a, &buf, e.impl) catch return -1; - if (e.bbox) |b| buf.print(a, ",\"bbox\":[{d},{d},{d},{d}]", .{ b[0], b[1], b[2], b[3] }) catch return -1; - buf.append(a, '}') catch return -1; - } - buf.append(a, ']') catch return -1; - const bytes = gpa.dupe(u8, buf.items) catch return -1; - out.* = bytes.ptr; - out_len.* = bytes.len; - return 1; -} - -fn jsonStr(a: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) !void { - try buf.append(a, '"'); - for (s) |ch| switch (ch) { - '"' => try buf.appendSlice(a, "\\\""), - '\\' => try buf.appendSlice(a, "\\\\"), - else => if (ch < 0x20) { - try buf.print(a, "\\u{x:0>4}", .{ch}); - } else try buf.append(a, ch), - }; - try buf.append(a, '"'); -} - -/// The chart's features for the given comma-separated object-class acronyms -/// (e.g. "DEPARE,DRGARE") as a GeoJSON FeatureCollection: -/// lon/lat geometry, properties = {"class", …the full S-57 attribute map}. -/// Parsed without portrayal; a whole-ENC_ROOT query walks every cell — the -/// caller owns that cost. Returns 1 with *out/*out_len set (free with -/// tile57_free); 0 when nothing matched; -1 on error. -export fn tile57_chart_features(handle: ?*Chart, classes: ?[*:0]const u8, out: *[*]u8, out_len: *usize) callconv(.c) c_int { - const c = handle orelse return -1; - const cls = classes orelse return -1; - const bytes = (c.featuresJson(std.mem.span(cls)) catch return -1) orelse return 0; - out.* = bytes.ptr; - out_len.* = bytes.len; - return 1; + const bytes = c.renderView(lon, lat, zoom, width, height, paletteOf(&settings), &settings, .png, null) catch |e| return fail(err, e); + setBytes(o, n, bytes); + return OK; } -/// tile57_chart_render_view's vector twin: the SAME scene emitted as a -/// deterministic single-page PDF (1 px = 1 pt, 72 dpi page; vector fills, -/// native strokes, glyph-outline text). Same returns/ownership. -export fn tile57_chart_render_pdf( +/// tile57_render_view's vector twin: the SAME scene as a deterministic single-page +/// PDF (1 px = 1 pt, 72 dpi; vector fills, native strokes, glyph-outline text). +export fn tile57_render_pdf( handle: ?*Chart, lon: f64, lat: f64, @@ -661,35 +553,26 @@ export fn tile57_chart_render_pdf( width: u32, height: u32, m: ?*const CMariner, - out: *[*]u8, - out_len: *usize, + out: ?*?[*]u8, + out_len: ?*usize, + err: ?*CError, ) callconv(.c) c_int { - const c = handle orelse return -1; - if (width == 0 or height == 0 or width > 16384 or height > 16384) return -2; + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const c = handle orelse return failWith(err, .badarg, "chart must not be null"); + if (width == 0 or height == 0 or width > MAX_RENDER_PX or height > MAX_RENDER_PX) + return failWith(err, .badarg, bad_size); const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; - const palette: RenderPalette = switch (settings.scheme) { - .day => .day, - .dusk => .dusk, - .night => .night, - }; - const bytes = c.renderView(lon, lat, zoom, width, height, palette, &settings, .pdf, null) catch |e| switch (e) { - error.Unsupported => return -3, - else => return -2, - }; - out.* = bytes.ptr; - out_len.* = bytes.len; - return 0; + const bytes = c.renderView(lon, lat, zoom, width, height, paletteOf(&settings), &settings, .pdf, null) catch |e| return fail(err, e); + setBytes(o, n, bytes); + return OK; } const CbCanvas = @import("render").cb_canvas.CCanvas; -/// tile57_chart_render_view's GPU/vector twin: run the SAME view portrayal, but -/// paint every resolved, flattened primitive through the C callback table -/// `canvas` (see tile57.h) instead of rasterising. Geometry is emitted in canvas -/// PIXEL space (y down) in paint order; colours are resolved for the palette. -/// Same INVERTED return convention as tile57_chart_render_view: -/// 0 ok / -1 bad handle / -2 render failure / -3 unsupported source. -export fn tile57_chart_render_view_cb( +/// tile57_render_view's callback twin: the SAME view painted through the C +/// callback table `canvas` (see tile57.h) instead of rasterising. Geometry in +/// canvas PIXEL space (y down), paint order, palette-resolved colours. +export fn tile57_render_view_cb( handle: ?*Chart, lon: f64, lat: f64, @@ -698,34 +581,25 @@ export fn tile57_chart_render_view_cb( height: u32, m: ?*const CMariner, canvas: ?*const CbCanvas, + err: ?*CError, ) callconv(.c) c_int { - const c = handle orelse return -1; - const cb = canvas orelse return -2; - if (width == 0 or height == 0 or width > 16384 or height > 16384) return -2; + const c = handle orelse return failWith(err, .badarg, "chart must not be null"); + const cb = canvas orelse return failWith(err, .badarg, "canvas must not be null"); + if (width == 0 or height == 0 or width > MAX_RENDER_PX or height > MAX_RENDER_PX) + return failWith(err, .badarg, bad_size); const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; - const palette: RenderPalette = switch (settings.scheme) { - .day => .day, - .dusk => .dusk, - .night => .night, - }; - const bytes = c.renderView(lon, lat, zoom, width, height, palette, &settings, .callback, cb) catch |e| switch (e) { - error.Unsupported => return -3, - else => return -2, - }; + const bytes = c.renderView(lon, lat, zoom, width, height, paletteOf(&settings), &settings, .callback, cb) catch |e| return fail(err, e); chart.freeBytes(bytes); // the callback path returns an empty buffer - return 0; + return OK; } const CSurface = @import("render").vector.CSurface; -/// GPU vector twin of render_view_cb: run the SAME view portrayal, but emit a -/// WORLD-SPACE tagged stream (areas/lines in web-mercator [0,1]; symbols/text as -/// a world anchor + local reference-px outline; per-feature class + SCAMIN) to -/// the C surface callback `surface` (see tile57.h). The host transforms geometry -/// and pins symbols/text at a constant screen size, culling by SCAMIN — no -/// re-portrayal per pan/zoom. Works for a baked bundle OR a live cell. -/// 0 ok / -1 bad handle / -2 render failure / -3 unsupported source. -export fn tile57_chart_render_surface_cb( +/// The GPU vector twin: the SAME view emitted as a WORLD-SPACE tagged stream +/// (areas/lines in web-mercator [0,1]; symbols/text as a world anchor + local +/// reference-px outline; per-feature class + SCAMIN) to the C surface callback +/// `surface` (see tile57.h). Pan/zoom re-portray nothing on the host. +export fn tile57_render_surface_cb( handle: ?*Chart, lon: f64, lat: f64, @@ -734,35 +608,158 @@ export fn tile57_chart_render_surface_cb( height: u32, m: ?*const CMariner, surface: ?*const CSurface, + err: ?*CError, ) callconv(.c) c_int { - const c = handle orelse return -1; - const sfc = surface orelse return -2; - if (width == 0 or height == 0 or width > 16384 or height > 16384) return -2; + const c = handle orelse return failWith(err, .badarg, "chart must not be null"); + const sfc = surface orelse return failWith(err, .badarg, "surface must not be null"); + if (width == 0 or height == 0 or width > MAX_RENDER_PX or height > MAX_RENDER_PX) + return failWith(err, .badarg, bad_size); const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; - const palette: RenderPalette = switch (settings.scheme) { - .day => .day, - .dusk => .dusk, - .night => .night, - }; - c.renderSurfaceView(lon, lat, zoom, width, height, palette, &settings, sfc) catch |e| switch (e) { - error.Unsupported => return -3, - else => return -2, + c.renderSurfaceView(lon, lat, zoom, width, height, paletteOf(&settings), &settings, sfc) catch |e| return fail(err, e); + return OK; +} + +/// Release a chart and all cached tiles. Must not be called while any borrower +/// (a compositor, a renderer) may still read from it. See tile57.h. +export fn tile57_close(handle: ?*Chart) callconv(.c) void { + if (handle) |s| s.deinit(); +} + +// =========================================================================== +// 5. Compose — the runtime compositor over open charts (see tile57.h) +// =========================================================================== + +/// Coverage/zoom summary of a compositor, filled by tile57_compose_meta_get. +const CComposeMeta = extern struct { + min_zoom: u8, + max_zoom: u8, // deepest zoom that can be served (native windows + one fill-up overscale zoom) + cells: u32, // coverage-carrying charts held + west: f64, + south: f64, + east: f64, + north: f64, +}; + +/// Read a partition sidecar file into a fresh gpa-owned buffer (or error). Used only during open. +fn readSidecar(io: std.Io, path: []const u8) ![]u8 { + var f = try std.Io.Dir.cwd().openFile(io, path, .{}); + defer f.close(io); + const st = try f.stat(io); + const n: usize = @intCast(st.size); + const buf = try gpa.alloc(u8, n); + errdefer gpa.free(buf); + _ = try f.readPositionalAll(io, buf, 0); + return buf; +} + +/// Open a compositor over `n` open charts, BORROWING their archives + embedded +/// coverage (the charts must outlive it; close the compositor first). Charts whose +/// archives embed no coverage are skipped; none at all is TILE57_ERR_UNSUPPORTED. +/// `partition_path` (or NULL) names a partition sidecar (tile57_compose_save_partition; +/// the `tile57 bake` CLI writes partition.tpart) to load and skip the build — a +/// missing/stale one falls back to building. See tile57.h. +export fn tile57_compose_open( + charts: ?[*]const ?*Chart, + n: usize, + partition_path: ?[*:0]const u8, + out: ?*?*compose.ComposeSource, + err: ?*CError, +) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + o.* = null; + const cs = charts orelse return failWith(err, .badarg, "charts must not be null"); + if (n == 0) return failWith(err, .badarg, "n must not be zero"); + + const archives = gpa.alloc(compose.ChartArchive, n) catch |e| return fail(err, e); + defer gpa.free(archives); + var na: usize = 0; + for (0..n) |i| { + const c = cs[i] orelse return failWith(err, .badarg, "a chart in charts is null"); + const rd = c.pmtilesReader() orelse return failWith(err, .badarg, "a chart in charts is not archive-backed"); + const cov = c.cellCoverage() orelse continue; // embeds no coverage: owns no ground + archives[na] = .{ .reader = rd, .cov = cov }; + na += 1; + } + + // Optional partition sidecar; the lib has no std.process.Init, so stand up a + // threaded std.Io for the read (nothing else here does file I/O). + var owned: ?[]u8 = null; + defer if (owned) |b| gpa.free(b); + if (spanOpt(partition_path)) |pp| { + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + if (readSidecar(threaded.io(), pp)) |b| { + owned = b; + } else |_| {} + } + + o.* = (compose.openComposeSourceCharts(gpa, archives[0..na], owned) catch |e| return fail(err, e)) orelse + return failWith(err, .unsupported, "no chart carries per-cell coverage"); + return OK; +} + +/// Compose the tile (z,x,y) on demand into RAW (decompressed) MLT in *out / *out_len +/// (free with tile57_free) — the HTTP layer gzips on the wire. NULL/0 out with OK = +/// no bytes; *out_owned (NULL to ignore) then distinguishes true empty ocean (false, +/// safe to cache) from owned-but-empty (true — transient during a bake, suspect +/// after). Byte-faithful to the batch compositor. See tile57.h. +export fn tile57_compose_serve( + handle: ?*compose.ComposeSource, + z: u8, + x: u32, + y: u32, + out: ?*?[*]u8, + out_len: ?*usize, + out_owned: ?*bool, + err: ?*CError, +) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + if (out_owned) |p| p.* = false; + const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); + const res = src.serve(gpa, z, x, y) catch |e| return fail(err, e); + if (out_owned) |p| p.* = res.owned; + if (res.tile) |t| setBytes(o, n, t); + return OK; +} + +/// Fill *out with the compositor's zoom range + union coverage bounds (zeroed when +/// the handle is NULL). See tile57.h. +export fn tile57_compose_meta_get(handle: ?*compose.ComposeSource, out: ?*CComposeMeta) callconv(.c) void { + const o = out orelse return; + o.* = std.mem.zeroes(CComposeMeta); + const src = handle orelse return; + o.* = .{ + .min_zoom = src.minz, + .max_zoom = src.loop_max, + .cells = @intCast(src.readers.len), + .west = src.bounds[0], + .south = src.bounds[1], + .east = src.bounds[2], + .north = src.bounds[3], }; - return 0; } -/// Free any engine-returned buffer (tiles, style, scamin array, colortables, …). See tile57.h. -/// (chart-api.md — the universal free.) -export fn tile57_free(ptr: ?*anyopaque, len: usize) callconv(.c) void { - const p = ptr orelse return; - chart.freeBytes(@as([*]u8, @ptrCast(p))[0..len]); +/// Serialize the compositor's ownership partition to the file `path` (a sidecar a +/// later tile57_compose_open can load to skip the build). See tile57.h. +export fn tile57_compose_save_partition(handle: ?*compose.ComposeSource, path: ?[*:0]const u8, err: ?*CError) callconv(.c) c_int { + const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); + const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); + const bytes = src.serializePartition(gpa) catch |e| return fail(err, e); + defer gpa.free(bytes); + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + std.Io.Dir.cwd().writeFile(threaded.io(), .{ .sub_path = p, .data = bytes }) catch |e| return failCtx(err, e, p); + return OK; +} + +/// Release a compositor. Its charts stay open (and stay the caller's to close). +export fn tile57_compose_close(handle: ?*compose.ComposeSource) callconv(.c) void { + if (handle) |src| src.deinit(); } -// ---- portrayal asset generation (in-memory; mirrors tile57.h) -------------- -// -// Generate the S-101 portrayal assets from the library's embedded catalogue (or an -// on-disk PortrayalCatalog override). Reuses the same bundle emitters bake_bundle -// writes to disk, so the in-memory bundle matches the on-disk one byte-for-byte. +// =========================================================================== +// 6. Style + portrayal assets (see tile57.h) +// =========================================================================== // The S-52 colour profile baked into the library, or null if (somehow) absent. fn embeddedColorProfileXml() ?[]const u8 { @@ -772,13 +769,12 @@ fn embeddedColorProfileXml() ?[]const u8 { /// S-52 colortables.json from the colour profile baked into the library — no /// on-disk catalogue needed. Pair with tile57_style_template / tile57_build_style. -/// 1=ok + out/out_len (free with tile57_free), 0=error. -export fn tile57_colortables_default(out: *[*]u8, out_len: *usize) callconv(.c) c_int { - const xml = embeddedColorProfileXml() orelse return 0; - const json = style.colorTablesJson(gpa, xml) catch return 0; - out.* = json.ptr; - out_len.* = json.len; - return 1; +export fn tile57_colortables_default(out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const xml = embeddedColorProfileXml() orelse return failWith(err, .internal, "embedded colour profile missing"); + const json = style.colorTablesJson(gpa, xml) catch |e| return fail(err, e); + setBytes(o, n, json); + return OK; } // All portrayal assets in memory. Mirrors tile57_assets in tile57.h; each non-null @@ -816,11 +812,12 @@ fn fillAssets(out: *CAssets, ct: []const u8, ls: []const u8, spr_json: []const u out.pattern_png_len = pat_png.len; } -/// All portrayal assets in memory (the same files bake_bundle writes to disk), from -/// the embedded catalogue (catalog_dir NULL/"") or an on-disk one. 1=ok + *out filled -/// (free with tile57_assets_free), 0=error. See tile57.h. -export fn tile57_bake_assets(catalog_dir: ?[*:0]const u8, out: *CAssets) callconv(.c) c_int { - out.* = .{}; +/// All portrayal assets in memory (the same files the offline bake writes to disk), +/// from the embedded catalogue (catalog_dir NULL/"") or an on-disk one. Free with +/// tile57_assets_free. See tile57.h. +export fn tile57_bake_assets(catalog_dir: ?[*:0]const u8, out: ?*CAssets, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + o.* = .{}; // The bundle emitters do filesystem I/O for an on-disk catalogue; the lib has no // std.process.Init, so stand up a threaded std.Io for the call. var threaded: std.Io.Threaded = .init(gpa, .{}); @@ -832,24 +829,24 @@ export fn tile57_bake_assets(catalog_dir: ?[*:0]const u8, out: *CAssets) callcon const a = arena.allocator(); const cd = spanOpt(catalog_dir) orelse ""; - const ct = bundle.colorTablesBytes(io, a, cd) catch return 0; - const ls = bundle.linestylesBytes(io, a, cd) catch return 0; - const spr = bundle.spriteAtlasBytes(io, a, cd, bundle.DEFAULT_CSS) catch return 0; - const pat = bundle.patternAtlasBytes(io, a, cd, bundle.DEFAULT_CSS) catch return 0; + const ct = bundle.colorTablesBytes(io, a, cd) catch |e| return fail(err, e); + const ls = bundle.linestylesBytes(io, a, cd) catch |e| return fail(err, e); + const spr = bundle.spriteAtlasBytes(io, a, cd, bundle.DEFAULT_CSS) catch |e| return fail(err, e); + const pat = bundle.patternAtlasBytes(io, a, cd, bundle.DEFAULT_CSS) catch |e| return fail(err, e); - fillAssets(out, ct, ls, spr.json, spr.png, pat.json, pat.png) catch { - tile57_assets_free(out); - return 0; + fillAssets(o, ct, ls, spr.json, spr.png, pat.json, pat.png) catch |e| { + tile57_assets_free(o); + return fail(err, e); }; - return 1; + return OK; } /// Like tile57_bake_assets but the sprite_* fields carry the MapLibre sprite-mln -/// atlas: each symbol pivot-centred in its cell + {name:{x,y,width,height, -/// pixelRatio}} JSON. Only sprite_json/sprite_png are filled. Free with -/// tile57_assets_free. 1=ok, 0=error. See tile57.h. -export fn tile57_bake_sprite_mln(catalog_dir: ?[*:0]const u8, out: *CAssets) callconv(.c) c_int { - out.* = .{}; +/// atlas (pivot-centred cells + {name:{x,y,width,height,pixelRatio}} JSON). Only +/// sprite_json/sprite_png are filled. Free with tile57_assets_free. See tile57.h. +export fn tile57_bake_sprite_mln(catalog_dir: ?[*:0]const u8, out: ?*CAssets, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + o.* = .{}; var threaded: std.Io.Threaded = .init(gpa, .{}); defer threaded.deinit(); const io = threaded.io(); @@ -857,12 +854,12 @@ export fn tile57_bake_sprite_mln(catalog_dir: ?[*:0]const u8, out: *CAssets) cal defer arena.deinit(); const a = arena.allocator(); const cd = spanOpt(catalog_dir) orelse ""; - const spr = bundle.spriteMlnBytes(io, a, cd, bundle.DEFAULT_CSS, &[_][]const u8{}) catch return 0; - fillAssets(out, "", "", spr.json, spr.png, "", "") catch { - tile57_assets_free(out); - return 0; + const spr = bundle.spriteMlnBytes(io, a, cd, bundle.DEFAULT_CSS, &[_][]const u8{}) catch |e| return fail(err, e); + fillAssets(o, "", "", spr.json, spr.png, "", "") catch |e| { + tile57_assets_free(o); + return fail(err, e); }; - return 1; + return OK; } const glyph_sdf = @import("sprite").glyph; @@ -885,39 +882,42 @@ fn glyphMetricsJson(a: std.mem.Allocator, atlas: *const glyph_sdf.Atlas) ![]u8 { /// SDF glyph atlas for GPU text: sprite_png = the RGBA SDF atlas, sprite_json = /// {"em_px","pad","glyphs":{codepoint:[u0,v0,u1,v1,ox,oy,w,h,adv]}} (EM units). -/// Only sprite_* filled. Free with tile57_assets_free. 1=ok, 0=error. See tile57.h. -export fn tile57_bake_glyph_sdf(out: *CAssets) callconv(.c) c_int { - out.* = .{}; +/// Only sprite_* filled. Free with tile57_assets_free. See tile57.h. +export fn tile57_bake_glyph_sdf(out: ?*CAssets, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + o.* = .{}; var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); const font = @import("render").font.notosans; - const cps = glyph_sdf.defaultCodepoints(a) catch return 0; - var atlas = glyph_sdf.build(a, font, cps, 32.0, 6) catch return 0; - const png = (atlas.encodePng(a) catch return 0) orelse return 0; - const json = glyphMetricsJson(a, &atlas) catch return 0; - out.sprite_png = (gpa.dupe(u8, png) catch { - tile57_assets_free(out); - return 0; + const cps = glyph_sdf.defaultCodepoints(a) catch |e| return fail(err, e); + var atlas = glyph_sdf.build(a, font, cps, 32.0, 6) catch |e| return fail(err, e); + const png = (atlas.encodePng(a) catch |e| return fail(err, e)) orelse + return failWith(err, .internal, "glyph atlas PNG encode produced nothing"); + const json = glyphMetricsJson(a, &atlas) catch |e| return fail(err, e); + o.sprite_png = (gpa.dupe(u8, png) catch |e| { + tile57_assets_free(o); + return fail(err, e); }).ptr; - out.sprite_png_len = png.len; - out.sprite_json = (gpa.dupe(u8, json) catch { - tile57_assets_free(out); - return 0; + o.sprite_png_len = png.len; + o.sprite_json = (gpa.dupe(u8, json) catch |e| { + tile57_assets_free(o); + return fail(err, e); }).ptr; - out.sprite_json_len = json.len; - return 1; + o.sprite_json_len = json.len; + return OK; } /// Free every non-null buffer in *out and zero the struct. See tile57.h. -export fn tile57_assets_free(out: *CAssets) callconv(.c) void { - if (out.colortables) |p| chart.freeBytes(p[0..out.colortables_len]); - if (out.linestyles) |p| chart.freeBytes(p[0..out.linestyles_len]); - if (out.sprite_json) |p| chart.freeBytes(p[0..out.sprite_json_len]); - if (out.sprite_png) |p| chart.freeBytes(p[0..out.sprite_png_len]); - if (out.pattern_json) |p| chart.freeBytes(p[0..out.pattern_json_len]); - if (out.pattern_png) |p| chart.freeBytes(p[0..out.pattern_png_len]); - out.* = .{}; +export fn tile57_assets_free(out: ?*CAssets) callconv(.c) void { + const o = out orelse return; + if (o.colortables) |p| chart.freeBytes(p[0..o.colortables_len]); + if (o.linestyles) |p| chart.freeBytes(p[0..o.linestyles_len]); + if (o.sprite_json) |p| chart.freeBytes(p[0..o.sprite_json_len]); + if (o.sprite_png) |p| chart.freeBytes(p[0..o.sprite_png_len]); + if (o.pattern_json) |p| chart.freeBytes(p[0..o.pattern_json_len]); + if (o.pattern_png) |p| chart.freeBytes(p[0..o.pattern_png_len]); + o.* = .{}; } // ---- chart-style generation (mirrors tile57_mariner in tile57.h) ----------- @@ -953,8 +953,8 @@ const CMariner = extern struct { // end for ABI-append-safety. The pointee must outlive the tile57_build_style call. viewing_groups_off: [*c]const i32, viewing_groups_off_len: u32, - // scamin-layers.md: gate SCAMIN with a live client filter instead of per-value - // bucket layers (one *_scamin layer per render-type). Appended for ABI-append-safety. + // Gate SCAMIN with a live client filter instead of per-value bucket layers + // (one *_scamin layer per render-type). Appended for ABI-append-safety. scamin_filter_gate: bool, // S-52 §10.1.10 overscale indication (AP(OVERSC01) over overscaled coverage): // drives the `overscale` layer's visibility. Appended for ABI-append-safety; @@ -1027,11 +1027,11 @@ fn scaminBuf(scamin: ?[*]const i32, scamin_count: usize) ![]u32 { } /// Build a MapLibre style JSON from a template + mariner settings + colortables. -/// 1=ok + out/out_len (free with tile57_free), 0=error. +/// See tile57.h. export fn tile57_build_style( - template_json: [*]const u8, + template_json: ?[*]const u8, template_len: usize, - cm: *const CMariner, + cm: ?*const CMariner, colortables_json: ?[*]const u8, colortables_len: usize, enabled_bands: ?[*]const i32, @@ -1039,42 +1039,37 @@ export fn tile57_build_style( scamin: ?[*]const i32, scamin_count: usize, scamin_lat: f64, - out: *[*]u8, - out_len: *usize, + out: ?*?[*]u8, + out_len: ?*usize, + err: ?*CError, ) callconv(.c) c_int { - const m = marinerFromC(cm); - const tmpl = template_json[0..template_len]; + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const tp = template_json orelse return failWith(err, .badarg, "template_json must not be null"); + const cmp = cm orelse return failWith(err, .badarg, "mariner must not be null"); + const m = marinerFromC(cmp); + const tmpl = tp[0..template_len]; const cts: []const u8 = if (colortables_json) |p| p[0..colortables_len] else ""; const bands: ?[]const i32 = if (enabled_bands) |p| p[0..enabled_band_count] else null; // SCAMIN manifest (the distinct denominators the host read from the source / // TileJSON): converted from the host's i32 to the u32 denominators styleJson - // buckets on. Empty/NULL -> the *_scamin layers stay ungated (host §"Still - // needed" #1: the runtime build_style now emits the SAME per-value native-minzoom - // buckets the offline bundle does when given the manifest). - const scamin_buf = scaminBuf(scamin, scamin_count) catch return 0; + // buckets on. Empty/NULL -> the *_scamin layers stay ungated. + const scamin_buf = scaminBuf(scamin, scamin_count) catch |e| return fail(err, e); defer if (scamin_buf.len > 0) gpa.free(scamin_buf); const now_unix: i64 = @intCast(time(null)); - // Single style builder: regenerate the full style with the mariner baked in - // (mariner.buildStyle's template-patch pass is retired). buildFromTemplate lifts - // the source config out of the passed template and drives the one styleJson. - const style_json = style.buildFromTemplateScamin(gpa, tmpl, &m, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; - out.* = style_json.ptr; - out_len.* = style_json.len; - return 1; + const style_json = style.buildFromTemplateScamin(gpa, tmpl, &m, cts, bands, now_unix, scamin_buf, scamin_lat) catch |e| return fail(err, e); + setBytes(o, n, style_json); + return OK; } -/// Compute the minimal MapLibre style-mutation ops to turn the style for `old_m` -/// into the style for `new_m` (same template/colortables/bands/scamin inputs as -/// tile57_build_style, so the two styles are comparable). Writes a JSON op array to -/// out/out_len (free with tile57_free): "[]" when nothing changed, one op per -/// differing filter/paint/layout key, or [{"op":"rebuild"}] when the two mariners -/// would produce a different SET of layers (host falls back to a full setStyle). -/// 1=ok, 0=error. See style-diff.md. +/// Compute the minimal MapLibre style-mutation ops turning the style for `old_m` +/// into the style for `new_m` (same inputs as tile57_build_style, so the styles are +/// comparable): a JSON op array — "[]" when nothing changed, [{"op":"rebuild"}] +/// when the layer SET differs (host falls back to setStyle). See tile57.h. export fn tile57_style_diff( - template_json: [*]const u8, + template_json: ?[*]const u8, template_len: usize, - old_m: *const CMariner, - new_m: *const CMariner, + old_m: ?*const CMariner, + new_m: ?*const CMariner, colortables_json: ?[*]const u8, colortables_len: usize, enabled_bands: ?[*]const i32, @@ -1082,45 +1077,40 @@ export fn tile57_style_diff( scamin: ?[*]const i32, scamin_count: usize, scamin_lat: f64, - out: *[*]u8, - out_len: *usize, + out: ?*?[*]u8, + out_len: ?*usize, + err: ?*CError, ) callconv(.c) c_int { - const om = marinerFromC(old_m); - const nm = marinerFromC(new_m); - const tmpl = template_json[0..template_len]; + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const tp = template_json orelse return failWith(err, .badarg, "template_json must not be null"); + const omp = old_m orelse return failWith(err, .badarg, "old_m must not be null"); + const nmp = new_m orelse return failWith(err, .badarg, "new_m must not be null"); + const om = marinerFromC(omp); + const nm = marinerFromC(nmp); + const tmpl = tp[0..template_len]; const cts: []const u8 = if (colortables_json) |p| p[0..colortables_len] else ""; const bands: ?[]const i32 = if (enabled_bands) |p| p[0..enabled_band_count] else null; - const scamin_buf = scaminBuf(scamin, scamin_count) catch return 0; + const scamin_buf = scaminBuf(scamin, scamin_count) catch |e| return fail(err, e); defer if (scamin_buf.len > 0) gpa.free(scamin_buf); // One wall-clock read shared by both builds so "today" date resolution matches // on both sides — otherwise a clock tick could show as a spurious date-filter op. const now_unix: i64 = @intCast(time(null)); - const old_style = style.buildFromTemplateScamin(gpa, tmpl, &om, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; + const old_style = style.buildFromTemplateScamin(gpa, tmpl, &om, cts, bands, now_unix, scamin_buf, scamin_lat) catch |e| return fail(err, e); defer gpa.free(old_style); - const new_style = style.buildFromTemplateScamin(gpa, tmpl, &nm, cts, bands, now_unix, scamin_buf, scamin_lat) catch return 0; + const new_style = style.buildFromTemplateScamin(gpa, tmpl, &nm, cts, bands, now_unix, scamin_buf, scamin_lat) catch |e| return fail(err, e); defer gpa.free(new_style); - const ops = style.diff(gpa, old_style, new_style) catch return 0; - out.* = ops.ptr; - out_len.* = ops.len; - return 1; + const ops = style.diff(gpa, old_style, new_style) catch |e| return fail(err, e); + setBytes(o, n, ops); + return OK; } /// Generate the base MapLibre style template from the catalogue baked into the -/// library — no on-disk catalogue or template file needed. This carries the chart -/// `sources` block, sprite/glyph URLs and the layer set; mariner settings are then -/// applied on top with tile57_build_style (which substitutes only paint/filter -/// props and takes no source). `scheme` is a tile57_scheme. `source_tiles` is the -/// {z}/{x}/{y} chart tiles URL (NULL -> a default pmtiles:// source). `sprite` / -/// `glyphs` are base URLs that enable the symbol / text layers (NULL omits them). -/// `minzoom` is the chart source's tile floor, emitted VERBATIM — pass the -/// archive's real minzoom (0 = tiles exist from z0; MapLibre never requests below -/// a source's minzoom, so an inflated floor blanks every lower zoom). `maxzoom` -/// of 0 -> engine default. `tile_encoding` is the chart source's tile encoding -/// (a tile57_tile_type; TILE57_TILE_TYPE_MLT emits `"encoding":"mlt"` on the -/// source so maplibre-gl >=5.12 decodes MLT natively; 0/MVT emits nothing). -/// 1=ok + out/out_len (free with tile57_free), 0=error. +/// library — the chart `sources` block, sprite/glyph URLs and the layer set; +/// mariner settings are then applied on top with tile57_build_style. See tile57.h +/// for the parameter semantics (minzoom emitted verbatim; tile_encoding MLT emits +/// "encoding":"mlt" on the source). export fn tile57_style_template( scheme: c_int, source_tiles: ?[*:0]const u8, @@ -1129,11 +1119,13 @@ export fn tile57_style_template( minzoom: u32, maxzoom: u32, tile_encoding: u8, - out: *[*]u8, - out_len: *usize, + out: ?*?[*]u8, + out_len: ?*usize, + err: ?*CError, ) callconv(.c) c_int { - const xml = embeddedColorProfileXml() orelse return 0; - const cts = style.colorTablesJson(gpa, xml) catch return 0; + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const xml = embeddedColorProfileXml() orelse return failWith(err, .internal, "embedded colour profile missing"); + const cts = style.colorTablesJson(gpa, xml) catch |e| return fail(err, e); defer gpa.free(cts); var opts = style.Options{ .scheme = switch (scheme) { @@ -1149,16 +1141,16 @@ export fn tile57_style_template( opts.minzoom = minzoom; if (maxzoom != 0) opts.maxzoom = maxzoom; if (tile_encoding == TILE_TYPE_MLT) opts.encoding = "mlt"; - const style_json = style.json(gpa, opts) catch return 0; - out.* = style_json.ptr; - out_len.* = style_json.len; - return 1; + const style_json = style.json(gpa, opts) catch |e| return fail(err, e); + setBytes(o, n, style_json); + return OK; } /// Fill `cm` with the canonical default mariner settings. date_view = "". -export fn tile57_mariner_defaults(cm: *CMariner) callconv(.c) void { +export fn tile57_mariner_defaults(cm: ?*CMariner) callconv(.c) void { + const o = cm orelse return; const d = mariner.Settings{}; - cm.* = .{ + o.* = .{ .scheme = @intCast(@intFromEnum(d.scheme)), .shallow_contour = d.shallow_contour, .safety_contour = d.safety_contour, @@ -1190,3 +1182,21 @@ export fn tile57_mariner_defaults(cm: *CMariner) callconv(.c) void { .show_overscale = d.show_overscale, }; } + +// =========================================================================== +// 7. Util (see tile57.h) +// =========================================================================== + +/// Populate the process-global read-only registries (S-100 catalogue + linestyles) on +/// the calling thread. Call ONCE on the main thread before opening/baking cells from +/// worker threads, so concurrent bake/render is race-free. See tile57.h. +export fn tile57_warmup() callconv(.c) void { + chart.warmup(); +} + +/// Free any engine-returned buffer (tiles, style, scamin array, colortables, …), +/// passing the same length. The universal free. See tile57.h. +export fn tile57_free(ptr: ?*anyopaque, len: usize) callconv(.c) void { + const p = ptr orelse return; + chart.freeBytes(@as([*]u8, @ptrCast(p))[0..len]); +} diff --git a/src/chart.zig b/src/chart.zig index 32124a9..2e89817 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -577,77 +577,6 @@ fn readCellFiles(path: []const u8) !CellFiles { return .{ .base = base, .updates = try updates.toOwnedSlice(gpa) }; } -/// Open a SINGLE .000 cell (+ updates) for its METADATA ONLY — bbox, compilation -/// scale, and M_COVR coverage — via a cheap parse (no portrayal, no geometry cache, -/// no tile bake). For the host's header/scan pass, where nothing renders. The -/// resulting `.cell` chart must NOT be render_surface'd (it has no portrayal). -pub fn openCellHeader(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart { - _ = rules_dir; - var cf = try readCellFiles(path); - defer cf.deinit(); - // Propagate the specific parse error (BadLeader / UnknownRUIN / …) so the C - // caller reads the exact reason the cell is invalid, not a generic code. - const cell = try s57.parseCellWithUpdates(gpa, cf.base, cf.updates); - var cb = CellBackend{ .cell = cell, .cscl = s57.peekScale(gpa, cf.base) orelse 0 }; - const pa = gpa.create(std.heap.ArenaAllocator) catch { - freeCellBackend(&cb); - return error.OutOfMemory; - }; - pa.* = std.heap.ArenaAllocator.init(gpa); - cb.portray_arena = pa; - cb.coverage = cb.cell.mcovrCoverage(pa.allocator()); - const src = gpa.create(Chart) catch { - freeCellBackend(&cb); - return error.OutOfMemory; - }; - src.* = .{ .backend = .{ .cell = cb }, .cache = std.AutoHashMap(u64, []u8).init(gpa), .pick_attrs = pick_attrs }; - return src; -} - -/// Open a SINGLE .000 cell (+ updates) by BAKING it to an in-memory PMTiles across -/// [minzoom, maxzoom] and serving it via the fast `.reader` path — a live cell -/// re-portrayed per view is orders of magnitude slower. The baked tiles carry no -/// M_COVR / CSCL, so the source cell's real coverage + compilation scale are captured -/// and attached (coverage()/nativeScale()). A host can bake a narrow band quickly -/// then re-open the full range in the background (progressive load). -pub fn openCellBaked(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool, minzoom: u8, maxzoom: u8) !*Chart { - var cf = try readCellFiles(path); - defer cf.deinit(); - const cscl = s57.peekScale(gpa, cf.base) orelse 0; - var cov_arena = try gpa.create(std.heap.ArenaAllocator); - cov_arena.* = std.heap.ArenaAllocator.init(gpa); - errdefer { - cov_arena.deinit(); - gpa.destroy(cov_arena); - } - var coverage: []const []const []const s57.LonLat = &.{}; - if (s57.parseCellWithUpdates(gpa, cf.base, cf.updates)) |parsed| { - var cell = parsed; - coverage = cell.mcovrCoverage(cov_arena.allocator()); // assembled into cov_arena - cell.deinit(); - } else |_| {} - - const cell_in = [_]CellInput{.{ .base = cf.base, .updates = cf.updates }}; - const archive = (bakeArchive(&cell_in, resolveRulesDir(rules_dir), minzoom, maxzoom, .mlt, pick_attrs, null, null, null) catch null) orelse return error.InvalidCell; - defer gpa.free(archive); - const src = try Chart.openBytes(archive, .pmtiles, rules_dir); - // Replace any coverage the archive metadata attached (this parse is fresher). - if (src.coverage_arena) |ca| { - ca.deinit(); - gpa.destroy(ca); - src.cell_cov = null; - } - src.cscl_override = cscl; - src.coverage_override = coverage; - src.coverage_arena = cov_arena; - return src; -} - -/// Bake the full zoom range (the default single-cell open). -pub fn openCellPath(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart { - return openCellBaked(path, rules_dir, pick_attrs, 0, 18); -} - /// Bake a SINGLE .000 cell (+ updates) to a PMTiles archive over its NATIVE band's /// zoom range (`bandZooms(bandOf(cscl))`) and nothing else — the composite model bakes /// each cell at its own compilation scale; the stitcher combines them and handles any @@ -666,8 +595,8 @@ pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 { var cf = try readCellFiles(cell_path); defer cf.deinit(); - // Capture coverage for the embedded sidecar (one cheap parse, as openCellBaked - // does). The stem is the ownership tie-break name — matches the coverage loader. + // Capture coverage for the embedded sidecar (one cheap parse). The stem is the + // ownership tie-break name — matches the coverage loader. var cov_arena = std.heap.ArenaAllocator.init(gpa); defer cov_arena.deinit(); var coverage_json: ?[]const u8 = null; @@ -980,17 +909,12 @@ pub const Chart = struct { // A PMTiles/reader backend ignores it — stored tiles serve verbatim in their // baked encoding (see tileType). tile_format: scene.TileFormat = .mvt, - // A live cell baked to an in-memory PMTiles (openCellBaked) is a .reader for - // fast render, but still carries the source cell's real M_COVR coverage and - // compilation scale (the baked tiles don't). These override coverage()/ - // nativeScale(); coverage_arena owns the copied polygons (freed in deinit). - coverage_override: []const []const []const s57.LonLat = &.{}, - coverage_arena: ?*std.heap.ArenaAllocator = null, - cscl_override: i32 = 0, // The per-cell coverage decoded from the opened archive's metadata (name, date, - // cscl, bbox, M_COVR rings) — what a compositor borrows to place this chart in - // the ownership partition. Storage lives in coverage_arena. + // cscl, bbox, M_COVR rings) — read back by coverage()/nativeScale(), and what a + // compositor borrows to place this chart in the ownership partition. Storage + // lives in coverage_arena (freed in deinit). cell_cov: ?cell_coverage.CellCoverage = null, + coverage_arena: ?*std.heap.ArenaAllocator = null, // A path-opened archive is mmap'd rather than copied (never fully resident); // released in deinit. Bytes-opened archives use `data` instead. data_map: ?[]align(std.heap.page_size_min) const u8 = null, @@ -1185,7 +1109,6 @@ pub const Chart = struct { /// gaps to coarser cells. A live cell parses it; a per-cell baked PMTiles /// surfaces the copy embedded in its archive metadata. Null when absent. pub fn coverage(self: *const Chart) ?[]const []const []const s57.LonLat { - if (self.coverage_override.len > 0) return self.coverage_override; if (self.cell_cov) |cc| { if (cc.cov1.len > 0) return cc.cov1; } @@ -1200,7 +1123,6 @@ pub const Chart = struct { /// baked PMTiles reads the copy in its archive metadata. 0 = unknown (derive from /// the zoom band instead). pub fn nativeScale(self: *const Chart) i32 { - if (self.cscl_override != 0) return self.cscl_override; if (self.cell_cov) |cc| { if (cc.cscl != 0) return cc.cscl; } diff --git a/src/errors.zig b/src/errors.zig index 3c74277..dea1da5 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -39,6 +39,7 @@ pub fn describe(e: anyerror) []const u8 { error.ModifyMissingFeature => "update references a missing feature record", error.UnknownRUIN => "unknown record update instruction (RUIN)", error.BadFeatureRecord => "malformed feature record", + error.TileGen => "tile generation failed", else => @errorName(e), }; } From e880329bc19228f1e318bcaebdfd4fd2b10edbb5 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 05:21:59 -0400 Subject: [PATCH 096/140] =?UTF-8?q?feat(go):=20track=20the=20reworked=20C?= =?UTF-8?q?=20ABI=20=E2=80=94=20PMTiles-only=20Source,=20handle-free=20S-5?= =?UTF-8?q?7=20readers,=20compose=20over=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open/OpenBytes take baked archives (Info gains NativeScale; Coverage reads the embedded M_COVR rings). Cells/Features/FeaturesBytes/CatalogEntries are now package functions over raw S-57 paths/bytes. OpenCompose(paths) opens and OWNS its charts; OpenComposeCharts borrows already-open ones. Every wrapper rides tile57_status + tile57_error (ErrInternal sentinel added). OpenChartBytes and OpenPMTiles are gone. Co-Authored-By: Claude Fable 5 --- bindings/go/README.md | 27 ++++--- bindings/go/asset.go | 11 +-- bindings/go/bake.go | 67 ++++++++--------- bindings/go/bundle_test.go | 7 +- bindings/go/compose.go | 127 +++++++++++++++++++++----------- bindings/go/compose_test.go | 56 ++++++++++++++ bindings/go/coverage.go | 57 +++++++++++++++ bindings/go/errors.go | 1 + bindings/go/errors_test.go | 12 ++- bindings/go/s57meta.go | 97 +++++++++++++++--------- bindings/go/s57meta_test.go | 31 ++++---- bindings/go/style.go | 19 +++-- bindings/go/tile57.go | 142 ++++++++++++++++-------------------- bindings/go/tile57_test.go | 96 ++++++++++++------------ 14 files changed, 468 insertions(+), 282 deletions(-) create mode 100644 bindings/go/coverage.go diff --git a/bindings/go/README.md b/bindings/go/README.md index 379680e..5c7a92d 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -43,24 +43,31 @@ src, _ := tile57.OpenCompose([]string{"/out/tiles/US5MD1MC.pmtiles"}, "/out/part defer src.Close() body, owned, _ := src.Serve(13, 2359, 3139) // owned=false, body=nil => open ocean -// Or open one cell/archive for metadata (cells, features, SCAMIN, bounds). -chart, _ := tile57.Open("/enc/ENC_ROOT") +// Or open one baked archive as a chart (bounds, scale, coverage, SCAMIN). +chart, _ := tile57.Open("/out/tiles/US5MD1MC.pmtiles") defer chart.Close() -cells, _ := chart.Cells() +info := chart.Info() // zoom range, bounds, embedded 1:N scale scamin := chart.Scamin() // []uint32, ascending + +// Raw S-57 reading is handle-free (cell inventory, feature extraction). +cells, _ := tile57.Cells("/enc/ENC_ROOT") +water, _ := tile57.Features("/enc/ENC_ROOT/US5MD1MC/US5MD1MC.000", "DEPARE", "DRGARE") ``` ## Surface -- **Charts (metadata + query)** — `Open` (path, streaming), `OpenChartBytes` (one - in-memory cell), `OpenPMTiles` (a baked archive); `Source.Info`, `Meta`, `Cells`, - `Features`, `Scamin`, `Close`. +- **Charts (a baked archive: metadata + query)** — `Open` (path, mmap'd), + `OpenBytes`; `Source.Info`, `Meta`, `Scamin`, `Coverage`, `Close`. +- **S-57 source readers (handle-free)** — `Cells` (per-cell metadata of a .000 or + ENC_ROOT), `Features` / `FeaturesBytes` (GeoJSON extraction), `CatalogEntries` + (CATALOG.031 decode). - **Bake** — `BakeCell` (one cell → PMTiles bytes), `BakeTree` (an ENC_ROOT → per-cell - archives + `partition.tpart`), `BakeAssets` (portrayal assets in memory). -- **Compose** — `OpenCompose` (archives + partition); `ComposeSource.Serve` (a tile, - with an ownership flag), `Meta`, `SavePartition`, `Close`. + archives), `BakeAssets` (portrayal assets in memory). +- **Compose** — `OpenCompose` (paths; owns its charts) / `OpenComposeCharts` + (borrows yours); `ComposeSource.Serve` (a tile, with an ownership flag), `Meta`, + `SavePartition`, `Close`. - **Style** — `ColortablesDefault`, `Style`, `BuildStyle`, `StyleDiff`, - `MarinerDefaults`, `CatalogEntries`. + `MarinerDefaults`. `libtile57` is not internally synchronized; every `Source` method is mutex-guarded, so a `Source` is safe for concurrent use. diff --git a/bindings/go/asset.go b/bindings/go/asset.go index d3f2e08..90e4894 100644 --- a/bindings/go/asset.go +++ b/bindings/go/asset.go @@ -9,7 +9,6 @@ package tile57 import "C" import ( - "fmt" "unsafe" ) @@ -19,8 +18,9 @@ import ( func ColortablesDefault() ([]byte, error) { var out *C.uint8_t var n C.size_t - if C.tile57_colortables_default(&out, &n) != 1 { - return nil, fmt.Errorf("tile57: colortables_default failed") + var cerr C.tile57_error + if st := C.tile57_colortables_default(&out, &n, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) } return tileBytes(out, n), nil } @@ -39,8 +39,9 @@ func BakeAssets(catalogDir string) (Assets, error) { cdir, free := cStringOrNil(catalogDir) defer free() var ca C.tile57_assets - if C.tile57_bake_assets(cdir, &ca) != 1 { - return Assets{}, fmt.Errorf("tile57: bake_assets failed") + var cerr C.tile57_error + if st := C.tile57_bake_assets(cdir, &ca, &cerr); st != C.TILE57_OK { + return Assets{}, statusError(st, &cerr) } defer C.tile57_assets_free(&ca) return Assets{ diff --git a/bindings/go/bake.go b/bindings/go/bake.go index f331268..3b0b3d8 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -26,9 +26,10 @@ func tile57GoBakeProgress(ctx unsafe.Pointer, done, total C.uint32_t) { // BakeCell bakes ONE on-disk cell (a .000 path + its .001.. updates) to a PMTiles // archive over its NATIVE band zoom range and returns the bytes — the per-cell tile -// store the composite stitcher consumes (the stitcher handles any cross-band zoom -// expansion). The archive metadata embeds that cell's own coverage (M_COVR + cscl + -// date/name); read it back with [PMTilesMetadata]. +// store the compositor consumes (it handles any cross-band zoom expansion). The +// archive metadata embeds that cell's own coverage (M_COVR + cscl + date/name); +// read it back with [PMTilesMetadata], or open the archive with [OpenBytes]. A +// cell that produces no tiles returns ErrNoCoverage. func BakeCell(path string) ([]byte, error) { if path == "" { return nil, fmt.Errorf("tile57: BakeCell needs a cell path: %w", ErrEmptyInput) @@ -38,24 +39,25 @@ func BakeCell(path string) ([]byte, error) { var out *C.uint8_t var outLen C.size_t - rc := C.tile57_bake_cell_bytes(cPath, &out, &outLen) - switch rc { - case 1: - return tileBytes(out, outLen), nil - case 0: + var cerr C.tile57_error + if st := C.tile57_bake_cell_bytes(cPath, &out, &outLen, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) + } + if out == nil { return nil, fmt.Errorf("tile57: cell bake produced no tiles: %w", ErrNoCoverage) - default: - return nil, fmt.Errorf("tile57: cell bake failed") } + return tileBytes(out, outLen), nil } // BakeTree walks inDir for S-57 base cells (*.000) and bakes each IN PARALLEL to the SAME relative // path under outDir with a .pmtiles extension (+ an .sha content-hash sidecar), creating -// subdirs. A cell whose archive is already up to date (newer than its .000 and its update chain) is -// skipped. The engine writes and frees each archive as it goes, so this never holds N archives in -// memory (peak ~ workers). outDir is the caller's OWN cache — it owns the location + layout, so +// subdirs. INCREMENTAL: a cell whose archive is already up to date (newer than its .000 and its +// update chain) is skipped, so a re-run over an unchanged tree bakes nothing — 0 over a warm cache +// is success. The engine writes and frees each archive as it goes, so this never holds N archives +// in memory (peak ~ workers). outDir is the caller's OWN cache — it owns the location + layout, so // distinct consumers don't clash. onProgress(done, total) fires per baked cell (may be called -// concurrently from workers, so it must be safe for concurrent use). Returns the number baked. +// concurrently from workers, so it must be safe for concurrent use). Returns the number baked +// THIS run. func BakeTree(inDir, outDir string, workers int, onProgress func(done, total int)) (int, error) { if inDir == "" || outDir == "" { return 0, fmt.Errorf("tile57: BakeTree needs input + output dirs: %w", ErrEmptyInput) @@ -76,11 +78,12 @@ func BakeTree(inDir, outDir string, workers int, onProgress func(done, total int cb = C.tile57_bake_progress(C.tile57GoBakeProgress) ctx = unsafe.Pointer(h) //nolint:govet // cgo.Handle passed as the void* ctx, retrieved verbatim } - rc := C.tile57_bake_tree(cIn, cOut, C.uint32_t(workers), cb, ctx) - if rc < 0 { - return 0, fmt.Errorf("tile57: bake tree failed") + var baked C.uint32_t + var cerr C.tile57_error + if st := C.tile57_bake_tree(cIn, cOut, C.uint32_t(workers), cb, ctx, &baked, &cerr); st != C.TILE57_OK { + return 0, statusError(st, &cerr) } - return int(rc), nil + return int(baked), nil } // PMTilesMetadata returns a PMTiles archive's metadata JSON blob (decompressed), or @@ -92,15 +95,14 @@ func PMTilesMetadata(pmtiles []byte) ([]byte, error) { } var out *C.uint8_t var outLen C.size_t - rc := C.tile57_pmtiles_metadata((*C.uint8_t)(unsafe.Pointer(&pmtiles[0])), C.size_t(len(pmtiles)), &out, &outLen) - switch rc { - case 1: - return tileBytes(out, outLen), nil - case 0: + var cerr C.tile57_error + if st := C.tile57_pmtiles_metadata((*C.uint8_t)(unsafe.Pointer(&pmtiles[0])), C.size_t(len(pmtiles)), &out, &outLen, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) + } + if out == nil { return nil, nil // archive has no metadata - default: - return nil, fmt.Errorf("tile57: read metadata failed") } + return tileBytes(out, outLen), nil } // PartitionBand selects which ownership-partition map [BakePartitionDebug] emits. @@ -131,7 +133,7 @@ const ( // [BandBerthing]…[BandOverview] emit that one band's own map at every zoom. minZoom and // maxZoom bound the tiles — the coarse bands are cheap, but harbor-level detail (maxZoom // >= 13) multiplies the tile count ~4× per zoom, so raise it deliberately. Returns the -// cell count. +// cell count; nothing covered returns ErrNoCoverage (no file written). func BakePartitionDebug(encRoot, outPath string, minZoom, maxZoom uint8, band PartitionBand) (cellCount int, err error) { if encRoot == "" || outPath == "" { return 0, fmt.Errorf("tile57: BakePartitionDebug needs an ENC root and out path: %w", ErrEmptyInput) @@ -142,13 +144,12 @@ func BakePartitionDebug(encRoot, outPath string, minZoom, maxZoom uint8, band Pa defer C.free(unsafe.Pointer(cOut)) var cells C.uint32_t - rc := C.tile57_bake_partition_debug(cRoot, cOut, C.uint8_t(minZoom), C.uint8_t(maxZoom), C.int8_t(band), &cells) - switch rc { - case 1: - return int(cells), nil - case 0: + var cerr C.tile57_error + if st := C.tile57_bake_partition_debug(cRoot, cOut, C.uint8_t(minZoom), C.uint8_t(maxZoom), C.int8_t(band), &cells, &cerr); st != C.TILE57_OK { + return 0, statusError(st, &cerr) + } + if cells == 0 { return 0, fmt.Errorf("tile57: partition-debug bake covered nothing: %w", ErrNoCoverage) - default: - return 0, fmt.Errorf("tile57: partition-debug bake failed") } + return int(cells), nil } diff --git a/bindings/go/bundle_test.go b/bindings/go/bundle_test.go index 5603da4..2725907 100644 --- a/bindings/go/bundle_test.go +++ b/bindings/go/bundle_test.go @@ -3,16 +3,11 @@ package tile57 import ( - "os" "testing" ) func TestSourceScamin(t *testing.T) { - data, err := os.ReadFile(testCell) - if err != nil { - t.Skipf("no test cell: %v", err) - } - src, err := OpenChartBytes(data) + src, err := OpenBytes(bakeTestCell(t)) if err != nil { t.Fatal(err) } diff --git a/bindings/go/compose.go b/bindings/go/compose.go index e68c136..c94e508 100644 --- a/bindings/go/compose.go +++ b/bindings/go/compose.go @@ -14,50 +14,57 @@ import ( "unsafe" ) -// ComposeSource is a resident runtime compositor: the per-cell PMTiles held mmap'd and the -// ownership partition loaded once, so Serve composes any tile on demand (byte-faithful to the -// batch ComposeFiles). It is the on-demand counterpart of ComposeFiles — for a live tile server -// rather than producing a full archive. Serve is serialised by an internal mutex (the underlying -// per-reader leaf cache is not safe for concurrent access). +// ComposeSource is a resident runtime compositor over open charts: the ownership +// partition loaded once, so Serve composes any tile on demand. It BORROWS its +// charts — they must outlive it — except charts opened for it by [OpenCompose], +// which it owns and closes. Serve is serialised by an internal mutex (the +// compositor reads through the charts, which are not thread-safe). type ComposeSource struct { - mu sync.Mutex - ptr *C.tile57_compose_source + mu sync.Mutex + ptr *C.tile57_compose + owned []*Source // charts OpenCompose opened for us; closed on Close } // ComposeMeta is a ComposeSource's served zoom range + union coverage bounds (degrees). type ComposeMeta struct { - MinZoom, MaxZoom uint8 - Cells uint32 - West, South, East, North float64 + MinZoom, MaxZoom uint8 + Cells uint32 + West, South, East, North float64 } -// OpenCompose opens a resident compositor over the per-cell PMTiles at paths (each written by -// [BakeCell] / `tile57 compose --keep-cells`), mmap'd so the cell set is never fully resident. If -// partitionPath is non-empty it names a partition sidecar (from `tile57 compose --save-partition`) -// to load and skip the owned-face build; a missing/stale one falls back to building. Close it when -// done — callers must not Close while any goroutine can still call Serve. -func OpenCompose(paths []string, partitionPath string) (*ComposeSource, error) { - if len(paths) == 0 { - return nil, fmt.Errorf("tile57: OpenCompose needs at least one path: %w", ErrEmptyInput) +// OpenComposeCharts opens a resident compositor over already-open charts, +// BORROWING them: every chart must outlive the compositor (Close the compositor +// first), and while it serves, don't call those charts' own methods from other +// goroutines. Charts whose archives embed no coverage are skipped; if none +// carries coverage the open fails with ErrNoCoverage. If partitionPath is +// non-empty it names a partition sidecar (from [ComposeSource.SavePartition]; the +// `tile57 bake` CLI writes partition.tpart) to load and skip the owned-face +// build; a missing/stale one falls back to building. +func OpenComposeCharts(charts []*Source, partitionPath string) (*ComposeSource, error) { + if len(charts) == 0 { + return nil, fmt.Errorf("tile57: OpenComposeCharts needs at least one chart: %w", ErrEmptyInput) } var ar cArena defer ar.free() - // A C array of C strings — pointer-free from Go's view (cgocheck-safe). - cpaths := (**C.char)(ar.track(C.malloc(C.size_t(len(paths)) * C.size_t(unsafe.Sizeof((*C.char)(nil)))))) - pv := unsafe.Slice(cpaths, len(paths)) - for i, p := range paths { - pv[i] = ar.str(p) + // A C array of chart handles — pointer-free from Go's view (cgocheck-safe). + cc := (**C.tile57)(ar.track(C.malloc(C.size_t(len(charts)) * C.size_t(unsafe.Sizeof((*C.tile57)(nil)))))) + cv := unsafe.Slice(cc, len(charts)) + for i, s := range charts { + if s == nil || s.ptr == nil { + return nil, fmt.Errorf("tile57: OpenComposeCharts: chart %d is nil/closed: %w", i, ErrEmptyInput) + } + cv[i] = s.ptr } var cpart *C.char if partitionPath != "" { cpart = ar.str(partitionPath) } - var ptr *C.tile57_compose_source + var ptr *C.tile57_compose var cerr C.tile57_error - if st := C.tile57_compose_open(cpaths, C.size_t(len(paths)), cpart, &ptr, &cerr); st != C.TILE57_OK { - // No coverage-carrying archive is reported as TILE57_ERR_PARSE; surface it - // as ErrNoCoverage so a host can branch, keeping the specific message. - if st == C.TILE57_ERR_PARSE { + if st := C.tile57_compose_open(cc, C.size_t(len(charts)), cpart, &ptr, &cerr); st != C.TILE57_OK { + // "No coverage-carrying chart" is TILE57_ERR_UNSUPPORTED; surface it as + // ErrNoCoverage so a host can branch, keeping the specific message. + if st == C.TILE57_ERR_UNSUPPORTED { return nil, fmt.Errorf("%s: %w", C.GoString(&cerr.message[0]), ErrNoCoverage) } return nil, statusError(st, &cerr) @@ -65,6 +72,38 @@ func OpenCompose(paths []string, partitionPath string) (*ComposeSource, error) { return &ComposeSource{ptr: ptr}, nil } +// OpenCompose opens a resident compositor over the per-cell PMTiles at paths (each +// written by [BakeCell] / [BakeTree]): every path is opened as a chart (mmap'd, so +// the cell set is never fully resident) and the compositor OWNS those charts — +// Close releases them too. See [OpenComposeCharts] to compose over charts you +// keep. partitionPath as in OpenComposeCharts. +func OpenCompose(paths []string, partitionPath string) (*ComposeSource, error) { + if len(paths) == 0 { + return nil, fmt.Errorf("tile57: OpenCompose needs at least one path: %w", ErrEmptyInput) + } + charts := make([]*Source, 0, len(paths)) + closeAll := func() { + for _, s := range charts { + _ = s.Close() + } + } + for _, p := range paths { + s, err := Open(p) + if err != nil { + closeAll() + return nil, fmt.Errorf("tile57: OpenCompose: %w", err) + } + charts = append(charts, s) + } + cs, err := OpenComposeCharts(charts, partitionPath) + if err != nil { + closeAll() + return nil, err + } + cs.owned = charts + return cs, nil +} + // Serve composes the tile (z,x,y) on demand, returning raw (decompressed) MLT bytes plus `owned` — // whether the ownership partition says a cell SHOULD render here. body!=nil → served (owned). body // ==nil && owned → a cell owns this ground but produced nothing (transient while its per-cell bake @@ -78,17 +117,15 @@ func (c *ComposeSource) Serve(z uint8, x, y uint32) (body []byte, owned bool, er } var out *C.uint8_t var outLen C.size_t - rc := C.tile57_compose_serve(c.ptr, C.uint8_t(z), C.uint32_t(x), C.uint32_t(y), &out, &outLen) - switch rc { - case 1: - return tileBytes(out, outLen), true, nil // served (content implies owned) - case 2: - return nil, true, nil // owned but empty - case 0: - return nil, false, nil // not owned — true empty - default: - return nil, false, fmt.Errorf("tile57: compose serve failed at %d/%d/%d", z, x, y) + var cowned C.bool + var cerr C.tile57_error + if st := C.tile57_compose_serve(c.ptr, C.uint8_t(z), C.uint32_t(x), C.uint32_t(y), &out, &outLen, &cowned, &cerr); st != C.TILE57_OK { + return nil, false, statusError(st, &cerr) + } + if out == nil { + return nil, bool(cowned), nil } + return tileBytes(out, outLen), true, nil } // Meta returns the compositor's served zoom range + union coverage bounds. @@ -112,7 +149,7 @@ func (c *ComposeSource) Meta() ComposeMeta { } // SavePartition serializes the resident ownership partition to path — a sidecar a later -// OpenCompose can load (as partitionPath) to skip the owned-face build. +// compose open can load (as partitionPath) to skip the owned-face build. func (c *ComposeSource) SavePartition(path string) error { c.mu.Lock() defer c.mu.Unlock() @@ -121,13 +158,15 @@ func (c *ComposeSource) SavePartition(path string) error { } var ar cArena defer ar.free() - if C.tile57_compose_save_partition(c.ptr, ar.str(path)) != 1 { - return fmt.Errorf("tile57: SavePartition to %q failed", path) + var cerr C.tile57_error + if st := C.tile57_compose_save_partition(c.ptr, ar.str(path), &cerr); st != C.TILE57_OK { + return statusError(st, &cerr) } return nil } -// Close releases the compositor (munmaps the archives, frees the partition). Idempotent. +// Close releases the compositor, then any charts [OpenCompose] opened for it. +// Borrowed charts (from [OpenComposeCharts]) stay open. Idempotent. func (c *ComposeSource) Close() error { c.mu.Lock() defer c.mu.Unlock() @@ -135,5 +174,9 @@ func (c *ComposeSource) Close() error { C.tile57_compose_close(c.ptr) c.ptr = nil } + for _, s := range c.owned { + _ = s.Close() + } + c.owned = nil return nil } diff --git a/bindings/go/compose_test.go b/bindings/go/compose_test.go index ddd088b..f3f91e7 100644 --- a/bindings/go/compose_test.go +++ b/bindings/go/compose_test.go @@ -12,6 +12,62 @@ import ( // lonLatToTile lives in tile57_test.go (same package). +// The whole pipeline over the in-repo fixture: bake the cell, open it as a chart, +// compose over the chart (borrowed), serve the coverage-centre tile, and round-trip +// the partition sidecar through a path-based (owning) open. +func TestComposeSingleCell(t *testing.T) { + pm := bakeTestCell(t) + path := filepath.Join(t.TempDir(), "US5MD1MC.pmtiles") + if err := os.WriteFile(path, pm, 0o644); err != nil { + t.Fatal(err) + } + chart, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer chart.Close() + + src, err := OpenComposeCharts([]*Source{chart}, "") + if err != nil { + t.Fatalf("OpenComposeCharts: %v", err) + } + m := src.Meta() + if m.Cells != 1 { + t.Fatalf("compose cells = %d, want 1", m.Cells) + } + cx, cy := lonLatToTile((m.West+m.East)/2, (m.South+m.North)/2, m.MinZoom) + tile, owned, err := src.Serve(m.MinZoom, cx, cy) + if err != nil { + t.Fatalf("Serve: %v", err) + } + if len(tile) == 0 || !owned { + t.Fatalf("centre tile z%d/%d/%d: %d bytes owned=%v, want content", m.MinZoom, cx, cy, len(tile), owned) + } + part := filepath.Join(t.TempDir(), "partition.tpart") + if err := src.SavePartition(part); err != nil { + t.Fatalf("SavePartition: %v", err) + } + if err := src.Close(); err != nil { + t.Fatal(err) + } + + // Path-based open owns its charts and loads the sidecar. + src2, err := OpenCompose([]string{path}, part) + if err != nil { + t.Fatalf("OpenCompose: %v", err) + } + defer src2.Close() + tile2, owned2, err := src2.Serve(m.MinZoom, cx, cy) + if err != nil { + t.Fatalf("Serve(sidecar): %v", err) + } + if !owned2 || len(tile2) != len(tile) { + t.Fatalf("sidecar-loaded serve differs: %d bytes owned=%v (want %d bytes)", len(tile2), owned2, len(tile)) + } + t.Logf("compose z%d..%d served %d bytes at z%d/%d/%d (sidecar round-trip ok)", + m.MinZoom, m.MaxZoom, len(tile), m.MinZoom, cx, cy) +} + // Set TILE57_COMPOSE_TESTDIR to a dir of per-cell *.cell.tmp/*.pmtiles archives (e.g. from // `tile57 compose -o out.pmtiles --keep-cells`), optionally TILE57_COMPOSE_PARTITION to // a sidecar (`--save-partition`). Skips when unset — no machine paths baked into the test. diff --git a/bindings/go/coverage.go b/bindings/go/coverage.go new file mode 100644 index 0000000..a287ab0 --- /dev/null +++ b/bindings/go/coverage.go @@ -0,0 +1,57 @@ +//go:build cgo + +package tile57 + +/* +#include "tile57.h" + +// Trampoline: the C ring callback calls back into this exported Go function. +// (cgo exports can't carry const, so the helper casts to the header's type.) +extern void tile57GoCoverageRing(void *ctx, double *lonlat, size_t npts); + +// Build the callback table C-side so no C function pointer crosses into Go. +static tile57_status t57CoverageGo(tile57 *chart, void *handle, tile57_error *err) { + tile57_coverage_cb cb = {handle, (void (*)(void *, const double *, size_t))tile57GoCoverageRing}; + return tile57_coverage(chart, &cb, err); +} +*/ +import "C" + +import ( + "runtime/cgo" + "unsafe" +) + +//export tile57GoCoverageRing +func tile57GoCoverageRing(ctx unsafe.Pointer, lonlat *C.double, npts C.size_t) { + rings, ok := cgo.Handle(uintptr(ctx)).Value().(*[][][2]float64) + if !ok || npts == 0 { + return + } + pts := unsafe.Slice(lonlat, int(npts)*2) + ring := make([][2]float64, int(npts)) + for i := range ring { + ring[i] = [2]float64{float64(pts[2*i]), float64(pts[2*i+1])} + } + *rings = append(*rings, ring) +} + +// Coverage returns the chart's M_COVR data-coverage polygons (one exterior ring +// per polygon, lon/lat points) from the coverage the bake embedded in the archive +// metadata — the real coverage a host reports so a quilt fills gaps to coarser +// cells. Nil when the archive embeds none (a composed/foreign archive). +func (s *Source) Coverage() ([][][2]float64, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.ptr == nil { + return nil, ErrSourceClosed + } + var rings [][][2]float64 + h := cgo.NewHandle(&rings) + defer h.Delete() + var cerr C.tile57_error + if st := C.t57CoverageGo(s.ptr, unsafe.Pointer(h), &cerr); st != C.TILE57_OK { //nolint:govet // cgo.Handle as void* ctx + return nil, statusError(st, &cerr) + } + return rings, nil +} diff --git a/bindings/go/errors.go b/bindings/go/errors.go index 2e15c44..6bc0c3f 100644 --- a/bindings/go/errors.go +++ b/bindings/go/errors.go @@ -29,4 +29,5 @@ var ( ErrNoMem = errors.New("tile57: out of memory") ErrUnsupported = errors.New("tile57: unsupported input") ErrRender = errors.New("tile57: render failed") + ErrInternal = errors.New("tile57: internal error") ) diff --git a/bindings/go/errors_test.go b/bindings/go/errors_test.go index c76381f..182b8a5 100644 --- a/bindings/go/errors_test.go +++ b/bindings/go/errors_test.go @@ -12,8 +12,16 @@ import ( // scraping the message string. func TestSentinelErrors(t *testing.T) { // ErrEmptyInput: empty input. - if _, err := OpenChartBytes(nil); !errors.Is(err, ErrEmptyInput) { - t.Errorf("OpenChartBytes(nil): want ErrEmptyInput, got %v", err) + if _, err := OpenBytes(nil); !errors.Is(err, ErrEmptyInput) { + t.Errorf("OpenBytes(nil): want ErrEmptyInput, got %v", err) + } + // Category sentinels ride the C status: a missing file is ErrIO, garbage + // archive bytes are ErrParse. + if _, err := Open("testdata/definitely-missing.pmtiles"); !errors.Is(err, ErrIO) { + t.Errorf("Open(missing): want ErrIO, got %v", err) + } + if _, err := OpenBytes([]byte("not a pmtiles archive")); !errors.Is(err, ErrParse) { + t.Errorf("OpenBytes(garbage): want ErrParse, got %v", err) } if _, err := Open(""); !errors.Is(err, ErrEmptyInput) { t.Errorf("Open(\"\"): want ErrEmptyInput, got %v", err) diff --git a/bindings/go/s57meta.go b/bindings/go/s57meta.go index c999043..5a65531 100644 --- a/bindings/go/s57meta.go +++ b/bindings/go/s57meta.go @@ -2,8 +2,8 @@ package tile57 -// Chart metadata + raw S-57 access: per-cell metadata, exchange-set catalogue -// decode, and raw feature access — everything a host previously parsed from +// Raw S-57 access, handle-free: per-cell metadata, GeoJSON feature extraction, +// and exchange-set catalogue decode — everything a host previously parsed from // S-57/ISO-8211 itself. With these, a host's S-57 knowledge shrinks to file // staging conventions (.000/.NNN, ENC_ROOT/, CATALOG.031 as opaque bytes). @@ -33,23 +33,23 @@ type CellInfo struct { HasBBox bool `json:"-"` } -// Cells returns the chart's per-cell metadata (one entry per cell, DSID fields -// after the update chain). A PMTiles source returns nil — its bundle manifest -// carries the cell inventory. -func (s *Source) Cells() ([]CellInfo, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return nil, ErrSourceClosed - } +// Cells returns the per-cell metadata of the S-57 data at path — one .000 file +// (with its update chain applied) or a whole ENC_ROOT directory — for a host's +// chart-database scan. +func Cells(path string) ([]CellInfo, error) { + if path == "" { + return nil, fmt.Errorf("tile57: empty path: %w", ErrEmptyInput) + } + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) var out *C.uint8_t var outLen C.size_t - switch C.tile57_chart_cells(s.ptr, &out, &outLen) { - case 1: - case 0: + var cerr C.tile57_error + if st := C.tile57_s57_cells(cPath, &out, &outLen, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) + } + if out == nil { return nil, nil - default: - return nil, fmt.Errorf("tile57: cell metadata error") } raw := tileBytes(out, outLen) var wire []struct { @@ -87,12 +87,12 @@ func CatalogEntries(catalog []byte) ([]CatalogEntry, error) { } var out *C.uint8_t var outLen C.size_t - switch C.tile57_catalog_entries((*C.uint8_t)(unsafe.Pointer(&catalog[0])), C.size_t(len(catalog)), &out, &outLen) { - case 1: - case 0: + var cerr C.tile57_error + if st := C.tile57_s57_catalog((*C.uint8_t)(unsafe.Pointer(&catalog[0])), C.size_t(len(catalog)), &out, &outLen, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) + } + if out == nil { return nil, nil - default: - return nil, fmt.Errorf("tile57: catalogue parse error") } raw := tileBytes(out, outLen) var wire []struct { @@ -113,8 +113,8 @@ func CatalogEntries(catalog []byte) ([]CatalogEntry, error) { return entries, nil } -// Feature is one S-57 feature from [Source.Features]: its class acronym, the -// full attribute map (acronym → raw string value), and GeoJSON geometry. +// Feature is one S-57 feature from [Features]: its class acronym, the full +// attribute map (acronym → raw string value), and GeoJSON geometry. type Feature struct { Class string // e.g. "DEPARE" Attrs map[string]string // full S-57 attribute set, e.g. {"DRVAL1":"3.6"} @@ -122,28 +122,55 @@ type Feature struct { Geometry json.RawMessage // the GeoJSON geometry object (lon/lat; soundings carry depth as a 3rd coord) } -// Features returns the chart's features for the given object-class acronyms -// (e.g. "DEPARE", "DRGARE"), parsed without portrayal. A whole-ENC_ROOT query +// Features returns the features of the S-57 data at path (one cell, updates +// applied, or a whole ENC_ROOT) for the given object-class acronyms (e.g. +// "DEPARE", "DRGARE"), parsed without portrayal. A whole-ENC_ROOT extraction // walks every cell — the caller owns that cost. -func (s *Source) Features(classes ...string) ([]Feature, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return nil, ErrSourceClosed +func Features(path string, classes ...string) ([]Feature, error) { + if path == "" { + return nil, fmt.Errorf("tile57: empty path: %w", ErrEmptyInput) } if len(classes) == 0 { return nil, nil } + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) cs := C.CString(strings.Join(classes, ",")) defer C.free(unsafe.Pointer(cs)) var out *C.uint8_t var outLen C.size_t - switch C.tile57_chart_features(s.ptr, cs, &out, &outLen) { - case 1: - case 0: + var cerr C.tile57_error + if st := C.tile57_s57_features(cPath, cs, &out, &outLen, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) + } + return decodeFeatures(out, outLen) +} + +// FeaturesBytes is [Features] over in-memory base-cell bytes (a .000 read from a +// zip member, say). No update chain is applied. +func FeaturesBytes(base []byte, classes ...string) ([]Feature, error) { + if len(base) == 0 { + return nil, fmt.Errorf("tile57: empty cell bytes: %w", ErrEmptyInput) + } + if len(classes) == 0 { + return nil, nil + } + cs := C.CString(strings.Join(classes, ",")) + defer C.free(unsafe.Pointer(cs)) + var out *C.uint8_t + var outLen C.size_t + var cerr C.tile57_error + if st := C.tile57_s57_features_bytes((*C.uint8_t)(unsafe.Pointer(&base[0])), C.size_t(len(base)), cs, &out, &outLen, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) + } + return decodeFeatures(out, outLen) +} + +// decodeFeatures unmarshals an engine GeoJSON FeatureCollection buffer (freeing +// it) into []Feature. A nil buffer (nothing matched) decodes to nil. +func decodeFeatures(out *C.uint8_t, outLen C.size_t) ([]Feature, error) { + if out == nil { return nil, nil - default: - return nil, fmt.Errorf("tile57: feature query error") } raw := tileBytes(out, outLen) var fc struct { diff --git a/bindings/go/s57meta_test.go b/bindings/go/s57meta_test.go index 304ee09..0ac00bc 100644 --- a/bindings/go/s57meta_test.go +++ b/bindings/go/s57meta_test.go @@ -4,19 +4,14 @@ package tile57 import ( "encoding/json" + "os" "testing" ) -// TestCells reads the testdata cell's DSID identity + coverage through the -// chart handle — the metadata a host previously parsed from ISO-8211 itself. +// TestCells reads the testdata cell's DSID identity + coverage handle-free — +// the metadata a host previously parsed from ISO-8211 itself. func TestCells(t *testing.T) { - src, err := Open(testCell) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer src.Close() - - cells, err := src.Cells() + cells, err := Cells(testCell) if err != nil { t.Fatalf("Cells: %v", err) } @@ -44,15 +39,21 @@ func TestCells(t *testing.T) { // TestFeatures runs the NMEA-simulator water-mask query: DEPARE/DRGARE // polygons with DRVAL1, closed rings, lon/lat coordinates. func TestFeatures(t *testing.T) { - src, err := Open(testCell) + feats, err := Features(testCell, "DEPARE", "DRGARE") if err != nil { - t.Fatalf("Open: %v", err) + t.Fatalf("Features: %v", err) } - defer src.Close() - - feats, err := src.Features("DEPARE", "DRGARE") + // The bytes variant (the NMEA simulator's zip-member path) must agree. + data, err := os.ReadFile(testCell) if err != nil { - t.Fatalf("Features: %v", err) + t.Fatal(err) + } + bfeats, err := FeaturesBytes(data, "DEPARE", "DRGARE") + if err != nil { + t.Fatalf("FeaturesBytes: %v", err) + } + if len(bfeats) == 0 { + t.Fatal("FeaturesBytes returned nothing") } if len(feats) == 0 { t.Fatal("no DEPARE/DRGARE features") diff --git a/bindings/go/style.go b/bindings/go/style.go index 7c0e13a..26c0c1d 100644 --- a/bindings/go/style.go +++ b/bindings/go/style.go @@ -66,7 +66,7 @@ type Mariner struct { DateDependent, HighlightDateDependent bool DateView string // "YYYYMMDD" or "" (today) IgnoreScamin bool // ?ignoreScamin: drop SCAMIN gating, show all in-band - ScaminFilterGate bool // scamin-layers.md: one live-filtered *_scamin layer per render-type instead of per-value buckets + ScaminFilterGate bool // one live-filtered *_scamin layer per render-type instead of per-value buckets ShowOverscale bool // S-52 §10.1.10 AP(OVERSC01) overscale indication (default true) SizeScale float64 // physical-scale multiplier for icon/line/text sizes (1.0 = verbatim) ViewingGroupsOff []int32 // S-52 §14.5 DENY-LIST: vg ids turned OFF (nil/empty = show all) @@ -99,9 +99,10 @@ func StyleTemplate(scheme Scheme, sourceTiles, sprite, glyphs string, minZoom, m defer f3() var out *C.uint8_t var n C.size_t - if C.tile57_style_template(C.tile57_scheme(scheme), cSrc, cSpr, cGly, - C.uint32_t(minZoom), C.uint32_t(maxZoom), C.uint8_t(encoding), &out, &n) != 1 { - return nil, fmt.Errorf("tile57: style_template failed") + var cerr C.tile57_error + if st := C.tile57_style_template(C.tile57_scheme(scheme), cSrc, cSpr, cGly, + C.uint32_t(minZoom), C.uint32_t(maxZoom), C.uint8_t(encoding), &out, &n, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) } return tileBytes(out, n), nil } @@ -136,8 +137,9 @@ func BuildStyle(template []byte, m Mariner, colortables []byte, enabledBands []i var out *C.uint8_t var outLen C.size_t - if C.tile57_build_style(tmplPtr, tmplLen, &cm, ctPtr, ctLen, bandsPtr, bandsN, scaminPtr, scaminN, C.double(scaminLat), &out, &outLen) != 1 { - return nil, fmt.Errorf("tile57: build_style failed") + var cerr C.tile57_error + if st := C.tile57_build_style(tmplPtr, tmplLen, &cm, ctPtr, ctLen, bandsPtr, bandsN, scaminPtr, scaminN, C.double(scaminLat), &out, &outLen, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) } return tileBytes(out, outLen), nil } @@ -165,8 +167,9 @@ func StyleDiff(template []byte, from, to Mariner, colortables []byte, enabledBan var out *C.uint8_t var outLen C.size_t - if C.tile57_style_diff(tmplPtr, tmplLen, &cFrom, &cTo, ctPtr, ctLen, bandsPtr, bandsN, scaminPtr, scaminN, C.double(scaminLat), &out, &outLen) != 1 { - return nil, fmt.Errorf("tile57: style_diff failed") + var cerr C.tile57_error + if st := C.tile57_style_diff(tmplPtr, tmplLen, &cFrom, &cTo, ctPtr, ctLen, bandsPtr, bandsN, scaminPtr, scaminN, C.double(scaminLat), &out, &outLen, &cerr); st != C.TILE57_OK { + return nil, statusError(st, &cerr) } return tileBytes(out, outLen), nil } diff --git a/bindings/go/tile57.go b/bindings/go/tile57.go index 64b84a6..af87001 100644 --- a/bindings/go/tile57.go +++ b/bindings/go/tile57.go @@ -10,10 +10,15 @@ // below link it by a path relative to this package, so an importing module needs a // `replace` pointing at a local checkout (see this package's README). // -// A [Source] opens a path, in-memory ENC cell, or baked PMTiles bundle and serves -// decompressed Mapbox Vector Tiles by (z, x, y); its method set (Tile, Info, Meta, -// Scamin, …) satisfies a host's tile-source interface directly. libtile57 -// is NOT internally synchronized, so every call into a Source is guarded by a mutex. +// The pipeline mirrors the C header: bake ENC cells to per-cell PMTiles +// ([BakeCell] / [BakeTree]), open each archive as a [Source] ([Open] / +// [OpenBytes]) for metadata, and compose the open charts into one seamless tile +// pyramid with [OpenCompose] / [OpenComposeCharts] for serving. Raw S-57 reading +// (cell inventory, feature extraction, catalogue decode) is handle-free — see +// [Cells], [Features], [FeaturesBytes], [CatalogEntries]. +// +// libtile57 is NOT internally synchronized, so every call into a handle is +// guarded by a mutex. package tile57 /* @@ -30,7 +35,7 @@ import ( "unsafe" ) -// Version returns the libtile57 version string (e.g. "0.1.0"). +// Version returns the libtile57 version string (e.g. "0.2.0"). func Version() string { return C.GoString(C.tile57_version()) } // statusError turns a non-OK tile57_status (with the optional tile57_error the @@ -52,6 +57,8 @@ func statusError(st C.tile57_status, cerr *C.tile57_error) error { switch st { case C.TILE57_ERR_BADARG: sentinel = ErrBadArg + case C.TILE57_ERR_IO: + sentinel = ErrIO case C.TILE57_ERR_PARSE: sentinel = ErrParse case C.TILE57_ERR_NOMEM: @@ -61,13 +68,13 @@ func statusError(st C.tile57_status, cerr *C.tile57_error) error { case C.TILE57_ERR_RENDER: sentinel = ErrRender default: - sentinel = ErrIO + sentinel = ErrInternal } return fmt.Errorf("%s (%w)", msg, sentinel) } -// TileFormat is a tile encoding (tile57_tile_type / tile57_bake_opts.format). -// The zero value means "the engine default" (MLT) in bake options. +// TileFormat is a tile encoding (tile57_tile_type). The zero value means "the +// engine default" (MLT). type TileFormat uint8 const ( @@ -89,89 +96,62 @@ func (f TileFormat) Encoding() string { // Meta is a chart source's display metadata — the shape a host tile server needs to // publish a TileJSON: zoom range, geographic bounds (degrees), whether tile bodies // are gzip-compressed (always false here — tile57 serves decompressed tiles), the -// distinct SCAMIN denominators present (the live SCAMIN manifest; see [Source.Scamin]), -// and the tile encoding Tile returns ("mvt" or "mlt" — the TileJSON/style `encoding` -// hint). A host with its own metadata type copies these fields across. +// distinct SCAMIN denominators present (see [Source.Scamin]), and the tile encoding +// ("mvt" or "mlt" — the TileJSON/style `encoding` hint). A host with its own +// metadata type copies these fields across. type Meta struct { MinZoom, MaxZoom uint8 W, S, E, N float64 // lon/lat bounds (degrees) Gzipped bool // tile bodies gzip-compressed (always false for tile57) Scamin []uint32 // distinct SCAMIN denominators present (ascending) - TileType string // "mvt" | "mlt" — the encoding Tile returns + TileType string // "mvt" | "mlt" — the archive's stored encoding } -// Source is an open libtile57 chart. Construct it with [Open], [OpenChartBytes], or -// [OpenPMTiles]; release it with [Source.Close]. It is safe for concurrent use: the -// underlying handle is not thread-safe, so calls are serialized internally. +// Source is an open libtile57 chart: ONE baked PMTiles archive. Construct it with +// [Open] (mmap'd path) or [OpenBytes] (copied); release it with [Source.Close]. It +// is safe for concurrent use: the underlying handle is not thread-safe, so calls +// are serialized internally. type Source struct { - mu sync.Mutex - ptr *C.tile57_chart - // scamin caches the SCAMIN manifest — for a cell/ENC_ROOT source the ABI scans - // every cell's features to compute it, so it is resolved once on first use. - scamin []uint32 + mu sync.Mutex + ptr *C.tile57 + scamin []uint32 // cached SCAMIN manifest (resolved once on first use) scaminDone bool } -// Cell is one ENC cell for [BakePmtiles]: the base .000 bytes -// plus its sequential update files (.001, .002, … in order). -type Cell struct { - Base []byte - Updates [][]byte - // Name is the source cell name (e.g. "US4MD81M"), emitted as the `cell` - // pick-report property on every feature from this cell. "" = omit it. - Name string -} - -// Open opens an on-disk ENC_ROOT directory (or a single .000 file) as a STREAMING -// chart: the engine enumerates + peeks the cells at open, then reads cell bytes on -// demand (working set only), so memory tracks what tiles need rather than the whole -// ENC_ROOT. Rules are the library's embedded catalogue. (chart-api.md) +// Open opens a baked PMTiles archive from a file path, mmap'd — a whole chart +// library can be open without being resident. The file must stay in place while +// the Source is open. func Open(path string) (*Source, error) { if path == "" { return nil, fmt.Errorf("tile57: empty path: %w", ErrEmptyInput) } cPath := C.CString(path) defer C.free(unsafe.Pointer(cPath)) - var ptr *C.tile57_chart + var ptr *C.tile57 var cerr C.tile57_error - if st := C.tile57_chart_open(cPath, &ptr, &cerr); st != C.TILE57_OK { + if st := C.tile57_open(cPath, &ptr, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } return &Source{ptr: ptr}, nil } -// OpenChartBytes opens one in-memory ENC cell (base .000 bytes) as a resident chart. -// Bytes are copied. (chart-api.md) -func OpenChartBytes(base []byte) (*Source, error) { - if len(base) == 0 { - return nil, fmt.Errorf("tile57: empty cell bytes: %w", ErrEmptyInput) +// OpenBytes opens a baked PMTiles archive from in-memory bytes (e.g. straight from +// [BakeCell], before any file exists). Bytes are copied. +func OpenBytes(pmtiles []byte) (*Source, error) { + if len(pmtiles) == 0 { + return nil, fmt.Errorf("tile57: empty archive bytes: %w", ErrEmptyInput) } - var ptr *C.tile57_chart + var ptr *C.tile57 var cerr C.tile57_error - if st := C.tile57_chart_open_bytes((*C.uint8_t)(unsafe.Pointer(&base[0])), C.size_t(len(base)), &ptr, &cerr); st != C.TILE57_OK { + if st := C.tile57_open_bytes((*C.uint8_t)(unsafe.Pointer(&pmtiles[0])), C.size_t(len(pmtiles)), &ptr, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } return &Source{ptr: ptr}, nil } -// OpenPMTiles opens a baked PMTiles bundle from a file path. (chart-api.md) -func OpenPMTiles(path string) (*Source, error) { - if path == "" { - return nil, fmt.Errorf("tile57: empty path: %w", ErrEmptyInput) - } - cPath := C.CString(path) - defer C.free(unsafe.Pointer(cPath)) - var ptr *C.tile57_chart - var cerr C.tile57_error - if st := C.tile57_chart_open_pmtiles(cPath, &ptr, &cerr); st != C.TILE57_OK { - return nil, statusError(st, &cerr) - } - return &Source{ptr: ptr}, nil -} - -// ChartInfo is a chart's fixed metadata (chart-api.md) — zoom range, bands, bounds, -// a good initial camera, and the tile encoding Tile returns. HasBounds/HasAnchor -// guard the respective fields. +// ChartInfo is a chart's fixed metadata — zoom range, bands, bounds, a good +// initial camera, the archive's tile encoding, and the compilation scale the bake +// embedded. HasBounds/HasAnchor guard the respective fields. type ChartInfo struct { MinZoom, MaxZoom uint8 Bands uint32 @@ -179,16 +159,19 @@ type ChartInfo struct { West, South, East, North float64 HasAnchor bool AnchorLat, AnchorLon, AnchorZoom float64 - // TileType is the encoding Tile returns (FormatMVT or FormatMLT): a PMTiles- - // backed source reports its archive's stored type; a cell-backed source its - // live generation format (see [Source.SetTileFormat]). + // TileType is the archive's stored encoding (FormatMVT or FormatMLT). TileType TileFormat + // NativeScale is the compilation scale (1:N) embedded by the per-cell bake; + // 0 = unknown (a composed/foreign archive — derive from the zoom band). + NativeScale int32 } // Info returns the chart's fixed metadata in one call. func (s *Source) Info() ChartInfo { - var ci C.tile57_chart_info - C.tile57_chart_get_info(s.ptr, &ci) + s.mu.Lock() + defer s.mu.Unlock() + var ci C.tile57_info + C.tile57_get_info(s.ptr, &ci) return ChartInfo{ MinZoom: uint8(ci.min_zoom), MaxZoom: uint8(ci.max_zoom), Bands: uint32(ci.bands), @@ -196,15 +179,14 @@ func (s *Source) Info() ChartInfo { West: float64(ci.west), South: float64(ci.south), East: float64(ci.east), North: float64(ci.north), HasAnchor: bool(ci.has_anchor), AnchorLat: float64(ci.anchor_lat), AnchorLon: float64(ci.anchor_lon), AnchorZoom: float64(ci.anchor_zoom), - TileType: TileFormat(ci.tile_type), + TileType: TileFormat(ci.tile_type), + NativeScale: int32(ci.native_scale), } } // Meta reports the source's display metadata (zoom range, bounds, SCAMIN manifest). -// tile57 serves decompressed MVT, so Gzipped is always false. Bounds fall back to the -// world extent when libtile57 reports them as degenerate/near-global. The SCAMIN -// manifest is resolved (and cached) here — for a cell/ENC_ROOT source that scans -// every cell once, so the first Meta call on a large set does real work. +// tile57 serves decompressed tiles, so Gzipped is always false. Bounds fall back to +// the world extent when the archive reports none. func (s *Source) Meta() Meta { s.mu.Lock() defer s.mu.Unlock() @@ -212,8 +194,8 @@ func (s *Source) Meta() Meta { if s.ptr == nil { return m } - var ci C.tile57_chart_info - C.tile57_chart_get_info(s.ptr, &ci) + var ci C.tile57_info + C.tile57_get_info(s.ptr, &ci) m.MinZoom, m.MaxZoom = uint8(ci.min_zoom), uint8(ci.max_zoom) if bool(ci.has_bounds) { m.W, m.S, m.E, m.N = float64(ci.west), float64(ci.south), float64(ci.east), float64(ci.north) @@ -224,9 +206,8 @@ func (s *Source) Meta() Meta { } // Scamin returns the distinct SCAMIN denominators present in the source (the live -// SCAMIN manifest, ascending), so the client builds one native fractional-minzoom -// bucket layer per value. Cached: a cell/ENC_ROOT source scans every cell's -// features to compute it. +// SCAMIN manifest, ascending, from the archive metadata), so the client builds one +// native fractional-minzoom bucket layer per value. Cached after the first call. func (s *Source) Scamin() []uint32 { s.mu.Lock() defer s.mu.Unlock() @@ -242,7 +223,8 @@ func (s *Source) scaminLocked() []uint32 { s.scaminDone = true var out *C.int32_t var n C.size_t - if C.tile57_chart_scamin(s.ptr, &out, &n) == 1 && out != nil && n > 0 { + var cerr C.tile57_error + if C.tile57_scamin(s.ptr, &out, &n, &cerr) == C.TILE57_OK && out != nil && n > 0 { vals := unsafe.Slice(out, int(n)) res := make([]uint32, n) for i, v := range vals { @@ -255,13 +237,13 @@ func (s *Source) scaminLocked() []uint32 { } // Close releases the source and all cached tiles. It is idempotent. Per the ABI's -// lifetime rule, callers must not Close while any goroutine can still call Tile; -// the server Closes a set only after deregistering it. +// lifetime rule, callers must not Close while any borrower (a compositor built +// over this Source, a goroutine mid-call) can still read from it. func (s *Source) Close() error { s.mu.Lock() defer s.mu.Unlock() if s.ptr != nil { - C.tile57_chart_close(s.ptr) + C.tile57_close(s.ptr) s.ptr = nil } return nil diff --git a/bindings/go/tile57_test.go b/bindings/go/tile57_test.go index d2f9936..d5f71c6 100644 --- a/bindings/go/tile57_test.go +++ b/bindings/go/tile57_test.go @@ -5,6 +5,7 @@ package tile57 import ( "math" "os" + "path/filepath" "testing" ) @@ -27,71 +28,74 @@ func TestColortablesDefault(t *testing.T) { } } -// Pick-report attributes (class / s57 / cell, S-52 §10.8) ride on BAKED tiles by default -// (pick_attrs on). Cell-backed charts are metadata-only — bake first, serve the archive — -// so bake the fixture to PMTiles and scan its tiles for the pick-report keys. -// A cell-backed chart (OpenChartBytes) is METADATA-ONLY: it exposes bounds/scale for a -// header scan but never generates tiles on demand — the chart-api contract is "bake first, -// serve the archive". Assert the metadata is present and that Tile() is refused. -func TestOpenCellMetadata(t *testing.T) { - data, err := os.ReadFile(testCell) - if err != nil { +// bakeTestCell bakes the testdata cell for tests that need an archive. +func bakeTestCell(t *testing.T) []byte { + t.Helper() + if _, err := os.Stat(testCell); err != nil { t.Skipf("no test cell: %v", err) } - src, err := OpenChartBytes(data) + pm, err := BakeCell(testCell) if err != nil { - t.Fatalf("OpenChartBytes: %v", err) + t.Fatalf("BakeCell: %v", err) + } + return pm +} + +// A chart is ONE baked archive: opened from bytes it must report the zoom range, +// bounds, tile encoding, the compilation scale the bake embedded, and the real +// M_COVR coverage rings — everything a host chart-database needs with no .000. +func TestOpenBytesInfoCoverage(t *testing.T) { + src, err := OpenBytes(bakeTestCell(t)) + if err != nil { + t.Fatalf("OpenBytes: %v", err) } defer src.Close() info := src.Info() if info.MaxZoom < info.MinZoom || !info.HasBounds { - t.Fatalf("cell metadata looks unset: %+v", info) + t.Fatalf("chart info looks unset: %+v", info) } - t.Logf("zoom %d..%d, bounds W=%.4f S=%.4f E=%.4f N=%.4f", - info.MinZoom, info.MaxZoom, info.West, info.South, info.East, info.North) - -} - -// TestOpenPath opens a directory as a STREAMING chart (the engine enumerates cell -// metadata + reads the .000 on demand). Like a cell-backed chart it is metadata-only: -// bounds/zoom are known, but tiles come from a bake, not on-demand generation. -func TestOpenPath(t *testing.T) { - if _, err := os.Stat(testCell); err != nil { - t.Skipf("no test cell: %v", err) + if info.NativeScale != 12000 { + t.Fatalf("NativeScale = %d, want 12000 (embedded by the bake)", info.NativeScale) + } + if info.TileType != FormatMLT { + t.Fatalf("TileType = %v, want FormatMLT", info.TileType) } - src, err := Open("testdata") + cov, err := src.Coverage() if err != nil { - t.Fatalf("Open(testdata): %v", err) + t.Fatalf("Coverage: %v", err) } - defer src.Close() - - info := src.Info() - if !info.HasBounds { - t.Fatal("expected known bounds for the streamed ENC_ROOT") + if len(cov) == 0 || len(cov[0]) < 3 { + t.Fatalf("expected real M_COVR coverage rings, got %d", len(cov)) } - t.Logf("streamed: zoom %d..%d bounds W=%.4f S=%.4f E=%.4f N=%.4f", - info.MinZoom, info.MaxZoom, info.West, info.South, info.East, info.North) - + for _, ring := range cov { + for _, p := range ring { + if p[0] < info.West-1e-6 || p[0] > info.East+1e-6 || p[1] < info.South-1e-6 || p[1] > info.North+1e-6 { + t.Fatalf("coverage vertex %v outside bounds", p) + } + } + } + t.Logf("zoom %d..%d, scale 1:%d, %d coverage ring(s), bounds W=%.4f S=%.4f E=%.4f N=%.4f", + info.MinZoom, info.MaxZoom, info.NativeScale, len(cov), + info.West, info.South, info.East, info.North) } -// TestOpenPMTilesAndInfo bakes the fixture to a PMTiles file, opens it via the path, -// and exercises the chart_get_info getter. Also covers OpenChartBytes. -func TestOpenPMTilesAndInfo(t *testing.T) { - data, err := os.ReadFile(testCell) - if err != nil { - t.Skipf("no test cell: %v", err) +// Open (path) mmaps the archive and must agree with the bytes-open on metadata. +func TestOpenPath(t *testing.T) { + pm := bakeTestCell(t) + path := filepath.Join(t.TempDir(), "US5MD1MC.pmtiles") + if err := os.WriteFile(path, pm, 0o644); err != nil { + t.Fatal(err) } - // OpenChartBytes (resident single cell) + Info. - rc, err := OpenChartBytes(data) + src, err := Open(path) if err != nil { - t.Fatalf("OpenChartBytes: %v", err) + t.Fatalf("Open: %v", err) } - if info := rc.Info(); !info.HasBounds || info.MaxZoom < info.MinZoom { - t.Fatalf("resident chart info looks unset: %+v", info) + defer src.Close() + info := src.Info() + if !info.HasBounds || info.NativeScale != 12000 { + t.Fatalf("mmap'd chart info looks unset: %+v", info) } - rc.Close() - } // lonLatToTile maps a lon/lat to its XYZ web-Mercator tile at zoom z. From 327fe5fdc05b03eb72b253997bf207267f3b04b0 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 05:25:48 -0400 Subject: [PATCH 097/140] docs: C API page tracks the reworked ABI (bake/render/compose, tile57 handle, status model) Co-Authored-By: Claude Fable 5 --- build.zig.zon | 2 +- docs/docs/architecture.md | 15 +- docs/docs/c-api.md | 542 ++++++++++++++++++++--------------- docs/docs/getting-started.md | 44 +-- docs/docs/installation.md | 2 +- docs/docs/rendering.md | 14 +- 6 files changed, 351 insertions(+), 268 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 92d6966..65877c8 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,7 +9,7 @@ .name = .tile57, // This is a [Semantic Version](https://semver.org/). // In a future version of Zig it will be used for package deduplication. - .version = "0.1.0", + .version = "0.2.0", // Together with name, this represents a globally unique package // identifier. This field is generated by the Zig toolchain when the // package is first created, and then *never changes*. This allows diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index a90a341..eca5241 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -69,7 +69,7 @@ Bakes encode **MLT** ([MapLibre Tile](https://github.com/maplibre/maplibre-tile- by default; MapLibre GL JS ≥ 5.12 decodes it natively via the vector source `encoding` option, and the generated styles carry that hint. The engine can also encode Mapbox Vector Tiles for consumers without an MLT decoder. -`tile57_chart_info.tile_type` reports which encoding a chart's tiles use, so a +`tile57_info.tile_type` reports which encoding a chart's tiles use, so a host hints its renderer correctly. ### Band handoff (coverage-clipped ownership) @@ -128,8 +128,8 @@ The public surface composes the packages into high-level entry points: inspect it: `renderView` (PNG or PDF), `renderSurfaceView` (world-space GPU callbacks), `queryPoint` (the cursor pick), and the metadata getters. It reads a pre-baked **PMTiles** archive or portrays a live cell — the caller can't tell the - difference. (`tile57_chart_render_view` / `tile57_chart_render_pdf` / - `tile57_chart_render_surface_cb` / `tile57_chart_query` in the C ABI.) + difference. (`tile57_render_view` / `tile57_render_pdf` / + `tile57_render_surface_cb` / `tile57_query` in the C ABI.) - **Tile production** — bake each cell to its own PMTiles at its compilation scale (`tile57_bake_cell_bytes`, which runs the banded bake engine `scene/bake_enc.zig` on a single cell), then a runtime **compositor** stitches the overlapping cells @@ -151,10 +151,11 @@ tile57 is built to hold only its working set: ms); after that it is cached. - **Best-available band per tile.** Overlapping cells of different compilation scales are resolved per tile to the best band, not all overlaid blindly. -- **Streaming open.** `openCellsStreaming` (and its on-disk driver `openPath` / - `tile57_chart_open`) take per-cell metadata (bbox + scale) plus a reader; a cell's - bytes are read only on demand and freed on eviction. A host then holds only the - working set's bytes, not the whole catalogue — the right choice for a large ENC_ROOT. +- **Streaming open.** `openCellsStreaming` (and its on-disk driver `openPath`, + which backs the C `tile57_s57_*` readers) take per-cell metadata (bbox + scale) + plus a reader; a cell's bytes are read only on demand and freed on eviction. A + host then holds only the working set's bytes, not the whole catalogue — the + right choice for a large ENC_ROOT. - **Per-cell bakes.** Each cell bakes independently at its own compilation scale, so a bake holds a single cell's parsed data at a time — memory doesn't grow with the size of the catalogue. (The multi-cell `bakeArchive` streams band-by-band, diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 75329e6..68385a0 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -10,72 +10,181 @@ sidebar_position: 5 [`include/tile57.h`](../../include/tile57.h), prefix `tile57_`. It is a shim over the [Zig API](./zig-api.md); the two stay in lock-step. -Two handles cover the surface: +The pipeline is three stages, and the header (and this page) is organised the +same way: + +- **Bake** — ENC source data in, per-cell PMTiles out. Each cell bakes to its + own archive at its own compilation scale, with its M_COVR coverage + scale + embedded in the archive metadata. The bake section also carries the raw-source + readers (cell inventory, feature extraction, exchange-set catalogue). +- **Render** — a **`tile57`** chart handle opens ONE baked archive and answers + for it: metadata (info / SCAMIN / coverage), the S-52 cursor pick, and full + view renders (PNG / PDF / callback canvas / world-space surface). +- **Compose** — a **`tile57_compose`** handle stitches MANY open charts into one + seamless tile pyramid on demand through the cell-ownership partition — what a + live tile server hands its HTTP layer. The composed bytes are MapLibre Tiles + (MLT). + +Style + portrayal-asset generation rounds out the surface: the mariner's S-52 +display options become a concrete MapLibre style JSON plus the colortables and +sprite / pattern / glyph atlases it references. + +## Errors + +Every fallible call returns a `tile57_status` — `TILE57_OK` (0) or a coarse +cause — and takes an optional caller-owned `tile57_error*` it fills with the +status plus a specific message on failure (a stack local is fine; nothing to +free). Results come back through out-parameters, which are always defined on +return: the result on `TILE57_OK`, `NULL`/0 otherwise. "Nothing produced" is +NOT a failure — a call that finds nothing returns `TILE57_OK` with a +`NULL`/zero out. -- A **`tile57_chart`** is the metadata + render handle. Open a cell (or a baked - PMTiles) and read its bounds, scale, coverage, cells, and features; query the - feature under a point; or render a finished PNG / PDF / callback surface. -- A **`tile57_compose_source`** is the runtime **compositor**. Tiles are made one - way: bake each ENC cell to its own PMTiles, then compose them on demand by - `(z, x, y)` through the ownership partition. The composed bytes are MapLibre - Tiles (MLT, the default) or Mapbox Vector Tiles (MVT). +```c +typedef enum { + TILE57_OK = 0, /* success */ + TILE57_ERR_BADARG, /* a NULL or out-of-range argument */ + TILE57_ERR_IO, /* a file/directory could not be opened, read, or written */ + TILE57_ERR_PARSE, /* malformed input (S-57 cell, PMTiles, partition, JSON) */ + TILE57_ERR_NOMEM, /* an allocation failed */ + TILE57_ERR_UNSUPPORTED, /* valid but unusable input */ + TILE57_ERR_RENDER, /* tile generation or rendering failed */ + TILE57_ERR_INTERNAL, /* an unexpected engine failure */ +} tile57_status; + +const char *tile57_status_str(tile57_status status); /* static strerror-style text */ + +#define TILE57_ERROR_MSG_MAX 256 +typedef struct { + tile57_status status; + char message[TILE57_ERROR_MSG_MAX]; /* NUL-terminated; "" when no detail */ +} tile57_error; +``` -The header is organized into seven sections — version, chart open + metadata, -cell baking, live composing, render surface, style + assets, and util/catalogue/ -debug — mirrored below. +```c +tile57 *chart = NULL; +tile57_error err; +if (tile57_open("US5MD1MC.pmtiles", &chart, &err) != TILE57_OK) { + fprintf(stderr, "open failed: %s\n", err.message); /* "path: reason" */ +} +``` :::warning Lifetime + threading -Neither handle is internally synchronized — use one thread per handle. Each must -also outlive every consumer still holding it: if a long-lived renderer or -compositor captures it, close it only once nothing can still call into it. Calls -that return bytes allocate `*out`; free it with `tile57_free` (same length). -Input bytes are copied, so the caller may free them right after the call. +No handle is internally synchronized — use one thread per handle. Each must +also outlive every borrower still holding it: a compositor borrows its charts +(close the compositor first, then the charts), and a path-opened chart mmaps +its file, so the file must stay in place while the chart is open. Calls that +return bytes allocate `*out`; free it with `tile57_free` (same length). Input +bytes are copied, so the caller may free them right after the call. ::: -## Open a chart + read metadata +## Bake: ENC cells → per-cell archives + +Tile production is a two-step composite model. First bake each ENC cell to its +own PMTiles at that cell's compilation scale; the per-cell archive embeds the +cell's M_COVR coverage, compilation scale, and identity in its metadata. Then +open a **compositor** over the archives (as charts) and serve any `(z, x, y)` +tile on demand — the compositor stitches the overlapping cells through an +ownership partition, handling cross-band zoom. ```c -#include "tile57.h" +/* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over its + * native band zoom range. Returned in *out/*out_len (free with tile57_free); + * NULL/0 when the cell produced no tiles. */ +tile57_status tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len, + tile57_error *err); + +/* Bake `n` cells IN PARALLEL across up to `workers` threads (a MEMORY bound — + * pass a small count). out_bytes[i]/out_lens[i] receive cell i's archive or + * NULL/0; *out_baked (NULL to ignore) counts the cells that produced bytes. */ +tile57_status tile57_bake_cells(const char *const *paths, size_t n, uint32_t workers, + uint8_t **out_bytes, size_t *out_lens, + size_t *out_baked, tile57_error *err); + +/* Walk in_dir for *.000 cells and bake each IN PARALLEL to the SAME relative + * path under out_dir with a .pmtiles extension (+ an .sha sidecar). + * INCREMENTAL: a cell whose archive is already at least as new as its whole + * input (.000 + update chain) is skipped, so a re-run over an unchanged tree + * bakes nothing — *out_baked counts THIS run, and 0 over a warm cache is + * success. progress (or NULL) fires per cell, possibly from worker threads. */ +typedef void (*tile57_bake_progress)(void *ctx, uint32_t done, uint32_t total); +tile57_status tile57_bake_tree(const char *in_dir, const char *out_dir, uint32_t workers, + tile57_bake_progress progress, void *progress_ctx, + uint32_t *out_baked, tile57_error *err); + +/* Read a PMTiles archive's metadata JSON blob (decompressed); NULL/0 when the + * archive carries none. A per-cell bake embeds the cell's coverage + cscl + + * date/name under a "coverage" key. */ +tile57_status tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, + uint8_t **out, size_t *out_len, + tile57_error *err); +``` + +Every baked feature carries the pick-report properties `class` (object-class +acronym), `cell` (source cell stem), and `s57` (the full S-57 attribute set as a +JSON object) — what `tile57_query` and a host inspector read back. -const char *tile57_version(void); /* "0.1.0" */ +The `tile57 bake -o out/` CLI produces this structure +directly: `out/tiles/.pmtiles` per cell plus `out/partition.tpart`. + +### Read raw S-57 source data -/* Opaque chart handle. */ -typedef struct tile57_chart tile57_chart; +The bake section also reads the source data directly — no handle, no bake — for +a host's import UI: + +```c +/* Per-cell metadata of the S-57 data at `path` (one .000, updates applied, or a + * whole ENC_ROOT) as a JSON array: [{"name","scale","edition","update", + * "issueDate","agency","bbox"}, ...] — a host's chart-database scan. */ +tile57_status tile57_s57_cells(const char *path, uint8_t **out, size_t *out_len, + tile57_error *err); + +/* Features for comma-separated object-class acronyms (e.g. "DEPARE,DRGARE") as + * a GeoJSON FeatureCollection: lon/lat geometry, properties = {"class", plus the + * full S-57 acronym->value attribute map}. NULL/0 when nothing matched. */ +tile57_status tile57_s57_features(const char *path, const char *classes, + uint8_t **out, size_t *out_len, tile57_error *err); + +/* The same over in-memory base-cell bytes (a .000 from a zip member, say). */ +tile57_status tile57_s57_features_bytes(const uint8_t *base, size_t len, + const char *classes, + uint8_t **out, size_t *out_len, tile57_error *err); + +/* Decode a CATALOG.031 exchange-set catalogue into a JSON array of its CATD + * entries — file path, longName (chart title), impl (BIN/ASC/TXT), bbox. */ +tile57_status tile57_s57_catalog(const uint8_t *catalog_031, size_t len, + uint8_t **out, size_t *out_len, tile57_error *err); +``` + +The CLI mirrors these as `tile57 cells`, `tile57 features`, and +`tile57 catalog`. -/* Open an on-disk ENC_ROOT directory (or a single .000 file, with its .001.. - * update chain) via the streaming path: each cell's metadata (name, compilation - * scale, M_COVR coverage) is read up front and tiles are baked lazily per request, - * with no upfront full-cell bake. Rules are the library's embedded catalogue. - * NULL on failure. */ -tile57_chart *tile57_chart_open(const char *path); +## Render: the `tile57` chart handle -/* Open a cell for METADATA ONLY — bbox, native_scale, M_COVR coverage — via a - * cheap parse with no tile bake (a chart-database / header scan). Do NOT render - * this handle. NULL on failure. */ -tile57_chart *tile57_chart_open_header(const char *path); +A `tile57` is a chart: ONE baked PMTiles archive, opened for metadata and +rendering. Open it from a path (mmap'd — a whole chart library can be open +without being resident) or from bytes (copied). -/* Open a cell baking only [minzoom, maxzoom] — a narrow native band fast for - * first paint, then re-open the full range in the background (progressive load). */ -tile57_chart *tile57_chart_open_zoom(const char *path, uint8_t minzoom, uint8_t maxzoom); +```c +const char *tile57_version(void); /* "0.2.0" */ -/* Open one in-memory ENC cell (base .000 bytes) as a resident chart. Bytes are - * copied. NULL on failure. */ -tile57_chart *tile57_chart_open_bytes(const uint8_t *base, size_t len); +/* Opaque chart handle: one open baked archive. */ +typedef struct tile57 tile57; -/* Open a baked PMTiles bundle from a file path. NULL on failure. */ -tile57_chart *tile57_chart_open_pmtiles(const char *path); +tile57_status tile57_open(const char *path, tile57 **out, tile57_error *err); +tile57_status tile57_open_bytes(const uint8_t *pmtiles, size_t len, + tile57 **out, tile57_error *err); -/* Vector-tile encodings the engine produces (reported in chart_info.tile_type; - * the compositor serves MLT). */ +/* Vector-tile encodings an archive can store (reported in tile57_info.tile_type; + * the engine bakes MLT). */ typedef enum { TILE57_TILE_TYPE_MVT = 1, /* Mapbox Vector Tile */ - TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the default) */ + TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the bake default) */ } tile57_tile_type; /* Fixed chart metadata, for a host that frames its own camera. Bounds/anchor - * validity are flagged (false -> those fields are 0). tile_type is the encoding - * for this chart's tiles (PMTiles: the archive's stored type; a cell: the engine - * default). native_scale is the compilation scale 1:N (0 if unknown). */ + * validity are flagged (false -> those fields are 0). native_scale is the + * compilation scale 1:N the bake embedded (0 = unknown — derive from the zoom + * band). */ typedef struct { uint8_t min_zoom, max_zoom; uint32_t bands; /* bitmask: bit r = band rank r present */ @@ -83,130 +192,92 @@ typedef struct { bool has_anchor; double anchor_lat, anchor_lon, anchor_zoom; uint8_t tile_type; /* tile57_tile_type */ int32_t native_scale; -} tile57_chart_info; -void tile57_chart_get_info(tile57_chart *chart, tile57_chart_info *out); - -/* The distinct SCAMIN denominators present in the chart (ascending). On success - * returns 1 with *out pointing at *out_len int32 values, 0 if none, -1 on error. - * Free with tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ -int tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len); +} tile57_info; +void tile57_get_info(tile57 *chart, tile57_info *out); + +/* The distinct SCAMIN denominators present in the chart (ascending); NULL/0 when + * none. Free with tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ +tile57_status tile57_scamin(tile57 *chart, int32_t **out, size_t *out_len, + tile57_error *err); + +/* The chart's M_COVR data-coverage polygons, from the coverage the bake embedded: + * ring() is called once per polygon with its exterior ring as npts interleaved + * lon,lat doubles (valid only during the call). OK with no calls when the archive + * embeds none. */ +typedef struct { + void *ctx; + void (*ring)(void *ctx, const double *lonlat, size_t npts); +} tile57_coverage_cb; +tile57_status tile57_coverage(tile57 *chart, const tile57_coverage_cb *cb, + tile57_error *err); -/* Release a chart and all cached tiles. */ -void tile57_chart_close(tile57_chart *chart); +/* Release a chart and all cached tiles (not while a compositor still holds it). */ +void tile57_close(tile57 *chart); ``` -An ENC_ROOT cell is a base `.000` plus its sequential `.001`, `.002` … update -files; `tile57_chart_open` walks the directory (`CATALOG.031`, else a `*.000` -scan), applies each cell's updates, and overlays the cells by scale band. - -## Bake cells, then compose tiles +### Query the features under a point (object query / pick) -Tile production is a two-step composite model. First bake each ENC cell to its -own PMTiles at that cell's compilation scale; the per-cell archive embeds the -cell's M_COVR coverage in its metadata. Then open a **compositor** over the -archives and serve any `(z, x, y)` tile on demand — the compositor stitches the -overlapping cells through an ownership partition, handling cross-band zoom. - -```c -/* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over its - * native band zoom range. Returned in *out/*out_len (free with tile57_free) — - * persist the per-cell archive, then feed it to tile57_compose_open. The metadata - * embeds the cell's coverage (read via tile57_pmtiles_metadata). 1 = ok, 0 = - * nothing baked, -1 = error. */ -int tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len); - -/* Read a PMTiles archive's metadata JSON blob (decompressed) into *out/*out_len. - * A single-cell bake embeds that cell's coverage + cscl + date/name under a - * "coverage" key, so the compositor rebuilds the partition without re-parsing the - * .000. 1 = ok, 0 = no metadata, -1 = error. */ -int tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, - uint8_t **out, size_t *out_len); -``` - -Every baked feature carries the pick-report properties `class` (object-class -acronym), `cell` (source cell stem), and `s57` (the full S-57 attribute set as a -JSON object) — what `tile57_chart_query` and a host inspector read back. +The S-52 cursor pick. Given a lon/lat and the current view `zoom`, tile57 replays +the tile at that zoom and reports every feature the point falls in — an area you +are inside, or a line or point symbol within a small radius. Each hit calls you +back with the S-57 object-class acronym, the attribute JSON (acronym to value), +and the source cell name. This is what a chart application shows when you tap a +feature to see what it is. -The compositor holds the per-cell archives mmap'd and the ownership partition -resident, so a tile costs a classify plus one decode/clip or a decompress. Open -once, serve many, close. +Passing the view zoom matters: the query reports the features actually DISPLAYED +at that zoom (it applies the same SCAMIN cull the renderer does), and the pick +tolerance tracks on-screen distance instead of ground distance — so a buoy is just +as easy to tap zoomed out as zoomed in, and a zoomed-out click doesn't return +finer-scale features that aren't drawn. ```c -/* Opaque runtime-compositor handle. */ -typedef struct tile57_compose_source tile57_compose_source; - -/* Coverage/zoom summary filled by tile57_compose_meta_get. */ typedef struct { - uint8_t min_zoom; - uint8_t max_zoom; /* deepest zoom served (native + one overscale zoom) */ - uint32_t cells; /* coverage-carrying archives held */ - double west, south, east, north; /* union coverage bounds, degrees */ -} tile57_compose_meta; - -/* Open a resident compositor over the `n` per-cell PMTiles at `paths` (each from - * tile57_bake_cell_bytes, on disk), mmap'd so the cell set is never fully - * resident. partition_path (NULL to skip) names a sidecar — written by - * tile57_compose_save_partition (the `tile57 bake` CLI emits partition.tpart) — to - * load and skip the build; a missing/stale one falls back to building. NULL on - * error / no coverage-carrying archive. */ -tile57_compose_source *tile57_compose_open(const char *const *paths, size_t n, - const char *partition_path); - -/* Compose tile (z,x,y) on demand into RAW (decompressed) MLT in *out/*out_len — what - * a live tile server hands its HTTP layer (which gzips on the wire). Returns: - * 1 served (bytes in *out/*out_len), - * 2 OWNED but empty — a cell owns this ground but produced nothing (transient - * while its bake is still running; an error state once bakes are done), - * 0 not owned — true empty ocean (safe to cache), - * -1 error. */ -int tile57_compose_serve(tile57_compose_source *src, uint8_t z, uint32_t x, uint32_t y, - uint8_t **out, size_t *out_len); - -/* Fill *out with the compositor's zoom range + union coverage bounds. */ -void tile57_compose_meta_get(tile57_compose_source *src, tile57_compose_meta *out); - -/* Serialize the ownership partition to `path` (a sidecar a later - * tile57_compose_open loads to skip the build). 1 = ok, -1 = error. */ -int tile57_compose_save_partition(tile57_compose_source *src, const char *path); + void *ctx; + void (*feature)(void *ctx, const char *cls, size_t cls_len, + const char *s57, size_t s57_len, + const char *cell, size_t cell_len); +} tile57_query_cb; -/* Release a compositor (munmaps the archives, frees the partition). */ -void tile57_compose_close(tile57_compose_source *src); +/* Calls cb->feature once per displayed feature under (lon,lat) at view `zoom`. + * Callback pointers are valid only during that call. */ +tile57_status tile57_query(tile57 *chart, double lon, double lat, double zoom, + const tile57_query_cb *cb, tile57_error *err); ``` -The `tile57 bake -o out/` CLI produces this structure -directly: `out/tiles/.pmtiles` per cell plus `out/partition.tpart`. A host -opens the compositor over `out/tiles/*.pmtiles` with that sidecar. +The class and cell come through for any chart; the attribute JSON is filled in +from the `s57` pick property baked into the tiles (empty if a chart was baked +without pick attributes). -## Render a finished view (PNG / PDF) +### Render a finished view (PNG / PDF) The [native S-52 rendering engine](./rendering.md) draws a view of the chart — -centre + fractional zoom + pixel size — with the mariner settings evaluated -*live* (real safety contour, category/SCAMIN/text-group gates, day/dusk/night -palette), catalogue symbols replayed as vectors, and labels decluttered over the -whole canvas. +centre + fractional zoom + pixel size — by replaying the archive's baked tiles +through the S-52 pixel path: one scene across every covering tile, labels +decluttered over the whole canvas, catalogue symbols replayed as vectors. The +mariner's live-swappable settings (colour scheme, safety-contour danger and +sounding swaps, category/SCAMIN/text gates, size scale) evaluate at render +time; the rest of the portrayal context was fixed at bake time. + +`width`/`height` must be 1..16384 per side; `m` NULL = canonical defaults +(`tile57_mariner_defaults`). The `tile57_mariner` settings struct is shared +with the [style builders](#build-a-maplibre-style) below. ```c -/* PNG raster. `m` NULL = canonical defaults (tile57_mariner_defaults). Returns 0 - * with *out/*out_len set (free with tile57_free); -1 bad handle, -2 render - * failure, -3 unsupported source (a baked PMTiles chart carries no portrayal). */ -int tile57_chart_render_view(tile57_chart *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - uint8_t **out, size_t *out_len); +/* PNG raster in *out/*out_len (free with tile57_free). */ +tile57_status tile57_render_view(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); /* Its vector twin: the SAME scene as a deterministic single-page PDF - * (1 px = 1 pt, 72 dpi; vector fills + glyph-outline text). Same parameters, - * returns, and ownership. */ -int tile57_chart_render_pdf(tile57_chart *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - uint8_t **out, size_t *out_len); + * (1 px = 1 pt, 72 dpi; vector fills + glyph-outline text). */ +tile57_status tile57_render_pdf(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); ``` -The `tile57_mariner` settings struct is defined in the -[chart-style section](#build-a-maplibre-style) below. - -## Render to a host surface (vector callbacks) +### Render to a host surface (vector callbacks) Instead of a finished raster, tile57 can hand you the portrayed scene as a stream of draw calls in world space. A GPU host tessellates that stream once, then pans @@ -214,7 +285,7 @@ and zooms by transforming the vertices each frame, so symbols and text stay a constant size on screen and no re-portrayal is needed while the view moves. You fill in a `tile57_surface_cb` vtable and pass it to -`tile57_chart_render_surface_cb`. Area and line geometry come in web-mercator world +`tile57_render_surface_cb`. Area and line geometry come in web-mercator world coordinates (the range 0 to 1, with y pointing down). Point symbols, soundings, and text come as a world anchor plus a small outline in reference pixels, so you can draw them at a fixed size on screen. Every call carries the feature's SCAMIN, so you @@ -249,11 +320,11 @@ typedef struct { float size_px, tile57_rgba color, tile57_rgba halo); } tile57_surface_cb; -/* Portray the view once and drive the callbacks. 0 ok, -1 bad handle, - * -2 render failure, -3 unsupported source. */ -int tile57_chart_render_surface_cb(tile57_chart *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, const tile57_surface_cb *surface); +/* Portray the view once and drive the callbacks. */ +tile57_status tile57_render_surface_cb(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); ``` Set `draw_sprite` and `draw_pattern` once you have the sprite atlas loaded (see @@ -265,70 +336,73 @@ those two fields NULL, the same features arrive as vector outlines instead. tile57 also declutters overlapping text for you before it makes the calls (symbols and soundings always draw, per S-52), so you don't repeat that work. -There is a pixel-space twin, `tile57_chart_render_view_cb` with a `tile57_canvas_cb` +There is a pixel-space twin, `tile57_render_view_cb` with a `tile57_canvas_cb` vtable, that emits the SAME portrayal as resolved paint-order draw calls in canvas pixels — for a host that wants the engine's own paint pipeline without the PNG encode. -## Inspect a chart: cells, features, catalogues +## Compose: many charts, one tile pyramid -```c -/* The chart's per-cell metadata as a JSON array — name (DSNM stem), scale - * (DSPM CSCL), edition/update/issueDate/agency (after the update chain), and - * bbox. 1 = *out/*out_len set (free with tile57_free); 0 = no cells (e.g. a - * PMTiles chart — its bundle manifest carries the inventory); -1 = error. */ -int tile57_chart_cells(tile57_chart *chart, uint8_t **out, size_t *out_len); - -/* The chart's features for comma-separated object-class acronyms (e.g. - * "DEPARE,DRGARE") as a GeoJSON FeatureCollection: lon/lat geometry, - * properties = {"class": …, plus the full S-57 acronym->value attribute map}. - * Parsed without portrayal; an ENC_ROOT-wide query walks every cell. 1 = JSON - * set; 0 = no matches; -1 = error. */ -int tile57_chart_features(tile57_chart *chart, const char *classes, - uint8_t **out, size_t *out_len); - -/* Decode a CATALOG.031 exchange-set catalogue (raw bytes) into a JSON array of - * its CATD entries — file path, longName (chart title), impl (BIN/ASC/TXT), - * bbox. Not chart-scoped. 1 = JSON set; 0 = no CATD records; -1 = parse error. */ -int tile57_catalog_entries(const uint8_t *catalog_031, size_t len, - uint8_t **out, size_t *out_len); -``` +The compositor builds (or loads) the cell-ownership partition over its charts' +embedded coverage, then composes any tile for the cost of a classify plus one +decompress or one decode/clip. It **borrows** the charts — their mmap'd archives +and decoded coverage — so the cell set is never fully resident and the charts +must outlive the compositor. Open once, serve many, close. -The CLI mirrors these as `tile57 cells`, `tile57 features`, and -`tile57 catalog`. +```c +/* Opaque runtime-compositor handle. */ +typedef struct tile57_compose tile57_compose; -## Query the features under a point (object query / pick) +/* Coverage/zoom summary filled by tile57_compose_meta_get. */ +typedef struct { + uint8_t min_zoom; + uint8_t max_zoom; /* deepest zoom served (native + one overscale zoom) */ + uint32_t cells; /* coverage-carrying charts held */ + double west, south, east, north; /* union coverage bounds, degrees */ +} tile57_compose_meta; -The S-52 cursor pick. Given a lon/lat and the current view `zoom`, tile57 replays -the tile at that zoom and reports every feature the point falls in — an area you -are inside, or a line or point symbol within a small radius. Each hit calls you -back with the S-57 object-class acronym, the attribute JSON (acronym to value), -and the source cell name. This is what a chart application shows when you tap a -feature to see what it is. +/* Open a compositor over `n` open charts. Charts whose archives embed no + * coverage are skipped (they can own no ground); none at all is + * TILE57_ERR_UNSUPPORTED. partition_path (NULL to skip) names a sidecar — + * written by tile57_compose_save_partition (the `tile57 bake` CLI emits + * partition.tpart) — to load and skip the build; a missing/stale one falls back + * to building. Close with tile57_compose_close BEFORE closing the charts. */ +tile57_status tile57_compose_open(tile57 *const *charts, size_t n, + const char *partition_path, + tile57_compose **out, tile57_error *err); + +/* Compose tile (z,x,y) on demand into RAW (decompressed) MLT — what a live tile + * server hands its HTTP layer (which gzips on the wire). NULL/0 out with OK = + * no bytes; *out_owned (NULL to ignore) then distinguishes the two empties: + * owned=false: no cell owns this ground — true empty ocean, safe to cache; + * owned=true: a cell owns this ground but produced nothing — transient while + * its per-cell bake is running, suspect once bakes are done. */ +tile57_status tile57_compose_serve(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, + uint8_t **out, size_t *out_len, bool *out_owned, + tile57_error *err); -Passing the view zoom matters: the query reports the features actually DISPLAYED -at that zoom (it applies the same SCAMIN cull the renderer does), and the pick -tolerance tracks on-screen distance instead of ground distance — so a buoy is just -as easy to tap zoomed out as zoomed in, and a zoomed-out click doesn't return -finer-scale features that aren't drawn. +/* Fill *out with the compositor's zoom range + union coverage bounds. */ +void tile57_compose_meta_get(tile57_compose *c, tile57_compose_meta *out); -```c -typedef struct { - void *ctx; - void (*feature)(void *ctx, const char *cls, size_t cls_len, - const char *s57, size_t s57_len, - const char *cell, size_t cell_len); -} tile57_query_cb; +/* Serialize the ownership partition to `path` (a sidecar a later + * tile57_compose_open loads to skip the build). */ +tile57_status tile57_compose_save_partition(tile57_compose *c, const char *path, + tile57_error *err); -/* Calls cb->feature once per displayed feature under (lon,lat) at view `zoom`. - * 0 ok, -1 bad args. Callback pointers are valid only during that call. */ -int tile57_chart_query(tile57_chart *chart, double lon, double lat, double zoom, - const tile57_query_cb *cb); +/* Release a compositor. Its charts stay open (and stay yours to close). */ +void tile57_compose_close(tile57_compose *c); ``` -The class and cell come through for any chart; the attribute JSON is filled in -from the `s57` pick property baked into the tiles (empty if a chart was baked -without pick attributes). +```c +/* bake -> open -> compose -> serve */ +tile57 *charts[2]; +tile57_open("tiles/US5MD1MC.pmtiles", &charts[0], NULL); +tile57_open("tiles/US5MD1MD.pmtiles", &charts[1], NULL); +tile57_compose *cmp = NULL; +tile57_compose_open(charts, 2, "partition.tpart", &cmp, NULL); +uint8_t *tile; size_t n; bool owned; +tile57_compose_serve(cmp, 13, 2359, 3139, &tile, &n, &owned, NULL); +``` ## Generate portrayal assets @@ -346,7 +420,8 @@ typedef struct { uint8_t *pattern_json; size_t pattern_json_len; uint8_t *pattern_png; size_t pattern_png_len; } tile57_assets; -int tile57_bake_assets(const char *catalog_dir, tile57_assets *out); /* 1 = ok, 0 = error */ +tile57_status tile57_bake_assets(const char *catalog_dir, tile57_assets *out, + tile57_error *err); void tile57_assets_free(tile57_assets *out); ``` @@ -361,8 +436,9 @@ signed-distance-field atlas of the label font, for a host that draws text as SDF quads. Free either with `tile57_assets_free` as above. ```c -int tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out); /* 1 = ok, 0 = error */ -int tile57_bake_glyph_sdf(tile57_assets *out); /* 1 = ok, 0 = error */ +tile57_status tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out, + tile57_error *err); +tile57_status tile57_bake_glyph_sdf(tile57_assets *out, tile57_error *err); ``` ## Build a MapLibre style @@ -403,26 +479,25 @@ typedef struct tile57_mariner { void tile57_mariner_defaults(tile57_mariner *m); /* canonical defaults, date_view = "" */ /* enabled_bands: NULL = show all; else only features whose band rank is in the - * array. scamin: the distinct SCAMIN denominators present in the source (e.g. from - * tile57_chart_scamin) — when non-NULL the `_scamin` layers split into per-value - * native-minzoom buckets; scamin_lat is the representative latitude. Returns 1 with - * the style JSON in *out/*out_len (free with tile57_free); 0 on error. */ -int tile57_build_style(const char *template_json, size_t template_len, - const tile57_mariner *m, - const char *colortables_json, size_t colortables_len, - const int32_t *enabled_bands, size_t enabled_band_count, - const int32_t *scamin, size_t scamin_count, double scamin_lat, - uint8_t **out, size_t *out_len); + * array. scamin: the distinct SCAMIN denominators present in the source (e.g. + * from tile57_scamin) — when non-NULL the `_scamin` layers split into per-value + * native-minzoom buckets; scamin_lat is the representative latitude. */ +tile57_status tile57_build_style(const char *template_json, size_t template_len, + const tile57_mariner *m, + const char *colortables_json, size_t colortables_len, + const int32_t *enabled_bands, size_t enabled_band_count, + const int32_t *scamin, size_t scamin_count, double scamin_lat, + uint8_t **out, size_t *out_len, tile57_error *err); /* Minimal MapLibre style-mutation ops to turn the style for `old_m` into the style * for `new_m` (same inputs as tile57_build_style) — for flicker-free mariner * toggles. Writes a JSON op array to *out/*out_len (free with tile57_free). */ -int tile57_style_diff(const char *template_json, size_t template_len, - const tile57_mariner *old_m, const tile57_mariner *new_m, - const char *colortables_json, size_t colortables_len, - const int32_t *enabled_bands, size_t enabled_band_count, - const int32_t *scamin, size_t scamin_count, double scamin_lat, - uint8_t **out, size_t *out_len); +tile57_status tile57_style_diff(const char *template_json, size_t template_len, + const tile57_mariner *old_m, const tile57_mariner *new_m, + const char *colortables_json, size_t colortables_len, + const int32_t *enabled_bands, size_t enabled_band_count, + const int32_t *scamin, size_t scamin_count, double scamin_lat, + uint8_t **out, size_t *out_len, tile57_error *err); ``` The S-52 colortables and base style template are baked into the library, so a host @@ -431,21 +506,22 @@ buffer with `tile57_free`): ```c /* colortables.json (S-52 token -> hex per day/dusk/night) from the baked profile. */ -int tile57_colortables_default(uint8_t **out, size_t *out_len); +tile57_status tile57_colortables_default(uint8_t **out, size_t *out_len, + tile57_error *err); /* Base MapLibre style template (layers + chart source + sprite/glyph URLs). scheme * selects the palette; source_tiles is the {z}/{x}/{y} URL (NULL -> a default * pmtiles:// source); sprite/glyphs are base URLs (NULL omits those layers); * minzoom is the chart source's tile floor, emitted verbatim (pass the archive's * real minzoom); maxzoom 0 -> engine default. tile_encoding is the source's tile - * type (from chart_info.tile_type): TILE57_TILE_TYPE_MLT emits "encoding":"mlt" + * type (from tile57_info.tile_type): TILE57_TILE_TYPE_MLT emits "encoding":"mlt" * on the source so maplibre-gl >= 5.12 decodes MLT natively; 0 / MVT emits * nothing. */ -int tile57_style_template(tile57_scheme scheme, const char *source_tiles, - const char *sprite, const char *glyphs, - uint32_t minzoom, uint32_t maxzoom, - uint8_t tile_encoding, - uint8_t **out, size_t *out_len); +tile57_status tile57_style_template(tile57_scheme scheme, const char *source_tiles, + const char *sprite, const char *glyphs, + uint32_t minzoom, uint32_t maxzoom, + uint8_t tile_encoding, + uint8_t **out, size_t *out_len, tile57_error *err); ``` ## Util: warmup + free @@ -470,4 +546,4 @@ part of the embedding API. ## Versioning -Pre-1.0 (`0.1.0`). No external consumers yet, so the ABI is not frozen. +Pre-1.0 (`0.2.0`). No external consumers yet, so the ABI is not frozen. diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index f1358da..2183b58 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -85,26 +85,31 @@ Open a compositor over the bake output and serve tiles by `(z, x, y)`: ```c #include "tile57.h" -/* Open a compositor over the `tile57 bake` output (list every cell archive). */ -const char *paths[] = { "out/tiles/US5MD1MC.pmtiles" }; -tile57_compose_source *src = tile57_compose_open(paths, 1, "out/partition.tpart"); - -uint8_t *tile; size_t n; -switch (tile57_compose_serve(src, z, x, y, &tile, &n)) { - case 1: /* … render the decompressed MLT tile … */ tile57_free(tile, n); break; - case 2: /* owned but empty — a cell's bake is still in flight */ break; - case 0: /* not owned — open ocean; cache as blank */ break; - default: /* -1 error */ break; +/* Open each baked archive as a chart, then a compositor over the charts. */ +tile57 *chart = NULL; +tile57_error err; +if (tile57_open("out/tiles/US5MD1MC.pmtiles", &chart, &err) != TILE57_OK) { + fprintf(stderr, "%s\n", err.message); } -tile57_compose_close(src); +tile57_compose *src = NULL; +tile57_compose_open(&chart, 1, "out/partition.tpart", &src, &err); + +uint8_t *tile; size_t n; bool owned; +if (tile57_compose_serve(src, z, x, y, &tile, &n, &owned, &err) == TILE57_OK) { + if (tile) { /* … serve the decompressed MLT tile … */ tile57_free(tile, n); } + else if (owned) { /* owned but empty — a cell's bake is still in flight */ } + else { /* not owned — open ocean; cache as blank */ } +} +tile57_compose_close(src); /* the compositor borrows its charts… */ +tile57_close(chart); /* …so close them after it */ ``` Link against `libtile57.a`. The `partition.tpart` sidecar (NULL to skip) lets the -compositor load the ownership partition instead of rebuilding it. To render a -finished PNG/PDF, read metadata, or query the feature under a point, open a -`tile57_chart` instead — `tile57_chart_open` (an on-disk ENC_ROOT or a `.000`), -`tile57_chart_open_bytes` (one in-memory cell), or `tile57_chart_open_pmtiles` -(a baked archive). See the [C API](./c-api.md). +compositor load the ownership partition instead of rebuilding it. The same +`tile57` chart handle renders finished PNGs/PDFs, reads metadata (bounds, scale, +coverage, SCAMIN), and answers the cursor pick; raw S-57 reading (cell +inventory, feature extraction) is handle-free via `tile57_s57_*`. See the +[C API](./c-api.md). ## 3. Use the engine from Zig @@ -129,9 +134,10 @@ production (bake each cell, then compose on demand) is exposed through the ## ENC_ROOT and updates Open an ENC_ROOT (many cells, each with its sequential `.001`, `.002` … updates) -with `Chart.openPath` / `tile57_chart_open` — a single call that streams a whole -on-disk catalogue, reading each cell's bytes on demand. The engine parses, applies -the updates, and serves the best-available scale band per tile. +with `Chart.openPath` (Zig) or scan it with `tile57_s57_cells` (C) — a single +call that walks a whole on-disk catalogue, applying each cell's updates. Baking +(`tile57 bake` / `tile57_bake_tree`) turns it into the per-cell archives the +compositor serves. See the [**Architecture**](./architecture.md) page for how the engine fits together. diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 07f204e..855f124 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -44,7 +44,7 @@ zig build test # runs the test suite | `tile57` (`zig-out/bin/tile57`) | the offline CLI: bake cells/ENC_ROOTs to PMTiles or a chart bundle, and emit portrayal assets. | | `libtile57.a` | the static library behind the [C ABI](./c-api.md) (`include/tile57.h`). | -The engine is also a Zig package named `tile57` (v0.1.0); a Zig consumer +The engine is also a Zig package named `tile57` (v0.2.0); a Zig consumer depends on it and uses `@import("tile57")` — see the [Zig API](./zig-api.md). :::note Consume it as a path dependency (for now) diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index edbee92..fa107e0 100644 --- a/docs/docs/rendering.md +++ b/docs/docs/rendering.md @@ -98,12 +98,11 @@ rules decided for *your* settings — the ECDIS-faithful path. # One tile of a cell, as a 512px PNG tile57 png US5MD1MC.000 14 4712 6280 -o tile.png --size 512 -# A view (any centre, fractional zoom, any size) from a whole ENC_ROOT — -# cells are selected and quilted per band automatically -tile57 png ~/charts/ENC_ROOT --view -76.48,38.974,15.1 --size 1600x1200 -o annapolis.png +# A view (any centre, fractional zoom, any size) from a single cell +tile57 png US5MD1MC.000 --view -76.48,38.974,15.1 --size 1600x1200 -o annapolis.png # The same view as a vector PDF with selectable text -tile57 pdf ~/charts/ENC_ROOT --view -76.48,38.974,15.1 --size 1600x1200 -o annapolis.pdf +tile57 pdf US5MD1MC.000 --view -76.48,38.974,15.1 --size 1600x1200 -o annapolis.pdf # From a baked PMTiles bundle instead of source cells (tile replay) tile57 png chart.pmtiles --view -76.48,38.974,15.1 --size 1024x768 -o out.png @@ -119,7 +118,8 @@ Two calls in `include/tile57.h` (same allocate-`*out` / free-with-`tile57_free` convention as the rest of the ABI): ```c -tile57_chart *c = tile57_chart_open("/path/to/ENC_ROOT"); +tile57 *c = NULL; +tile57_open("US5MD1MC.pmtiles", &c, NULL); /* a baked archive */ tile57_mariner m; tile57_mariner_defaults(&m); @@ -127,12 +127,12 @@ m.safety_contour = 5.0; m.scheme = TILE57_SCHEME_NIGHT; uint8_t *png; size_t len; -tile57_chart_render_view(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &png, &len); +tile57_render_view(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &png, &len, NULL); /* ... write/display png ... */ tile57_free(png, len); uint8_t *pdf; size_t plen; -tile57_chart_render_pdf(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &pdf, &plen); +tile57_render_pdf(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &pdf, &plen, NULL); ``` `m.size_scale` calibrates physical size (so 1 S-52 millimetre is a true From cb7409a72120a0fbf1b2a0c1d74e54b70b63d64f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 05:33:33 -0400 Subject: [PATCH 098/140] =?UTF-8?q?refactor(capi):=20tile57=5Fs57=5F*=20re?= =?UTF-8?q?aders=20renamed=20tile57=5Fenc=5F*=20=E2=80=94=20one=20ENC=20vo?= =?UTF-8?q?cabulary=20for=20S-57=20source=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- bindings/go/s57meta.go | 8 ++++---- docs/docs/architecture.md | 2 +- docs/docs/c-api.md | 8 ++++---- docs/docs/getting-started.md | 4 ++-- include/tile57.h | 10 +++++----- src/capi.zig | 10 +++++----- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/bindings/go/s57meta.go b/bindings/go/s57meta.go index 5a65531..9d472c2 100644 --- a/bindings/go/s57meta.go +++ b/bindings/go/s57meta.go @@ -45,7 +45,7 @@ func Cells(path string) ([]CellInfo, error) { var out *C.uint8_t var outLen C.size_t var cerr C.tile57_error - if st := C.tile57_s57_cells(cPath, &out, &outLen, &cerr); st != C.TILE57_OK { + if st := C.tile57_enc_cells(cPath, &out, &outLen, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } if out == nil { @@ -88,7 +88,7 @@ func CatalogEntries(catalog []byte) ([]CatalogEntry, error) { var out *C.uint8_t var outLen C.size_t var cerr C.tile57_error - if st := C.tile57_s57_catalog((*C.uint8_t)(unsafe.Pointer(&catalog[0])), C.size_t(len(catalog)), &out, &outLen, &cerr); st != C.TILE57_OK { + if st := C.tile57_enc_catalog((*C.uint8_t)(unsafe.Pointer(&catalog[0])), C.size_t(len(catalog)), &out, &outLen, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } if out == nil { @@ -140,7 +140,7 @@ func Features(path string, classes ...string) ([]Feature, error) { var out *C.uint8_t var outLen C.size_t var cerr C.tile57_error - if st := C.tile57_s57_features(cPath, cs, &out, &outLen, &cerr); st != C.TILE57_OK { + if st := C.tile57_enc_features(cPath, cs, &out, &outLen, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } return decodeFeatures(out, outLen) @@ -160,7 +160,7 @@ func FeaturesBytes(base []byte, classes ...string) ([]Feature, error) { var out *C.uint8_t var outLen C.size_t var cerr C.tile57_error - if st := C.tile57_s57_features_bytes((*C.uint8_t)(unsafe.Pointer(&base[0])), C.size_t(len(base)), cs, &out, &outLen, &cerr); st != C.TILE57_OK { + if st := C.tile57_enc_features_bytes((*C.uint8_t)(unsafe.Pointer(&base[0])), C.size_t(len(base)), cs, &out, &outLen, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } return decodeFeatures(out, outLen) diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index eca5241..9fe8c23 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -152,7 +152,7 @@ tile57 is built to hold only its working set: - **Best-available band per tile.** Overlapping cells of different compilation scales are resolved per tile to the best band, not all overlaid blindly. - **Streaming open.** `openCellsStreaming` (and its on-disk driver `openPath`, - which backs the C `tile57_s57_*` readers) take per-cell metadata (bbox + scale) + which backs the C `tile57_enc_*` readers) take per-cell metadata (bbox + scale) plus a reader; a cell's bytes are read only on demand and freed on eviction. A host then holds only the working set's bytes, not the whole catalogue — the right choice for a large ENC_ROOT. diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 68385a0..557b63d 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -135,23 +135,23 @@ a host's import UI: /* Per-cell metadata of the S-57 data at `path` (one .000, updates applied, or a * whole ENC_ROOT) as a JSON array: [{"name","scale","edition","update", * "issueDate","agency","bbox"}, ...] — a host's chart-database scan. */ -tile57_status tile57_s57_cells(const char *path, uint8_t **out, size_t *out_len, +tile57_status tile57_enc_cells(const char *path, uint8_t **out, size_t *out_len, tile57_error *err); /* Features for comma-separated object-class acronyms (e.g. "DEPARE,DRGARE") as * a GeoJSON FeatureCollection: lon/lat geometry, properties = {"class", plus the * full S-57 acronym->value attribute map}. NULL/0 when nothing matched. */ -tile57_status tile57_s57_features(const char *path, const char *classes, +tile57_status tile57_enc_features(const char *path, const char *classes, uint8_t **out, size_t *out_len, tile57_error *err); /* The same over in-memory base-cell bytes (a .000 from a zip member, say). */ -tile57_status tile57_s57_features_bytes(const uint8_t *base, size_t len, +tile57_status tile57_enc_features_bytes(const uint8_t *base, size_t len, const char *classes, uint8_t **out, size_t *out_len, tile57_error *err); /* Decode a CATALOG.031 exchange-set catalogue into a JSON array of its CATD * entries — file path, longName (chart title), impl (BIN/ASC/TXT), bbox. */ -tile57_status tile57_s57_catalog(const uint8_t *catalog_031, size_t len, +tile57_status tile57_enc_catalog(const uint8_t *catalog_031, size_t len, uint8_t **out, size_t *out_len, tile57_error *err); ``` diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 2183b58..7497d4d 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -108,7 +108,7 @@ Link against `libtile57.a`. The `partition.tpart` sidecar (NULL to skip) lets th compositor load the ownership partition instead of rebuilding it. The same `tile57` chart handle renders finished PNGs/PDFs, reads metadata (bounds, scale, coverage, SCAMIN), and answers the cursor pick; raw S-57 reading (cell -inventory, feature extraction) is handle-free via `tile57_s57_*`. See the +inventory, feature extraction) is handle-free via `tile57_enc_*`. See the [C API](./c-api.md). ## 3. Use the engine from Zig @@ -134,7 +134,7 @@ production (bake each cell, then compose on demand) is exposed through the ## ENC_ROOT and updates Open an ENC_ROOT (many cells, each with its sequential `.001`, `.002` … updates) -with `Chart.openPath` (Zig) or scan it with `tile57_s57_cells` (C) — a single +with `Chart.openPath` (Zig) or scan it with `tile57_enc_cells` (C) — a single call that walks a whole on-disk catalogue, applying each cell's updates. Baking (`tile57 bake` / `tile57_bake_tree`) turns it into the per-cell archives the compositor serves. diff --git a/include/tile57.h b/include/tile57.h index c3b85c8..93724a2 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -145,7 +145,7 @@ typedef struct { * agency are DSID EDTN/UPDN/ISDT/AGEN after the update chain is applied; * `bbox` is omitted when none parses. For a host's chart-database scan. * TILE57_OK with the JSON in *out / *out_len (free with tile57_free). */ -tile57_status tile57_s57_cells(const char *path, uint8_t **out, size_t *out_len, +tile57_status tile57_enc_cells(const char *path, uint8_t **out, size_t *out_len, tile57_error *err); /* The features of the S-57 data at `path` (one cell or a whole ENC_ROOT) for @@ -157,13 +157,13 @@ tile57_status tile57_s57_cells(const char *path, uint8_t **out, size_t *out_len, * extraction walks every cell — the caller owns that cost. TILE57_OK with the * JSON in *out / *out_len (free with tile57_free); NULL/0 when nothing * matched. */ -tile57_status tile57_s57_features(const char *path, const char *classes, +tile57_status tile57_enc_features(const char *path, const char *classes, uint8_t **out, size_t *out_len, tile57_error *err); -/* tile57_s57_features over in-memory base-cell bytes (a .000 read from a zip +/* tile57_enc_features over in-memory base-cell bytes (a .000 read from a zip * member, say) instead of a path. No update chain is applied. */ -tile57_status tile57_s57_features_bytes(const uint8_t *base, size_t len, +tile57_status tile57_enc_features_bytes(const uint8_t *base, size_t len, const char *classes, uint8_t **out, size_t *out_len, tile57_error *err); @@ -177,7 +177,7 @@ tile57_status tile57_s57_features_bytes(const uint8_t *base, size_t len, * `bbox` is omitted when SLAT/WLON/NLAT/ELON are not all present (aux files). * TILE57_OK with the JSON in *out / *out_len (free with tile57_free); NULL/0 * when the file holds no CATD records. */ -tile57_status tile57_s57_catalog(const uint8_t *catalog_031, size_t len, +tile57_status tile57_enc_catalog(const uint8_t *catalog_031, size_t len, uint8_t **out, size_t *out_len, tile57_error *err); diff --git a/src/capi.zig b/src/capi.zig index 3227c08..0513a31 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -161,7 +161,7 @@ export fn tile57_version() callconv(.c) [*:0]const u8 { /// The per-cell metadata of the S-57 data at `path` (one .000 or a whole ENC_ROOT) /// as a JSON array — name/scale/edition/update/issueDate/agency/bbox per cell, /// DSID fields reflecting the applied update chain. See tile57.h. -export fn tile57_s57_cells(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { +export fn tile57_enc_cells(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); const c = Chart.openPath(p, null, false) catch |e| return failCtx(err, e, p); @@ -175,7 +175,7 @@ export fn tile57_s57_cells(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize /// comma-separated object-class acronyms `classes`, as a GeoJSON FeatureCollection /// (lon/lat geometry; properties = {"class", ...full S-57 attribute map}). Parsed /// without portrayal. NULL/0 out when nothing matched. See tile57.h. -export fn tile57_s57_features(path: ?[*:0]const u8, classes: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { +export fn tile57_enc_features(path: ?[*:0]const u8, classes: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); const cls = spanOpt(classes) orelse return failWith(err, .badarg, "classes must not be null"); @@ -186,8 +186,8 @@ export fn tile57_s57_features(path: ?[*:0]const u8, classes: ?[*:0]const u8, out return OK; } -/// tile57_s57_features over in-memory base-cell bytes (no update chain). See tile57.h. -export fn tile57_s57_features_bytes(base: ?[*]const u8, len: usize, classes: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { +/// tile57_enc_features over in-memory base-cell bytes (no update chain). See tile57.h. +export fn tile57_enc_features_bytes(base: ?[*]const u8, len: usize, classes: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const b = base orelse return failWith(err, .badarg, "base must not be null"); if (len == 0) return failWith(err, .badarg, "len must not be zero"); @@ -203,7 +203,7 @@ export fn tile57_s57_features_bytes(base: ?[*]const u8, len: usize, classes: ?[* /// Decode a CATALOG.031 exchange-set catalogue into a JSON array of its CATD /// entries: [{"file","longName","impl","bbox"?}, ...]. NULL/0 out when the file /// holds no CATD records. See tile57.h. -export fn tile57_s57_catalog(catalog_031: ?[*]const u8, len: usize, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { +export fn tile57_enc_catalog(catalog_031: ?[*]const u8, len: usize, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const cat = catalog_031 orelse return failWith(err, .badarg, "catalog_031 must not be null"); if (len == 0) return failWith(err, .badarg, "len must not be zero"); From 599f8ad28a24c39e8efb457e412360d51bb9cd56 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 05:38:26 -0400 Subject: [PATCH 099/140] refactor(capi): tile57_compose_serve -> tile57_compose_tile; Go ComposeSource.Serve -> Tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call returns ONE composed tile for (z,x,y) — name the result, not the server use-case, matching the handle-noun convention. Co-Authored-By: Claude Fable 5 --- bindings/go/README.md | 2 +- bindings/go/compose.go | 14 +++++++------- bindings/go/compose_test.go | 12 ++++++------ docs/docs/architecture.md | 4 ++-- docs/docs/c-api.md | 4 ++-- docs/docs/getting-started.md | 2 +- include/tile57.h | 2 +- src/capi.zig | 2 +- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/bindings/go/README.md b/bindings/go/README.md index 5c7a92d..879074f 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -41,7 +41,7 @@ n, err := tile57.BakeTree("/enc/ENC_ROOT", "/out", 4, nil) // Open the compositor over the baked archives + partition, and serve tiles. src, _ := tile57.OpenCompose([]string{"/out/tiles/US5MD1MC.pmtiles"}, "/out/partition.tpart") defer src.Close() -body, owned, _ := src.Serve(13, 2359, 3139) // owned=false, body=nil => open ocean +body, owned, _ := src.Tile(13, 2359, 3139) // owned=false, body=nil => open ocean // Or open one baked archive as a chart (bounds, scale, coverage, SCAMIN). chart, _ := tile57.Open("/out/tiles/US5MD1MC.pmtiles") diff --git a/bindings/go/compose.go b/bindings/go/compose.go index c94e508..882b4ab 100644 --- a/bindings/go/compose.go +++ b/bindings/go/compose.go @@ -15,9 +15,9 @@ import ( ) // ComposeSource is a resident runtime compositor over open charts: the ownership -// partition loaded once, so Serve composes any tile on demand. It BORROWS its +// partition loaded once, so Tile composes any tile on demand. It BORROWS its // charts — they must outlive it — except charts opened for it by [OpenCompose], -// which it owns and closes. Serve is serialised by an internal mutex (the +// which it owns and closes. Tile is serialised by an internal mutex (the // compositor reads through the charts, which are not thread-safe). type ComposeSource struct { mu sync.Mutex @@ -104,22 +104,22 @@ func OpenCompose(paths []string, partitionPath string) (*ComposeSource, error) { return cs, nil } -// Serve composes the tile (z,x,y) on demand, returning raw (decompressed) MLT bytes plus `owned` — -// whether the ownership partition says a cell SHOULD render here. body!=nil → served (owned). body +// Tile composes the tile (z,x,y) on demand, returning raw (decompressed) MLT bytes plus `owned` — +// whether the ownership partition says a cell SHOULD render here. body!=nil → composed (owned). body // ==nil && owned → a cell owns this ground but produced nothing (transient while its per-cell bake // runs; an error state once bakes are done). body==nil && !owned → true empty ocean (safe to cache). // Thread-safe (serialised). -func (c *ComposeSource) Serve(z uint8, x, y uint32) (body []byte, owned bool, err error) { +func (c *ComposeSource) Tile(z uint8, x, y uint32) (body []byte, owned bool, err error) { c.mu.Lock() defer c.mu.Unlock() if c.ptr == nil { - return nil, false, fmt.Errorf("tile57: Serve on a closed ComposeSource") + return nil, false, fmt.Errorf("tile57: Tile on a closed ComposeSource") } var out *C.uint8_t var outLen C.size_t var cowned C.bool var cerr C.tile57_error - if st := C.tile57_compose_serve(c.ptr, C.uint8_t(z), C.uint32_t(x), C.uint32_t(y), &out, &outLen, &cowned, &cerr); st != C.TILE57_OK { + if st := C.tile57_compose_tile(c.ptr, C.uint8_t(z), C.uint32_t(x), C.uint32_t(y), &out, &outLen, &cowned, &cerr); st != C.TILE57_OK { return nil, false, statusError(st, &cerr) } if out == nil { diff --git a/bindings/go/compose_test.go b/bindings/go/compose_test.go index f3f91e7..94beec9 100644 --- a/bindings/go/compose_test.go +++ b/bindings/go/compose_test.go @@ -36,9 +36,9 @@ func TestComposeSingleCell(t *testing.T) { t.Fatalf("compose cells = %d, want 1", m.Cells) } cx, cy := lonLatToTile((m.West+m.East)/2, (m.South+m.North)/2, m.MinZoom) - tile, owned, err := src.Serve(m.MinZoom, cx, cy) + tile, owned, err := src.Tile(m.MinZoom, cx, cy) if err != nil { - t.Fatalf("Serve: %v", err) + t.Fatalf("Tile: %v", err) } if len(tile) == 0 || !owned { t.Fatalf("centre tile z%d/%d/%d: %d bytes owned=%v, want content", m.MinZoom, cx, cy, len(tile), owned) @@ -57,9 +57,9 @@ func TestComposeSingleCell(t *testing.T) { t.Fatalf("OpenCompose: %v", err) } defer src2.Close() - tile2, owned2, err := src2.Serve(m.MinZoom, cx, cy) + tile2, owned2, err := src2.Tile(m.MinZoom, cx, cy) if err != nil { - t.Fatalf("Serve(sidecar): %v", err) + t.Fatalf("Tile(sidecar): %v", err) } if !owned2 || len(tile2) != len(tile) { t.Fatalf("sidecar-loaded serve differs: %d bytes owned=%v (want %d bytes)", len(tile2), owned2, len(tile)) @@ -114,7 +114,7 @@ func TestOpenComposeServe(t *testing.T) { z = m.MaxZoom } cx, cy := lonLatToTile((m.West+m.East)/2, (m.South+m.North)/2, z) - tile, owned, err := src.Serve(z, cx, cy) + tile, owned, err := src.Tile(z, cx, cy) if err != nil { t.Fatal(err) } @@ -127,7 +127,7 @@ func TestOpenComposeServe(t *testing.T) { } // A tile far outside coverage must be blank (nil) AND not owned (true empty ocean), not an error. - blank, blankOwned, err := src.Serve(z, 0, 0) + blank, blankOwned, err := src.Tile(z, 0, 0) if err != nil { t.Fatal(err) } diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 9fe8c23..4c607ef 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -134,7 +134,7 @@ The public surface composes the packages into high-level entry points: (`tile57_bake_cell_bytes`, which runs the banded bake engine `scene/bake_enc.zig` on a single cell), then a runtime **compositor** stitches the overlapping cells for any `(z, x, y)` tile on demand through an ownership partition - (`tile57_compose_open` / `tile57_compose_serve`). The public Zig `bakeArchive` + (`tile57_compose_open` / `tile57_compose_tile`). The public Zig `bakeArchive` runs the same engine over a slice of cells to make one merged archive. - **`style.build`** (`style/maplibre.zig`) + **`style`** / **`sprite`** — generate the MapLibre style and the portrayal assets it references @@ -178,7 +178,7 @@ out/ ``` There is no merged archive: any `(z, x, y)` tile is composed from the overlapping -cells on demand (`tile57_compose_serve`), so re-baking one cell doesn't rewrite a +cells on demand (`tile57_compose_tile`), so re-baking one cell doesn't rewrite a whole district. The portrayal assets are generated separately (`tile57 assets` / `style`); the tiles carry S-52 colour **tokens**, never RGB, and both halves come from the *same* S-101 catalogue, so they cannot drift. The tile-schema vocabulary diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 557b63d..668a2f7 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -377,7 +377,7 @@ tile57_status tile57_compose_open(tile57 *const *charts, size_t n, * owned=false: no cell owns this ground — true empty ocean, safe to cache; * owned=true: a cell owns this ground but produced nothing — transient while * its per-cell bake is running, suspect once bakes are done. */ -tile57_status tile57_compose_serve(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, +tile57_status tile57_compose_tile(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, uint8_t **out, size_t *out_len, bool *out_owned, tile57_error *err); @@ -401,7 +401,7 @@ tile57_open("tiles/US5MD1MD.pmtiles", &charts[1], NULL); tile57_compose *cmp = NULL; tile57_compose_open(charts, 2, "partition.tpart", &cmp, NULL); uint8_t *tile; size_t n; bool owned; -tile57_compose_serve(cmp, 13, 2359, 3139, &tile, &n, &owned, NULL); +tile57_compose_tile(cmp, 13, 2359, 3139, &tile, &n, &owned, NULL); ``` ## Generate portrayal assets diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 7497d4d..093ca1e 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -95,7 +95,7 @@ tile57_compose *src = NULL; tile57_compose_open(&chart, 1, "out/partition.tpart", &src, &err); uint8_t *tile; size_t n; bool owned; -if (tile57_compose_serve(src, z, x, y, &tile, &n, &owned, &err) == TILE57_OK) { +if (tile57_compose_tile(src, z, x, y, &tile, &n, &owned, &err) == TILE57_OK) { if (tile) { /* … serve the decompressed MLT tile … */ tile57_free(tile, n); } else if (owned) { /* owned but empty — a cell's bake is still in flight */ } else { /* not owned — open ocean; cache as blank */ } diff --git a/include/tile57.h b/include/tile57.h index 93724a2..1097752 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -565,7 +565,7 @@ tile57_status tile57_compose_open(tile57 *const *charts, size_t n, * owned = true: a cell owns this ground but produced nothing — transient * while its per-cell bake is still running; suspect once * bakes are done. */ -tile57_status tile57_compose_serve(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, +tile57_status tile57_compose_tile(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, uint8_t **out, size_t *out_len, bool *out_owned, tile57_error *err); diff --git a/src/capi.zig b/src/capi.zig index 0513a31..a8c7835 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -703,7 +703,7 @@ export fn tile57_compose_open( /// no bytes; *out_owned (NULL to ignore) then distinguishes true empty ocean (false, /// safe to cache) from owned-but-empty (true — transient during a bake, suspect /// after). Byte-faithful to the batch compositor. See tile57.h. -export fn tile57_compose_serve( +export fn tile57_compose_tile( handle: ?*compose.ComposeSource, z: u8, x: u32, From 56f83995d061e07d82b3f59da746a6130a99c3b7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 05:50:23 -0400 Subject: [PATCH 100/140] docs: say "call that can fail", not "fallible call" Co-Authored-By: Claude Fable 5 --- docs/docs/c-api.md | 2 +- include/tile57.h | 4 ++-- src/capi.zig | 2 +- src/errors.zig | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 668a2f7..4b95665 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -31,7 +31,7 @@ sprite / pattern / glyph atlases it references. ## Errors -Every fallible call returns a `tile57_status` — `TILE57_OK` (0) or a coarse +Every call that can fail returns a `tile57_status` — `TILE57_OK` (0) or a coarse cause — and takes an optional caller-owned `tile57_error*` it fills with the status plus a specific message on failure (a stack local is fine; nothing to free). Results come back through out-parameters, which are always defined on diff --git a/include/tile57.h b/include/tile57.h index 1097752..2dcdbbe 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -29,7 +29,7 @@ * maplibre-gl >= 5.12 decodes them natively via the vector source `encoding` * option. The ABI is renderer-agnostic. * - * Errors: every fallible call returns a tile57_status (TILE57_OK = 0) and takes + * Errors: every call that can fail returns a tile57_status (TILE57_OK = 0) and takes * an optional caller-owned tile57_error* it fills on failure — see section 2. * Out-parameters are always defined on return: the result on TILE57_OK, * NULL/0 otherwise. "Nothing produced" is NOT a failure — a call that finds @@ -79,7 +79,7 @@ const char *tile57_version(void); /* ======================================================================== * * 2. Errors * - * A fallible call returns a tile57_status; TILE57_OK (0) is success and every + * A call that can fail returns a tile57_status; TILE57_OK (0) is success and every * other value is a failure with a coarse cause. Results come back through * out-parameters, which are always defined on return (NULL/0 on failure). * diff --git a/src/capi.zig b/src/capi.zig index a8c7835..4d5b97d 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -1,6 +1,6 @@ //! C ABI for libtile57.a — a thin shim over the Zig engine API (chart.zig). //! -//! Contract: POD across the seam. Every fallible export returns a tile57_status +//! Contract: POD across the seam. Every export that can fail returns a tile57_status //! (0 = OK), takes an optional caller-owned tile57_error* it fills on failure, //! and defines its out-parameters on every return (result on OK, NULL/0 //! otherwise). Zig errors, slices and optionals stay inside chart.zig. Public diff --git a/src/errors.zig b/src/errors.zig index dea1da5..2dfc93c 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -1,4 +1,4 @@ -//! The engine's error taxonomy. Every fallible engine entry point returns one of +//! The engine's error taxonomy. Every engine entry point that can fail returns one of //! these; the C ABI maps each to a tile57_status (see capi.zig) and carries //! describe() as the per-call tile57_error.message. Keep this set, the C //! tile57_status enum, and capi's statusOf in sync. From 2b8bdd9c5d4a8bdbffe75a6438498d16ec0587a3 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 05:56:52 -0400 Subject: [PATCH 101/140] =?UTF-8?q?refactor(capi):=20tile57=5Fbuild=5Fstyl?= =?UTF-8?q?e=20->=20tile57=5Fstyle=5Fbuild=20=E2=80=94=20completes=20the?= =?UTF-8?q?=20style=5F=20family=20(build/diff/template)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- bindings/go/cmem.go | 2 +- bindings/go/style.go | 2 +- bindings/go/style_test.go | 2 +- bindings/js/README.md | 2 +- docs/docs/architecture.md | 2 +- docs/docs/c-api.md | 6 +++--- include/tile57.h | 16 ++++++++-------- src/capi.zig | 12 ++++++------ src/style/maplibre.zig | 6 +++--- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/bindings/go/cmem.go b/bindings/go/cmem.go index e7475dd..42e2693 100644 --- a/bindings/go/cmem.go +++ b/bindings/go/cmem.go @@ -48,7 +48,7 @@ func (a *cArena) str(s string) *C.char { // int32Array copies a Go []int32 into a malloc'd C int32_t array, returning the // base pointer + count (NULL/0 for an empty slice). Used for the enabled-bands and -// SCAMIN-manifest inputs to tile57_build_style. +// SCAMIN-manifest inputs to tile57_style_build. func (a *cArena) int32Array(v []int32) (*C.int32_t, C.size_t) { n := len(v) if n == 0 { diff --git a/bindings/go/style.go b/bindings/go/style.go index 26c0c1d..fe12941 100644 --- a/bindings/go/style.go +++ b/bindings/go/style.go @@ -138,7 +138,7 @@ func BuildStyle(template []byte, m Mariner, colortables []byte, enabledBands []i var out *C.uint8_t var outLen C.size_t var cerr C.tile57_error - if st := C.tile57_build_style(tmplPtr, tmplLen, &cm, ctPtr, ctLen, bandsPtr, bandsN, scaminPtr, scaminN, C.double(scaminLat), &out, &outLen, &cerr); st != C.TILE57_OK { + if st := C.tile57_style_build(tmplPtr, tmplLen, &cm, ctPtr, ctLen, bandsPtr, bandsN, scaminPtr, scaminN, C.double(scaminLat), &out, &outLen, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } return tileBytes(out, outLen), nil diff --git a/bindings/go/style_test.go b/bindings/go/style_test.go index 506b305..d84689b 100644 --- a/bindings/go/style_test.go +++ b/bindings/go/style_test.go @@ -39,7 +39,7 @@ func TestStyle(t *testing.T) { } // TestIgnoreScamin verifies the ?ignoreScamin toggle disables SCAMIN scale-gating -// through the full C ABI: tile57_build_style carries no SCAMIN manifest, so the +// through the full C ABI: tile57_style_build carries no SCAMIN manifest, so the // gated style uses the per-feature zoom-gate fallback ("log2"); IgnoreScamin drops // it so every feature shows in-band. func TestIgnoreScamin(t *testing.T) { diff --git a/bindings/js/README.md b/bindings/js/README.md index 164ecd5..331ce4f 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -6,7 +6,7 @@ contour, display category, and the rest — **entirely client-side**. Under the hood it runs the chartplotter **`tile57` style engine** (pure Zig) compiled to a ~145 KB WebAssembly module. The same engine ships in the native -`libtile57` C ABI (`tile57_build_style`) and the `tile57` CLI, so the style your +`libtile57` C ABI (`tile57_style_build`) and the `tile57` CLI, so the style your front-end produces is **byte-for-byte identical** to the native build (see [Parity](#parity)). No server round-trip, no second implementation to keep in sync. diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 4c607ef..ccb4cd7 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -138,7 +138,7 @@ The public surface composes the packages into high-level entry points: runs the same engine over a slice of cells to make one merged archive. - **`style.build`** (`style/maplibre.zig`) + **`style`** / **`sprite`** — generate the MapLibre style and the portrayal assets it references - (`tile57_build_style` / `tile57_bake_assets` in the C ABI). + (`tile57_style_build` / `tile57_bake_assets` in the C ABI). ## The memory design diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 4b95665..edf9fc1 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -443,7 +443,7 @@ tile57_status tile57_bake_glyph_sdf(tile57_assets *out, tile57_error *err); ## Build a MapLibre style -`tile57_build_style` turns a MapLibre style template + the mariner's S-52 display +`tile57_style_build` turns a MapLibre style template + the mariner's S-52 display options + the S-52 colortables into a concrete style JSON, client-side. The template + colortables come from the built-in `tile57_style_template` / `tile57_colortables_default` (or the generated assets); the host fills @@ -482,7 +482,7 @@ void tile57_mariner_defaults(tile57_mariner *m); /* canonical defaults, date_v * array. scamin: the distinct SCAMIN denominators present in the source (e.g. * from tile57_scamin) — when non-NULL the `_scamin` layers split into per-value * native-minzoom buckets; scamin_lat is the representative latitude. */ -tile57_status tile57_build_style(const char *template_json, size_t template_len, +tile57_status tile57_style_build(const char *template_json, size_t template_len, const tile57_mariner *m, const char *colortables_json, size_t colortables_len, const int32_t *enabled_bands, size_t enabled_band_count, @@ -490,7 +490,7 @@ tile57_status tile57_build_style(const char *template_json, size_t template_len, uint8_t **out, size_t *out_len, tile57_error *err); /* Minimal MapLibre style-mutation ops to turn the style for `old_m` into the style - * for `new_m` (same inputs as tile57_build_style) — for flicker-free mariner + * for `new_m` (same inputs as tile57_style_build) — for flicker-free mariner * toggles. Writes a JSON op array to *out/*out_len (free with tile57_free). */ tile57_status tile57_style_diff(const char *template_json, size_t template_len, const tile57_mariner *old_m, const tile57_mariner *new_m, diff --git a/include/tile57.h b/include/tile57.h index 2dcdbbe..8781f27 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -583,7 +583,7 @@ void tile57_compose_close(tile57_compose *c); /* ======================================================================== * * 6. Style + portrayal assets * - * tile57 ships tile generation AND style generation together. tile57_build_style + * tile57 ships tile generation AND style generation together. tile57_style_build * turns a MapLibre style template + the mariner's S-52 display options + the S-52 * colortables into a concrete style JSON, client-side; tile57_bake_assets produces * the colour tables, line styles, and sprite / pattern / glyph atlases the style @@ -593,7 +593,7 @@ void tile57_compose_close(tile57_compose *c); /* ---- portrayal assets ---------------------------------------------------- */ /* All portrayal assets in memory, from the library's embedded catalogue - * (catalog_dir NULL/"") or an on-disk one. Pairs with tile57_build_style + the + * (catalog_dir NULL/"") or an on-disk one. Pairs with tile57_style_build + the * composed tiles for a complete renderable chart. Free with * tile57_assets_free. */ typedef struct { @@ -619,7 +619,7 @@ void tile57_assets_free(tile57_assets *out); /* ---- chart-style generation --------------------------------------------- * - * tile57_build_style patches the mariner-driven parts of the template (depth + * tile57_style_build patches the mariner-driven parts of the template (depth * shading, sounding/danger symbol swaps, contour-label units, the per-scheme * recolour) and AND-s the display filters (category, band, boundary/point style, * date validity, text groups, …) onto every source:"chart" layer. The template + @@ -630,7 +630,7 @@ void tile57_assets_free(tile57_assets *out); * host can generate a complete style with no on-disk catalogue or template file: * tile57_colortables_default(&ct,&ctn,NULL); * tile57_style_template(scheme, "http://host/{z}/{x}/{y}", NULL,NULL,0,0,0, &t,&tn,NULL); - * tile57_build_style(t,tn, &m, ct,ctn, bands,nb, scamin,nsm,lat, &style,&sn,NULL); + * tile57_style_build(t,tn, &m, ct,ctn, bands,nb, scamin,nsm,lat, &style,&sn,NULL); * Free each buffer with tile57_free. */ /* Build a MapLibre style JSON from a template + mariner settings + S-52 colortables. @@ -645,7 +645,7 @@ void tile57_assets_free(tile57_assets *out); * scamin_lat: representative latitude (degrees) for the bucket minzooms (the SCAMIN * display cutoff is latitude-dependent); use the source's center latitude. * TILE57_OK with the style JSON in *out / *out_len (free with tile57_free). */ -tile57_status tile57_build_style(const char *template_json, size_t template_len, +tile57_status tile57_style_build(const char *template_json, size_t template_len, const tile57_mariner *m, const char *colortables_json, size_t colortables_len, const int32_t *enabled_bands, size_t enabled_band_count, @@ -654,7 +654,7 @@ tile57_status tile57_build_style(const char *template_json, size_t template_len, /* Compute the minimal MapLibre style-mutation ops to turn the style for `old_m` * into the style for `new_m` — same template/colortables/bands/scamin inputs as - * tile57_build_style, so the two styles are comparable. For a flicker-free mariner + * tile57_style_build, so the two styles are comparable. For a flicker-free mariner * toggle the host applies each op in place (map.setFilter / setPaintProperty / * setLayoutProperty) instead of re-setStyle-ing, leaving overlays and sources * untouched. The output is a JSON array; each element is one mutation: @@ -682,7 +682,7 @@ tile57_status tile57_colortables_default(uint8_t **out, size_t *out_len, /* Base MapLibre style template (layers + chart `sources` + sprite/glyph URLs) from * the catalogue baked into the library — no template file needed. The source lives - * in the template; the per-change mariner patch (tile57_build_style) takes none. + * in the template; the per-change mariner patch (tile57_style_build) takes none. * scheme: a tile57_scheme (selects the per-scheme palette). * source_tiles: the chart {z}/{x}/{y} tiles URL (NULL -> a default pmtiles:// source). * sprite,glyphs:base URLs that enable the symbol / text layers (NULL omits them). @@ -694,7 +694,7 @@ tile57_status tile57_colortables_default(uint8_t **out, size_t *out_len, * tile57_info.tile_type). TILE57_TILE_TYPE_MLT emits "encoding":"mlt" on * the source so maplibre-gl >= 5.12 decodes MLT natively; 0 / * TILE57_TILE_TYPE_MVT emits nothing (the MapLibre default). The hint - * survives tile57_build_style / tile57_style_diff. + * survives tile57_style_build / tile57_style_diff. * TILE57_OK with *out / *out_len set (free with tile57_free). */ tile57_status tile57_style_template(tile57_scheme scheme, const char *source_tiles, const char *sprite, const char *glyphs, diff --git a/src/capi.zig b/src/capi.zig index 4d5b97d..ce6d53f 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -26,7 +26,7 @@ const colorprofile_registry = @import("colorprofile_registry"); const gpa = std.heap.smp_allocator; const Chart = chart.Chart; -// Wall-clock time for "today" date resolution in tile57_build_style. Zig 0.16 +// Wall-clock time for "today" date resolution in tile57_style_build. Zig 0.16 // keeps the clock behind Io; the lib links libc, so call time(3) directly. extern fn time(tloc: ?*c_long) callconv(.c) c_long; @@ -768,7 +768,7 @@ fn embeddedColorProfileXml() ?[]const u8 { } /// S-52 colortables.json from the colour profile baked into the library — no -/// on-disk catalogue needed. Pair with tile57_style_template / tile57_build_style. +/// on-disk catalogue needed. Pair with tile57_style_template / tile57_style_build. export fn tile57_colortables_default(out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const xml = embeddedColorProfileXml() orelse return failWith(err, .internal, "embedded colour profile missing"); @@ -950,7 +950,7 @@ const CMariner = extern struct { size_scale: f64, // S-52 §14.5 fine-grained viewing-group control: a DENY-LIST of the raw `vg` // ids the mariner turned OFF (NULL/len 0 -> every group shown). Appended at the - // end for ABI-append-safety. The pointee must outlive the tile57_build_style call. + // end for ABI-append-safety. The pointee must outlive the tile57_style_build call. viewing_groups_off: [*c]const i32, viewing_groups_off_len: u32, // Gate SCAMIN with a live client filter instead of per-value bucket layers @@ -1028,7 +1028,7 @@ fn scaminBuf(scamin: ?[*]const i32, scamin_count: usize) ![]u32 { /// Build a MapLibre style JSON from a template + mariner settings + colortables. /// See tile57.h. -export fn tile57_build_style( +export fn tile57_style_build( template_json: ?[*]const u8, template_len: usize, cm: ?*const CMariner, @@ -1062,7 +1062,7 @@ export fn tile57_build_style( } /// Compute the minimal MapLibre style-mutation ops turning the style for `old_m` -/// into the style for `new_m` (same inputs as tile57_build_style, so the styles are +/// into the style for `new_m` (same inputs as tile57_style_build, so the styles are /// comparable): a JSON op array — "[]" when nothing changed, [{"op":"rebuild"}] /// when the layer SET differs (host falls back to setStyle). See tile57.h. export fn tile57_style_diff( @@ -1108,7 +1108,7 @@ export fn tile57_style_diff( /// Generate the base MapLibre style template from the catalogue baked into the /// library — the chart `sources` block, sprite/glyph URLs and the layer set; -/// mariner settings are then applied on top with tile57_build_style. See tile57.h +/// mariner settings are then applied on top with tile57_style_build. See tile57.h /// for the parameter semantics (minzoom emitted verbatim; tile_encoding MLT emits /// "encoding":"mlt" on the source). export fn tile57_style_template( diff --git a/src/style/maplibre.zig b/src/style/maplibre.zig index 71c847b..0d2353d 100644 --- a/src/style/maplibre.zig +++ b/src/style/maplibre.zig @@ -181,7 +181,7 @@ pub const Options = struct { // S-52 mariner display options. null = a TEMPLATE (no client display filters // baked in — the bundle + tile57_style_template path; the client gates live). // Non-null = the full style with the mariner's display filters + depth shading / - // sounding-split / danger-swap baked in (the C-ABI tile57_build_style path). + // sounding-split / danger-swap baked in (the C-ABI tile57_style_build path). // Colour + layout always resolve through the mariner builders (default mariner // when null), so there is ONE style builder — the mariner builders is retired. mariner: ?mariner.Settings = null, @@ -1031,7 +1031,7 @@ pub fn buildFromTemplate( /// Same as buildFromTemplate but threading a SCAMIN manifest (the distinct /// denominators present + a representative latitude), so the RUNTIME style gets the /// SAME per-value native-minzoom bucket layers the offline bundle does (host -/// host-canonical-backend.md §"Still needed" #1 — the `tile57_build_style` runtime +/// host-canonical-backend.md §"Still needed" #1 — the `tile57_style_build` runtime /// path otherwise leaves every `_scamin` layer ungated). Empty `scamin` == the /// plain buildFromTemplate behaviour. pub fn buildFromTemplateScamin( @@ -1082,7 +1082,7 @@ pub fn buildFromTemplateScamin( if (t == .array and t.array.items.len > 0 and t.array.items[0] == .string) opts.source_tiles = t.array.items[0].string; } - if (c.get("encoding")) |e| { // MLT hint rides the rebuild (tile57_build_style / style_diff) + if (c.get("encoding")) |e| { // MLT hint rides the rebuild (tile57_style_build / style_diff) if (e == .string) opts.encoding = e.string; } if (c.get("minzoom")) |z| { From ff05d8d1a7ccc9b52ec17b27a5ba21bf80878ee5 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 06:04:37 -0400 Subject: [PATCH 102/140] refactor(compose): ComposeSource.serve -> tile; sync Zig package version + Zig API docs to 0.2.0 Co-Authored-By: Claude Fable 5 --- docs/docs/zig-api.md | 36 ++++++++++++++++++++++++++---------- src/capi.zig | 2 +- src/compose/compose.zig | 2 +- src/tile57.zig | 7 ++++--- tools/compose_tile.zig | 4 ++-- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index ffdd022..13feb8d 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -6,7 +6,7 @@ sidebar_position: 4 # Zig API -The engine is a Zig package named **`tile57`** (v0.1.0, requires Zig 0.16). +The engine is a Zig package named **`tile57`** (v0.2.0, requires Zig 0.16). Add it as a dependency and `@import("tile57")` for the curated public surface (`src/tile57.zig`). The [C ABI](./c-api.md) is a thin shim over this same API. @@ -26,8 +26,9 @@ and read metadata: ```zig const tile57 = @import("tile57"); -// A PMTiles archive, or a raw S-57 cell portrayed live (auto-sniffed). -var chart = try tile57.Chart.openBytes(cell_bytes, .auto, null); +// A baked archive, mmap'd (never fully resident). openBytes takes a PMTiles +// archive or a raw S-57 cell portrayed live (auto-sniffed). +var chart = try tile57.Chart.openPmtilesPath(io, "US5MD1MC.pmtiles"); defer chart.deinit(); const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null @@ -42,6 +43,7 @@ The Zig `Chart` does not itself serve `(z, x, y)` tiles — the runtime composit | Method | Purpose | |--------|---------| +| `openPmtilesPath(io, path)` | open a baked PMTiles archive from a path, mmap'd. | | `openBytes(bytes, format, rules_dir)` | open one chart from bytes (PMTiles or S-57 cell). | | `openPath(path, rules_dir, pick_attrs)` | open an on-disk ENC_ROOT dir (or a single `.000`) as a streaming chart. | | `openCells(cells, rules_dir, pick_attrs)` | open a multi-cell ENC_ROOT from bytes (each cell's `.001…` updates applied). | @@ -51,12 +53,13 @@ The Zig `Chart` does not itself serve `(z, x, y)` tiles — the runtime composit | `renderAscii(lon, lat, zoom, cols, rows, palette, settings, ansi)` | the same view as a terminal text grid. | | `queryPoint(lon, lat, zoom, cb)` | the S-52 cursor pick — features under a point at the view zoom. | | `cellsJson()` / `featuresJson(classes)` | per-cell metadata / GeoJSON feature query (the `cells` / `features` CLI). | -| `coverage()` | the M_COVR data-coverage rings (a live cell only). | +| `coverage()` | the M_COVR data-coverage rings (a live cell, or the copy a per-cell bake embeds in its archive metadata). | | `bounds() -> ?[4]f64` | geographic extent `[w, s, e, n]`, if known. | | `anchor()` | a good initial camera (lat, lon, zoom) on real data. | | `bands() -> u32` | bitmask of navigational bands present. | | `zoomRange()` | the min/max zoom the chart serves. | -| `nativeScale() -> i32` | the compilation scale 1:N (a live cell; 0 if unknown). | +| `nativeScale() -> i32` | the compilation scale 1:N (a live cell or an archive's embedded metadata; 0 if unknown). | +| `pmtilesReader()` / `cellCoverage()` | the archive reader + decoded per-cell coverage a compositor borrows. | | `scamin() -> ![]u32` | the distinct SCAMIN denominators present (the live SCAMIN manifest). | | `tileType()` | the tile encoding the chart's tiles use (MVT/MLT). | | `format() -> Format` | the resolved backend (after `.auto`). | @@ -82,12 +85,24 @@ never double-draw at a seam. ```zig // Bake an ENC_ROOT: each cell -> /tiles/.pmtiles + /partition.tpart. -const n = tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null); +// Incremental: an archive already newer than its whole input is skipped. +const n = try tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null); -// Open the compositor over the archives + partition, then serve tiles. +// Open the compositor over the archives + partition, then compose tiles. var src = (try tile57.compose.openComposeSourceFiles(io, gpa, paths, "/out/partition.tpart")).?; defer src.deinit(); -const result = try src.serve(gpa, 13, 2359, 3139); // result.tile: ?[]u8, result.owned: bool +const result = try src.tile(gpa, 13, 2359, 3139); // result.tile: ?[]u8, result.owned: bool +``` + +A host that already holds open charts composes over them instead — the +compositor borrows each chart's mmap'd reader + decoded coverage, so the charts +must outlive it: + +```zig +const archives = [_]tile57.compose.ChartArchive{ + .{ .reader = chart.pmtilesReader().?, .cov = chart.cellCoverage().? }, +}; +var src = (try tile57.compose.openComposeSourceCharts(gpa, &archives, null)).?; ``` | Surface | What it does | @@ -97,7 +112,8 @@ const result = try src.serve(gpa, 13, 2359, 3139); // result.tile: ?[]u8, result | `tile57.bake.archive(...)` | the offline path: merge a slice of cells into one archive. | | `tile57.bake.Progress` | the optional progress-callback type. | | `tile57.compose.openComposeSourceFiles(...)` | open a `ComposeSource` over on-disk archives + a partition. | -| `tile57.compose.composeTile(...)` | compose one tile (the stateless core `serve` uses). | +| `tile57.compose.openComposeSourceCharts(...)` | the same over borrowed `ChartArchive`s (already-open charts). | +| `tile57.compose.composeTile(...)` | compose one tile (the stateless core `ComposeSource.tile` uses). | | `tile57.partition` | the ownership partition and its `.tpart` sidecar (serialize / deserialize). | ## Style + portrayal assets @@ -136,5 +152,5 @@ The pure-Zig foundational parsers under `tile57.formats`: | `tile57.formats.s57` | S-57 ENC cell parser + geometry | | `tile57.formats.s101` | the S-101 catalogue, adapter, and instruction stream | -`tile57.version` is the package version string (`"0.1.0"`), matching +`tile57.version` is the package version string (`"0.2.0"`), matching `build.zig.zon` and `tile57_version()`. diff --git a/src/capi.zig b/src/capi.zig index ce6d53f..9ecad85 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -716,7 +716,7 @@ export fn tile57_compose_tile( const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); if (out_owned) |p| p.* = false; const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); - const res = src.serve(gpa, z, x, y) catch |e| return fail(err, e); + const res = src.tile(gpa, z, x, y) catch |e| return fail(err, e); if (out_owned) |p| p.* = res.owned; if (res.tile) |t| setBytes(o, n, t); return OK; diff --git a/src/compose/compose.zig b/src/compose/compose.zig index 66cb8be..5fc9f64 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -245,7 +245,7 @@ pub const ComposeSource = struct { /// Compose one tile → raw (decompressed) MLT + the ownership flag (gpa-owned bytes; null when /// nothing rendered — `owned` then says whether a cell SHOULD have). This is what a live tile /// server hands its HTTP layer, which gzips on the wire. Byte-faithful to the batch. - pub fn serve(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !TileResult { + pub fn tile(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !TileResult { return composeTile(gpa, &self.part, self.readers, z, tx, ty, false); } /// Serialize the resident ownership partition to a sidecar blob (gpa-owned) a later open can diff --git a/src/tile57.zig b/src/tile57.zig index b8c7fd3..e51041c 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -26,7 +26,7 @@ const std = @import("std"); /// Library version (matches build.zig.zon and tile57_version()). -pub const version = "0.1.0"; +pub const version = "0.2.0"; // ---- Chart: open a chart, then render / query / inspect -------------------- const chart = @import("chart.zig"); @@ -61,8 +61,9 @@ pub const bake = struct { }; // ---- Compose: per-cell archives + a partition -> tiles on demand ----------- -/// The runtime compositor: `ComposeSource` over mmap'd archives + a partition, -/// `composeTile` for one tile, `openComposeSourceFiles` to open from disk. +/// The runtime compositor: `ComposeSource` over per-cell archives + a partition +/// (`ComposeSource.tile` composes one tile on demand), `openComposeSourceFiles` to +/// open from disk, `openComposeSourceCharts` to borrow already-open charts. pub const compose = @import("compose"); /// The ownership partition and its `.tpart` sidecar (serialize / deserialize). pub const partition = @import("geometry").partition; diff --git a/tools/compose_tile.zig b/tools/compose_tile.zig index c19923d..efe641d 100644 --- a/tools/compose_tile.zig +++ b/tools/compose_tile.zig @@ -99,7 +99,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // Serve the requested tile. const serve_t0 = nowNs(); - const res = try src.serve(a, z, tx, ty); + const res = try src.tile(a, z, tx, ty); const tile = res.tile; const serve_ms = @as(f64, @floatFromInt(nowNs() - serve_t0)) / 1e6; if (tile) |t| { @@ -121,7 +121,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { while (dy < bench) : (dy += 1) { const bx = (tx + dx) -| half; const by = (ty + dy) -| half; - const tl = (src.serve(a, z, bx, by) catch continue).tile; + const tl = (src.tile(a, z, bx, by) catch continue).tile; if (tl) |t| { served += 1; bytes += t.len; From 8aae6ab238a5c895ce2aaa2a0d2eb72bf1cbc064 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 06:20:10 -0400 Subject: [PATCH 103/140] =?UTF-8?q?feat(capi):=20the=20compose=20handle=20?= =?UTF-8?q?mirrors=20the=20chart's=20full=20output=20set=20=E2=80=94=20png?= =?UTF-8?q?/pdf/canvas/surface/query;=20chart=20gains=20tile57=5Ftile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything is bake, then compose (or bake, then render): a chart answers for ONE archive with no composition — tile57_tile hands back its stored tiles verbatim (the primitive for writing your own compositor) and tile57_png/pdf/ canvas/surface render it alone (renamed from tile57_render_*). The compositor offers the SAME outputs across the whole composed set: tile57_compose_png/pdf/ canvas/surface replay composed tiles through the S-52 pixel path (multi-cell views were previously impossible via C), and tile57_compose_query picks across seams. Engine: renderComposeView/renderComposeSurfaceView/composeQueryPoint over a ComposeSource; the view symbol-store setup deduplicated into viewSymbolStore. Verified end-to-end: single-chart PNG, raw tile access, composed two-cell PNG, composed pick over known content. Co-Authored-By: Claude Fable 5 --- build.zig | 1 + include/tile57.h | 129 +++++++++++++++++++++++------------ src/capi.zig | 144 +++++++++++++++++++++++++++++++++++---- src/chart.zig | 172 ++++++++++++++++++++++++++++++++++++----------- 4 files changed, 350 insertions(+), 96 deletions(-) diff --git a/build.zig b/build.zig index 95063d2..e862a3e 100644 --- a/build.zig +++ b/build.zig @@ -469,6 +469,7 @@ pub fn build(b: *std.Build) void { }); chart_mod.addImport("style", style_mod); // linestyle XML analysis chart_mod.addImport("coverage", coverage_mod); // embedded-coverage attach on PMTiles opens + chart_mod.addImport("compose", compose_mod); // compose-backed view renders const bake_target = if (target.result.os.tag == .linux and target.result.abi != .musl) b.resolveTargetQuery(.{ .cpu_arch = target.result.cpu.arch, .os_tag = .linux, .abi = .musl }) diff --git a/include/tile57.h b/include/tile57.h index 8781f27..91f39bf 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -5,7 +5,10 @@ * those archives on demand, renders finished charts to pixels / PDF / a callback * surface, and generates the matching S-52 MapLibre style + portrayal assets. * - * The pipeline is three stages, and the header is organised the same way: + * Everything is BAKE, THEN COMPOSE (or bake, then render): source cells bake + * once to per-cell archives; every output — a tile, a PNG, a PDF, a callback + * stream — is produced from baked archives. The header is organised the same + * way: * * 3. BAKE ENC source data in, per-cell PMTiles out. Each cell bakes to its * own archive at its own compilation scale, with its M_COVR @@ -14,14 +17,16 @@ * (cell inventory, feature extraction, exchange-set catalogue). * * 4. RENDER a `tile57` chart handle opens ONE baked archive (mmap'd from a - * path, or copied from bytes) and answers for it: metadata - * (info / scamin / coverage), the cursor pick (query), and full - * view renders (PNG / PDF / callback canvas / world-space - * surface). + * path, or copied from bytes) and answers for it with NO + * composition: metadata (info / scamin / coverage), its stored + * tiles verbatim (tile57_tile — the primitive for writing your + * own compositor), the cursor pick (query), and view outputs + * (png / pdf / canvas / surface). * - * 5. COMPOSE a `tile57_compose` handle stitches MANY open charts into one - * seamless tile pyramid on demand, via the cell-ownership - * partition — what a live tile server hands its HTTP layer. + * 5. COMPOSE a `tile57_compose` handle stitches MANY open charts through the + * cell-ownership partition and offers the SAME output set, + * composed: tile (what a live tile server hands its HTTP layer), + * png, pdf, canvas, surface, query. * * Section 6 (style + portrayal assets) turns the mariner's S-52 display options * into a concrete MapLibre style JSON plus the colortables / sprite / pattern / @@ -253,21 +258,22 @@ tile57_status tile57_bake_partition_debug(const char *enc_root, const char *out_ * 4. Render * * A `tile57` is a chart: ONE baked PMTiles archive, opened for metadata and - * rendering. Open it from a path (mmap'd — a whole chart library can be open - * without being resident) or from bytes (copied). It reports the archive's - * zoom range / bounds / tile encoding, the coverage + compilation scale the - * bake embedded, and the live SCAMIN manifest; it answers the S-52 cursor - * pick; and it renders any VIEW (centre + fractional zoom + pixel size) as a - * finished PNG, a vector PDF, resolved pixel-space draw calls, or a - * world-space semantically-tagged stream. + * output — with NO composition (the compositor, section 5, offers the same + * outputs across many charts). Open it from a path (mmap'd — a whole chart + * library can be open without being resident) or from bytes (copied). It + * reports the archive's zoom range / bounds / tile encoding, the coverage + + * compilation scale the bake embedded, and the live SCAMIN manifest; hands + * back its stored tiles verbatim (the primitive an embedder's own compositor + * consumes); answers the S-52 cursor pick; and renders any VIEW (centre + + * fractional zoom + pixel size) as a finished PNG, a vector PDF, resolved + * pixel-space draw calls, or a world-space semantically-tagged stream. * - * Rendering REPLAYS the archive's baked tiles through the native S-52 pixel - * path: one whole scene across every covering tile, labels decluttered over - * the full canvas (no tile seams), symbols replayed as vectors from the - * catalogue. The mariner's live-swappable settings — colour scheme, safety - * contour danger/sounding swaps, category/SCAMIN/text gates, size scale — - * re-evaluate at render time; the rest of the portrayal context was fixed at - * bake time. + * Rendering REPLAYS baked tiles through the native S-52 pixel path: one whole + * scene across every covering tile, labels decluttered over the full canvas + * (no tile seams), symbols replayed as vectors from the catalogue. The + * mariner's live-swappable settings — colour scheme, safety contour + * danger/sounding swaps, category/SCAMIN/text gates, size scale — re-evaluate + * at render time; the rest of the portrayal context was fixed at bake time. * ======================================================================== */ /* Opaque chart handle: one open baked archive. */ @@ -328,6 +334,14 @@ typedef struct { tile57_status tile57_coverage(tile57 *chart, const tile57_coverage_cb *cb, tile57_error *err); +/* The chart's own stored tile at (z,x,y), decompressed (MLT or MVT per + * tile57_info.tile_type), with NO composition — the per-archive primitive for + * an embedder writing its own compositor. TILE57_OK with the bytes in *out / + * *out_len (free with tile57_free); NULL/0 when the archive has no tile + * there. */ +tile57_status tile57_tile(tile57 *chart, uint8_t z, uint32_t x, uint32_t y, + uint8_t **out, size_t *out_len, tile57_error *err); + /* Cursor object-query (S-52 §10.8 pick): feature() is invoked once per feature * the point (lon,lat) falls in — area point-in-polygon, line/point within a * small radius — with the S-57 object-class acronym, the attribute JSON @@ -401,18 +415,18 @@ void tile57_mariner_defaults(tile57_mariner *m); * TILE57_ERR_BADARG. */ /* The view as a PNG, in *out / *out_len (free with tile57_free). */ -tile57_status tile57_render_view(tile57 *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_png(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); /* The view as a deterministic single-page vector PDF (1 px = 1 pt, 72 dpi; * vector fills + native strokes + glyph-outline text), in *out / *out_len * (free with tile57_free). */ -tile57_status tile57_render_pdf(tile57 *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_pdf(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); /* ---- callback Canvas: the pixel-space callback twin ------------------------ * The same view painted through a table of C function pointers instead of @@ -444,10 +458,10 @@ typedef struct { void (*draw_glyphs) (void *ctx, const tile57_rings *outline, tile57_rgba color, tile57_rgba halo, float halo_px); } tile57_canvas_cb; -tile57_status tile57_render_view_cb(tile57 *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - const tile57_canvas_cb *canvas, tile57_error *err); +tile57_status tile57_canvas(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_canvas_cb *canvas, tile57_error *err); /* ---- world-space Surface callback: the GPU vector twin --------------------- * The same view emitted as a WORLD-SPACE, semantically TAGGED stream rather @@ -511,10 +525,10 @@ typedef struct { void (*draw_text_str)(void *ctx, const tile57_feature *f, tile57_world_point anchor, float ox_px, float oy_px, const char *text, size_t text_len, float size_px, tile57_rgba color, tile57_rgba halo); } tile57_surface_cb; -tile57_status tile57_render_surface_cb(tile57 *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - const tile57_surface_cb *surface, tile57_error *err); +tile57_status tile57_surface(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); /* Release a chart and all cached tiles. Must not be called while any borrower * (a compositor, a renderer thread) may still read from it. */ @@ -523,11 +537,13 @@ void tile57_close(tile57 *chart); /* ======================================================================== * * 5. Compose * - * The runtime compositor: MANY open charts in, one seamless tile pyramid out. - * It builds (or loads) the cell-ownership partition over the charts' embedded - * coverage, then composes any (z, x, y) on demand for the cost of a classify - * plus one decompress or one decode/clip — what a live tile server hands its - * HTTP layer. Open once, serve many, close. + * The runtime compositor: MANY open charts in, one seamless chart out. It + * builds (or loads) the cell-ownership partition over the charts' embedded + * coverage, then offers the SAME output set as a single chart, composed: + * any (z, x, y) tile on demand for the cost of a classify plus one decompress + * or one decode/clip (what a live tile server hands its HTTP layer), plus the + * composed view outputs (png / pdf / canvas / surface) and the composed cursor + * pick (query). Open once, serve many, close. * * The compositor BORROWS its charts (their mmap'd archives and decoded * coverage): every chart must outlive the compositor, and while it serves, do @@ -569,6 +585,33 @@ tile57_status tile57_compose_tile(tile57_compose *c, uint8_t z, uint32_t x, uint uint8_t **out, size_t *out_len, bool *out_owned, tile57_error *err); +/* The composed view outputs — tile57_png / tile57_pdf / tile57_canvas / + * tile57_surface across the WHOLE composed set: every covering tile is + * composed on demand (seams stitched through the ownership partition) and + * replayed through the native S-52 pixel path. Same parameters, limits, and + * ownership as the single-chart forms in section 4. */ +tile57_status tile57_compose_png(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_compose_pdf(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_compose_canvas(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_canvas_cb *canvas, tile57_error *err); +tile57_status tile57_compose_surface(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); + +/* The composed cursor pick (S-52 §10.8, seams included): tile57_query across + * the whole composed set. */ +tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, double zoom, + const tile57_query_cb *cb, tile57_error *err); + /* Fill *out with the compositor's zoom range + union coverage bounds. */ void tile57_compose_meta_get(tile57_compose *c, tile57_compose_meta *out); diff --git a/src/capi.zig b/src/capi.zig index 9ecad85..28bedfa 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -491,6 +491,19 @@ export fn tile57_coverage(handle: ?*Chart, cb: ?*const CCoverageCb, err: ?*CErro return OK; } +/// The chart's own stored tile at (z,x,y), decompressed (MLT or MVT per +/// tile57_info.tile_type), with NO composition — the per-archive primitive an +/// embedder's own compositor consumes. NULL/0 out when the archive has no tile +/// there. See tile57.h. +export fn tile57_tile(handle: ?*Chart, z: u8, x: u32, y: u32, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const c = handle orelse return failWith(err, .badarg, "chart must not be null"); + const rd = c.pmtilesReader() orelse return failWith(err, .badarg, "chart is not archive-backed"); + const bytes = (rd.getTile(gpa, z, x, y) catch |e| return fail(err, e)) orelse return OK; + setBytes(o, n, bytes); + return OK; +} + const CQueryCb = @import("render").query.QueryCb; /// Cursor object-query at (lon,lat) for the view `zoom` (web-mercator): invokes @@ -517,11 +530,12 @@ fn paletteOf(settings: *const mariner.Settings) RenderPalette { const MAX_RENDER_PX = 16384; const bad_size = "width/height must be 1..16384"; -/// Render a VIEW of the chart (centre + fractional zoom + pixel size) to PNG: -/// the archive's baked tiles replayed through the native S-52 pixel path — one -/// scene across every covering tile, labels decluttered over the whole canvas. +/// Render a VIEW of this ONE chart (centre + fractional zoom + pixel size) to +/// PNG: the archive's baked tiles replayed through the native S-52 pixel path — +/// one scene across every covering tile, labels decluttered over the whole +/// canvas. No composition (see tile57_compose_png for the composed twin). /// `m` NULL = defaults. See tile57.h. -export fn tile57_render_view( +export fn tile57_png( handle: ?*Chart, lon: f64, lat: f64, @@ -543,9 +557,9 @@ export fn tile57_render_view( return OK; } -/// tile57_render_view's vector twin: the SAME scene as a deterministic single-page -/// PDF (1 px = 1 pt, 72 dpi; vector fills, native strokes, glyph-outline text). -export fn tile57_render_pdf( +/// tile57_png's vector twin: the SAME scene as a deterministic single-page PDF +/// (1 px = 1 pt, 72 dpi; vector fills, native strokes, glyph-outline text). +export fn tile57_pdf( handle: ?*Chart, lon: f64, lat: f64, @@ -569,10 +583,10 @@ export fn tile57_render_pdf( const CbCanvas = @import("render").cb_canvas.CCanvas; -/// tile57_render_view's callback twin: the SAME view painted through the C -/// callback table `canvas` (see tile57.h) instead of rasterising. Geometry in -/// canvas PIXEL space (y down), paint order, palette-resolved colours. -export fn tile57_render_view_cb( +/// tile57_png's callback twin: the SAME view painted through the C callback +/// table `canvas` (see tile57.h) instead of rasterising. Geometry in canvas +/// PIXEL space (y down), paint order, palette-resolved colours. +export fn tile57_canvas( handle: ?*Chart, lon: f64, lat: f64, @@ -599,7 +613,7 @@ const CSurface = @import("render").vector.CSurface; /// (areas/lines in web-mercator [0,1]; symbols/text as a world anchor + local /// reference-px outline; per-feature class + SCAMIN) to the C surface callback /// `surface` (see tile57.h). Pan/zoom re-portray nothing on the host. -export fn tile57_render_surface_cb( +export fn tile57_surface( handle: ?*Chart, lon: f64, lat: f64, @@ -722,6 +736,112 @@ export fn tile57_compose_tile( return OK; } +/// Render a VIEW over the compositor to PNG — the composed twin of tile57_png: +/// every covering tile is composed on demand (seams stitched through the +/// ownership partition) and replayed through the native S-52 pixel path. See +/// tile57.h. +export fn tile57_compose_png( + handle: ?*compose.ComposeSource, + lon: f64, + lat: f64, + zoom: f64, + width: u32, + height: u32, + m: ?*const CMariner, + out: ?*?[*]u8, + out_len: ?*usize, + err: ?*CError, +) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); + if (width == 0 or height == 0 or width > MAX_RENDER_PX or height > MAX_RENDER_PX) + return failWith(err, .badarg, bad_size); + const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; + const bytes = chart.renderComposeView(src, lon, lat, zoom, width, height, paletteOf(&settings), &settings, .png, null) catch |e| return fail(err, e); + setBytes(o, n, bytes); + return OK; +} + +/// tile57_compose_png's vector twin: the SAME composed scene as a deterministic +/// single-page PDF. See tile57.h. +export fn tile57_compose_pdf( + handle: ?*compose.ComposeSource, + lon: f64, + lat: f64, + zoom: f64, + width: u32, + height: u32, + m: ?*const CMariner, + out: ?*?[*]u8, + out_len: ?*usize, + err: ?*CError, +) callconv(.c) c_int { + const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); + const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); + if (width == 0 or height == 0 or width > MAX_RENDER_PX or height > MAX_RENDER_PX) + return failWith(err, .badarg, bad_size); + const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; + const bytes = chart.renderComposeView(src, lon, lat, zoom, width, height, paletteOf(&settings), &settings, .pdf, null) catch |e| return fail(err, e); + setBytes(o, n, bytes); + return OK; +} + +/// tile57_compose_png's callback twin: the SAME composed view painted through the +/// C callback table `canvas` (pixel space, paint order). See tile57.h. +export fn tile57_compose_canvas( + handle: ?*compose.ComposeSource, + lon: f64, + lat: f64, + zoom: f64, + width: u32, + height: u32, + m: ?*const CMariner, + canvas: ?*const CbCanvas, + err: ?*CError, +) callconv(.c) c_int { + const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); + const cb = canvas orelse return failWith(err, .badarg, "canvas must not be null"); + if (width == 0 or height == 0 or width > MAX_RENDER_PX or height > MAX_RENDER_PX) + return failWith(err, .badarg, bad_size); + const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; + const bytes = chart.renderComposeView(src, lon, lat, zoom, width, height, paletteOf(&settings), &settings, .callback, cb) catch |e| return fail(err, e); + chart.freeBytes(bytes); // the callback path returns an empty buffer + return OK; +} + +/// The composed GPU vector twin: the SAME composed view emitted as a WORLD-SPACE +/// tagged stream to the C surface callback (see tile57.h). See tile57_surface for +/// the single-chart form. +export fn tile57_compose_surface( + handle: ?*compose.ComposeSource, + lon: f64, + lat: f64, + zoom: f64, + width: u32, + height: u32, + m: ?*const CMariner, + surface: ?*const CSurface, + err: ?*CError, +) callconv(.c) c_int { + const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); + const sfc = surface orelse return failWith(err, .badarg, "surface must not be null"); + if (width == 0 or height == 0 or width > MAX_RENDER_PX or height > MAX_RENDER_PX) + return failWith(err, .badarg, bad_size); + const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; + chart.renderComposeSurfaceView(src, lon, lat, zoom, width, height, paletteOf(&settings), &settings, sfc) catch |e| return fail(err, e); + return OK; +} + +/// Cursor object-query over the composed set (the S-52 pick, seams included): +/// invokes cb->feature once per displayed feature the point falls in. See +/// tile57.h. +export fn tile57_compose_query(handle: ?*compose.ComposeSource, lon: f64, lat: f64, zoom: f64, cb: ?*const CQueryCb, err: ?*CError) callconv(.c) c_int { + const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); + const cbp = cb orelse return failWith(err, .badarg, "cb must not be null"); + chart.composeQueryPoint(src, lon, lat, zoom, cbp) catch |e| return fail(err, e); + return OK; +} + /// Fill *out with the compositor's zoom range + union coverage bounds (zeroed when /// the handle is NULL). See tile57.h. export fn tile57_compose_meta_get(handle: ?*compose.ComposeSource, out: ?*CComposeMeta) callconv(.c) void { diff --git a/src/chart.zig b/src/chart.zig index 2e89817..b1afb05 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -18,6 +18,7 @@ const std = @import("std"); const pmtiles = @import("tiles").pmtiles; +const mlt = @import("tiles").mlt; const gzip = @import("tiles").gzip; const s57 = @import("s57"); const scene = @import("scene"); @@ -30,6 +31,7 @@ const sprite = @import("sprite"); const embedded_assets = @import("catalog"); // S-101 portrayal assets (renderView store) const style = @import("style"); // displayDenomZ (the physical display-scale formula) const cell_coverage = @import("coverage"); // per-cell M_COVR coverage embedded in archive metadata +const compose_mod = @import("compose"); // the runtime compositor (compose-backed view renders) // smp_allocator (Zig's fast thread-safe GPA), not page_allocator: the engine // makes many small, short-lived allocations (tile cache, cell dupes, index @@ -818,6 +820,133 @@ fn newestInputNs(io: std.Io, in_path: []const u8) ?i96 { return newest; } +// Build the embedded-catalogue symbol + area-fill store for a view render (in `a`; +// deinit when done) and register the complex-linestyle table (idempotent, +// gpa-backed — the registry outlives the render, shared with any later bake). +fn viewSymbolStore(a: std.mem.Allocator, palette: render.resolve.PaletteId) !*sprite.CatalogStore { + const css_name = switch (palette) { + .day => "daySvgStyle", + .dusk => "duskSvgStyle", + .night => "nightSvgStyle", + }; + var css_data: []const u8 = ""; + for (embedded_assets.css) |e| { + if (std.mem.eql(u8, e.name, css_name)) css_data = e.bytes; + } + const sym_srcs = try a.alloc(sprite.SvgSrc, embedded_assets.symbols.len); + for (embedded_assets.symbols, 0..) |e, i| sym_srcs[i] = .{ .id = e.name, .svg = e.bytes }; + const fill_srcs = try a.alloc(sprite.AreaFillSrc, embedded_assets.areafills.len); + for (embedded_assets.areafills, 0..) |e, i| fill_srcs[i] = .{ .id = e.name, .xml = e.bytes }; + const store = try sprite.CatalogStore.init(a, sym_srcs, fill_srcs, css_data); + + var ls_srcs = std.ArrayList(style.LineStyleSrc).empty; + defer ls_srcs.deinit(gpa); + for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; + scene.linestyle.registerLinestylesXml(gpa, ls_srcs.items); + return store; +} + +// ---- compose-backed view renders -------------------------------------------- +// +// The compositor is the tile source; these are its VIEW backends: compose every +// covering tile on demand (seams stitched through the ownership partition) and +// replay it through the native S-52 pixel path — the same scene Chart.renderView +// replays from a single archive, but across the whole composed set. + +/// Render a VIEW over a runtime compositor to PNG / PDF / a callback canvas +/// (per `output`; bytes are gpa-owned, freeBytes — empty for the callback +/// output). The mariner's live-swappable settings evaluate at render time. +pub fn renderComposeView(src: *compose_mod.ComposeSource, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, output: render.pixel.Output, cb_table: ?*const render.cb_canvas.CCanvas) ![]u8 { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + + var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); + const store = try viewSymbolStore(a, palette); + defer store.deinit(); + + const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); + var ps = render.pixel.PixelSurface.initView(a, &colors, palette, settings, zoom, w, h, pt, tile.EXTENT); + ps.store = store.asStore(); + ps.output = output; + ps.cb = cb_table; + + var vt = scene.ViewTiles.init(lon, lat, zoom, w, h, pt); + const surf = ps.asSurface(); + try surf.beginScene(vt.z); + while (vt.next()) |t| { + const res = src.tile(a, t.z, t.x, t.y) catch continue; + const bytes = res.tile orelse continue; + const layers = mlt.decode(a, bytes) catch continue; // the compositor serves raw MLT + ps.setOrigin(t.origin_x, t.origin_y); + scene.replayTile(a, surf, layers) catch return error.TileGen; + } + return surf.endScene(gpa) catch error.TileGen; +} + +/// renderComposeView's GPU-vector twin: the SAME composed view emitted as a +/// WORLD-SPACE tagged stream to the C surface callback (see render/vector.zig). +pub fn renderComposeSurfaceView(src: *compose_mod.ComposeSource, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, cb: *const render.vector.CSurface) !void { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + + var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); + const store = try viewSymbolStore(a, palette); + defer store.deinit(); + + const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); + var vs = render.vector.VectorSurface.init(a, &colors, palette, settings, cb); + vs.store = store.asStore(); + vs.view_zoom = zoom; // scale at which labels/symbols declutter + + var vt = scene.ViewTiles.init(lon, lat, zoom, w, h, pt); + const surf = vs.asSurface(); + try surf.beginScene(vt.z); + while (vt.next()) |t| { + const res = src.tile(a, t.z, t.x, t.y) catch continue; + const bytes = res.tile orelse continue; + const layers = mlt.decode(a, bytes) catch continue; + vs.setTile(t.z, t.x, t.y); + scene.replayTile(a, surf, layers) catch continue; + } + _ = try surf.endScene(a); +} + +/// Cursor object-query over a runtime compositor (the S-52 §10.8 pick across the +/// whole composed set — seams included): replay the composed tile covering +/// (lon,lat) at the view zoom through a QuerySurface and report each feature the +/// point falls in via `cb`. +pub fn composeQueryPoint(src: *compose_mod.ComposeSource, lon: f64, lat: f64, zoom: f64, cb: *const render.query.QueryCb) !void { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + // Query the tile at the VIEW zoom, clamped into the served range: its features + // are already SCAMIN-bucketed to what's displayed and the pick radius (tile + // units) maps to a constant on-screen distance. + const zc = std.math.clamp(@round(zoom), @as(f64, @floatFromInt(src.minz)), @as(f64, @floatFromInt(src.loop_max))); + const z: u8 = @intFromFloat(zc); + const world = tile.lonLatToWorld(lon, lat); + const n = std.math.exp2(@as(f64, @floatFromInt(z))); + const tx: u32 = @intFromFloat(@floor(world[0] * n)); + const ty: u32 = @intFromFloat(@floor(world[1] * n)); + const local = tile.project(lon, lat, z, tx, ty, tile.EXTENT); + var qs = render.query.QuerySurface{ + .qx = @floatFromInt(local.x), + .qy = @floatFromInt(local.y), + .radius = 96.0, // ~6 px at native tile scale + .view_zoom = zoom, // raw view zoom for the SCAMIN cull + .cb = cb, + }; + const surf = qs.asSurface(); + try surf.beginScene(z); + const res = src.tile(a, z, tx, ty) catch return; + const bytes = res.tile orelse return; + const layers = mlt.decode(a, bytes) catch return; + scene.replayTile(a, surf, layers) catch return; + _ = surf.endScene(a) catch {}; +} + /// The metadata JSON blob of a PMTiles archive (decompressed), duped into `a`, or null /// if the archive carries none. For a host to read the embedded scamin / coverage /// without a full open. This engine writes metadata uncompressed; gzip is handled for @@ -1282,30 +1411,9 @@ pub const Chart = struct { const a = arena.allocator(); var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); - const css_name = switch (palette) { - .day => "daySvgStyle", - .dusk => "duskSvgStyle", - .night => "nightSvgStyle", - }; - var css_data: []const u8 = ""; - for (embedded_assets.css) |e| { - if (std.mem.eql(u8, e.name, css_name)) css_data = e.bytes; - } - const sym_srcs = try a.alloc(sprite.SvgSrc, embedded_assets.symbols.len); - for (embedded_assets.symbols, 0..) |e, i| sym_srcs[i] = .{ .id = e.name, .svg = e.bytes }; - const fill_srcs = try a.alloc(sprite.AreaFillSrc, embedded_assets.areafills.len); - for (embedded_assets.areafills, 0..) |e, i| fill_srcs[i] = .{ .id = e.name, .xml = e.bytes }; - const store = try sprite.CatalogStore.init(a, sym_srcs, fill_srcs, css_data); + const store = try viewSymbolStore(a, palette); defer store.deinit(); - // Complex-linestyle table (idempotent): without it MARSYS51/cable/ - // pipeline styles degrade to generic dashed strokes. gpa-backed: the - // registry outlives this render (shared with any later bake). - var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; - defer ls_srcs.deinit(gpa); - for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; - scene.linestyle.registerLinestylesXml(gpa, ls_srcs.items); - // Continuous scaling between integer zooms; the host applies physical // calibration / @2x via settings.size_scale. const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); @@ -1360,27 +1468,9 @@ pub const Chart = struct { const a = arena.allocator(); var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); - const css_name = switch (palette) { - .day => "daySvgStyle", - .dusk => "duskSvgStyle", - .night => "nightSvgStyle", - }; - var css_data: []const u8 = ""; - for (embedded_assets.css) |e| { - if (std.mem.eql(u8, e.name, css_name)) css_data = e.bytes; - } - const sym_srcs = try a.alloc(sprite.SvgSrc, embedded_assets.symbols.len); - for (embedded_assets.symbols, 0..) |e, i| sym_srcs[i] = .{ .id = e.name, .svg = e.bytes }; - const fill_srcs = try a.alloc(sprite.AreaFillSrc, embedded_assets.areafills.len); - for (embedded_assets.areafills, 0..) |e, i| fill_srcs[i] = .{ .id = e.name, .xml = e.bytes }; - const store = try sprite.CatalogStore.init(a, sym_srcs, fill_srcs, css_data); + const store = try viewSymbolStore(a, palette); defer store.deinit(); - var ls_srcs = std.ArrayList(@import("style").LineStyleSrc).empty; - defer ls_srcs.deinit(gpa); - for (embedded_assets.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; - scene.linestyle.registerLinestylesXml(gpa, ls_srcs.items); - const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); var vs = render.vector.VectorSurface.init(a, &colors, palette, settings, cb); vs.store = store.asStore(); From 9895f23714d2fa7d929019760abae940199fc2cd Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 06:29:17 -0400 Subject: [PATCH 104/140] docs: every page reviewed against the v0.2 surface; API style codified in CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bake-then-compose framing throughout; the Zig API page is organized by the API sections with per-open capability tables; the composed view renders surface as tile57.compose.renderView/renderSurfaceView/queryPoint (the public root curates the namespace over the module layering); stale claims fixed — the C ABI DOES expose the Canvas and Surface callback seats, streaming opens serve metadata and extraction rather than tiles, and the live-portrayal caveats say exactly which sources run the rules live. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 77 ++++++++++++++++++++++++++ docs/docs/architecture.md | 40 ++++++++------ docs/docs/c-api.md | 102 +++++++++++++++++++++++----------- docs/docs/getting-started.md | 31 ++++++----- docs/docs/intro.md | 16 +++--- docs/docs/limitations.md | 21 ++++--- docs/docs/rendering.md | 46 ++++++++++------ docs/docs/zig-api.md | 104 ++++++++++++++++++++++------------- src/tile57.zig | 30 ++++++++-- 9 files changed, 327 insertions(+), 140 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b1c9e84 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# tile57 — engine conventions + +## The API style + +The public surface (include/tile57.h, src/tile57.zig, bindings/go) follows one +style. Hold every new or renamed symbol against it. + +**The pipeline is the organizing principle.** Everything is bake, then compose +(or bake, then render): source cells bake once to per-cell archives; every +output is produced from baked archives. Sections, in order: Version, Errors, +Bake, Render (the chart), Compose, Style, Util. The header, the Zig public +root, and the docs all follow that same section order. + +**Handles are nouns; the chart is the namesake.** `tile57` is one open baked +archive (the sqlite3 pattern: the library's name is its central handle). +Further handles extend the prefix: `tile57_compose`. Handle lifecycle verbs +attach to the handle name: `tile57_open` / `tile57_open_bytes` / +`tile57_close`; `tile57_compose_open` / `tile57_compose_close`. + +**Outputs are named by what you get, never by how it's produced or served.** +`tile`, `png`, `pdf`, `canvas`, `surface`, `query` — not serve/render/build/ +generate. The chart and the compositor offer the SAME output set, so the names +mirror exactly: `tile57_png` ↔ `tile57_compose_png`, `tile57_tile` ↔ +`tile57_compose_tile`, and so on. The compositor is "many charts in, one chart +out"; keep that symmetry when adding an output — it goes on both handles or +has a stated reason not to. + +**Families are noun-first prefixes.** `tile57_style_build` / `_style_diff` / +`_style_template` (never `build_style`); `tile57_bake_*` for the import +pipeline; `tile57_enc_*` for the handle-free raw S-57 source readers. ENC is +the ONE vocabulary for raw source data (enc_root, tile57_enc_cells) — never +s57_/source_/scan_ variants of the same idea. + +**The status model is universal.** Every call that can fail returns +`tile57_status` (never a bare int, count, or bool) and takes an optional +caller-owned `tile57_error*` as its LAST parameter. Out-parameters come after +inputs and are ALWAYS defined on return — the result on TILE57_OK, NULL/0 +otherwise. "Nothing produced" is NOT a failure: OK with a NULL/zero out. Every +pointer argument is BADARG-checked (no NULL derefs across the ABI). Counts and +flags come back through optional out-pointers (NULL to ignore), not return +values. Say "call that can fail" in docs, not "fallible". + +**Memory has one rule.** Calls that return bytes allocate `*out`; the caller +releases with `tile57_free(ptr, len)` — the same length. POD across the seam: +no Zig errors, slices, or optionals cross the ABI. + +**Ownership and borrowing are explicit.** The compositor BORROWS its charts +(charts outlive it; close the compositor first). A path-opened chart mmaps its +file (the file stays in place). No handle is internally synchronized; document +lifetime + threading on every handle-producing call. + +**The Zig public root is curated, not a module mirror.** src/tile57.zig shapes +the public namespace by the API sections; when module layering puts an +implementation elsewhere (e.g. composed view renders live beside Chart because +the `compose` module is a dependency leaf without the render path), the root +still surfaces it under the name it belongs to (`tile57.compose.renderView`). +Public surface trumps internal layering. + +**Go bindings track shape, not spelling.** Idiomatic Go names over the same +structure: `Open`/`OpenBytes` on the archive, package-level `Cells`/`Features`/ +`FeaturesBytes`/`CatalogEntries` for the enc readers, `ComposeSource.Tile` +mirroring `tile57_compose_tile`, errors as wrapped sentinels +(`errors.Is(err, tile57.ErrParse)`). + +## Other repo rules + +- Never reference specs/*.md in code comments (specs/ is never committed); + describe designs inline. +- Docs read standalone: present-tense capability statements, no prior-version + framing ("now supports", "anymore"), no "plain language", no "fallible", no + "seam" in user-facing docs. +- Never `git add -A` (vendor/ submodules; charts/ + test/ hold huge untracked + local data). Stage explicit paths. Commit after every change. +- Never hardcode machine-specific paths in code or tests; real-cell paths come + from args/env. +- Validate conversion/portrayal changes on real ENC_ROOT cells, not just unit + tests. diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index ccb4cd7..642bd3b 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -123,19 +123,24 @@ the same Zig API as the `tile57` module. The public surface composes the packages into high-level entry points: -- **`Chart`** — open a chart from a path (`openPath`), from bytes (`openBytes`), or - as a multi-cell ENC_ROOT (`openCells` / `openCellsStreaming`), then render and - inspect it: `renderView` (PNG or PDF), `renderSurfaceView` (world-space GPU - callbacks), `queryPoint` (the cursor pick), and the metadata getters. It reads a - pre-baked **PMTiles** archive or portrays a live cell — the caller can't tell the - difference. (`tile57_render_view` / `tile57_render_pdf` / - `tile57_render_surface_cb` / `tile57_query` in the C ABI.) +- **`Chart`** — ONE open chart, no composition. Open a baked **PMTiles** archive + (`openPmtilesPath`, mmap'd; `openBytes`) or a live cell (`openBytes` on `.000` + bytes), then take its outputs: view renders (`renderView` — PNG, PDF, or a + callback canvas; `renderSurfaceView` — world-space GPU callbacks), the cursor + pick (`queryPoint`), the metadata getters, and — for an archive — its stored + tiles verbatim through `pmtilesReader()`. In the C ABI: `tile57_tile` / + `tile57_png` / `tile57_pdf` / `tile57_canvas` / `tile57_surface` / + `tile57_query`. A streaming ENC_ROOT open (`openPath` / `openCells` / + `openCellsStreaming`) is the metadata + extraction view of raw source data + (the C `tile57_enc_*` readers); it serves no tiles or renders. - **Tile production** — bake each cell to its own PMTiles at its compilation scale (`tile57_bake_cell_bytes`, which runs the banded bake engine `scene/bake_enc.zig` on a single cell), then a runtime **compositor** stitches the overlapping cells - for any `(z, x, y)` tile on demand through an ownership partition - (`tile57_compose_open` / `tile57_compose_tile`). The public Zig `bakeArchive` - runs the same engine over a slice of cells to make one merged archive. + through an ownership partition and offers the SAME outputs as a chart, composed: + `tile57_compose_tile` for any `(z, x, y)` on demand, `tile57_compose_png` / + `_pdf` / `_canvas` / `_surface` / `_query` for composed views and the composed + pick. The public Zig `bakeArchive` runs the same engine over a slice of cells to + make one merged archive. - **`style.build`** (`style/maplibre.zig`) + **`style`** / **`sprite`** — generate the MapLibre style and the portrayal assets it references (`tile57_style_build` / `tile57_bake_assets` in the C ABI). @@ -144,13 +149,14 @@ The public surface composes the packages into high-level entry points: tile57 is built to hold only its working set: -- **Lazy per-cell.** An ENC_ROOT is opened by scanning each cell's header for a - cheap spatial index (band + bbox). A cell is parsed and portrayed only when a - requested tile needs it; recently used cells are held in an LRU and evicted - under bound. The first tile over a fresh area pays a parse/portray cost (tens of - ms); after that it is cached. -- **Best-available band per tile.** Overlapping cells of different compilation - scales are resolved per tile to the best band, not all overlaid blindly. +- **Lazy per-cell reads.** An ENC_ROOT is opened by scanning each cell's header + for a cheap spatial index (band + bbox). A cell's bytes are read and parsed + only when a metadata or feature query needs them, then released under an LRU + bound. +- **Ownership, not overlays.** Overlapping cells of different compilation scales + are resolved by the precomputed ownership partition — each tile's ground + belongs to exactly one cell per band, so composing never loads every + overlapping cell. - **Streaming open.** `openCellsStreaming` (and its on-disk driver `openPath`, which backs the C `tile57_enc_*` readers) take per-cell metadata (bbox + scale) plus a reader; a cell's bytes are read only on demand and freed on eviction. A diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index edf9fc1..0e9bf81 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -18,12 +18,17 @@ same way: embedded in the archive metadata. The bake section also carries the raw-source readers (cell inventory, feature extraction, exchange-set catalogue). - **Render** — a **`tile57`** chart handle opens ONE baked archive and answers - for it: metadata (info / SCAMIN / coverage), the S-52 cursor pick, and full - view renders (PNG / PDF / callback canvas / world-space surface). -- **Compose** — a **`tile57_compose`** handle stitches MANY open charts into one - seamless tile pyramid on demand through the cell-ownership partition — what a - live tile server hands its HTTP layer. The composed bytes are MapLibre Tiles - (MLT). + for it with NO composition: metadata (info / SCAMIN / coverage), its stored + tiles verbatim (`tile57_tile` — the primitive for writing your own + compositor), the S-52 cursor pick, and view outputs (`tile57_png` / `_pdf` / + `_canvas` / `_surface`). +- **Compose** — a **`tile57_compose`** handle stitches MANY open charts through + the cell-ownership partition and offers the SAME output set, composed: + `tile57_compose_tile` (what a live tile server hands its HTTP layer — the + bytes are MapLibre Tiles), `_png`, `_pdf`, `_canvas`, `_surface`, `_query`. + +Everything is **bake, then compose** (or bake, then render): source cells bake +once to per-cell archives; every output is produced from baked archives. Style + portrayal-asset generation rounds out the surface: the mariner's S-52 display options become a concrete MapLibre style JSON plus the colortables and @@ -161,8 +166,9 @@ The CLI mirrors these as `tile57 cells`, `tile57 features`, and ## Render: the `tile57` chart handle A `tile57` is a chart: ONE baked PMTiles archive, opened for metadata and -rendering. Open it from a path (mmap'd — a whole chart library can be open -without being resident) or from bytes (copied). +output — with no composition (the compositor below offers the same outputs +across many charts). Open it from a path (mmap'd — a whole chart library can +be open without being resident) or from bytes (copied). ```c const char *tile57_version(void); /* "0.2.0" */ @@ -211,6 +217,13 @@ typedef struct { tile57_status tile57_coverage(tile57 *chart, const tile57_coverage_cb *cb, tile57_error *err); +/* The chart's own stored tile at (z,x,y), decompressed (MLT or MVT per + * tile57_info.tile_type), with NO composition — the per-archive primitive for + * an embedder writing its own compositor. NULL/0 when the archive has no tile + * there. */ +tile57_status tile57_tile(tile57 *chart, uint8_t z, uint32_t x, uint32_t y, + uint8_t **out, size_t *out_len, tile57_error *err); + /* Release a chart and all cached tiles (not while a compositor still holds it). */ void tile57_close(tile57 *chart); ``` @@ -248,7 +261,7 @@ The class and cell come through for any chart; the attribute JSON is filled in from the `s57` pick property baked into the tiles (empty if a chart was baked without pick attributes). -### Render a finished view (PNG / PDF) +### Render a finished view (PNG / PDF), one chart The [native S-52 rendering engine](./rendering.md) draws a view of the chart — centre + fractional zoom + pixel size — by replaying the archive's baked tiles @@ -264,19 +277,23 @@ with the [style builders](#build-a-maplibre-style) below. ```c /* PNG raster in *out/*out_len (free with tile57_free). */ -tile57_status tile57_render_view(tile57 *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_png(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); /* Its vector twin: the SAME scene as a deterministic single-page PDF * (1 px = 1 pt, 72 dpi; vector fills + glyph-outline text). */ -tile57_status tile57_render_pdf(tile57 *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_pdf(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); ``` +The composed twins — `tile57_compose_png` / `tile57_compose_pdf`, same +parameters over a `tile57_compose` — render the same view across the WHOLE +composed set (see below). + ### Render to a host surface (vector callbacks) Instead of a finished raster, tile57 can hand you the portrayed scene as a stream @@ -284,8 +301,9 @@ of draw calls in world space. A GPU host tessellates that stream once, then pans and zooms by transforming the vertices each frame, so symbols and text stay a constant size on screen and no re-portrayal is needed while the view moves. -You fill in a `tile57_surface_cb` vtable and pass it to -`tile57_render_surface_cb`. Area and line geometry come in web-mercator world +You fill in a `tile57_surface_cb` vtable and pass it to `tile57_surface` (or +`tile57_compose_surface` for the composed set). Area and line geometry come in +web-mercator world coordinates (the range 0 to 1, with y pointing down). Point symbols, soundings, and text come as a world anchor plus a small outline in reference pixels, so you can draw them at a fixed size on screen. Every call carries the feature's SCAMIN, so you @@ -321,10 +339,10 @@ typedef struct { } tile57_surface_cb; /* Portray the view once and drive the callbacks. */ -tile57_status tile57_render_surface_cb(tile57 *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - const tile57_surface_cb *surface, tile57_error *err); +tile57_status tile57_surface(tile57 *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); ``` Set `draw_sprite` and `draw_pattern` once you have the sprite atlas loaded (see @@ -336,18 +354,21 @@ those two fields NULL, the same features arrive as vector outlines instead. tile57 also declutters overlapping text for you before it makes the calls (symbols and soundings always draw, per S-52), so you don't repeat that work. -There is a pixel-space twin, `tile57_render_view_cb` with a `tile57_canvas_cb` -vtable, that emits the SAME portrayal as resolved paint-order draw calls in canvas +There is a pixel-space twin, `tile57_canvas` with a `tile57_canvas_cb` vtable, +that emits the SAME portrayal as resolved paint-order draw calls in canvas pixels — for a host that wants the engine's own paint pipeline without the PNG -encode. +encode. Both callback forms have composed twins (`tile57_compose_canvas` / +`tile57_compose_surface`). -## Compose: many charts, one tile pyramid +## Compose: many charts, one chart The compositor builds (or loads) the cell-ownership partition over its charts' -embedded coverage, then composes any tile for the cost of a classify plus one -decompress or one decode/clip. It **borrows** the charts — their mmap'd archives -and decoded coverage — so the cell set is never fully resident and the charts -must outlive the compositor. Open once, serve many, close. +embedded coverage, then offers the SAME output set as a single chart, composed: +any tile on demand for the cost of a classify plus one decompress or one +decode/clip, plus the composed view outputs and the composed cursor pick. It +**borrows** the charts — their mmap'd archives and decoded coverage — so the +cell set is never fully resident and the charts must outlive the compositor. +Open once, serve many, close. ```c /* Opaque runtime-compositor handle. */ @@ -381,6 +402,25 @@ tile57_status tile57_compose_tile(tile57_compose *c, uint8_t z, uint32_t x, uint uint8_t **out, size_t *out_len, bool *out_owned, tile57_error *err); +/* The composed view outputs and pick — the section-4 calls across the WHOLE + * composed set: every covering tile is composed on demand (seams stitched + * through the ownership partition) and replayed through the S-52 pixel path. + * Same parameters, limits, and ownership as the single-chart forms. */ +tile57_status tile57_compose_png(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_compose_pdf(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_compose_canvas(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, const tile57_mariner *m, + const tile57_canvas_cb *canvas, tile57_error *err); +tile57_status tile57_compose_surface(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); +tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, double zoom, + const tile57_query_cb *cb, tile57_error *err); + /* Fill *out with the compositor's zoom range + union coverage bounds. */ void tile57_compose_meta_get(tile57_compose *c, tile57_compose_meta *out); diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 093ca1e..dfdea1a 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -105,11 +105,12 @@ tile57_close(chart); /* …so close them after it */ ``` Link against `libtile57.a`. The `partition.tpart` sidecar (NULL to skip) lets the -compositor load the ownership partition instead of rebuilding it. The same -`tile57` chart handle renders finished PNGs/PDFs, reads metadata (bounds, scale, -coverage, SCAMIN), and answers the cursor pick; raw S-57 reading (cell -inventory, feature extraction) is handle-free via `tile57_enc_*`. See the -[C API](./c-api.md). +compositor load the ownership partition instead of rebuilding it. The chart and +the compositor offer the same outputs — `tile57_png(chart, …)` renders one chart +alone; `tile57_compose_png(src, …)` renders the composed set; `tile57_tile` +hands back one chart's stored tiles verbatim for anyone writing their own +compositor. Raw S-57 reading (cell inventory, feature extraction) is +handle-free via `tile57_enc_*`. See the [C API](./c-api.md). ## 3. Use the engine from Zig @@ -118,18 +119,22 @@ Add tile57 as a dependency and `@import("tile57")`: ```zig const tile57 = @import("tile57"); -// Open an on-disk ENC_ROOT (or a single .000) for rendering + inspection. -var chart = try tile57.Chart.openPath("ENC_ROOT/", null, true); +// Open one baked archive as a chart (mmap'd): metadata, pick, view renders. +var chart = try tile57.Chart.openPmtilesPath(io, "out/tiles/US5MD1MC.pmtiles"); defer chart.deinit(); - const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null -// … render a view (chart.renderView), query features (chart.featuresJson), -// or read per-cell metadata (chart.cellsJson) … + +// Or compose the whole bake output and take any output from it. +var src = (try tile57.compose.openComposeSourceFiles(io, gpa, paths, "out/partition.tpart")).?; +defer src.deinit(); +const result = try src.tile(gpa, 15, 9371, 12534); // one composed tile +const png = try tile57.compose.renderView(src, -76.48, 38.974, 13.5, + 1600, 1200, .day, &settings, .png, null); // one composed view ``` -The Zig `Chart` renders views, queries features, and reads metadata. Tile -production (bake each cell, then compose on demand) is exposed through the -[C ABI](./c-api.md). See the [Zig API](./zig-api.md). +Baking (`tile57.bake.tree`), raw S-57 reading (`Chart.openPath` + +`cellsJson`/`featuresJson`), and the style builders are all in the same +package. See the [Zig API](./zig-api.md). ## ENC_ROOT and updates diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 45009ba..95639f0 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -84,14 +84,14 @@ render Surface ──► MVT / MLT tiles + PMTiles (src/tiles/) + MapLibre sty The engine holds only its working set: -- **Lazy, per-cell work.** A multi-cell ENC_ROOT is indexed cheaply (band + - bbox); cells are parsed and portrayed only when a requested tile needs them, - then held under an LRU bound. A **streaming** open reads a cell's bytes on - demand (and frees them on eviction), so a host holds only the working set — not - the whole catalogue. -- **Per-cell bakes.** Each cell bakes to its own PMTiles at its compilation scale, - so a bake holds one cell at a time; the runtime compositor stitches the cells by - `(z, x, y)` on demand through an ownership partition. +- **Lazy, per-cell reads.** A multi-cell ENC_ROOT is indexed cheaply (band + + bbox); a **streaming** open reads a cell's bytes only when a metadata or + feature query needs them (and frees them on eviction), so a host holds only + the working set — not the whole catalogue. +- **Per-cell bakes, composed on demand.** Each cell bakes to its own PMTiles at + its compilation scale, so a bake holds one cell at a time; the runtime + compositor mmaps the archives and stitches the cells by `(z, x, y)` on demand + through an ownership partition, so serving never loads the cell set either. - **Pure-Zig core.** The foundational format/encode modules (`iso8211`, `s57`, `s101`, `tiles`, `render`, `scene`, `style`) have no libc; only the Lua portrayal (`portray`) and the sprite rasterizer (`sprite`) pull in C. diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 38e6a52..8bafe97 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -78,18 +78,17 @@ has its own short list of deliberate gaps — see ## ENC_ROOT loading Opening an ENC_ROOT (`Chart.openPath` / `openCellsStreaming`) builds a cheap -spatial index (band + bbox per cell) and generates tiles **on demand**, parsing + -portraying only the cells a requested tile needs (best-available scale band per -tile, recent cells held in an LRU). The catalogue opens in seconds; memory stays -bounded. Caveats: +spatial index (band + bbox per cell) and reads a cell's bytes only when a +metadata or feature query needs them — the catalogue opens in seconds and +memory stays bounded. It serves metadata and extraction only: tiles and views +always come from a bake (bake each cell once, then compose on demand). +Caveats: -- **First-view latency.** The first tile over a fresh area parses + portrays its - 1–4 cells (tens of ms), then they're cached. Opening the whole NOAA catalogue - also pays a one-time index scan (a few seconds, parsing every cell header). -- **Pre-bake for smooth panning.** For low first-view latency everywhere, bake the - ENC_ROOT once with `tile57 bake` (per-cell tiles + an ownership partition) and - serve tiles from the compositor. The whole catalogue is a multi-minute one-time - bake; a region is far quicker. +- **Baking is the import step.** The whole NOAA catalogue is a multi-minute + one-time bake (`tile57 bake` / `tile57_bake_tree`); a region is far quicker, + and re-runs are incremental — only cells whose source changed re-bake. +- **Index-scan cost.** Opening the whole catalogue as a streaming chart pays a + one-time index scan (a few seconds, parsing every cell header). - **Low zoom is style-gated.** The generated style's vector-source `minzoom` is the bake's tile floor (default 8), and MapLibre never requests tiles below a source's minzoom — nothing draws below it regardless of the data. diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index fa107e0..f9483c1 100644 --- a/docs/docs/rendering.md +++ b/docs/docs/rendering.md @@ -85,10 +85,15 @@ byte-deterministic: the same scene renders the same file, every time. The tile path has to **freeze** the portrayal context at bake time (a tile archive can't re-run Lua per user), and papers over it with swappable -properties the style toggles at runtime. The native path has no such limit: -`render_view` runs the S-101 rules with the mariner's *actual* safety -contour, boundary style, and point-symbol style. What you see is what the -rules decided for *your* settings — the ECDIS-faithful path. +properties the style toggles at runtime. The pixel path evaluates the +mariner's display gates — palette, display category, SCAMIN, viewing groups, +text groups, size scale — live at render time for any source. Rendering a +live cell (the CLI on a `.000`, or `Chart.openBytes` in Zig) goes further: +the S-101 rules themselves run with the mariner's *actual* safety contour, +boundary style, and point-symbol style — what you see is what the rules +decided for *your* settings, the ECDIS-faithful path. Rendering a baked +archive replays its tiles, so the rule outcomes are the bake's; the +swappable parts re-evaluate. ## Using it @@ -114,8 +119,9 @@ tile57 png ... --safety 5 --safety-depth 5 --feet --palette night \ ### From C (and therefore Go, Python, C++, …) -Two calls in `include/tile57.h` (same allocate-`*out` / free-with-`tile57_free` -convention as the rest of the ABI): +Everything is bake, then render: open a baked archive as a chart and ask it +for a view (same allocate-`*out` / free-with-`tile57_free` convention as the +rest of the ABI): ```c tile57 *c = NULL; @@ -127,14 +133,19 @@ m.safety_contour = 5.0; m.scheme = TILE57_SCHEME_NIGHT; uint8_t *png; size_t len; -tile57_render_view(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &png, &len, NULL); +tile57_png(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &png, &len, NULL); /* ... write/display png ... */ tile57_free(png, len); uint8_t *pdf; size_t plen; -tile57_render_pdf(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &pdf, &plen, NULL); +tile57_pdf(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &pdf, &plen, NULL); ``` +That renders ONE chart, no composition. A view across a whole chart library +is the same call on the compositor — `tile57_compose_png` / `_pdf` — which +composes every covering tile through the ownership partition first. See the +[C API](./c-api.md). + `m.size_scale` calibrates physical size (so 1 S-52 millimetre is a true millimetre on your display) and doubles as the @2x knob. Every field of `tile57_mariner` — categories, text groups, contours, units — evaluates live. @@ -159,18 +170,21 @@ the ten Surface methods and you receive the full semantic stream — this is how MVT and MLT are done (`TileSurface` in `src/scene/scene.zig`), and how a GeoJSON debug dump or a GPU display list would be done. -**From the C ABI:** not currently supported. Surface and Canvas are Zig -interfaces; the C ABI exposes the products (tiles, PNG, PDF, styles, assets) -and the inputs (charts, mariner settings), not the interfaces themselves. A -C-callback Canvas — C function pointers receiving resolved paths and glyph -runs — would be a mechanical addition and may come later. Until then, a -custom output format means a small Zig file in `src/render/`, wired into the -build in one place. +**From the C ABI:** both interfaces are exposed as callback tables. +`tile57_canvas` drives a `tile57_canvas_cb` — C function pointers receiving +resolved, flattened paths, patterns, and glyph outlines in pixel space, in +paint order (the Canvas seat). `tile57_surface` drives a `tile57_surface_cb` — +the world-space, semantically tagged stream (per-feature class + SCAMIN, world +anchors, reference-pixel outlines) a GPU host tessellates once and transforms +per frame (the Surface seat). Both have composed twins on the compositor +(`tile57_compose_canvas` / `tile57_compose_surface`). A custom output format in +Zig is still one small file in `src/render/`. ## What's deliberately not here (yet) - Contour labels render horizontal (not rotated along the line). - No kerning (chart labels are short; Noto's advances read fine). - Translucent fills print opaque in PDF. -- PMTiles replay doesn't overzoom past the archive's baked range. +- Archive replay doesn't overzoom past the baked range (the compositor serves + one fill-up zoom past it). - The PDF embeds the whole label font (~600 KB) rather than a subset. diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index 13feb8d..b1ac7fc 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -8,8 +8,10 @@ sidebar_position: 4 The engine is a Zig package named **`tile57`** (v0.2.0, requires Zig 0.16). Add it as a dependency and `@import("tile57")` for the curated public surface -(`src/tile57.zig`). The [C ABI](./c-api.md) is a thin shim over this same -API. +(`src/tile57.zig`). The [C ABI](./c-api.md) is a thin shim over this same API, +and both share the same shape: **bake, then compose** (or bake, then render) — +source cells bake once to per-cell archives, and every output is produced from +baked archives. :::note Add it as a **path dependency** on a local clone (submodules initialised) — @@ -18,80 +20,101 @@ Add it as a **path dependency** on a local clone (submodules initialised) — [Installation](./installation.md). ::: -## The high-level engine: `Chart` +## Bake -Open a **chart** from bytes, a path, or cells, then render views, query features, -and read metadata: +Bake each ENC cell to its own PMTiles at its compilation scale — the input the +compositor serves from. Free any returned bytes with `tile57.freeBytes`. + +```zig +// Bake an ENC_ROOT: each cell -> /tiles/.pmtiles + /partition.tpart. +// Incremental: an archive already newer than its whole input is skipped. +const n = try tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null); +``` + +| Surface | What it does | +|---------|--------------| +| `tile57.bake.cellBytes(path, rules)` | bake one cell (+ updates) to PMTiles bytes. | +| `tile57.bake.cellsParallel(...)` / `bake.cellsToFiles(...)` | bake many cells in parallel, to memory / to files. | +| `tile57.bake.tree(io, in, out, ...)` | walk an ENC_ROOT, bake each cell to a mirrored path (incremental). | +| `tile57.bake.archive(...)` | the offline path: merge a slice of cells into one archive. | +| `tile57.bake.pmtilesMetadata(a, bytes)` | read an archive's metadata JSON (embedded coverage + scamin). | +| `tile57.bake.Progress` | the optional progress-callback type. | + +Reading raw S-57 source data (a cell inventory, GeoJSON feature extraction) +goes through a streaming `Chart` — see `openPath` below. + +## Render: the `Chart` + +A `Chart` is one open chart: metadata, feature extraction, the S-52 cursor +pick, and view renders — with no composition. Tiles across many charts come +from the compositor (next section). ```zig const tile57 = @import("tile57"); -// A baked archive, mmap'd (never fully resident). openBytes takes a PMTiles -// archive or a raw S-57 cell portrayed live (auto-sniffed). +// A baked archive, mmap'd (never fully resident). var chart = try tile57.Chart.openPmtilesPath(io, "US5MD1MC.pmtiles"); defer chart.deinit(); const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null -// … chart.renderView(…) / chart.queryPoint(…) / chart.featuresJson(…) … +// … chart.renderView(…) / chart.queryPoint(…) … ``` -The Zig `Chart` does not itself serve `(z, x, y)` tiles — the runtime compositor -(bake each cell, then compose on demand) is exposed through the [C ABI](./c-api.md). -`tile57.bakeArchive` bakes a whole ENC_ROOT to one PMTiles archive offline. +What a chart can do depends on how it was opened: + +| Open | Backend | Serves | +|------|---------|--------| +| `openPmtilesPath(io, path)` | baked archive, mmap'd | metadata (embedded coverage + scale), query, view renders (tile replay), raw tiles via `pmtilesReader()`. | +| `openBytes(bytes, .pmtiles, …)` | baked archive, copied | the same, from memory. | +| `openBytes(cell_bytes, .auto, rules_dir)` | ONE live S-57 cell, fully portrayed | metadata, query, view renders with the S-101 rules evaluated live. | +| `openPath(path, rules_dir, pick_attrs)` | streaming ENC_ROOT (or a single `.000`) | metadata + extraction ONLY: `cellsJson`, `featuresJson`, `scamin`, bounds. Cells are enumerated up front and parsed on demand, so a whole catalogue opens instantly. No view renders, no tiles. | +| `openCells(cells, …)` / `openCellsStreaming(metas, reader, …)` | the same, from in-memory cells / a host reader callback | as `openPath`. | `Chart` methods: | Method | Purpose | |--------|---------| -| `openPmtilesPath(io, path)` | open a baked PMTiles archive from a path, mmap'd. | -| `openBytes(bytes, format, rules_dir)` | open one chart from bytes (PMTiles or S-57 cell). | -| `openPath(path, rules_dir, pick_attrs)` | open an on-disk ENC_ROOT dir (or a single `.000`) as a streaming chart. | -| `openCells(cells, rules_dir, pick_attrs)` | open a multi-cell ENC_ROOT from bytes (each cell's `.001…` updates applied). | -| `openCellsStreaming(metas, reader, user, rules_dir, pick_attrs)` | low-memory ENC_ROOT: read each cell's bytes on demand, free on eviction. | -| `renderView(lon, lat, zoom, w, h, palette, settings, output)` | render a view through the native S-52 pixel path — PNG or PDF. | +| `renderView(lon, lat, zoom, w, h, palette, settings, output, cb)` | render a view through the native S-52 pixel path — PNG, PDF, or a callback canvas. | | `renderSurfaceView(lon, lat, zoom, w, h, palette, settings, cb)` | drive world-space surface callbacks (the GPU vector twin). | | `renderAscii(lon, lat, zoom, cols, rows, palette, settings, ansi)` | the same view as a terminal text grid. | | `queryPoint(lon, lat, zoom, cb)` | the S-52 cursor pick — features under a point at the view zoom. | -| `cellsJson()` / `featuresJson(classes)` | per-cell metadata / GeoJSON feature query (the `cells` / `features` CLI). | +| `cellsJson()` / `featuresJson(classes)` | per-cell metadata / GeoJSON feature extraction (the `cells` / `features` CLI). | | `coverage()` | the M_COVR data-coverage rings (a live cell, or the copy a per-cell bake embeds in its archive metadata). | | `bounds() -> ?[4]f64` | geographic extent `[w, s, e, n]`, if known. | | `anchor()` | a good initial camera (lat, lon, zoom) on real data. | | `bands() -> u32` | bitmask of navigational bands present. | -| `zoomRange()` | the min/max zoom the chart serves. | +| `zoomRange()` | the min/max zoom the chart covers. | | `nativeScale() -> i32` | the compilation scale 1:N (a live cell or an archive's embedded metadata; 0 if unknown). | -| `pmtilesReader()` / `cellCoverage()` | the archive reader + decoded per-cell coverage a compositor borrows. | | `scamin() -> ![]u32` | the distinct SCAMIN denominators present (the live SCAMIN manifest). | | `tileType()` | the tile encoding the chart's tiles use (MVT/MLT). | | `format() -> Format` | the resolved backend (after `.auto`). | +| `pmtilesReader()` / `cellCoverage()` | the archive reader (raw per-archive tiles — the primitive for writing your own compositor) + the decoded per-cell coverage; what the built-in compositor borrows. | | `deinit()` | release the chart and its cached tiles. | `tile57.Format` is `.auto` / `.pmtiles` / `.s57_cell`. `rules_dir` is the S-101 portrayal rules directory for live S-57 cells; `null` (or `""`) uses the rules embedded in the binary (or `TILE57_S101_RULES` if set), so no on-disk catalogue -is required; a path overrides with an on-disk catalogue. Free any bytes returned -by the render / bake entry points with `tile57.freeBytes`. +is required; a path overrides with an on-disk catalogue. The streaming open uses the extern types `tile57.CellMeta` (bbox + `cscl`), `tile57.CellBytes` (the cell's base + updates, ownership transferred to the library), and `tile57.CellReadFn` (the reader callback). Multi-cell input for `openCells` is `tile57.CellInput`. -## Bake + compose +## Compose -Baking and compositing are separate steps. `tile57.bake` writes each cell to its -own PMTiles at its compilation scale; `tile57.compose` stitches those archives -into one tile for any `(z, x, y)` on demand, using an ownership partition so cells -never double-draw at a seam. +The runtime compositor stitches per-cell archives into one seamless chart: any +`(z, x, y)` tile on demand through the ownership partition (cells never +double-draw at a seam), and the same view outputs as a single chart, composed. ```zig -// Bake an ENC_ROOT: each cell -> /tiles/.pmtiles + /partition.tpart. -// Incremental: an archive already newer than its whole input is skipped. -const n = try tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null); - // Open the compositor over the archives + partition, then compose tiles. var src = (try tile57.compose.openComposeSourceFiles(io, gpa, paths, "/out/partition.tpart")).?; defer src.deinit(); const result = try src.tile(gpa, 13, 2359, 3139); // result.tile: ?[]u8, result.owned: bool + +// The composed view outputs live beside the Chart ones: +const png = try tile57.renderComposeView(src, lon, lat, 13.5, 1600, 1200, .day, &settings, .png, null); ``` A host that already holds open charts composes over them instead — the @@ -107,13 +130,13 @@ var src = (try tile57.compose.openComposeSourceCharts(gpa, &archives, null)).?; | Surface | What it does | |---------|--------------| -| `tile57.bake.cellBytes(path, rules)` | bake one cell (+ updates) to PMTiles bytes. | -| `tile57.bake.cellsToFiles(...)` / `bake.tree(...)` | bake many cells / a whole ENC_ROOT to files. | -| `tile57.bake.archive(...)` | the offline path: merge a slice of cells into one archive. | -| `tile57.bake.Progress` | the optional progress-callback type. | | `tile57.compose.openComposeSourceFiles(...)` | open a `ComposeSource` over on-disk archives + a partition. | | `tile57.compose.openComposeSourceCharts(...)` | the same over borrowed `ChartArchive`s (already-open charts). | -| `tile57.compose.composeTile(...)` | compose one tile (the stateless core `ComposeSource.tile` uses). | +| `ComposeSource.tile(gpa, z, x, y)` | compose one tile on demand (raw MLT + the ownership flag). | +| `tile57.renderComposeView(src, ...)` | the composed view render — PNG, PDF, or a callback canvas. | +| `tile57.renderComposeSurfaceView(src, ...)` | the composed world-space surface stream. | +| `tile57.composeQueryPoint(src, lon, lat, zoom, cb)` | the composed cursor pick (seams included). | +| `tile57.compose.composeTile(...)` | the stateless core `ComposeSource.tile` uses. | | `tile57.partition` | the ownership partition and its `.tpart` sidecar (serialize / deserialize). | ## Style + portrayal assets @@ -136,11 +159,13 @@ The mid-level packages, for callers that compose their own pipeline: | Module | Role | |--------|------| -| `tile57.mvt` | Mapbox Vector Tile encode/decode | +| `tile57.mvt` / `tile57.mlt` | Mapbox Vector Tile / MapLibre Tile encode/decode | | `tile57.tile` | web-mercator tiling + clipping | | `tile57.pmtiles` | PMTiles read/write | +| `tile57.band` | compilation-scale → zoom-range mapping | | `tile57.bake_enc` | banded multi-cell ENC_ROOT → PMTiles | | `tile57.scene` | S-57 feature → tile-surface scene generation | +| `tile57.render` | the Surface/Canvas rendering path (PNG, PDF, ASCII, callbacks) | ## Raw formats (advanced) @@ -152,5 +177,6 @@ The pure-Zig foundational parsers under `tile57.formats`: | `tile57.formats.s57` | S-57 ENC cell parser + geometry | | `tile57.formats.s101` | the S-101 catalogue, adapter, and instruction stream | -`tile57.version` is the package version string (`"0.2.0"`), matching -`build.zig.zon` and `tile57_version()`. +`tile57.coverage` is the per-cell M_COVR coverage sidecar (carried in an +archive's PMTiles metadata). `tile57.version` is the package version string +(`"0.2.0"`), matching `build.zig.zon` and `tile57_version()`. diff --git a/src/tile57.zig b/src/tile57.zig index e51041c..ee0cf59 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -60,11 +60,31 @@ pub const bake = struct { pub const Progress = chart.BakeProgress; }; -// ---- Compose: per-cell archives + a partition -> tiles on demand ----------- -/// The runtime compositor: `ComposeSource` over per-cell archives + a partition -/// (`ComposeSource.tile` composes one tile on demand), `openComposeSourceFiles` to -/// open from disk, `openComposeSourceCharts` to borrow already-open charts. -pub const compose = @import("compose"); +// ---- Compose: per-cell archives + a partition -> any output on demand ------ +/// The runtime compositor: a `ComposeSource` over per-cell archives + a +/// partition offers the SAME outputs as a Chart, composed — `ComposeSource.tile` +/// for one tile, `renderView` / `renderSurfaceView` / `queryPoint` for composed +/// views and the composed pick. (The view calls are implemented beside Chart — +/// the underlying `compose` module is a dependency leaf without the render +/// path — and surfaced here under the compose name they belong to.) +pub const compose = struct { + const mod = @import("compose"); + pub const ComposeSource = mod.ComposeSource; + pub const ChartArchive = mod.ChartArchive; + pub const TileResult = mod.TileResult; + pub const LoadedCov = mod.LoadedCov; + pub const openComposeSourceFiles = mod.openComposeSourceFiles; + pub const openComposeSourceCharts = mod.openComposeSourceCharts; + pub const composeTile = mod.composeTile; + pub const toPlaneCells = mod.toPlaneCells; + pub const clip = mod.clip; + /// The composed view render — PNG, PDF, or a callback canvas. + pub const renderView = chart.renderComposeView; + /// The composed world-space surface stream (the GPU vector twin). + pub const renderSurfaceView = chart.renderComposeSurfaceView; + /// The composed cursor pick (S-52 §10.8, seams included). + pub const queryPoint = chart.composeQueryPoint; +}; /// The ownership partition and its `.tpart` sidecar (serialize / deserialize). pub const partition = @import("geometry").partition; /// The integer computational geometry the compositor and baker share. From edccc5ac4941a9562a2eab457690b76bf493073e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 06:35:40 -0400 Subject: [PATCH 105/140] =?UTF-8?q?feat(capi):=20tile57=5Ffree=20takes=20j?= =?UTF-8?q?ust=20the=20pointer=20=E2=80=94=20ABI=20buffers=20are=20length-?= =?UTF-8?q?prefixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every buffer handed across the ABI is copied into a 16-byte length-prefixed export allocation, so freeing is the classic malloc shape (and the scamin sizeof formula disappears). The one-memcpy cost is negligible next to producing any output. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 +- bindings/go/tile57.go | 4 +- docs/docs/c-api.md | 6 +- docs/docs/getting-started.md | 2 +- docs/docs/rendering.md | 2 +- include/tile57.h | 12 ++-- src/capi.zig | 117 +++++++++++++++++++++-------------- 7 files changed, 89 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b1c9e84..92f7f81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,9 @@ flags come back through optional out-pointers (NULL to ignore), not return values. Say "call that can fail" in docs, not "fallible". **Memory has one rule.** Calls that return bytes allocate `*out`; the caller -releases with `tile57_free(ptr, len)` — the same length. POD across the seam: -no Zig errors, slices, or optionals cross the ABI. +releases with `tile57_free(ptr)` — buffers are length-prefixed at allocation, +so the pointer is all it needs. POD across the seam: no Zig errors, slices, or +optionals cross the ABI. **Ownership and borrowing are explicit.** The compositor BORROWS its charts (charts outlive it; close the compositor first). A path-opened chart mmaps its diff --git a/bindings/go/tile57.go b/bindings/go/tile57.go index af87001..e7fbb51 100644 --- a/bindings/go/tile57.go +++ b/bindings/go/tile57.go @@ -230,7 +230,7 @@ func (s *Source) scaminLocked() []uint32 { for i, v := range vals { res[i] = uint32(v) } - C.tile57_free(unsafe.Pointer(out), n*C.size_t(unsafe.Sizeof(C.int32_t(0)))) + C.tile57_free(unsafe.Pointer(out)) s.scamin = res } return s.scamin @@ -259,7 +259,7 @@ func tileBytes(p *C.uint8_t, n C.size_t) []byte { if n > 0 { b = C.GoBytes(unsafe.Pointer(p), C.int(n)) } - C.tile57_free(unsafe.Pointer(p), n) + C.tile57_free(unsafe.Pointer(p)) return b } diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 0e9bf81..4a5efc8 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -78,7 +78,7 @@ No handle is internally synchronized — use one thread per handle. Each must also outlive every borrower still holding it: a compositor borrows its charts (close the compositor first, then the charts), and a path-opened chart mmaps its file, so the file must stay in place while the chart is open. Calls that -return bytes allocate `*out`; free it with `tile57_free` (same length). Input +return bytes allocate `*out`; free it with `tile57_free(ptr)`. Input bytes are copied, so the caller may free them right after the call. ::: @@ -574,8 +574,8 @@ tile57_status tile57_style_template(tile57_scheme scheme, const char *source_til void tile57_warmup(void); /* Free ANY buffer the engine returned (tiles, style JSON, the scamin array, - * colortables, …), passing the same length. The universal free. */ -void tile57_free(void *ptr, size_t len); + * colortables, …) — length-prefixed, so the pointer is all it needs. */ +void tile57_free(void *ptr); ``` ## Diagnostics header diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index dfdea1a..cb8b65e 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -96,7 +96,7 @@ tile57_compose_open(&chart, 1, "out/partition.tpart", &src, &err); uint8_t *tile; size_t n; bool owned; if (tile57_compose_tile(src, z, x, y, &tile, &n, &owned, &err) == TILE57_OK) { - if (tile) { /* … serve the decompressed MLT tile … */ tile57_free(tile, n); } + if (tile) { /* … serve the decompressed MLT tile … */ tile57_free(tile); } else if (owned) { /* owned but empty — a cell's bake is still in flight */ } else { /* not owned — open ocean; cache as blank */ } } diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index f9483c1..6fc7807 100644 --- a/docs/docs/rendering.md +++ b/docs/docs/rendering.md @@ -135,7 +135,7 @@ m.scheme = TILE57_SCHEME_NIGHT; uint8_t *png; size_t len; tile57_png(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &png, &len, NULL); /* ... write/display png ... */ -tile57_free(png, len); +tile57_free(png); uint8_t *pdf; size_t plen; tile57_pdf(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &pdf, &plen, NULL); diff --git a/include/tile57.h b/include/tile57.h index 91f39bf..bc90287 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -54,8 +54,9 @@ * call those charts' own methods from other threads. Distinct handles are * independent. * - * Memory: calls that return bytes allocate *out; release it with tile57_free, - * passing the same length. All pointers are POD across the seam. + * Memory: calls that return bytes allocate *out; release it with + * tile57_free(ptr) — buffers are length-prefixed at allocation, so the + * pointer is all it needs. All pointers are POD across the seam. * * The S-101 portrayal self-test / bring-up entry points live in a separate * header, tile57_diag.h (developer tooling, not part of the embedding API). @@ -317,7 +318,7 @@ void tile57_get_info(tile57 *chart, tile57_info *out); * native fractional-minzoom bucket layer per value (features honour their 1:N * min-display-scale at zero per-zoom cost). TILE57_OK with *out pointing at * *out_len int32 values, or NULL/0 when the chart has none. Free *out with - * tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ + * tile57_free. */ tile57_status tile57_scamin(tile57 *chart, int32_t **out, size_t *out_len, tile57_error *err); @@ -758,8 +759,9 @@ tile57_status tile57_style_template(tile57_scheme scheme, const char *source_til void tile57_warmup(void); /* Free ANY buffer the engine returned (tiles, style JSON, the scamin array, - * colortables, atlases, …), passing the same length. The universal free. */ -void tile57_free(void *ptr, size_t len); + * colortables, …). Buffers are length-prefixed at allocation, so the pointer is + * all it needs — the universal free. */ +void tile57_free(void *ptr); #ifdef __cplusplus } diff --git a/src/capi.zig b/src/capi.zig index 28bedfa..95f3a19 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -127,9 +127,30 @@ fn bytesOut(out: ?*?[*]u8, out_len: ?*usize) error{BadArg}!struct { *?[*]u8, *us return .{ o, n }; } -fn setBytes(o: *?[*]u8, n: *usize, bytes: []u8) void { - o.* = bytes.ptr; +// ---- export allocations ------------------------------------------------------ +// Every buffer handed across the ABI is length-prefixed: a 16-byte header (the +// total allocation size in its first usize) sits before the returned pointer, so +// tile57_free needs only the pointer — the classic malloc shape — and the payload +// stays 16-aligned. +const EXPORT_HDR: usize = 16; + +fn exportAlloc(len: usize) ?[*]u8 { + const total = EXPORT_HDR + len; + const raw = gpa.alignedAlloc(u8, .@"16", total) catch return null; + std.mem.writeInt(usize, raw[0..@sizeOf(usize)], total, .little); + return raw.ptr + EXPORT_HDR; +} + +// Hand an engine-owned buffer across the ABI through (out, out_len): copy it into +// an export allocation and free the engine buffer. Returns OK, or NOMEM with the +// outs left NULL/0. +fn exportOut(err: ?*CError, o: *?[*]u8, n: *usize, bytes: []u8) c_int { + defer chart.freeBytes(bytes); + const p = exportAlloc(bytes.len) orelse return failWith(err, .nomem, "out of memory"); + @memcpy(p[0..bytes.len], bytes); + o.* = p; n.* = bytes.len; + return OK; } const bad_out = "out/out_len must not be null"; @@ -167,8 +188,7 @@ export fn tile57_enc_cells(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize const c = Chart.openPath(p, null, false) catch |e| return failCtx(err, e, p); defer c.deinit(); const bytes = (c.cellsJson() catch |e| return fail(err, e)) orelse return OK; - setBytes(o, n, bytes); - return OK; + return exportOut(err, o, n, bytes); } /// The features of the S-57 data at `path` (one cell or a whole ENC_ROOT) for the @@ -182,8 +202,7 @@ export fn tile57_enc_features(path: ?[*:0]const u8, classes: ?[*:0]const u8, out const c = Chart.openPath(p, null, false) catch |e| return failCtx(err, e, p); defer c.deinit(); const bytes = (c.featuresJson(cls) catch |e| return fail(err, e)) orelse return OK; - setBytes(o, n, bytes); - return OK; + return exportOut(err, o, n, bytes); } /// tile57_enc_features over in-memory base-cell bytes (no update chain). See tile57.h. @@ -196,8 +215,7 @@ export fn tile57_enc_features_bytes(base: ?[*]const u8, len: usize, classes: ?[* const c = Chart.openCells(&cells, null, false) catch |e| return fail(err, e); defer c.deinit(); const bytes = (c.featuresJson(cls) catch |e| return fail(err, e)) orelse return OK; - setBytes(o, n, bytes); - return OK; + return exportOut(err, o, n, bytes); } /// Decode a CATALOG.031 exchange-set catalogue into a JSON array of its CATD @@ -215,8 +233,7 @@ export fn tile57_enc_catalog(catalog_031: ?[*]const u8, len: usize, out: ?*?[*]u var buf = std.ArrayList(u8).empty; catalogJson(a, &buf, entries) catch |e| return fail(err, e); const bytes = gpa.dupe(u8, buf.items) catch |e| return fail(err, e); - setBytes(o, n, bytes); - return OK; + return exportOut(err, o, n, bytes); } fn catalogJson(a: std.mem.Allocator, buf: *std.ArrayList(u8), entries: []const s57.CatalogEntry) !void { @@ -254,7 +271,7 @@ export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ? const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); const archive = chart.bakeCellBytes(p, null) catch |e| return failCtx(err, e, p); - if (archive) |a| setBytes(o, n, a); + if (archive) |a| return exportOut(err, o, n, a); return OK; } @@ -287,12 +304,27 @@ export fn tile57_bake_cells( defer gpa.free(results); chart.bakeCellsParallel(list, null, workers, results); var baked: usize = 0; + var oom = false; for (0..n) |i| { - if (results[i]) |b| { - ob[i] = b.ptr; - ol[i] = b.len; - baked += 1; + const b = results[i] orelse continue; + defer chart.freeBytes(b); + if (oom) continue; + const p = exportAlloc(b.len) orelse { + oom = true; + continue; + }; + @memcpy(p[0..b.len], b); + ob[i] = p; + ol[i] = b.len; + baked += 1; + } + if (oom) { + for (0..n) |i| { + if (ob[i]) |p| tile57_free(p); + ob[i] = null; + ol[i] = 0; } + return failWith(err, .nomem, "out of memory"); } if (out_baked) |p| p.* = baked; return OK; @@ -331,7 +363,7 @@ export fn tile57_pmtiles_metadata(pmtiles_ptr: ?[*]const u8, len: usize, out: ?* const p = pmtiles_ptr orelse return failWith(err, .badarg, "pmtiles must not be null"); if (len == 0) return failWith(err, .badarg, "len must not be zero"); const meta = chart.pmtilesMetadata(gpa, p[0..len]) catch |e| return fail(err, e); - if (meta) |m| setBytes(o, n, m); + if (meta) |m| return exportOut(err, o, n, m); return OK; } @@ -444,7 +476,7 @@ export fn tile57_get_info(src: ?*Chart, out: ?*CInfo) callconv(.c) void { /// The distinct SCAMIN denominators present in the chart (ascending, from the /// archive metadata); NULL/0 out when there are none. Free *out with -/// tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). See tile57.h. +/// tile57_free. See tile57.h. export fn tile57_scamin(handle: ?*Chart, out: ?*?[*]i32, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o = out orelse return failWith(err, .badarg, bad_out); const n = out_len orelse return failWith(err, .badarg, bad_out); @@ -452,11 +484,12 @@ export fn tile57_scamin(handle: ?*Chart, out: ?*?[*]i32, out_len: ?*usize, err: n.* = 0; const s = handle orelse return failWith(err, .badarg, "chart must not be null"); const vals = s.scamin() catch |e| return fail(err, e); - if (vals.len == 0) { - chart.freeBytes(@as([*]u8, @ptrCast(vals.ptr))[0 .. vals.len * @sizeOf(u32)]); - return OK; - } - o.* = @ptrCast(vals.ptr); // SCAMIN denominators fit in int32 (engine caps them) + defer chart.freeBytes(std.mem.sliceAsBytes(vals)); + if (vals.len == 0) return OK; + // SCAMIN denominators fit in int32 (the engine caps them). + const p = exportAlloc(vals.len * @sizeOf(i32)) orelse return failWith(err, .nomem, "out of memory"); + @memcpy(p[0 .. vals.len * @sizeOf(u32)], std.mem.sliceAsBytes(vals)); + o.* = @ptrCast(@alignCast(p)); n.* = vals.len; return OK; } @@ -500,8 +533,7 @@ export fn tile57_tile(handle: ?*Chart, z: u8, x: u32, y: u32, out: ?*?[*]u8, out const c = handle orelse return failWith(err, .badarg, "chart must not be null"); const rd = c.pmtilesReader() orelse return failWith(err, .badarg, "chart is not archive-backed"); const bytes = (rd.getTile(gpa, z, x, y) catch |e| return fail(err, e)) orelse return OK; - setBytes(o, n, bytes); - return OK; + return exportOut(err, o, n, bytes); } const CQueryCb = @import("render").query.QueryCb; @@ -553,8 +585,7 @@ export fn tile57_png( return failWith(err, .badarg, bad_size); const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; const bytes = c.renderView(lon, lat, zoom, width, height, paletteOf(&settings), &settings, .png, null) catch |e| return fail(err, e); - setBytes(o, n, bytes); - return OK; + return exportOut(err, o, n, bytes); } /// tile57_png's vector twin: the SAME scene as a deterministic single-page PDF @@ -577,8 +608,7 @@ export fn tile57_pdf( return failWith(err, .badarg, bad_size); const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; const bytes = c.renderView(lon, lat, zoom, width, height, paletteOf(&settings), &settings, .pdf, null) catch |e| return fail(err, e); - setBytes(o, n, bytes); - return OK; + return exportOut(err, o, n, bytes); } const CbCanvas = @import("render").cb_canvas.CCanvas; @@ -732,7 +762,7 @@ export fn tile57_compose_tile( const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); const res = src.tile(gpa, z, x, y) catch |e| return fail(err, e); if (out_owned) |p| p.* = res.owned; - if (res.tile) |t| setBytes(o, n, t); + if (res.tile) |t| return exportOut(err, o, n, t); return OK; } @@ -758,8 +788,7 @@ export fn tile57_compose_png( return failWith(err, .badarg, bad_size); const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; const bytes = chart.renderComposeView(src, lon, lat, zoom, width, height, paletteOf(&settings), &settings, .png, null) catch |e| return fail(err, e); - setBytes(o, n, bytes); - return OK; + return exportOut(err, o, n, bytes); } /// tile57_compose_png's vector twin: the SAME composed scene as a deterministic @@ -782,8 +811,7 @@ export fn tile57_compose_pdf( return failWith(err, .badarg, bad_size); const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; const bytes = chart.renderComposeView(src, lon, lat, zoom, width, height, paletteOf(&settings), &settings, .pdf, null) catch |e| return fail(err, e); - setBytes(o, n, bytes); - return OK; + return exportOut(err, o, n, bytes); } /// tile57_compose_png's callback twin: the SAME composed view painted through the @@ -893,8 +921,7 @@ export fn tile57_colortables_default(out: ?*?[*]u8, out_len: ?*usize, err: ?*CEr const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const xml = embeddedColorProfileXml() orelse return failWith(err, .internal, "embedded colour profile missing"); const json = style.colorTablesJson(gpa, xml) catch |e| return fail(err, e); - setBytes(o, n, json); - return OK; + return exportOut(err, o, n, json); } // All portrayal assets in memory. Mirrors tile57_assets in tile57.h; each non-null @@ -1177,8 +1204,7 @@ export fn tile57_style_build( defer if (scamin_buf.len > 0) gpa.free(scamin_buf); const now_unix: i64 = @intCast(time(null)); const style_json = style.buildFromTemplateScamin(gpa, tmpl, &m, cts, bands, now_unix, scamin_buf, scamin_lat) catch |e| return fail(err, e); - setBytes(o, n, style_json); - return OK; + return exportOut(err, o, n, style_json); } /// Compute the minimal MapLibre style-mutation ops turning the style for `old_m` @@ -1222,8 +1248,7 @@ export fn tile57_style_diff( defer gpa.free(new_style); const ops = style.diff(gpa, old_style, new_style) catch |e| return fail(err, e); - setBytes(o, n, ops); - return OK; + return exportOut(err, o, n, ops); } /// Generate the base MapLibre style template from the catalogue baked into the @@ -1262,8 +1287,7 @@ export fn tile57_style_template( if (maxzoom != 0) opts.maxzoom = maxzoom; if (tile_encoding == TILE_TYPE_MLT) opts.encoding = "mlt"; const style_json = style.json(gpa, opts) catch |e| return fail(err, e); - setBytes(o, n, style_json); - return OK; + return exportOut(err, o, n, style_json); } /// Fill `cm` with the canonical default mariner settings. date_view = "". @@ -1314,9 +1338,12 @@ export fn tile57_warmup() callconv(.c) void { chart.warmup(); } -/// Free any engine-returned buffer (tiles, style, scamin array, colortables, …), -/// passing the same length. The universal free. See tile57.h. -export fn tile57_free(ptr: ?*anyopaque, len: usize) callconv(.c) void { +/// Free any engine-returned buffer (tiles, style, the scamin array, colortables, +/// …). Buffers are length-prefixed at allocation, so the pointer is all it +/// needs — the universal free. See tile57.h. +export fn tile57_free(ptr: ?*anyopaque) callconv(.c) void { const p = ptr orelse return; - chart.freeBytes(@as([*]u8, @ptrCast(p))[0..len]); + const base: [*]align(16) u8 = @alignCast(@as([*]u8, @ptrCast(p)) - EXPORT_HDR); + const total = std.mem.readInt(usize, base[0..@sizeOf(usize)], .little); + gpa.free(base[0..total]); } From 798e97deea7fd8d8d7aa78e0f534f41fcb2736ad Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 06:53:01 -0400 Subject: [PATCH 106/140] fix(compose): keep light-sector figures whole across ownership seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LIGHTS linestring is always a constructed sector figure (LIGHTS is a point class) — a fixed-size decoration anchored at the light, not ground. Clipping it to the owned face amputated legs and arcs exactly at cell seams; now it draws whole over neighbouring ground, as a single-chart render would. Validated on US5PHLDF/US5PHLDG (a sectored light 30 m from the shared edge): the figure previously stopped at the seam line and now crosses it. Figures reaching into tiles where the owning cell holds no ground remain absent there (noted in limitations). Also: CLAUDE.md prefix taxonomy corrected (bare chart namespace / handle namespaces / section-token families) and 'whole district' -> 'whole ENC_ROOT'. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 31 ++++++++++++++++++++----------- docs/docs/architecture.md | 4 ++-- docs/docs/getting-started.md | 2 +- docs/docs/limitations.md | 12 ++++++++---- src/compose/clip.zig | 28 +++++++++++++++++++++++++++- 5 files changed, 58 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92f7f81..bfdefd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,11 +11,26 @@ output is produced from baked archives. Sections, in order: Version, Errors, Bake, Render (the chart), Compose, Style, Util. The header, the Zig public root, and the docs all follow that same section order. -**Handles are nouns; the chart is the namesake.** `tile57` is one open baked -archive (the sqlite3 pattern: the library's name is its central handle). -Further handles extend the prefix: `tile57_compose`. Handle lifecycle verbs -attach to the handle name: `tile57_open` / `tile57_open_bytes` / -`tile57_close`; `tile57_compose_open` / `tile57_compose_close`. +**Three kinds of prefix, and only three.** + +1. *The chart is the bare namespace.* `tile57` is one open baked archive (the + sqlite3 pattern: the library's name is its central handle), so functions on + it carry no extra prefix: `tile57_open` / `tile57_close` / + `tile57_get_info` / `tile57_tile` / `tile57_png` / `tile57_query`. This is + why there is no `render_` prefix — the header's Render section IS the chart + handle, and its outputs are named bare. +2. *Every other handle namespaces under its noun* and mirrors the chart's + shape: `tile57_compose_open` / `_close` / `_tile` / `_png` / `_query` on + `tile57_compose`. +3. *Handle-free families lead with their section token, then what the call + acts on or yields* — the token's part of speech follows the section, so the + name reads naturally after it. `tile57_bake_*` is a verb family + (`bake_cell_bytes`, `bake_tree` — "bake the X"); `tile57_enc_*` is a domain + family (the raw S-57 source readers — ENC is the ONE vocabulary for raw + source data, never s57_/source_/scan_ variants); `tile57_style_*` is a + product family (`style_build` / `style_diff` / `style_template`, never + `build_style` — "the style's build/diff/template"). Never bury the family + token mid-name. **Outputs are named by what you get, never by how it's produced or served.** `tile`, `png`, `pdf`, `canvas`, `surface`, `query` — not serve/render/build/ @@ -25,12 +40,6 @@ mirror exactly: `tile57_png` ↔ `tile57_compose_png`, `tile57_tile` ↔ out"; keep that symmetry when adding an output — it goes on both handles or has a stated reason not to. -**Families are noun-first prefixes.** `tile57_style_build` / `_style_diff` / -`_style_template` (never `build_style`); `tile57_bake_*` for the import -pipeline; `tile57_enc_*` for the handle-free raw S-57 source readers. ENC is -the ONE vocabulary for raw source data (enc_root, tile57_enc_cells) — never -s57_/source_/scan_ variants of the same idea. - **The status model is universal.** Every call that can fail returns `tile57_status` (never a bare int, count, or bool) and takes an optional caller-owned `tile57_error*` as its LAST parameter. Out-parameters come after diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 642bd3b..984410f 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -184,8 +184,8 @@ out/ ``` There is no merged archive: any `(z, x, y)` tile is composed from the overlapping -cells on demand (`tile57_compose_tile`), so re-baking one cell doesn't rewrite a -whole district. The portrayal assets are generated separately (`tile57 assets` / +cells on demand (`tile57_compose_tile`), so re-baking one cell doesn't rewrite the +whole ENC_ROOT. The portrayal assets are generated separately (`tile57 assets` / `style`); the tiles carry S-52 colour **tokens**, never RGB, and both halves come from the *same* S-101 catalogue, so they cannot drift. The tile-schema vocabulary (`tile57/2`) is the contract a renderer checks. diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index cb8b65e..1472a24 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -73,7 +73,7 @@ out/ ``` There is no merged archive: any `(z, x, y)` tile is composed from the overlapping -cells on demand, so a re-bake of one cell doesn't rewrite a whole district. The +cells on demand, so a re-bake of one cell doesn't rewrite the whole ENC_ROOT. The portrayal assets travel separately (`tile57 assets` / `style`); the tiles carry S-52 colour **tokens**, never RGB, so one set of tiles renders in any palette. diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 8bafe97..3a2c4a5 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -37,10 +37,14 @@ domain and not for navigation; this renderer adds its own gaps on top. S-52 display length; the mariner's *full-length sector line* variant (legs drawn to the light's nominal range) is not emitted, so the `show_full_sector_lines` setting currently has nothing to act on. -- **Sector figures stop at the cell's tile span.** Legs and arcs are - tessellated per tile, and only tiles covering the source cell's bounding box - are generated for that cell — a fixed-display-size arc near a cell edge is - chopped where the neighbouring tile isn't baked from that cell. +- **Sector figures can stop at a tile boundary beyond the owning cell.** The + compositor keeps a light's sector legs and arcs WHOLE across ownership seams + (they are fixed-size decorations anchored at the light, exempt from face + clipping). The remaining gap: a figure reaching into a tile where the owning + cell holds no ground at all is absent there — the compositor never consults + that cell for the tile — so a figure within roughly one tile of the cell's + owned ground can cut at that tile's edge (directional ground-length legs can + reach further). - **Single-primitive rules vs. non-conformant geometry.** Some S-101 rules handle only one primitive (e.g. RecommendedTrack is Curve-only); a cell that encodes the feature with another primitive (an area-encoded recommended diff --git a/src/compose/clip.zig b/src/compose/clip.zig index fb90a9f..0d76451 100644 --- a/src/compose/clip.zig +++ b/src/compose/clip.zig @@ -32,10 +32,25 @@ fn narrowRing(a: std.mem.Allocator, ring: []const Pt) ![]mvt.Point { return out; } +// A linestring carrying class=LIGHTS is a constructed sector figure (LIGHTS is a +// point class; its only line output is emitAugFigures legs/arcs). +fn isLightFigure(feat: mvt.DecodedFeature) bool { + for (feat.properties) |p| { + if (std.mem.eql(u8, p.key, "class")) { + return switch (p.value) { + .string => |v| std.mem.eql(u8, v, "LIGHTS"), + else => false, + }; + } + } + return false; +} + /// Clip `feat` (tile-pixel space) to `face` (the cell's owned rings, tile-pixel space) and /// append the surviving feature(s) to `out`, or nothing if the feature is entirely outside /// the face. Geometry is freshly allocated in `a`; `properties` are borrowed from `feat`. -/// `face` must be a simple even-odd ring-set (as `partition.ownedFace` emits). +/// `face` must be a simple even-odd ring-set (as `partition.ownedFace` emits). One +/// exception to face ownership: LIGHTS sector figures are kept whole (see isLightFigure). pub fn clipFeatureToFace(a: std.mem.Allocator, out: *std.ArrayList(mvt.Feature), feat: mvt.DecodedFeature, face: []const []const Pt) !void { switch (feat.geom_type) { .polygon => { @@ -48,6 +63,17 @@ pub fn clipFeatureToFace(a: std.mem.Allocator, out: *std.ArrayList(mvt.Feature), try out.append(a, .{ .geom_type = .polygon, .parts = parts, .properties = feat.properties }); }, .linestring => { + // A LIGHTS line is always a constructed sector figure (legs/arcs around + // the light — LIGHTS itself is a point class): a fixed-size decoration + // anchored at the light, not ground. Clipping it to the owned face + // amputates the figure at the seam, so keep it WHOLE — it draws over + // neighbouring ground exactly as a single-chart render would. + if (isLightFigure(feat)) { + const parts = try a.alloc([]const mvt.Point, feat.parts.len); + for (feat.parts, 0..) |part, i| parts[i] = try a.dupe(mvt.Point, part); + try out.append(a, .{ .geom_type = .linestring, .parts = parts, .properties = feat.properties }); + return; + } var parts = std.ArrayList([]const mvt.Point).empty; for (feat.parts) |part| { const runs = try plane.clipLineInsidePolys(a, try widenRing(a, part), face); From da6d488fac16f116a30bee4b1310583b392c6894 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 06:55:43 -0400 Subject: [PATCH 107/140] =?UTF-8?q?refactor(zig):=20drop=20the=20multi-cel?= =?UTF-8?q?l=20bakeArchive=20from=20the=20public=20surface=20=E2=80=94=20b?= =?UTF-8?q?aking=20is=20strictly=20one=20cell,=20one=20archive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine keeps the banded bake core internally (bakeCellBytes drives it per cell); the merged-archive form is gone from tile57.bake and the docs. The composed view calls surface as tile57.compose.renderView/renderSurfaceView/ queryPoint. Co-Authored-By: Claude Fable 5 --- docs/docs/architecture.md | 6 ++---- docs/docs/zig-api.md | 9 ++++----- src/chart.zig | 8 ++++---- src/tile57.zig | 6 ++---- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 984410f..0fbbe3e 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -139,8 +139,7 @@ The public surface composes the packages into high-level entry points: through an ownership partition and offers the SAME outputs as a chart, composed: `tile57_compose_tile` for any `(z, x, y)` on demand, `tile57_compose_png` / `_pdf` / `_canvas` / `_surface` / `_query` for composed views and the composed - pick. The public Zig `bakeArchive` runs the same engine over a slice of cells to - make one merged archive. + pick. Baking is strictly per-cell: one cell, one archive. - **`style.build`** (`style/maplibre.zig`) + **`style`** / **`sprite`** — generate the MapLibre style and the portrayal assets it references (`tile57_style_build` / `tile57_bake_assets` in the C ABI). @@ -164,8 +163,7 @@ tile57 is built to hold only its working set: right choice for a large ENC_ROOT. - **Per-cell bakes.** Each cell bakes independently at its own compilation scale, so a bake holds a single cell's parsed data at a time — memory doesn't grow with - the size of the catalogue. (The multi-cell `bakeArchive` streams band-by-band, - finest → coarsest, so its peak memory tracks the largest single band.) + the size of the catalogue. - **Tile cache.** Generated/decoded tiles are memoized per chart (keyed `z<<48 | x<<24 | y`) and released with the chart, so a long-running host bounds memory by closing charts it no longer renders. diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index b1ac7fc..1857f2e 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -36,7 +36,6 @@ const n = try tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null) | `tile57.bake.cellBytes(path, rules)` | bake one cell (+ updates) to PMTiles bytes. | | `tile57.bake.cellsParallel(...)` / `bake.cellsToFiles(...)` | bake many cells in parallel, to memory / to files. | | `tile57.bake.tree(io, in, out, ...)` | walk an ENC_ROOT, bake each cell to a mirrored path (incremental). | -| `tile57.bake.archive(...)` | the offline path: merge a slice of cells into one archive. | | `tile57.bake.pmtilesMetadata(a, bytes)` | read an archive's metadata JSON (embedded coverage + scamin). | | `tile57.bake.Progress` | the optional progress-callback type. | @@ -114,7 +113,7 @@ defer src.deinit(); const result = try src.tile(gpa, 13, 2359, 3139); // result.tile: ?[]u8, result.owned: bool // The composed view outputs live beside the Chart ones: -const png = try tile57.renderComposeView(src, lon, lat, 13.5, 1600, 1200, .day, &settings, .png, null); +const png = try tile57.compose.renderView(src, lon, lat, 13.5, 1600, 1200, .day, &settings, .png, null); ``` A host that already holds open charts composes over them instead — the @@ -133,9 +132,9 @@ var src = (try tile57.compose.openComposeSourceCharts(gpa, &archives, null)).?; | `tile57.compose.openComposeSourceFiles(...)` | open a `ComposeSource` over on-disk archives + a partition. | | `tile57.compose.openComposeSourceCharts(...)` | the same over borrowed `ChartArchive`s (already-open charts). | | `ComposeSource.tile(gpa, z, x, y)` | compose one tile on demand (raw MLT + the ownership flag). | -| `tile57.renderComposeView(src, ...)` | the composed view render — PNG, PDF, or a callback canvas. | -| `tile57.renderComposeSurfaceView(src, ...)` | the composed world-space surface stream. | -| `tile57.composeQueryPoint(src, lon, lat, zoom, cb)` | the composed cursor pick (seams included). | +| `tile57.compose.renderView(src, ...)` | the composed view render — PNG, PDF, or a callback canvas. | +| `tile57.compose.renderSurfaceView(src, ...)` | the composed world-space surface stream. | +| `tile57.compose.queryPoint(src, lon, lat, zoom, cb)` | the composed cursor pick (seams included). | | `tile57.compose.composeTile(...)` | the stateless core `ComposeSource.tile` uses. | | `tile57.partition` | the ownership partition and its `.tpart` sidecar (serialize / deserialize). | diff --git a/src/chart.zig b/src/chart.zig index b1afb05..44b054e 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -3,8 +3,8 @@ //! cells) and it serves decompressed Mapbox Vector Tiles by (z, x, y). Multi-cell //! ENC_ROOT sources index cells cheaply and parse + portray them lazily per //! requested tile (LRU-bounded), so a host can open the whole NOAA catalogue -//! instantly and pay only for the cells under the current view. `bakeArchive` -//! bakes an ENC_ROOT into one band-streamed PMTiles archive. +//! instantly and pay only for the cells under the current view. Baking is +//! strictly per-cell: each cell to its own PMTiles archive. //! //! This is the single source of truth; the C ABI (capi.zig / include/tile57.h) //! is a thin shim over these types. The engine uses a single thread-safe @@ -13,8 +13,8 @@ //! //! Threading: a Chart is NOT internally synchronized — don't call its render / //! query methods on the same Chart from multiple threads concurrently. Distinct -//! charts are independent. `openCells`/`bakeArchive` parallelize internally over -//! cores. +//! charts are independent. `openCells`/`bakeCellsParallel` parallelize +//! internally over cores. const std = @import("std"); const pmtiles = @import("tiles").pmtiles; diff --git a/src/tile57.zig b/src/tile57.zig index ee0cf59..907fdb9 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -48,14 +48,12 @@ pub const warmup = chart.warmup; // ---- Bake: cells -> per-cell PMTiles archives ------------------------------ /// Bake each cell to its own PMTiles at its compilation scale — the input the -/// compositor serves from. `archive` is the alternative offline path that merges -/// a slice of cells into one band-streamed archive. +/// compositor serves from. Strictly one cell, one archive. pub const bake = struct { pub const cellBytes = chart.bakeCellBytes; // one cell + updates -> PMTiles bytes pub const cellsParallel = chart.bakeCellsParallel; // N cells -> N archives, threaded pub const cellsToFiles = chart.bakeCellsToFiles; // N cells -> files under a dir pub const tree = chart.bakeTree; // walk an ENC_ROOT, bake each cell to a mirrored path - pub const archive = chart.bakeArchive; // offline: merge a slice of cells into one archive pub const pmtilesMetadata = chart.pmtilesMetadata; // read an archive's TileJSON metadata pub const Progress = chart.BakeProgress; }; @@ -108,7 +106,7 @@ pub const pmtiles = @import("tiles").pmtiles; // PMTiles read/write pub const gzip = @import("tiles").gzip; // gzip (tile payloads, PMTiles internals) pub const band = @import("tiles").band; // compilation-scale -> zoom-range mapping pub const scene = @import("scene"); // S-57 + portrayal -> tile surface -pub const bake_enc = @import("scene").bake_enc; // the banded multi-cell baker +pub const bake_enc = @import("scene").bake_enc; // the banded cell baker // ---- Render surfaces ------------------------------------------------------- /// The Surface/Canvas rendering path: PNG raster, vector PDF, ASCII, and the From df39f97f5000024244576747bf6b7047acc62a88 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 07:18:55 -0400 Subject: [PATCH 108/140] =?UTF-8?q?feat(style):=20render=20complex=20lines?= =?UTF-8?q?tyles=20on=20the=20MapLibre=20path=20=E2=80=94=20per-linestyle?= =?UTF-8?q?=20dasharray=20+=20line-placed=20symbol=20layers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tiles carry each complex-linestyle run UN-tessellated, tagged ls_style (display-independent bake; the native pixel path re-walks them), but the generated style never referenced ls_style — cables, the IALA boundary, and caution-area boundaries drew as plain solid strokes via the lines-solid coalesce filter. The style template now emits one line layer per analysed LineStyles id (line-dasharray in line-width units from the pattern's on-runs; colour/width ride the run's own color_token/width_px, so palettes resolve like every other line) plus a symbol-placement:line layer per embedded symbol spaced one period apart, and lines-solid excludes ls_style runs. The analysed patterns ride the style's "tile57:linestyles" metadata, so a mariner rebuild from the style-as-template keeps the layers. Also adds `tile57 tiledump` (raw MLT/MVT tile summariser) used to diagnose this. Co-Authored-By: Claude Fable 5 --- bindings/go/style_linestyles_test.go | 55 +++++++++++ src/capi.zig | 10 ++ src/style/maplibre.zig | 131 ++++++++++++++++++++++++++- tools/main.zig | 5 + tools/tiledump.zig | 66 ++++++++++++++ 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 bindings/go/style_linestyles_test.go create mode 100644 tools/tiledump.zig diff --git a/bindings/go/style_linestyles_test.go b/bindings/go/style_linestyles_test.go new file mode 100644 index 0000000..d1d5390 --- /dev/null +++ b/bindings/go/style_linestyles_test.go @@ -0,0 +1,55 @@ +//go:build cgo + +package tile57 + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestLinestyleLayersInTemplate(t *testing.T) { + tmpl, err := StyleTemplate(SchemeDay, "http://x/{z}/{x}/{y}", "http://x/sprite", "http://x/glyphs/{fontstack}/{range}", 0, 0, FormatMLT) + if err != nil { + t.Fatal(err) + } + var doc struct { + Layers []map[string]any `json:"layers"` + Metadata map[string]any `json:"metadata"` + } + if err := json.Unmarshal(tmpl, &doc); err != nil { + t.Fatal(err) + } + ls, sym := 0, 0 + for _, l := range doc.Layers { + id, _ := l["id"].(string) + if strings.HasPrefix(id, "lines-ls-") { + if l["type"] == "symbol" { + sym++ + } else { + ls++ + } + } + } + if ls == 0 { + t.Fatal("no lines-ls-* layers in the template") + } + if doc.Metadata["tile57:linestyles"] == nil { + t.Fatal("no tile57:linestyles metadata carrier") + } + t.Logf("%d linestyle line layers, %d symbol layers", ls, sym) + + // The mariner rebuild (BuildStyle) must keep them via the metadata carrier. + ct, _ := ColortablesDefault() + built, err := BuildStyle(tmpl, MarinerDefaults(), ct, nil, nil, 39.0) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(built), "lines-ls-") { + t.Fatal("BuildStyle dropped the linestyle layers") + } + // And lines-solid must exclude ls_style runs. + if !strings.Contains(string(built), "ls_style") { + t.Fatal("built style has no ls_style references at all") + } +} diff --git a/src/capi.zig b/src/capi.zig index 95f3a19..8bfcab3 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -19,6 +19,7 @@ const errors = @import("errors"); // the engine error taxonomy + describe() // the style C ABI generates colortables + a base style template with no on-disk // catalogue. Symbols/linestyles are NOT embedded here (only the bake exe needs them). const colorprofile_registry = @import("colorprofile_registry"); +const catalog_embed = @import("catalog"); // embedded S-101 assets (linestyles for the style template) // smp_allocator (Zig's fast thread-safe GPA), not page_allocator: the live // tile/chart path makes many small, short-lived allocations; page_allocator @@ -1286,6 +1287,15 @@ export fn tile57_style_template( opts.minzoom = minzoom; if (maxzoom != 0) opts.maxzoom = maxzoom; if (tile_encoding == TILE_TYPE_MLT) opts.encoding = "mlt"; + // Analysed complex linestyles from the embedded catalogue: the template gains + // the ls_style decoration layers + the "tile57:linestyles" metadata carrier + // (tile57_style_build rebuilds them from that carrier on every mariner change). + var ls_srcs = std.ArrayList(style.LineStyleSrc).empty; + defer ls_srcs.deinit(gpa); + for (catalog_embed.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; + const ls_json: ?[]u8 = style.linestylesJson(gpa, ls_srcs.items) catch null; + defer if (ls_json) |b| gpa.free(b); + opts.linestyles_json = ls_json; const style_json = style.json(gpa, opts) catch |e| return fail(err, e); return exportOut(err, o, n, style_json); } diff --git a/src/style/maplibre.zig b/src/style/maplibre.zig index 0d2353d..736213d 100644 --- a/src/style/maplibre.zig +++ b/src/style/maplibre.zig @@ -108,7 +108,7 @@ test "displayDenom is the exact inverse of scaminDisplayZoom" { // S-52 DrawingPriority fill order: draw_prio*1000 - drval1. const FILL_SORT = .{ "-", .{ "*", .{ "coalesce", .{ "get", "draw_prio" }, 0 }, 1000 }, .{ "coalesce", .{ "get", "drval1" }, 0 } }; -const FILT_SOLID = .{ "==", .{ "coalesce", .{ "get", "dash" }, "solid" }, "solid" }; +const FILT_SOLID = .{ "all", .{ "==", .{ "coalesce", .{ "get", "dash" }, "solid" }, "solid" }, .{ "!", .{ "has", "ls_style" } } }; const FILT_DASHED = .{ "==", .{ "get", "dash" }, "dashed" }; const FILT_DOTTED = .{ "==", .{ "get", "dash" }, "dotted" }; @@ -192,6 +192,14 @@ pub const Options = struct { // so all features show in-band regardless of their SCAMIN denominator (and // regardless of whether a manifest is present). Default off = normal gating. ignore_scamin: bool = false, + // Analysed complex-linestyle patterns (the linestyles.json emitted for clients: + // id -> {period_px, dash[], color_token, width_px, symbols[]}). When present the + // style gains one dasharray line layer per id (plus line-placed symbol layers + // when a sprite atlas is wired) decorating the un-tessellated ls_style runs the + // tiles carry, and the JSON rides the style's metadata ("tile57:linestyles") so + // a mariner rebuild from the template keeps the layers. null = ls_style runs + // draw as plain solid lines. + linestyles_json: ?[]const u8 = null, // §4 physical-scale multiplier applied to icon-size / line-width / text-size // (the host's _featureSizeScale from its calibrated CSS-pixel pitch). 1.0 = the // catalogue sizes verbatim (byte-identical output); other values wrap each size @@ -427,6 +435,93 @@ fn lineLayers(js: *Stringify, s: *const SCtx, sl: []const u8, bkt: Bucket) !void try lineLayer(js, s, sl, "dotted", FILT_DOTTED, .{ 1, 2 }, bkt); } +// One decorated layer set for one analysed complex linestyle: a line layer whose +// line-dasharray is the pattern's on/off runs in line-width units, and (sprite +// permitting) a line-placed symbol layer per embedded symbol, spaced one period +// apart. Colour/width ride the run's own color_token/width_px tile properties, so +// the palette resolves exactly like every other line. +fn linestyleLayers(js: *Stringify, s: *const SCtx, ls: std.json.Value, bkt: Bucket, sprite_on: bool) !void { + var it = ls.object.iterator(); + while (it.next()) |e| { + const id = e.key_ptr.*; + const v = e.value_ptr.*; + if (v != .object) continue; + const period = jsonNum(v.object.get("period_px")) orelse continue; + const width = jsonNum(v.object.get("width_px")) orelse 1; + const w = if (width > 0.05) width else 1.0; + const dash_v = v.object.get("dash") orelse continue; + if (dash_v != .array or dash_v.array.items.len == 0) continue; + + var buf: [96]u8 = undefined; + try js.beginObject(); + try layerHead(js, try std.fmt.bufPrint(&buf, "lines-ls-{s}{s}", .{ id, bkt.suffix }), "line", "lines"); + try applyBucket(js, .{ "==", .{ "get", "ls_style" }, id }, true, bkt, s, null); + try js.objectField("layout"); + try js.beginObject(); + try js.objectField("line-sort-key"); + try js.write(.{ "coalesce", .{ "get", "draw_prio" }, 0 }); + try js.endObject(); + try js.objectField("paint"); + try js.beginObject(); + try js.objectField("line-color"); + try js.write(s.line_color); + try js.objectField("line-width"); + try writeScaled(js, .{ "coalesce", .{ "get", "width_px" }, 1 }, s.size_scale); + // MapLibre dasharray units are multiples of the line width. + try js.objectField("line-dasharray"); + try js.beginArray(); + for (dash_v.array.items) |dv| { + const px = jsonNumV(dv) orelse 0; + try js.write(px / w); + } + try js.endArray(); + try js.endObject(); // paint + try js.endObject(); // layer + + if (!sprite_on) continue; + const syms_v = v.object.get("symbols") orelse continue; + if (syms_v != .array) continue; + for (syms_v.array.items, 0..) |sv, si| { + if (sv != .object) continue; + const name_v = sv.object.get("n") orelse continue; + if (name_v != .string) continue; + try js.beginObject(); + try layerHead(js, try std.fmt.bufPrint(&buf, "lines-ls-{s}-sym{d}{s}", .{ id, si, bkt.suffix }), "symbol", "lines"); + try applyBucket(js, .{ "==", .{ "get", "ls_style" }, id }, true, bkt, s, null); + try js.objectField("layout"); + try js.beginObject(); + try js.objectField("symbol-placement"); + try js.write("line"); + try js.objectField("symbol-spacing"); + try js.write(@max(period * s.size_scale, 1)); + try js.objectField("icon-image"); + try js.write(name_v.string); + try js.objectField("icon-size"); + try writeScaled(js, ICON_SIZE, s.size_scale); + try js.objectField("icon-rotation-alignment"); + try js.write("map"); + try js.objectField("icon-allow-overlap"); + try js.write(true); + try js.objectField("icon-ignore-placement"); + try js.write(true); + try js.endObject(); // layout + try js.endObject(); // layer + } + } +} + +fn jsonNum(v: ?std.json.Value) ?f64 { + return jsonNumV(v orelse return null); +} + +fn jsonNumV(v: std.json.Value) ?f64 { + return switch (v) { + .float => |f| f, + .integer => |i| @floatFromInt(i), + else => null, + }; +} + fn pointLayout(js: *Stringify, alignment: []const u8, icon: std.json.Value, scale: f64) !void { try js.beginObject(); try js.objectField("icon-image"); @@ -825,6 +920,13 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { var barena = std.heap.ArenaAllocator.init(alloc); defer barena.deinit(); const ba = barena.allocator(); + // Analysed complex-linestyle patterns for the ls_style layers (kept in the + // bucket arena; malformed input just drops the decoration layers). + const ls_parsed: ?std.json.Value = if (opts.linestyles_json) |lsj| blk: { + const pv = std.json.parseFromSliceLeaky(std.json.Value, ba, lsj, .{}) catch break :blk null; + break :blk if (pv == .object) pv else null; + } else null; + const gate: Bucket = if (opts.ignore_scamin) .{} else if (opts.scamin_filter_gate) @@ -956,6 +1058,15 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { // source-layers to draw. for (scamin_buckets) |bkt| try lineLayers(js, &s, "lines", bkt); + // 4b. complex (symbolised) linestyles: the tiles carry each run UN-tessellated, + // tagged ls_style + color_token + width_px (scene.zig storeComplexRun). One + // dasharray line layer per analysed LineStyles id decorates the runs, plus a + // line-placed symbol layer per embedded symbol when a sprite atlas is wired. + // lines-solid excludes ls_style runs, so nothing double-draws. + if (ls_parsed) |lsv| { + for (scamin_buckets) |bkt| try linestyleLayers(js, &s, lsv, bkt, sprite_on); + } + // 5. point symbols + soundings (sprite required) over the merged `point_symbols` // layer, stacked by z-order — draw_prio is the SOLE axis, partitioned at the // soundings boundary (18): @@ -996,6 +1107,16 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { try js.endArray(); // layers + // Carry the analysed linestyles in the style itself, so a mariner rebuild from + // this style-as-template (buildFromTemplateScamin) re-emits the ls layers. + if (ls_parsed) |lsv| { + try js.objectField("metadata"); + try js.beginObject(); + try js.objectField("tile57:linestyles"); + try js.write(lsv); + try js.endObject(); + } + if (opts.glyphs) |g| { try js.objectField("glyphs"); try js.write(g); @@ -1047,6 +1168,8 @@ pub fn buildFromTemplateScamin( var parsed = std.json.parseFromSlice(std.json.Value, alloc, template_json, .{}) catch return alloc.dupe(u8, template_json); defer parsed.deinit(); + var lifted_ls: ?[]u8 = null; + defer if (lifted_ls) |b| alloc.free(b); var opts = Options{ .scheme = switch (m.scheme) { .dusk => "dusk", @@ -1071,6 +1194,12 @@ pub fn buildFromTemplateScamin( if (root.get("glyphs")) |v| { if (v == .string) opts.glyphs = v.string; } + if (root.get("metadata")) |mv| { + if (mv == .object) if (mv.object.get("tile57:linestyles")) |lv| { + lifted_ls = std.json.Stringify.valueAlloc(alloc, lv, .{}) catch null; + opts.linestyles_json = lifted_ls; + }; + } if (root.get("sources")) |sv| { if (sv == .object) if (sv.object.get("chart")) |cv| { if (cv == .object) { diff --git a/tools/main.zig b/tools/main.zig index da8d14a..e4269f3 100644 --- a/tools/main.zig +++ b/tools/main.zig @@ -29,6 +29,7 @@ const sprite = @import("sprite.zig"); const pattern = @import("pattern.zig"); const sprite_mln = @import("sprite_mln.zig"); const style = @import("style.zig"); +const tiledump = @import("tiledump.zig"); const render = @import("render.zig"); const ascii = @import("ascii.zig"); const explore = @import("explore.zig"); @@ -77,6 +78,10 @@ pub fn main(init: std.process.Init) !void { return style.run(io, arena, args); } + if (std.mem.eql(u8, sub, "tiledump")) { + return tiledump.run(io, arena, args); + } + if (std.mem.eql(u8, sub, "png") or std.mem.eql(u8, sub, "renderpng")) { return render.run(io, arena, args, .png); } diff --git a/tools/tiledump.zig b/tools/tiledump.zig new file mode 100644 index 0000000..9d9d152 --- /dev/null +++ b/tools/tiledump.zig @@ -0,0 +1,66 @@ +//! tile57 tiledump — decode ONE raw (decompressed) vector +//! tile and summarise it: per-layer feature counts by geometry type, plus value +//! histograms for the properties that identify portrayal output (class, +//! symbol_name, ls). For debugging what a bake or the compositor actually put +//! in a tile. + +const std = @import("std"); +const engine = @import("engine"); + +const Count = struct { pts: u32 = 0, lines: u32 = 0, polys: u32 = 0 }; + +pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { + if (args.len < 3) { + std.debug.print("usage: tile57 tiledump [--prop KEY]\n", .{}); + return; + } + const bytes = try std.Io.Dir.cwd().readFileAlloc(io, args[2], a, .unlimited); + var extra_prop: ?[]const u8 = null; + var i: usize = 3; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--prop") and i + 1 < args.len) { + extra_prop = args[i + 1]; + i += 1; + } + } + + const layers = engine.mlt.decode(a, bytes) catch |mlt_err| blk: { + break :blk engine.mvt.decode(a, bytes) catch { + std.debug.print("not a decodable MLT ({s}) or MVT tile\n", .{@errorName(mlt_err)}); + return; + }; + }; + + var out = std.ArrayList(u8).empty; + for (layers) |layer| { + var c = Count{}; + var hist = std.StringHashMap(u32).init(a); + const keys = [_][]const u8{ "class", "symbol_name", "ls" }; + for (layer.features) |f| { + switch (f.geom_type) { + .point => c.pts += 1, + .linestring => c.lines += 1, + .polygon => c.polys += 1, + .unknown => {}, + } + for (f.properties) |p| { + const interesting = for (keys) |k| { + if (std.mem.eql(u8, p.key, k)) break true; + } else (extra_prop != null and std.mem.eql(u8, p.key, extra_prop.?)); + if (!interesting) continue; + switch (p.value) { + .string => |v| { + const tag = try std.fmt.allocPrint(a, "{s}={s}", .{ p.key, v }); + const g = try hist.getOrPutValue(tag, 0); + g.value_ptr.* += 1; + }, + else => {}, + } + } + } + try out.print(a, "layer {s}: {d} pts, {d} lines, {d} polys\n", .{ layer.name, c.pts, c.lines, c.polys }); + var it = hist.iterator(); + while (it.next()) |e| try out.print(a, " {s} x{d}\n", .{ e.key_ptr.*, e.value_ptr.* }); + } + std.Io.File.stdout().writeStreamingAll(io, out.items) catch {}; +} From 042dd5ef74dc948789b7c51b072a756c62ff42ac Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 07:22:43 -0400 Subject: [PATCH 109/140] =?UTF-8?q?fix(style):=20linestyle=20symbols=20at?= =?UTF-8?q?=20the=20engine's=20SYMBOL=5FSCALE=20=E2=80=94=20they=20rendere?= =?UTF-8?q?d=20~2.8x=20too=20large?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line-placed linestyle symbols draw at SYMBOL_SCALE (0.0283), not the 0.08 point-symbol default the atlas is sized for; icon-size is now their ratio. Co-Authored-By: Claude Fable 5 --- src/style/maplibre.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/style/maplibre.zig b/src/style/maplibre.zig index 736213d..2396fcc 100644 --- a/src/style/maplibre.zig +++ b/src/style/maplibre.zig @@ -435,6 +435,12 @@ fn lineLayers(js: *Stringify, s: *const SCtx, sl: []const u8, bkt: Bucket) !void try lineLayer(js, s, sl, "dotted", FILT_DOTTED, .{ 1, 2 }, bkt); } +// Linestyle-embedded symbols draw at the engine's SYMBOL_SCALE (0.028346…, see +// scene/linestyle.zig drawComplexLine), not the 0.08 point-symbol default the +// sprite atlas is sized for — icon-size is their ratio so the style-driven +// symbols match the tessellated ones exactly. +const LS_ICON_SIZE: f64 = 0.02834627777338028 / 0.08; + // One decorated layer set for one analysed complex linestyle: a line layer whose // line-dasharray is the pattern's on/off runs in line-width units, and (sprite // permitting) a line-placed symbol layer per embedded symbol, spaced one period @@ -497,7 +503,7 @@ fn linestyleLayers(js: *Stringify, s: *const SCtx, ls: std.json.Value, bkt: Buck try js.objectField("icon-image"); try js.write(name_v.string); try js.objectField("icon-size"); - try writeScaled(js, ICON_SIZE, s.size_scale); + try writeScaled(js, LS_ICON_SIZE, s.size_scale); try js.objectField("icon-rotation-alignment"); try js.write("map"); try js.objectField("icon-allow-overlap"); From c64de7307df74a688f99e5b7601a5194ad6bd4b5 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 07:33:03 -0400 Subject: [PATCH 110/140] =?UTF-8?q?feat(bake):=20tessellate=20complex=20li?= =?UTF-8?q?nestyles=20into=20tiles=20=E2=80=94=20phase-correct=20dashes=20?= =?UTF-8?q?+=20symbols,=20zero=20extra=20style=20layers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The un-tessellated ls_style storage needed a client that re-walks runs by arc length; MapLibre can't phase-lock dasharray or icon spacing to anything, so style-side decoration (one layer per linestyle id + per embedded symbol, ~220 layers) could never line up — chevrons drifted against dashes. The tile bake now tessellates like every other surface: dash on-runs become plain solid line segments and embedded symbols become tangent-rotated point features (per-tile symbol ownership already handled), so the EXISTING lines-solid and point_symbols layers draw them phase-perfect with no per-linestyle layers at all. The style-side linestyle layers are removed; replayTile keeps its ls_style re-walk so archives baked before this render unchanged through the native path (on the map they degrade to plain strokes until re-baked). Co-Authored-By: Claude Fable 5 --- bindings/go/style_linestyles_test.go | 55 ----------- src/capi.zig | 10 -- src/scene/scene.zig | 17 ---- src/style/maplibre.zig | 137 +-------------------------- 4 files changed, 1 insertion(+), 218 deletions(-) delete mode 100644 bindings/go/style_linestyles_test.go diff --git a/bindings/go/style_linestyles_test.go b/bindings/go/style_linestyles_test.go deleted file mode 100644 index d1d5390..0000000 --- a/bindings/go/style_linestyles_test.go +++ /dev/null @@ -1,55 +0,0 @@ -//go:build cgo - -package tile57 - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestLinestyleLayersInTemplate(t *testing.T) { - tmpl, err := StyleTemplate(SchemeDay, "http://x/{z}/{x}/{y}", "http://x/sprite", "http://x/glyphs/{fontstack}/{range}", 0, 0, FormatMLT) - if err != nil { - t.Fatal(err) - } - var doc struct { - Layers []map[string]any `json:"layers"` - Metadata map[string]any `json:"metadata"` - } - if err := json.Unmarshal(tmpl, &doc); err != nil { - t.Fatal(err) - } - ls, sym := 0, 0 - for _, l := range doc.Layers { - id, _ := l["id"].(string) - if strings.HasPrefix(id, "lines-ls-") { - if l["type"] == "symbol" { - sym++ - } else { - ls++ - } - } - } - if ls == 0 { - t.Fatal("no lines-ls-* layers in the template") - } - if doc.Metadata["tile57:linestyles"] == nil { - t.Fatal("no tile57:linestyles metadata carrier") - } - t.Logf("%d linestyle line layers, %d symbol layers", ls, sym) - - // The mariner rebuild (BuildStyle) must keep them via the metadata carrier. - ct, _ := ColortablesDefault() - built, err := BuildStyle(tmpl, MarinerDefaults(), ct, nil, nil, 39.0) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(built), "lines-ls-") { - t.Fatal("BuildStyle dropped the linestyle layers") - } - // And lines-solid must exclude ls_style runs. - if !strings.Contains(string(built), "ls_style") { - t.Fatal("built style has no ls_style references at all") - } -} diff --git a/src/capi.zig b/src/capi.zig index 8bfcab3..95f3a19 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -19,7 +19,6 @@ const errors = @import("errors"); // the engine error taxonomy + describe() // the style C ABI generates colortables + a base style template with no on-disk // catalogue. Symbols/linestyles are NOT embedded here (only the bake exe needs them). const colorprofile_registry = @import("colorprofile_registry"); -const catalog_embed = @import("catalog"); // embedded S-101 assets (linestyles for the style template) // smp_allocator (Zig's fast thread-safe GPA), not page_allocator: the live // tile/chart path makes many small, short-lived allocations; page_allocator @@ -1287,15 +1286,6 @@ export fn tile57_style_template( opts.minzoom = minzoom; if (maxzoom != 0) opts.maxzoom = maxzoom; if (tile_encoding == TILE_TYPE_MLT) opts.encoding = "mlt"; - // Analysed complex linestyles from the embedded catalogue: the template gains - // the ls_style decoration layers + the "tile57:linestyles" metadata carrier - // (tile57_style_build rebuilds them from that carrier on every mariner change). - var ls_srcs = std.ArrayList(style.LineStyleSrc).empty; - defer ls_srcs.deinit(gpa); - for (catalog_embed.linestyles) |e| ls_srcs.append(gpa, .{ .id = e.name, .xml = e.bytes }) catch {}; - const ls_json: ?[]u8 = style.linestylesJson(gpa, ls_srcs.items) catch null; - defer if (ls_json) |b| gpa.free(b); - opts.linestyles_json = ls_json; const style_json = style.json(gpa, opts) catch |e| return fail(err, e); return exportOut(err, o, n, style_json); } diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 7074672..30702f1 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -461,7 +461,6 @@ pub const TileSurface = struct { .endScene = endScene, // Bake path: store complex runs un-tessellated (no size_scale set — bake is // native; replay re-walks the period display-scaled). - .store_complex_run = storeComplexRun, }; pub fn init(a: Allocator, format: TileFormat) TileSurface { @@ -557,22 +556,6 @@ pub const TileSurface = struct { try s.linesL().append(s.a, .{ .geom_type = .linestring, .parts = lines, .properties = props.items }); } - /// Store one clipped complex-linestyle run un-tessellated (display-independent - /// bake): a `lines` feature tagged with `ls_style`/`ls_arc0` so replayTile - /// re-looks-up the linestyle.Info and re-walks the period display-scaled at render time. - fn storeComplexRun(ctx: *anyopaque, line_style: []const u8, color: rs.ColorToken, width_px: f64, arc0: f64, run: []const rs.TilePoint) anyerror!void { - const s = sp(ctx); - var props = std.ArrayList(mvt.Prop).empty; - try props.append(s.a, .{ .key = "color_token", .value = .{ .string = color } }); - try props.append(s.a, .{ .key = "width_px", .value = .{ .double = width_px } }); - try props.append(s.a, .{ .key = "ls_style", .value = .{ .string = line_style } }); - try props.append(s.a, .{ .key = "ls_arc0", .value = .{ .double = arc0 } }); - try appendMeta(s.a, &props, s.cur); - const parts = try s.a.alloc([]const mvt.Point, 1); - parts[0] = run; - try s.linesL().append(s.a, .{ .geom_type = .linestring, .parts = parts, .properties = props.items }); - } - fn drawSymbol(ctx: *anyopaque, name: rs.SymbolName, at: rs.TilePoint, rot_deg: f64, scale: f64, rot_north: bool, placement: rs.SymbolPlacement, danger_depth: ?f64) anyerror!void { const s = sp(ctx); var props = std.ArrayList(mvt.Prop).empty; diff --git a/src/style/maplibre.zig b/src/style/maplibre.zig index 2396fcc..0d2353d 100644 --- a/src/style/maplibre.zig +++ b/src/style/maplibre.zig @@ -108,7 +108,7 @@ test "displayDenom is the exact inverse of scaminDisplayZoom" { // S-52 DrawingPriority fill order: draw_prio*1000 - drval1. const FILL_SORT = .{ "-", .{ "*", .{ "coalesce", .{ "get", "draw_prio" }, 0 }, 1000 }, .{ "coalesce", .{ "get", "drval1" }, 0 } }; -const FILT_SOLID = .{ "all", .{ "==", .{ "coalesce", .{ "get", "dash" }, "solid" }, "solid" }, .{ "!", .{ "has", "ls_style" } } }; +const FILT_SOLID = .{ "==", .{ "coalesce", .{ "get", "dash" }, "solid" }, "solid" }; const FILT_DASHED = .{ "==", .{ "get", "dash" }, "dashed" }; const FILT_DOTTED = .{ "==", .{ "get", "dash" }, "dotted" }; @@ -192,14 +192,6 @@ pub const Options = struct { // so all features show in-band regardless of their SCAMIN denominator (and // regardless of whether a manifest is present). Default off = normal gating. ignore_scamin: bool = false, - // Analysed complex-linestyle patterns (the linestyles.json emitted for clients: - // id -> {period_px, dash[], color_token, width_px, symbols[]}). When present the - // style gains one dasharray line layer per id (plus line-placed symbol layers - // when a sprite atlas is wired) decorating the un-tessellated ls_style runs the - // tiles carry, and the JSON rides the style's metadata ("tile57:linestyles") so - // a mariner rebuild from the template keeps the layers. null = ls_style runs - // draw as plain solid lines. - linestyles_json: ?[]const u8 = null, // §4 physical-scale multiplier applied to icon-size / line-width / text-size // (the host's _featureSizeScale from its calibrated CSS-pixel pitch). 1.0 = the // catalogue sizes verbatim (byte-identical output); other values wrap each size @@ -435,99 +427,6 @@ fn lineLayers(js: *Stringify, s: *const SCtx, sl: []const u8, bkt: Bucket) !void try lineLayer(js, s, sl, "dotted", FILT_DOTTED, .{ 1, 2 }, bkt); } -// Linestyle-embedded symbols draw at the engine's SYMBOL_SCALE (0.028346…, see -// scene/linestyle.zig drawComplexLine), not the 0.08 point-symbol default the -// sprite atlas is sized for — icon-size is their ratio so the style-driven -// symbols match the tessellated ones exactly. -const LS_ICON_SIZE: f64 = 0.02834627777338028 / 0.08; - -// One decorated layer set for one analysed complex linestyle: a line layer whose -// line-dasharray is the pattern's on/off runs in line-width units, and (sprite -// permitting) a line-placed symbol layer per embedded symbol, spaced one period -// apart. Colour/width ride the run's own color_token/width_px tile properties, so -// the palette resolves exactly like every other line. -fn linestyleLayers(js: *Stringify, s: *const SCtx, ls: std.json.Value, bkt: Bucket, sprite_on: bool) !void { - var it = ls.object.iterator(); - while (it.next()) |e| { - const id = e.key_ptr.*; - const v = e.value_ptr.*; - if (v != .object) continue; - const period = jsonNum(v.object.get("period_px")) orelse continue; - const width = jsonNum(v.object.get("width_px")) orelse 1; - const w = if (width > 0.05) width else 1.0; - const dash_v = v.object.get("dash") orelse continue; - if (dash_v != .array or dash_v.array.items.len == 0) continue; - - var buf: [96]u8 = undefined; - try js.beginObject(); - try layerHead(js, try std.fmt.bufPrint(&buf, "lines-ls-{s}{s}", .{ id, bkt.suffix }), "line", "lines"); - try applyBucket(js, .{ "==", .{ "get", "ls_style" }, id }, true, bkt, s, null); - try js.objectField("layout"); - try js.beginObject(); - try js.objectField("line-sort-key"); - try js.write(.{ "coalesce", .{ "get", "draw_prio" }, 0 }); - try js.endObject(); - try js.objectField("paint"); - try js.beginObject(); - try js.objectField("line-color"); - try js.write(s.line_color); - try js.objectField("line-width"); - try writeScaled(js, .{ "coalesce", .{ "get", "width_px" }, 1 }, s.size_scale); - // MapLibre dasharray units are multiples of the line width. - try js.objectField("line-dasharray"); - try js.beginArray(); - for (dash_v.array.items) |dv| { - const px = jsonNumV(dv) orelse 0; - try js.write(px / w); - } - try js.endArray(); - try js.endObject(); // paint - try js.endObject(); // layer - - if (!sprite_on) continue; - const syms_v = v.object.get("symbols") orelse continue; - if (syms_v != .array) continue; - for (syms_v.array.items, 0..) |sv, si| { - if (sv != .object) continue; - const name_v = sv.object.get("n") orelse continue; - if (name_v != .string) continue; - try js.beginObject(); - try layerHead(js, try std.fmt.bufPrint(&buf, "lines-ls-{s}-sym{d}{s}", .{ id, si, bkt.suffix }), "symbol", "lines"); - try applyBucket(js, .{ "==", .{ "get", "ls_style" }, id }, true, bkt, s, null); - try js.objectField("layout"); - try js.beginObject(); - try js.objectField("symbol-placement"); - try js.write("line"); - try js.objectField("symbol-spacing"); - try js.write(@max(period * s.size_scale, 1)); - try js.objectField("icon-image"); - try js.write(name_v.string); - try js.objectField("icon-size"); - try writeScaled(js, LS_ICON_SIZE, s.size_scale); - try js.objectField("icon-rotation-alignment"); - try js.write("map"); - try js.objectField("icon-allow-overlap"); - try js.write(true); - try js.objectField("icon-ignore-placement"); - try js.write(true); - try js.endObject(); // layout - try js.endObject(); // layer - } - } -} - -fn jsonNum(v: ?std.json.Value) ?f64 { - return jsonNumV(v orelse return null); -} - -fn jsonNumV(v: std.json.Value) ?f64 { - return switch (v) { - .float => |f| f, - .integer => |i| @floatFromInt(i), - else => null, - }; -} - fn pointLayout(js: *Stringify, alignment: []const u8, icon: std.json.Value, scale: f64) !void { try js.beginObject(); try js.objectField("icon-image"); @@ -926,13 +825,6 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { var barena = std.heap.ArenaAllocator.init(alloc); defer barena.deinit(); const ba = barena.allocator(); - // Analysed complex-linestyle patterns for the ls_style layers (kept in the - // bucket arena; malformed input just drops the decoration layers). - const ls_parsed: ?std.json.Value = if (opts.linestyles_json) |lsj| blk: { - const pv = std.json.parseFromSliceLeaky(std.json.Value, ba, lsj, .{}) catch break :blk null; - break :blk if (pv == .object) pv else null; - } else null; - const gate: Bucket = if (opts.ignore_scamin) .{} else if (opts.scamin_filter_gate) @@ -1064,15 +956,6 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { // source-layers to draw. for (scamin_buckets) |bkt| try lineLayers(js, &s, "lines", bkt); - // 4b. complex (symbolised) linestyles: the tiles carry each run UN-tessellated, - // tagged ls_style + color_token + width_px (scene.zig storeComplexRun). One - // dasharray line layer per analysed LineStyles id decorates the runs, plus a - // line-placed symbol layer per embedded symbol when a sprite atlas is wired. - // lines-solid excludes ls_style runs, so nothing double-draws. - if (ls_parsed) |lsv| { - for (scamin_buckets) |bkt| try linestyleLayers(js, &s, lsv, bkt, sprite_on); - } - // 5. point symbols + soundings (sprite required) over the merged `point_symbols` // layer, stacked by z-order — draw_prio is the SOLE axis, partitioned at the // soundings boundary (18): @@ -1113,16 +996,6 @@ pub fn json(alloc: std.mem.Allocator, opts: Options) ![]u8 { try js.endArray(); // layers - // Carry the analysed linestyles in the style itself, so a mariner rebuild from - // this style-as-template (buildFromTemplateScamin) re-emits the ls layers. - if (ls_parsed) |lsv| { - try js.objectField("metadata"); - try js.beginObject(); - try js.objectField("tile57:linestyles"); - try js.write(lsv); - try js.endObject(); - } - if (opts.glyphs) |g| { try js.objectField("glyphs"); try js.write(g); @@ -1174,8 +1047,6 @@ pub fn buildFromTemplateScamin( var parsed = std.json.parseFromSlice(std.json.Value, alloc, template_json, .{}) catch return alloc.dupe(u8, template_json); defer parsed.deinit(); - var lifted_ls: ?[]u8 = null; - defer if (lifted_ls) |b| alloc.free(b); var opts = Options{ .scheme = switch (m.scheme) { .dusk => "dusk", @@ -1200,12 +1071,6 @@ pub fn buildFromTemplateScamin( if (root.get("glyphs")) |v| { if (v == .string) opts.glyphs = v.string; } - if (root.get("metadata")) |mv| { - if (mv == .object) if (mv.object.get("tile57:linestyles")) |lv| { - lifted_ls = std.json.Stringify.valueAlloc(alloc, lv, .{}) catch null; - opts.linestyles_json = lifted_ls; - }; - } if (root.get("sources")) |sv| { if (sv == .object) if (sv.object.get("chart")) |cv| { if (cv == .object) { From 7f2a6c0b944456e434d805c1ea1792939a5e9df5 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 07:57:26 -0400 Subject: [PATCH 111/140] =?UTF-8?q?fix(scene):=20honor=20S-52=20=C2=A78.6.?= =?UTF-8?q?2=20edge=20masking=20in=20the=20M=5FNSYS=20and=20dashed-boundar?= =?UTF-8?q?y=20fallbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portrayal stroke path already drops MASK==1/USAG==3 edges (drawableLineParts); the native fallbacks stroked the full fill rings, so the IALA system boundary drew along every cell junction of a uniform region — producers mask exactly those cell-limit edges so the boundary strokes only where a real division runs. Validated on US5PHLDF/US5PHLDG: the false A/B line along the shared cell edge is gone; real (unmasked) divisions still stroke. Fill geometry is untouched. Co-Authored-By: Claude Fable 5 --- src/scene/scene.zig | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 30702f1..d5ff9e7 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1692,7 +1692,14 @@ fn emitSweptAreaFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize /// absent on the reference data; the A/B boundary line is the visible feature. fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { if (opts.suppress_lines) return; - const geo_parts = featureParts(a, cell, geo, fi, f) catch return; + // S-52 §8.6.2 boundary masking, mirrored from the portrayal stroke path: the + // producer flags the M_NSYS edges that coincide with the cell limit (MASK/USAG), + // so the system boundary strokes only where a REAL division runs — not along + // every cell junction of a uniform IALA region. The fill is unaffected. + const geo_parts = if (cell.needsDrawableBoundary(f)) + cell.drawableLineParts(a, f) catch return + else + featureParts(a, cell, geo, fi, f) catch return; if (geo_parts.len == 0) return; const fmeta = rs.FeatureMeta{ @@ -1743,7 +1750,12 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize fn emitDashedBoundary(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, color: []const u8, width: f64, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { if (f.prim != 2 and f.prim != 3) return; if (opts.suppress_lines) return; // coarse band over finer M_COVR (centre): drop the stroke - const geo_parts = featureParts(a, cell, geo, fi, f) catch return; + // Same S-52 §8.6.2 boundary masking as the portrayal stroke path (see + // emitNavSystemFallback): masked cell-limit edges don't stroke. + const geo_parts = if (cell.needsDrawableBoundary(f)) + cell.drawableLineParts(a, f) catch return + else + featureParts(a, cell, geo, fi, f) catch return; if (geo_parts.len == 0) return; const fmeta = rs.FeatureMeta{ From 046f3129f88cda85ad196bdd620ea520c431bfdb Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 09:44:16 -0400 Subject: [PATCH 112/140] =?UTF-8?q?feat(compose):=20fill-up=20quilting=20?= =?UTF-8?q?=E2=80=94=20finer=20cells=20serve=20coarser=20zooms=20where=20n?= =?UTF-8?q?othing=20coarser=20covers,=20SCAMIN-restricted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zooming out over ground with only coastal/harbor coverage went blank below the band floor. Three pieces close it: - Per-cell bakes extend to z0 (the existing extend_min pass): sub-band tiles carry the cell's content thinned by a new scene-level SCAMIN cull — below the native band floor, a feature whose (floored) SCAMIN cannot display anywhere in the tile is dropped (latitude-correct threshold with one zoom of slack, strictly looser than any client gate), so a harbor cell's z4 copy is land/coast/majors, not every sounding. SCAMIN-less features always ride. - The ownership partition gains fill-up gap-fillers: finer cells own ground at coarser tiers ONLY where no eligible cell covers, appended after every eligible cell (they can never take ground from a band's own cells — the prior stashed attempt failed exactly there, in the batch baker's emitted dedup, a mechanism the per-cell model does not have). Among fillers the coarsest wins; fully-covered fillers add no face, so a nested harbor cell changes nothing. Partition sidecar format bumped to 2 (stale ones rebuild). - Compose derives its floor from what the archives actually carry. Validated: two harbor-only cells (US5PHLDF/DG) compose a 28 KB z4 tile (LNDARE/DEPARE/COALNE, 14 soundings — the un-culled bake would carry ~1800) in 3.9 ms, and render land + labels at z4 where before was blank; partition fill-up unit test + fuzz oracle extended to the new semantics. Co-Authored-By: Claude Fable 5 --- bindings/go/compose_test.go | 17 +++++++--- src/chart.zig | 7 ++-- src/compose/compose.zig | 3 ++ src/geometry/partition.zig | 34 +++++++++++++++++++- src/geometry/plane.zig | 64 +++++++++++++++++++++++++++++++++++-- src/scene/bake_enc.zig | 17 ++++++++-- src/scene/scene.zig | 19 +++++++++++ 7 files changed, 147 insertions(+), 14 deletions(-) diff --git a/bindings/go/compose_test.go b/bindings/go/compose_test.go index 94beec9..5737aa6 100644 --- a/bindings/go/compose_test.go +++ b/bindings/go/compose_test.go @@ -35,13 +35,20 @@ func TestComposeSingleCell(t *testing.T) { if m.Cells != 1 { t.Fatalf("compose cells = %d, want 1", m.Cells) } - cx, cy := lonLatToTile((m.West+m.East)/2, (m.South+m.North)/2, m.MinZoom) - tile, owned, err := src.Tile(m.MinZoom, cx, cy) + // The fill-up bake serves from z0, but a single harbour cell is sub-pixel at + // world zooms (its low-zoom tiles are legitimately empty) — probe a zoom + // where the cell has real extent. + z := uint8(10) + if z < m.MinZoom { + z = m.MinZoom + } + cx, cy := lonLatToTile((m.West+m.East)/2, (m.South+m.North)/2, z) + tile, owned, err := src.Tile(z, cx, cy) if err != nil { t.Fatalf("Tile: %v", err) } if len(tile) == 0 || !owned { - t.Fatalf("centre tile z%d/%d/%d: %d bytes owned=%v, want content", m.MinZoom, cx, cy, len(tile), owned) + t.Fatalf("centre tile z%d/%d/%d: %d bytes owned=%v, want content", z, cx, cy, len(tile), owned) } part := filepath.Join(t.TempDir(), "partition.tpart") if err := src.SavePartition(part); err != nil { @@ -57,7 +64,7 @@ func TestComposeSingleCell(t *testing.T) { t.Fatalf("OpenCompose: %v", err) } defer src2.Close() - tile2, owned2, err := src2.Tile(m.MinZoom, cx, cy) + tile2, owned2, err := src2.Tile(z, cx, cy) if err != nil { t.Fatalf("Tile(sidecar): %v", err) } @@ -65,7 +72,7 @@ func TestComposeSingleCell(t *testing.T) { t.Fatalf("sidecar-loaded serve differs: %d bytes owned=%v (want %d bytes)", len(tile2), owned2, len(tile)) } t.Logf("compose z%d..%d served %d bytes at z%d/%d/%d (sidecar round-trip ok)", - m.MinZoom, m.MaxZoom, len(tile), m.MinZoom, cx, cy) + m.MinZoom, m.MaxZoom, len(tile), z, cx, cy) } // Set TILE57_COMPOSE_TESTDIR to a dir of per-cell *.cell.tmp/*.pmtiles archives (e.g. from diff --git a/src/chart.zig b/src/chart.zig index 44b054e..b0977b9 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -613,10 +613,13 @@ pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 { coverage_json = scene.coverage.encodeJson(cov_arena.allocator(), cc) catch null; } else |_| {} - // Native scale only: the cell's band window, no fill-down / overscale. + // The cell's band window, plus the extend_min fill DOWN to z0: sub-band tiles + // (scamin-thinned by the scene cull) let the compositor pull this cell up into + // coarser zooms where nothing coarser covers — a harbor-only region still + // shows land and coast at z4. No overscale above the window. const zr = bake_enc.bandZooms(bake_enc.bandOf(cscl)); const cell_in = [_]CellInput{.{ .base = cf.base, .updates = cf.updates }}; - return bakeArchive(&cell_in, resolveRulesDir(rules_dir), zr.min, zr.max, .mlt, true, null, null, coverage_json); + return bakeArchive(&cell_in, resolveRulesDir(rules_dir), 0, zr.max, .mlt, true, null, null, coverage_json); } // ---- parallel batch cell-bake ------------------------------------------------- diff --git a/src/compose/compose.zig b/src/compose/compose.zig index 5fc9f64..a1465ad 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -394,6 +394,9 @@ fn finishOpen( var minz: u8 = 255; var maxz: u8 = 0; var ubox = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; // union coverage [w, s, e, n] + // The floor is what the archives actually carry (a fill-up bake starts at z0; + // an archive baked before fill-up starts at its band floor), not the band model. + for (readers) |rp| minz = @min(minz, rp.header.min_zoom); for (shims) |sh| { const bz = band.bandZooms(band.bandOf(sh.cscl)); minz = @min(minz, bz.min); diff --git a/src/geometry/partition.zig b/src/geometry/partition.zig index 33c2848..5070eac 100644 --- a/src/geometry/partition.zig +++ b/src/geometry/partition.zig @@ -150,7 +150,7 @@ pub fn build(gpa: std.mem.Allocator, cells: []const plane.Cell) !Partition { // which is what makes an incremental recompose safe when coverage is unchanged. pub const MAGIC = [4]u8{ 'T', '5', '7', 'P' }; -pub const FORMAT_VERSION: u32 = 1; +pub const FORMAT_VERSION: u32 = 2; // 2: fill-up gap-filler faces (finer cells own uncovered ground at coarse tiers) pub const LoadError = error{ BadMagic, @@ -359,6 +359,38 @@ test "partition band-stack: tiers descending, mapForZoom + ownerAt resolve per b try testing.expect(part.ownedFace(99, 14) == null); // out of range } +test "fill-up: finer cells own coarse-tier ground only where nothing coarser covers" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // A coastal cell [0,100]² (floor 9) and two finer cells: a harbor [40,60]² + // (floor 13) inside the coastal coverage, and a harbor [200,220]² (floor 13) + // over ground NOTHING coarser covers. + const coastal = try boxPoly(a, 0, 0, 100, 100); + const harbor_in = try boxPoly(a, 40, 40, 60, 60); + const harbor_out = try boxPoly(a, 200, 200, 220, 220); + const cells = [_]plane.Cell{ + .{ .cscl = 100_000, .band_floor = 9, .order = 0, .cov1 = &.{coastal} }, + .{ .cscl = 20_000, .band_floor = 13, .order = 0, .cov1 = &.{harbor_in} }, + .{ .cscl = 20_000, .band_floor = 13, .order = 1, .cov1 = &.{harbor_out} }, + }; + + var part = try build(testing.allocator, &cells); + defer part.deinit(); + + // z4 → the coarsest map (tier 9). The coastal cell keeps every point it + // covers — the nested harbor is a gap-filler and takes NOTHING from it — + // while the uncovered harbor owns its own ground instead of a blank. + try testing.expectEqual(@as(?usize, 0), part.ownerAt(4, 50, 50)); + try testing.expectEqual(@as(?usize, 0), part.ownerAt(4, 10, 10)); + try testing.expectEqual(@as(?usize, 2), part.ownerAt(4, 210, 210)); + try testing.expectEqual(@as(?usize, null), part.ownerAt(4, 400, 400)); + + // At the harbor band the nested harbor owns its box as before. + try testing.expectEqual(@as(?usize, 1), part.ownerAt(14, 50, 50)); +} + test "partition serialize/deserialize round-trips exactly" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); diff --git a/src/geometry/plane.zig b/src/geometry/plane.zig index 1d5969f..7e27fb9 100644 --- a/src/geometry/plane.zig +++ b/src/geometry/plane.zig @@ -127,6 +127,24 @@ pub fn ownedAtTier(gpa: Allocator, cells: []const Cell, tier: u8) ![]OwnedCell { }.lt); reachCut(cells, &order, tier); + // Fill-up gap-fillers: FINER cells (band_floor > tier) own ground at this + // coarser tier only where NO eligible cell covers — appended after every + // eligible cell, so they can never take ground from the band's own cells + // (position in `order` is priority: each cell's face is its coverage minus + // every earlier cell's). Among the fillers the COARSEST wins (closest to + // this tier's display scale), equal scales by the usual tie-break. So a + // harbor-only region still has an owner at z4, scamin-thinned by the bake. + const n_eligible = order.items.len; + for (cells, 0..) |c, i| { + if (c.band_floor > tier) try order.append(gpa, i); + } + std.mem.sort(usize, order.items[n_eligible..], cells, struct { + fn lt(cs: []const Cell, ia: usize, ib: usize) bool { + if (cs[ia].cscl != cs[ib].cscl) return cs[ia].cscl > cs[ib].cscl; // coarsest first + return cs[ia].order < cs[ib].order; + } + }.lt); + var scratch = std.heap.ArenaAllocator.init(gpa); defer scratch.deinit(); const sa = scratch.allocator(); @@ -140,14 +158,20 @@ pub fn ownedAtTier(gpa: Allocator, cells: []const Cell, tier: u8) ![]OwnedCell { // Accumulated coverage of all finer eligible cells processed so far. var covered: [][]Pt = try sa.alloc([]Pt, 0); - for (order.items) |i| { + for (order.items, 0..) |i, k| { const cov = try cellCoverage(sa, cells[i]); // owned = cov \ covered, allocated in gpa (the kept result). const owned = if (covered.len == 0) try dupePolygonGpa(gpa, cov) else try boolean.compute(gpa, cov, covered, .diff); - try out.append(gpa, .{ .index = i, .owned = owned }); + // A gap-filler fully covered by the eligible cells owns nothing here — + // drop its empty face so a nested finer cell adds no face at all. + if (k >= n_eligible and owned.len == 0) { + boolean.freePolygon(gpa, owned); + } else { + try out.append(gpa, .{ .index = i, .owned = owned }); + } // covered ∪= cov (scratch). const merged = try boolean.compute(sa, covered, cov, .unite); @@ -192,6 +216,24 @@ pub fn ownedAtTierIndexed(gpa: Allocator, cells: []const Cell, tier: u8) ![]Owne }.lt); reachCut(cells, &order, tier); + // Fill-up gap-fillers: FINER cells (band_floor > tier) own ground at this + // coarser tier only where NO eligible cell covers — appended after every + // eligible cell, so they can never take ground from the band's own cells + // (position in `order` is priority: each cell's face is its coverage minus + // every earlier cell's). Among the fillers the COARSEST wins (closest to + // this tier's display scale), equal scales by the usual tie-break. So a + // harbor-only region still has an owner at z4, scamin-thinned by the bake. + const n_eligible = order.items.len; + for (cells, 0..) |c, i| { + if (c.band_floor > tier) try order.append(gpa, i); + } + std.mem.sort(usize, order.items[n_eligible..], cells, struct { + fn lt(cs: []const Cell, ia: usize, ib: usize) bool { + if (cs[ia].cscl != cs[ib].cscl) return cs[ia].cscl > cs[ib].cscl; // coarsest first + return cs[ia].order < cs[ib].order; + } + }.lt); + var scratch = std.heap.ArenaAllocator.init(gpa); defer scratch.deinit(); const sa = scratch.allocator(); @@ -244,6 +286,12 @@ pub fn ownedAtTierIndexed(gpa: Allocator, cells: []const Cell, tier: u8) ![]Owne defer boolean.freePolygon(pa, diff); break :blk try dupePolygonGpa(gpa, diff); }; + // A gap-filler fully covered by the eligible cells owns nothing here — + // drop its empty face so a nested finer cell adds no face at all. + if (k >= n_eligible and owned.len == 0) { + boolean.freePolygon(gpa, owned); + continue; + } try out.append(gpa, .{ .index = ci, .owned = owned }); } return out.toOwnedSlice(gpa); @@ -734,7 +782,8 @@ test "fuzz: partition == per-point finest-eligible-covering, zero overlap, zero while (qy <= 11 * grid) : (qy += 19) { var qx: i64 = -7 * grid; while (qx <= 11 * grid) : (qx += 19) { - // Reference: finest (smallest cscl) eligible cell whose bbox covers. + // Reference: finest (smallest cscl) eligible cell whose bbox covers; + // where none covers, the COARSEST (largest cscl) finer cell fills up. var best: ?usize = null; for (cells.items, 0..) |c, i| { if (c.band_floor > tier) continue; @@ -743,6 +792,15 @@ test "fuzz: partition == per-point finest-eligible-covering, zero overlap, zero if (best == null or c.cscl < cells.items[best.?].cscl) best = i; } } + if (best == null) { + for (cells.items, 0..) |c, i| { + if (c.band_floor <= tier) continue; + const bb = bboxes.items[i]; + if (qx >= bb[0] and qx <= bb[2] and qy >= bb[1] and qy <= bb[3]) { + if (best == null or c.cscl > cells.items[best.?].cscl) best = i; + } + } + } // Skip points on any bbox edge (even-odd ambiguity). var on_edge = false; for (bboxes.items) |bb| { diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index df4fdf2..d1bdc61 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -1490,15 +1490,26 @@ test "fill-down bake: a coastal-only bay gets low-zoom tiles the overview extend // band the extend_min fill rides), but it does not cover the bay. Without // fill-down the bay has no tiles below coastal's window (z<9): the reported // district-pack z6–z8 hole. + // Two features: a SCAMIN-gated buoy (hidden below its display scale — the + // sub-band cull drops it from fill-down tiles, where the client gate would + // hide it anyway) and a SCAMIN-less one (land/coast in real cells) that + // keeps the low-zoom tiles populated. const scamin_attr = [_]s57.Attr{.{ .code = 133, .value = "260000" }}; - const feats = [_]s57.Feature{.{ + const feats = [_]s57.Feature{ .{ .rcnm = 0, .rcid = 1, .prim = 1, .objl = 14, .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }}, .attrs = &scamin_attr, - }}; + }, .{ + .rcnm = 0, + .rcid = 2, + .prim = 1, + .objl = 14, + .refs = &.{.{ .name = .{ .rcnm = s57.RCNM_VI, .rcid = 1 }, .ornt = 255 }}, + .attrs = &.{}, + } }; var bay_cell = try testCell(gpa, 0.35, 0.35, 200_000, &feats); defer bay_cell.deinit(); var ov_cell = try testCell(gpa, 10.5, 10.5, 3_000_000, &feats); @@ -1513,7 +1524,7 @@ test "fill-down bake: a coastal-only bay gets low-zoom tiles the overview extend const cover = [_][]const []const s57.LonLat{&rings}; const bay_bounds = [4]f64{ 0.2, 0.2, 0.5, 0.5 }; const ov_bounds = [4]f64{ 10.2, 10.2, 10.8, 10.8 }; - const streams = [_]?[]const u8{"DrawingPriority:7;PointInstruction:BOYLAT01"}; + const streams = [_]?[]const u8{ "DrawingPriority:7;PointInstruction:BOYLAT01", "DrawingPriority:7;PointInstruction:BOYLAT01" }; const scamins = [_]u32{260_000}; var bay = Backend{ .cell = bay_cell, .portrayal = &streams, .bounds = bay_bounds, .cscl = 200_000, .coverage = &cover, .scamins = &scamins }; diff --git a/src/scene/scene.zig b/src/scene/scene.zig index d5ff9e7..472eddf 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -2095,10 +2095,29 @@ fn appendCellFeatures( // LIGHTS cull margin: display-mm sector figures reach ~1 tile at every zoom; // ground-length legs (directional lights) reach their honest per-zoom span. const light_reach = lightReachTiles(opts.light_range_m, z, (tb[1] + tb[3]) * 0.5); + // Sub-band SCAMIN cull: below the cell's native band floor this is a fill-up + // tile (the compositor pulls finer data into coarser zooms where nothing + // coarser covers), and a feature whose (floored) SCAMIN cannot display + // anywhere in this tile is dead weight — cull it so the z4 copy of a harbor + // cell carries land/coast/majors, not every sounding. The threshold is the + // tile's most permissive display denominator (its highest-|lat| edge) with + // one zoom of slack, so the cull stays strictly looser than any client + // gate-latitude choice; SCAMIN-less features always pass. + const subband_floor = @import("tiles").band.bandZooms(@import("tiles").band.bandOf(cell.params.cscl)).min; + const subband_min_denom: f64 = if (z < subband_floor) blk: { + const max_abs_lat = @max(@abs(tb[1]), @abs(tb[3])); + const k = @import("style").scaminGateK(max_abs_lat); + break :blk k / @as(f64, @floatFromInt(@as(u64, 1) << @intCast(z))) / 2.0; + } else 0; for (cell.features, 0..) |f, fi| { // Isolated single-feature render (explore --kitty thumbnail): draw only // the requested feature, skip the rest of the cell. if (opts.only_fi) |only| if (fi != only) continue; + if (subband_min_denom > 0) { + if (effScamin(f, opts)) |sc| { + if (@as(f64, @floatFromInt(sc)) < subband_min_denom) continue; + } + } var ml = mlon; var mt = mlat; if (f.objl == 75) { From c706a5c9851460c95a025629ac3ed6b05d475304 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 13:15:06 -0400 Subject: [PATCH 113/140] fix(scene): SCAMIN-less LIGHTS don't ride sub-band fill-up tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scene/scene.zig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 472eddf..ed36773 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -2116,6 +2116,17 @@ fn appendCellFeatures( if (subband_min_denom > 0) { if (effScamin(f, opts)) |sc| { if (@as(f64, @floatFromInt(sc)) < subband_min_denom) continue; + } else if (f.objl == 75) { + // LIGHTS is the one class whose SCAMIN-less features do NOT ride + // sub-band: producers leave SCAMIN off most fine-band lights (cell + // selection is the intended gate — an ECDIS at this scale would + // never load the cell), and a light's portrayal is all fixed + // display-size construction — flare, characteristic text, 20/25 mm + // sector legs and arcs — which reads as a continent-sized doodle + // on a fill-up tile. The true small-scale lights arrive from the + // overview/general cells, SCAMIN-carrying. Ground features + // (land/coast/depth) keep riding. + continue; } } var ml = mlon; From 42055d4a3c921caa1c08462d4a8fcd5e9b485513 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 13:19:34 -0400 Subject: [PATCH 114/140] =?UTF-8?q?fix(scene):=20size=20light=20sector=20f?= =?UTF-8?q?igures=20against=20the=20512-CSS-px=20tile=20=E2=80=94=20they?= =?UTF-8?q?=20drew=202x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scene/lightreach.zig | 7 ++++--- src/scene/scene.zig | 8 +++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/scene/lightreach.zig b/src/scene/lightreach.zig index 7c83710..103b968 100644 --- a/src/scene/lightreach.zig +++ b/src/scene/lightreach.zig @@ -10,9 +10,10 @@ const EARTH_CIRCUM_M: f64 = 40075016.686; // Worst-case reach of a light's sector legs/arcs (emitAugFigures) as a fraction of a // tile — these are drawn at a fixed DISPLAY size (radius/length in mm), so the reach -// is ~constant in tile units at every zoom (offset_tiles = mm * PX_PER_MM / 256). -// Used to widen the LIGHTS spatial-cull margin so an arc isn't dropped on the tiles it -// crosses (S-52 legs ~25 mm / arcs ~20 mm ≈ 0.8 tile; 1.0 leaves headroom). +// is ~constant in tile units at every zoom (offset_tiles = mm * PX_PER_MM / 512, the +// 512-CSS-px tile the figures are sized against). Used to widen the LIGHTS +// spatial-cull margin so an arc isn't dropped on the tiles it crosses (S-52 legs +// ~25 mm / arcs ~20 mm ≈ 0.2 tile; 1.0 is generous headroom). // GROUND-length legs (directional lights: nmi2metres(nominal range), LightSectored.lua) // exceed this by far at fine zooms — lightReachTiles is the honest per-zoom bound. pub const LIGHT_AUG_REACH_TILES: f64 = 1.0; diff --git a/src/scene/scene.zig b/src/scene/scene.zig index ed36773..6db4a5b 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1310,7 +1310,13 @@ const RING_MIN_ZOOM: u8 = 11; fn emitAugFigures(a: Allocator, figs: []const instructions.AugFigure, anchor: s57.LonLat, fmeta: rs.FeatureMeta, z: u8, x: u32, y: u32, box: tile.Box, surf: rs.Surface) !void { if (figs.len == 0) return; - const world_px = 256.0 * @as(f64, @floatFromInt(@as(u64, 1) << @intCast(z))); + // One tile renders as 512 CSS px (the engine's physical-scale model — style + // scaminGateK's M_PER_PX_Z0 is the 512-tile metres-per-CSS-px, and MapLibre + // lays vector tiles out at 512 logical px), so a display-mm length converts + // to a tile fraction against a 512·2^z world. The old 256-unit MVT world drew + // every figure at exactly 2x its catalogue size (20 mm sector arcs read + // 40 mm on the map). Ground-metre legs are a world-fraction ratio either way. + const world_px = 512.0 * @as(f64, @floatFromInt(@as(u64, 1) << @intCast(z))); const pxmm = instructions.PX_PER_MM; for (figs) |fig| { const alon = if (fig.has_anchor) fig.anchor_lon else anchor.lon(); From 3c46b234cc9d6577a9856369e975c8e2face2a2a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 13:36:41 -0400 Subject: [PATCH 115/140] =?UTF-8?q?feat(compose):=20reach=20ring=20?= =?UTF-8?q?=E2=80=94=20light=20sector=20figures=20survive=20into=20tiles?= =?UTF-8?q?=20their=20cell=20owns=20no=20ground=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/chart.zig | 21 ++++++++- src/compose/clip.zig | 7 +-- src/compose/compose.zig | 95 ++++++++++++++++++++++++++++++++------- src/coverage/coverage.zig | 24 +++++++++- src/geometry/plane.zig | 8 ++++ src/scene/lightreach.zig | 31 ++++--------- src/scene/scene.zig | 9 +++- src/tiles/tile.zig | 27 +++++++++++ 8 files changed, 176 insertions(+), 46 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index b0977b9..e881de4 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -2316,6 +2316,11 @@ pub fn bakeArchive( var loaded: usize = 0; var band_ord: u8 = 0; + // Union sector-figure reach across the baked cells — published as the + // archive's "light_reach" metadata so the compositor widens its tile + // addressing by the same ring the baker did (null = no figures anywhere). + var lr_union: ?[4]f64 = null; + var lr_range_m: f64 = 0; for (bake_enc.bands_fine_to_coarse) |band| { const idxs = band_idx[@intFromEnum(band)].items; const floor: bake_enc.FloorMode = if (coarsest_pop == band) .extend_min else .defer_down; @@ -2383,6 +2388,17 @@ pub fn bakeArchive( const q = bake_enc.overscaleGateDenom(be.cscl); if (q > 0) scamin_set.put(@intCast(q), {}) catch {}; } + // Fold the sector-figure reach for the archive's "light_reach" key + // (union bbox, max ground leg) while the backends are alive. + if (be.light_bbox) |lb| { + if (lr_union) |*u| { + u[0] = @min(u[0], lb[0]); + u[1] = @min(u[1], lb[1]); + u[2] = @max(u[2], lb[2]); + u[3] = @max(u[3], lb[3]); + } else lr_union = lb; + lr_range_m = @max(lr_range_m, be.light_range_m); + } } if (has_work) { // Own cells first, then the finer band's carry (bakeBand own_len split). @@ -2452,7 +2468,10 @@ pub fn bakeArchive( while (it.next()) |k| try scamin_vals.append(gpa, k.*); std.mem.sort(u32, scamin_vals.items, {}, std.sort.asc(u32)); } - const meta = try scene.metadataJson(gpa, scamin_vals.items, coverage_json); + var light_reach_json: ?[]const u8 = null; + defer if (light_reach_json) |lj| gpa.free(lj); + if (lr_union) |u| light_reach_json = scene.coverage.encodeLightReachJson(gpa, .{ .bbox = u, .range_m = lr_range_m }) catch null; + const meta = try scene.metadataJson(gpa, scamin_vals.items, coverage_json, light_reach_json); defer gpa.free(meta); return try sw.finishBytes(.{ .metadata_json = meta, diff --git a/src/compose/clip.zig b/src/compose/clip.zig index 0d76451..0e23ce5 100644 --- a/src/compose/clip.zig +++ b/src/compose/clip.zig @@ -32,9 +32,10 @@ fn narrowRing(a: std.mem.Allocator, ring: []const Pt) ![]mvt.Point { return out; } -// A linestring carrying class=LIGHTS is a constructed sector figure (LIGHTS is a -// point class; its only line output is emitAugFigures legs/arcs). -fn isLightFigure(feat: mvt.DecodedFeature) bool { +/// A linestring carrying class=LIGHTS is a constructed sector figure (LIGHTS is a +/// point class; its only line output is emitAugFigures legs/arcs). Public so the +/// compositor's reach-ring pass can pick figures out of a neighbouring cell's tile. +pub fn isLightFigure(feat: mvt.DecodedFeature) bool { for (feat.properties) |p| { if (std.mem.eql(u8, p.key, "class")) { return switch (p.value) { diff --git a/src/compose/compose.zig b/src/compose/compose.zig index a1465ad..49dc485 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -31,6 +31,7 @@ pub const LoadedCov = struct { cscl: i32, // compilation scale (1:N) coverage: []const []const []const s57.LonLat, // M_COVR(CATCOV=1) rings bounds: [4]f64, // [w,s,e,n] over the coverage + light_reach: ?coverage.LightReach = null, // sector-figure reach ("light_reach" metadata) }; pub fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]geometry.plane.Cell { @@ -62,6 +63,8 @@ pub fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]geometry .band_floor = band.bandZooms(band.bandOf(lc.cscl)).min, .order = order[i], .cov1 = out, + .light_bbox = if (lc.light_reach) |lr| lr.bbox else null, + .light_range_m = if (lc.light_reach) |lr| lr.range_m else 0, }; } return cells; @@ -136,7 +139,7 @@ fn tileClassifyBox(z: u8, tx: u32, ty: u32) geometry.plane.Box { // ancestor), clip its features to the owner's projected owned face, per-layer concat in // VECTOR_LAYERS order, re-orient polygons, encode. `ta` is a per-tile scratch allocator. Shared by // the batch pass 2 and the on-demand composeTile, so both emit byte-identical tiles. -fn composeSeamTile(ta: std.mem.Allocator, part: *const geometry.partition.Partition, map: *const geometry.partition.BandMap, readers: []const *pmtiles.Reader, slots: []const u32, z: u8, tx: u32, ty: u32) !?[]u8 { +fn composeSeamTile(ta: std.mem.Allocator, part: *const geometry.partition.Partition, map: *const geometry.partition.BandMap, readers: []const *pmtiles.Reader, slots: []const u32, reach_cells: []const u32, z: u8, tx: u32, ty: u32) !?[]u8 { const compose = clip; var buckets: [N_COMPOSE_LAYERS]std.ArrayList(mvt.Feature) = undefined; for (&buckets) |*b| b.* = std.ArrayList(mvt.Feature).empty; @@ -151,6 +154,23 @@ fn composeSeamTile(ta: std.mem.Allocator, part: *const geometry.partition.Partit for (layer.features) |feat| try compose.clipFeatureToFace(ta, &buckets[li], feat, face_px); } } + // Reach-ring cells: no owned ground in this tile, but their light sector + // figures sweep in (the bake addressed the tile for exactly that reach). + // Contribute ONLY the constructed LIGHTS figures, whole — the same + // clipFeatureToFace exception, minus a face. Everything else in the tile + // (ground the cell doesn't own here) stays with its owners. + for (reach_cells) |ci| { + const layers = (try ownerTile(ta, readers[ci], part.cells[ci].cscl, z, tx, ty)) orelse continue; + for (layers) |layer| { + const li = layerIndex(layer.name) orelse continue; + for (layer.features) |feat| { + if (feat.geom_type != .linestring or !compose.isLightFigure(feat)) continue; + const parts = try ta.alloc([]const mvt.Point, feat.parts.len); + for (feat.parts, 0..) |p, i| parts[i] = try ta.dupe(mvt.Point, p); + try buckets[li].append(ta, .{ .geom_type = .linestring, .parts = parts, .properties = feat.properties }); + } + } + } var out_layers = std.ArrayList(mvt.Layer).empty; for (&buckets, 0..) |*bucket, li| { if (bucket.items.len == 0) continue; @@ -178,13 +198,15 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const geometry.partition.Parti const ta = arena.allocator(); // Pass-1-equivalent for this one tile: walk owners in face order (the batch's tie order), cull - // by face bbox, classify. The FIRST owner that fully owns the tile (buffer included) and has a - // native blob wins the verbatim copy — faces are a disjoint partition, so that owner is unique. + // by face bbox, classify. An owner that fully owns the tile (buffer included) is the verbatim + // candidate — faces are a disjoint partition, so that owner is unique — but the copy is + // deferred until the reach scan below proves no neighbouring cell's sector figures sweep in. // Every other contributing owner (seam, or fully-owned-but-no-native) is collected in face order. // `owned` = at least one cell's coverage face covers this tile (the partition says it SHOULD // render here) — so a caller can tell a transient/erroneous empty from true empty ocean. var owned = false; var slots = std.ArrayList(u32).empty; + var verbatim: ?usize = null; // cell index of the unique tile+buffer-owning cell for (map.faces, 0..) |face, slot| { if (face.owned.len == 0) continue; const ci = face.index; @@ -194,24 +216,63 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const geometry.partition.Parti var grid = try geometry.plane.EdgeGrid.init(ta, face.owned, tileWidthE7(z)); defer grid.deinit(); - switch (grid.classify(tileClassifyBox(z, tx, ty))) { - .full => continue, // owns none of this tile - .empty => { // owns the whole tile: verbatim blob if present, else fall through - owned = true; - if (want_gzip) { - if (try readers[ci].getCompressed(z, tx, ty)) |blob| return .{ .tile = try gpa.dupe(u8, blob), .owned = true }; - } else if (try readers[ci].getTile(ta, z, tx, ty)) |raw| return .{ .tile = try gpa.dupe(u8, raw), .owned = true }; - }, - .seam => owned = true, - } + const cls = grid.classify(tileClassifyBox(z, tx, ty)); + if (cls == .full) continue; // owns none of this tile + owned = true; if (!(try ownerHasTile(readers[ci], cscl, z, tx, ty))) continue; + if (cls == .empty) { // owns the whole tile: its face projection can't be empty + verbatim = ci; + try slots.append(ta, @intCast(slot)); + continue; + } const face_px = try compose.projectFace(ta, face.owned, z, tx, ty); if (face_px.len == 0) continue; try slots.append(ta, @intCast(slot)); } - if (slots.items.len == 0) return .{ .tile = null, .owned = owned }; - const enc = (try composeSeamTile(ta, part, map, readers, slots.items, z, tx, ty)) orelse return .{ .tile = null, .owned = owned }; + // Reach ring (spec §2.3, the cross-TILE half): a cell owning ground at this + // tier — just none in this tile — can still have light sector figures + // sweeping in, and the bake addressed this tile for exactly that reach + // (buildTileMap's lightReachTiles ring around the cell's light_bbox). Apply + // the SAME ring here: consult each such cell's archive and let + // composeSeamTile take only its constructed LIGHTS figures, whole. + // Without this the figures amputate exactly at the composed-tile boundary. + var reach = std.ArrayList(u32).empty; + { + var contributed = try ta.alloc(bool, part.cells.len); + @memset(contributed, false); + for (slots.items) |slot| contributed[map.faces[slot].index] = true; + var seen = try ta.alloc(bool, part.cells.len); + @memset(seen, false); + for (map.faces) |face| { + if (face.owned.len == 0) continue; + const ci = face.index; + if (contributed[ci] or seen[ci]) continue; + seen[ci] = true; + const c = part.cells[ci]; + const lb = c.light_bbox orelse continue; + const r = tile.lightReachTiles(c.light_range_m, z, (lb[1] + lb[3]) * 0.5); + const w_tl = tile.lonLatToWorld(lb[0], lb[3]); + const w_br = tile.lonLatToWorld(lb[2], lb[1]); + const fx: f64 = @floatFromInt(tx); + const fy: f64 = @floatFromInt(ty); + if (fx + 1.0 <= w_tl[0] * scale - r or fx >= w_br[0] * scale + r or + fy + 1.0 <= w_tl[1] * scale - r or fy >= w_br[1] * scale + r) continue; + if (!(try ownerHasTile(readers[ci], c.cscl, z, tx, ty))) continue; + try reach.append(ta, @intCast(ci)); + } + } + + // Verbatim fast path: only when no reach cell contributes (else the figures + // must merge, so the tile seam-composes; content-identical for the owner). + if (reach.items.len == 0) if (verbatim) |ci| { + if (want_gzip) { + if (try readers[ci].getCompressed(z, tx, ty)) |blob| return .{ .tile = try gpa.dupe(u8, blob), .owned = true }; + } else if (try readers[ci].getTile(ta, z, tx, ty)) |raw| return .{ .tile = try gpa.dupe(u8, raw), .owned = true }; + }; + if (slots.items.len == 0 and reach.items.len == 0) return .{ .tile = null, .owned = owned }; + + const enc = (try composeSeamTile(ta, part, map, readers, slots.items, reach.items, z, tx, ty)) orelse return .{ .tile = null, .owned = owned }; // want_gzip → match the archive's stored (gzipped) bytes; else hand back the raw MLT. const bytes = if (want_gzip) try pmtiles.StreamWriter.gzipTile(gpa, enc) else try gpa.dupe(u8, enc); return .{ .tile = bytes, .owned = true }; @@ -323,7 +384,7 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const }; try maps.append(a, map); try readers.append(a, rp); - try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = covDegBounds(cc) }); + try shims.append(a, .{ .name = cc.name, .date = cc.date, .cscl = cc.cscl, .coverage = cc.cov1, .bounds = covDegBounds(cc), .light_reach = cc.light_reach }); } if (readers.items.len == 0) { src.arena.deinit(); @@ -359,7 +420,7 @@ pub fn openComposeSourceCharts(gpa: std.mem.Allocator, archives: []const ChartAr for (archives) |ar| { if (ar.cov.cov1.len == 0) continue; try readers.append(a, ar.reader); - try shims.append(a, .{ .name = ar.cov.name, .date = ar.cov.date, .cscl = ar.cov.cscl, .coverage = ar.cov.cov1, .bounds = covDegBounds(ar.cov) }); + try shims.append(a, .{ .name = ar.cov.name, .date = ar.cov.date, .cscl = ar.cov.cscl, .coverage = ar.cov.cov1, .bounds = covDegBounds(ar.cov), .light_reach = ar.cov.light_reach }); } if (readers.items.len == 0) { src.arena.deinit(); diff --git a/src/coverage/coverage.zig b/src/coverage/coverage.zig index 3169a27..78cea40 100644 --- a/src/coverage/coverage.zig +++ b/src/coverage/coverage.zig @@ -29,8 +29,29 @@ pub const CellCoverage = struct { bbox: [4]i32, // [w, s, e, n] integer lon/lat (lon_e7/lat_e7) cov1: []const []const []const s57.LonLat, // M_COVR CATCOV=1: [feature][ring][point] cov2: []const []const []const s57.LonLat = &.{}, // reserved (CATCOV=2 no-data) + // Sector-figure reach (the metadata's sibling "light_reach" key, null when the + // cell constructs no figures) — decodeFromMetadata fills it so the compositor + // can widen tile addressing without re-portraying the cell. + light_reach: ?LightReach = null, }; +/// Sector-figure reach summary carried beside "coverage" in the per-cell archive +/// metadata (key "light_reach", written only when the cell's portrayal constructs +/// light sector figures): the union bbox of the figure-bearing anchors plus the +/// max ground-length directional-leg span. The compositor widens its tile +/// addressing with the SAME ring the baker used (tiles.tile.lightReachTiles), so +/// legs/arcs survive into neighbouring tiles the cell owns no ground in. +pub const LightReach = struct { + bbox: [4]f64, // [w, s, e, n] degrees — union of figure-bearing anchors + range_m: f64 = 0, // max ground-length leg (directional lights), metres +}; + +/// Serialize a light-reach summary to the JSON object embedded under the +/// metadata's "light_reach" key. Caller owns the returned bytes. +pub fn encodeLightReachJson(a: std.mem.Allocator, lr: LightReach) ![]u8 { + return std.json.Stringify.valueAlloc(a, lr, .{}); +} + /// Build a CellCoverage from a parsed cell. `name` is the caller's identity string /// (the file basename stem, matching the coverage loader / ownership oracle) and /// `band` the caller's `bake_enc.bandOf(cscl)` tag; both keep this module free of the @@ -83,7 +104,7 @@ const Dto = struct { // The metadata object as far as this module cares: everything else (name, format, // vector_layers, scamin) is skipped via ignore_unknown_fields. -const Envelope = struct { coverage: ?Dto = null }; +const Envelope = struct { coverage: ?Dto = null, light_reach: ?LightReach = null }; /// Serialize `cov` to the JSON object embedded under the metadata's "coverage" key. /// Caller owns the returned bytes (allocated in `a`). @@ -115,6 +136,7 @@ pub fn decodeFromMetadata(a: std.mem.Allocator, metadata_json: []const u8) !?Cel .bbox = dto.bbox, .cov1 = try fromWire(a, dto.cov1), .cov2 = try fromWire(a, dto.cov2), + .light_reach = parsed.value.light_reach, // POD — safe past parsed.deinit() }; } diff --git a/src/geometry/plane.zig b/src/geometry/plane.zig index 7e27fb9..7aaa26e 100644 --- a/src/geometry/plane.zig +++ b/src/geometry/plane.zig @@ -60,6 +60,14 @@ pub const Cell = struct { cov1: []const Poly, /// CATCOV=2 explicit no-data features (subtracted, so a coarser band can fill). cov2: []const Poly = &.{}, + /// Sector-figure reach (the archive's "light_reach" metadata): union bbox + /// [w,s,e,n] in DEGREES of the cell's figure-constructing light anchors, and + /// the max ground-length directional leg in metres. The compositor consults + /// the cell in tiles this ring touches even when it owns no ground there, so + /// legs/arcs don't amputate at the tile boundary. null = no figures. Carried + /// beside the partition inputs (never serialized — it rides the live cells). + light_bbox: ?[4]f64 = null, + light_range_m: f64 = 0, }; /// A cell's owned region at a tier: `index` into the caller's cell slice and the diff --git a/src/scene/lightreach.zig b/src/scene/lightreach.zig index 103b968..9bbb96a 100644 --- a/src/scene/lightreach.zig +++ b/src/scene/lightreach.zig @@ -6,17 +6,14 @@ const std = @import("std"); const s57 = @import("s57"); -const EARTH_CIRCUM_M: f64 = 40075016.686; - -// Worst-case reach of a light's sector legs/arcs (emitAugFigures) as a fraction of a -// tile — these are drawn at a fixed DISPLAY size (radius/length in mm), so the reach -// is ~constant in tile units at every zoom (offset_tiles = mm * PX_PER_MM / 512, the -// 512-CSS-px tile the figures are sized against). Used to widen the LIGHTS -// spatial-cull margin so an arc isn't dropped on the tiles it crosses (S-52 legs -// ~25 mm / arcs ~20 mm ≈ 0.2 tile; 1.0 is generous headroom). -// GROUND-length legs (directional lights: nmi2metres(nominal range), LightSectored.lua) -// exceed this by far at fine zooms — lightReachTiles is the honest per-zoom bound. -pub const LIGHT_AUG_REACH_TILES: f64 = 1.0; +// The reach bounds live in the tiles leaf (tiles.tile) so the runtime +// compositor — a dependency leaf without the scene — applies the SAME ring +// when it pulls a cell's figures into neighbouring tiles. Re-exported here +// for the baker/emitter callers. GROUND-length legs (directional lights: +// nmi2metres(nominal range), LightSectored.lua) exceed the mm bound by far +// at fine zooms — lightReachTiles is the honest per-zoom bound. +pub const LIGHT_AUG_REACH_TILES = @import("tiles").tile.LIGHT_AUG_REACH_TILES; +pub const lightReachTiles = @import("tiles").tile.lightReachTiles; /// How far a cell's constructed sector figures (emitAugFigures) can reach beyond /// their feature anchors, summarised per cell so tile addressing (bake_enc @@ -31,18 +28,6 @@ pub const LightReach = struct { range_m: f64 = 0, }; -/// Worst-case tile-unit reach of sector figures at zoom z, latitude `lat`: -/// display-mm figures reach a ~constant LIGHT_AUG_REACH_TILES, ground-length legs -/// reach range_m metres = range_m·2^z/(cosφ·C) tiles (the emitAugFigures -/// projection: len_px = range_m/(cosφ·EARTH_CIRCUM_M)·worldPx). Never below the -/// mm bound — a cell with figures always reaches at least the display-sized ones. -pub fn lightReachTiles(range_m: f64, z: u8, lat: f64) f64 { - if (range_m <= 0) return LIGHT_AUG_REACH_TILES; - const cos_lat = @max(@cos(lat * std.math.pi / 180.0), 1e-6); - const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); - return @max(LIGHT_AUG_REACH_TILES, range_m * scale / (cos_lat * EARTH_CIRCUM_M)); -} - // The nth comma-separated field of an instruction argument ("" past the end). fn instrCsv(s: []const u8, n: usize) []const u8 { var it = std.mem.splitScalar(u8, s, ','); diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 6db4a5b..48723a6 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -714,8 +714,11 @@ pub const VECTOR_LAYERS = mvt.VECTOR_LAYERS; /// (vector_layers + scamin splice). `scamin` empty -> omit the field. `coverage_json`, /// when non-null, is a per-cell coverage object (see `coverage.encodeJson`) spliced /// under a "coverage" key — a single-cell composite bake carries its own M_COVR there. +/// `light_reach_json` (see `coverage.encodeLightReachJson`), when non-null, is the +/// cell's sector-figure reach summary spliced under a "light_reach" key so the +/// compositor can widen its tile addressing without re-portraying the cell. /// Caller owns the returned bytes (allocated in `a`). -pub fn metadataJson(a: Allocator, scamin: []const u32, coverage_json: ?[]const u8) ![]const u8 { +pub fn metadataJson(a: Allocator, scamin: []const u32, coverage_json: ?[]const u8, light_reach_json: ?[]const u8) ![]const u8 { var b = std.ArrayList(u8).empty; try b.appendSlice(a, "{\"name\":\"chartplotter\",\"format\":\"pbf\",\"vector_layers\":["); for (VECTOR_LAYERS, 0..) |name, i| { @@ -738,6 +741,10 @@ pub fn metadataJson(a: Allocator, scamin: []const u32, coverage_json: ?[]const u try b.appendSlice(a, ",\"coverage\":"); try b.appendSlice(a, cj); } + if (light_reach_json) |lj| { + try b.appendSlice(a, ",\"light_reach\":"); + try b.appendSlice(a, lj); + } try b.append(a, '}'); return b.toOwnedSlice(a); } diff --git a/src/tiles/tile.zig b/src/tiles/tile.zig index 7aa1058..215cede 100644 --- a/src/tiles/tile.zig +++ b/src/tiles/tile.zig @@ -85,6 +85,33 @@ pub fn tileBoundsLonLat(z: u8, tx: u32, ty: u32) [4]f64 { return .{ lon0, lat0, lon1, lat1 }; } +// ---- light sector-figure reach -------------------------------------------- +// A LIGHTS feature's constructed sector legs/arcs are FIXED display-size +// figures around the light (plus ground-length directional legs), so they can +// cross into tiles the light's cell owns no ground in. Both the baker's tile +// addressing (bake_enc buildTileMap) and the runtime compositor's reach ring +// (compose.composeTile) widen by the same bound so the figures survive there. + +const EARTH_CIRCUM_M: f64 = 40075016.686; + +/// Worst-case reach of a light's display-mm sector figures as a fraction of a +/// tile — ~constant at every zoom (offset_tiles = mm * PX_PER_MM / 512, the +/// 512-CSS-px tile the figures are sized against; S-52 legs ~25 mm / arcs +/// ~20 mm ≈ 0.2 tile; 1.0 is generous headroom). +pub const LIGHT_AUG_REACH_TILES: f64 = 1.0; + +/// Worst-case tile-unit reach of sector figures at zoom z, latitude `lat`: +/// display-mm figures reach a ~constant LIGHT_AUG_REACH_TILES, ground-length +/// legs (directional lights) reach range_m metres = range_m·2^z/(cosφ·C) tiles. +/// Never below the mm bound — a cell with figures always reaches at least the +/// display-sized ones. +pub fn lightReachTiles(range_m: f64, z: u8, lat: f64) f64 { + if (range_m <= 0) return LIGHT_AUG_REACH_TILES; + const cos_lat = @max(@cos(lat * std.math.pi / 180.0), 1e-6); + const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + return @max(LIGHT_AUG_REACH_TILES, range_m * scale / (cos_lat * EARTH_CIRCUM_M)); +} + /// Inclusive tile box used for clipping (extent +/- buffer). pub const Box = struct { min: i32, From bf030f1df76f25a8f1708fec1d72bf4565afb2b8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 16:58:26 -0400 Subject: [PATCH 116/140] feat(tools): tiledump --geom/--coords per-feature geometry mode + inspect -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 --- tools/inspect.zig | 9 +++++++ tools/tiledump.zig | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/tools/inspect.zig b/tools/inspect.zig index a374031..fb42efe 100644 --- a/tools/inspect.zig +++ b/tools/inspect.zig @@ -21,6 +21,15 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const y = try std.fmt.parseInt(u32, args[5], 10); if (try r.getTile(a, z, x, y)) |tile| { { + // Optional trailing `-o FILE` writes the raw (decompressed) tile + // bytes out, so tiledump and friends can chew on the same tile. + var ai: usize = 6; + while (ai + 1 < args.len) : (ai += 1) { + if (std.mem.eql(u8, args[ai], "-o")) { + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = args[ai + 1], .data = tile }); + std.debug.print(" wrote {s} ({d} bytes)\n", .{ args[ai + 1], tile.len }); + } + } // Decode with the codec matching the archive's tile type — both // return the same DecodedLayer shape, so the dump below is shared. const layers = if (h.tile_type == .mlt) diff --git a/tools/tiledump.zig b/tools/tiledump.zig index 9d9d152..a7bead4 100644 --- a/tools/tiledump.zig +++ b/tools/tiledump.zig @@ -3,6 +3,11 @@ //! histograms for the properties that identify portrayal output (class, //! symbol_name, ls). For debugging what a bake or the compositor actually put //! in a tile. +//! +//! --geom CLASS switches to per-feature geometry mode: each polygon/line +//! feature with class=CLASS prints its properties and, per ring/part, the +//! point count, bbox, and (polygons) signed area — plus the full coordinate +//! list under --coords. For hunting degenerate geometry. const std = @import("std"); const engine = @import("engine"); @@ -16,11 +21,18 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } const bytes = try std.Io.Dir.cwd().readFileAlloc(io, args[2], a, .unlimited); var extra_prop: ?[]const u8 = null; + var geom_class: ?[]const u8 = null; + var coords = false; var i: usize = 3; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--prop") and i + 1 < args.len) { extra_prop = args[i + 1]; i += 1; + } else if (std.mem.eql(u8, args[i], "--geom") and i + 1 < args.len) { + geom_class = args[i + 1]; + i += 1; + } else if (std.mem.eql(u8, args[i], "--coords")) { + coords = true; } } @@ -31,6 +43,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { }; }; + if (geom_class) |cls| return dumpGeom(io, a, layers, cls, coords); + var out = std.ArrayList(u8).empty; for (layers) |layer| { var c = Count{}; @@ -64,3 +78,48 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } std.Io.File.stdout().writeStreamingAll(io, out.items) catch {}; } + +fn dumpGeom(io: std.Io, a: std.mem.Allocator, layers: []const engine.mvt.DecodedLayer, cls: []const u8, coords: bool) !void { + var out = std.ArrayList(u8).empty; + for (layers) |layer| { + for (layer.features, 0..) |f, fi| { + if (f.geom_type == .point) continue; + const matched = for (f.properties) |p| { + if (std.mem.eql(u8, p.key, "class") and p.value == .string and std.mem.eql(u8, p.value.string, cls)) break true; + } else false; + if (!matched) continue; + try out.print(a, "{s}[{d}] {s}", .{ layer.name, fi, @tagName(f.geom_type) }); + for (f.properties) |p| { + switch (p.value) { + .string => |v| try out.print(a, " {s}={s}", .{ p.key, v }), + .float => |v| try out.print(a, " {s}={d}", .{ p.key, v }), + .double => |v| try out.print(a, " {s}={d}", .{ p.key, v }), + .int => |v| try out.print(a, " {s}={d}", .{ p.key, v }), + .uint => |v| try out.print(a, " {s}={d}", .{ p.key, v }), + .boolean => |v| try out.print(a, " {s}={}", .{ p.key, v }), + } + } + try out.print(a, "\n", .{}); + for (f.parts, 0..) |ring, ri| { + var min_x: i32 = std.math.maxInt(i32); + var min_y: i32 = std.math.maxInt(i32); + var max_x: i32 = std.math.minInt(i32); + var max_y: i32 = std.math.minInt(i32); + var area2: i64 = 0; // twice the signed area (shoelace) + for (ring, 0..) |pt, pi| { + min_x = @min(min_x, pt.x); + min_y = @min(min_y, pt.y); + max_x = @max(max_x, pt.x); + max_y = @max(max_y, pt.y); + const nxt = ring[(pi + 1) % ring.len]; + area2 += @as(i64, pt.x) * nxt.y - @as(i64, nxt.x) * pt.y; + } + try out.print(a, " ring[{d}]: {d} pts, bbox [{d},{d}..{d},{d}], area2 {d}\n", .{ ri, ring.len, min_x, min_y, max_x, max_y, area2 }); + if (coords) { + for (ring) |pt| try out.print(a, " {d},{d}\n", .{ pt.x, pt.y }); + } + } + } + } + std.Io.File.stdout().writeStreamingAll(io, out.items) catch {}; +} From d64634fcf78a38f69025c0abcd147dc3cac3483c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 17:32:50 -0400 Subject: [PATCH 117/140] =?UTF-8?q?fix(geometry):=20cancel=20same-operand?= =?UTF-8?q?=20collinear=20overlaps=20=E2=80=94=20open-ring=20wedge=20artif?= =?UTF-8?q?acts=20in=20composed=20tiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/geometry/boolean.zig | 284 +++++++++++++++++++++++----- src/geometry/boolean_repro_test.zig | 50 +++++ src/geometry/geometry.zig | 1 + 3 files changed, 290 insertions(+), 45 deletions(-) create mode 100644 src/geometry/boolean_repro_test.zig diff --git a/src/geometry/boolean.zig b/src/geometry/boolean.zig index 6372f46..cd32969 100644 --- a/src/geometry/boolean.zig +++ b/src/geometry/boolean.zig @@ -305,7 +305,6 @@ const Sweeper = struct { const it = findIntersection(le1.p, le1.other.p, le2.p, le2.other.p); if (it.n == 0) return 0; if (it.n == 1 and (le1.p.eql(le2.p) or le1.other.p.eql(le2.other.p))) return 0; // shared endpoint only - if (it.n == 2 and le1.subject == le2.subject) return 0; // overlap within one operand: even-odd cancels, ignore if (it.n == 1) { if (!le1.p.eql(it.p0) and !le1.other.p.eql(it.p0)) try self.divideSegment(le1, it.p0, gpa); @@ -347,12 +346,19 @@ const Sweeper = struct { } if (n == 2 or (n == 3 and ev[2] != null)) { - // Segments are equal, or share their left endpoint: one carries the - // transition, the other contributes nothing. - le1.edge_type = .non_contributing; - le2.edge_type = if (le1.in_out == le2.in_out) .same_transition else .different_transition; + // Segments are equal, or share their left endpoint. Across the two + // operands one carries the transition and the other contributes + // nothing. Within ONE operand there is no transition to type: both + // stay normal, and because the overlap was subdivided into exactly + // coincident pieces, the doubled pieces cancel in the result's mod-2 + // edge reduction (even-odd: a doubled edge is no boundary). if (n == 3) try self.divideSegment(ev[2].?.other, ev[1].?.p, gpa); - return 2; // typed → recompute + if (le1.subject != le2.subject) { + le1.edge_type = .non_contributing; + le2.edge_type = if (le1.in_out == le2.in_out) .same_transition else .different_transition; + return 2; // typed → recompute + } + return 3; } if (n == 3) { // Share the right endpoint: split the longer-left segment; the now @@ -393,13 +399,14 @@ const Sweeper = struct { /// Compute `subject op clip`. Result is a freshly allocated ring-set (open /// rings, even-odd fill); free with `freePolygon`. /// -/// Precondition: each operand is a *simple* polygon — its own rings do not -/// overlap in area (holes are allowed as properly-nested reverse rings). Two -/// coincident edges are fine when they come from *different* operands (the seam -/// case, typed once), but three coincident edges — which require one operand to -/// overlap itself — are not resolved. Build operands from overlapping coverage -/// rings with `unionAll`, whose pairwise fold guarantees this. Coincident and -/// shared edges across the two operands are the designed-for common case. +/// Each operand is an even-odd ring-set. Rings of one operand may cross, touch, +/// or share collinear runs with each other (a baked MVT polygon's rings all run +/// along the same tile clip edge — the case that broke the pre-subdivision +/// design): same-operand overlaps are subdivided into exactly coincident pieces +/// which cancel mod 2 in the result reduction, same-operand crossings are +/// subdivided like any crossing. Coincident edges across the two operands are +/// the designed-for seam case, typed once. `unionAll` remains the way to fold +/// many mutually-overlapping coverages into one region whose rings are clean. pub fn compute(gpa: Allocator, subject: Polygon, clip: Polygon, op: Op) ![][]Pt { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); @@ -416,8 +423,15 @@ pub fn compute(gpa: Allocator, subject: Polygon, clip: Polygon, op: Op) ![][]Pt defer sw.status.deinit(gpa); defer sw.processed.deinit(gpa); - for (subject) |ring| try addRing(&sw, ring, true, gpa); - for (clip) |ring| try addRing(&sw, ring, false, gpa); + // Each operand's edge multiset is reduced modulo 2 BEFORE the sweep: rings + // of one operand may share collinear runs (a baked MVT polygon's rings all + // running along one tile clip edge), and a doubled coincident edge flips + // even-odd membership twice — no boundary. Cancelling those here preserves + // the operand's region exactly and guarantees the sweep never sees more + // than one subject plus one clip edge coincident, which is the pair case + // its overlap typing resolves. + try addOperand(&sw, subject, true, gpa); + try addOperand(&sw, clip, false, gpa); while (sw.queue.pop()) |se| { try sw.processed.append(gpa, se); @@ -454,12 +468,26 @@ pub fn compute(gpa: Allocator, subject: Polygon, clip: Polygon, op: Op) ![][]Pt return connectEdges(gpa, sw.processed.items); } -fn addRing(sw: *Sweeper, ring: []const Pt, subject: bool, gpa: Allocator) !void { - if (ring.len < 3) return; - var j = ring.len - 1; - for (ring, 0..) |p, i| { - try sw.addEdge(ring[j], p, subject, gpa); - j = i; +/// Add one operand's rings to the sweep with its self-overlapping collinear +/// runs cancelled (reduceEdges). Survivors are added in reduceEdges' sorted +/// order so event ids — the deterministic tie-break — are a function of +/// geometry alone. +fn addOperand(sw: *Sweeper, rings: Polygon, subject: bool, gpa: Allocator) !void { + var scratch = std.heap.ArenaAllocator.init(gpa); + defer scratch.deinit(); + const sa = scratch.allocator(); + + var raw = std.ArrayList(HEdge).empty; + for (rings) |ring| { + if (ring.len < 3) continue; + var j = ring.len - 1; + for (ring, 0..) |p, i| { + if (!ring[j].eql(p)) try raw.append(sa, .{ .a = ring[j], .b = p }); + j = i; + } + } + for (try reduceEdges(sa, raw.items)) |e| { + try sw.addEdge(e.a, e.b, subject, gpa); } } @@ -488,6 +516,138 @@ fn canonEdge(a: Pt, b: Pt) EdgeKey { return .{ .ax = b.x, .ay = b.y, .bx = a.x, .by = a.y }; } +/// The line through an edge, canonicalised so every collinear edge shares one +/// key: direction reduced by gcd with a fixed sign, plus the line's offset. +const LineKey = struct { dx: i64, dy: i64, c: i128 }; + +fn lineKeyOf(a: Pt, b: Pt) LineKey { + var dx: i64 = b.x - a.x; + var dy: i64 = b.y - a.y; + const g: i64 = @intCast(std.math.gcd(@abs(dx), @abs(dy))); + dx = @divExact(dx, g); + dy = @divExact(dy, g); + if (dx < 0 or (dx == 0 and dy < 0)) { + dx = -dx; + dy = -dy; + } + // c = d × a is constant along the line for the canonical d. + const c: i128 = @as(i128, dx) * a.y - @as(i128, dy) * a.x; + return .{ .dx = dx, .dy = dy, .c = c }; +} + +/// Split every edge of a collinear group at every group endpoint interior to +/// it, so partially-overlapping collinear edges become exactly coincident +/// pieces the mod-2 reduction can cancel. The sweep subdivides edges pairwise +/// as it discovers them, but a bundle of 3+ coincident edges (two rings of one +/// operand sharing a tile-edge run with the other operand) can finish the sweep +/// subdivided inconsistently; this pass makes the reduction see one common +/// subdivision. All split points are existing endpoints — exact, no rounding. +fn splitCollinearGroup(sa: Allocator, group: []const HEdge, out: *std.ArrayList(HEdge)) !void { + // Parametrise along the canonical direction: t = d · p (monotonic, exact, + // and on one line a parameter identifies its point uniquely). + const Cut = struct { t: i128, p: Pt }; + var cuts = std.ArrayList(Cut).empty; + const k = lineKeyOf(group[0].a, group[0].b); + for (group) |e| { + for ([_]Pt{ e.a, e.b }) |p| { + try cuts.append(sa, .{ .t = @as(i128, k.dx) * p.x + @as(i128, k.dy) * p.y, .p = p }); + } + } + std.mem.sort(Cut, cuts.items, {}, struct { + fn lt(_: void, x: Cut, y: Cut) bool { + return x.t < y.t; + } + }.lt); + for (group) |e| { + var ta = @as(i128, k.dx) * e.a.x + @as(i128, k.dy) * e.a.y; + var tb = @as(i128, k.dx) * e.b.x + @as(i128, k.dy) * e.b.y; + var pa = e.a; + var pb = e.b; + if (ta > tb) { + std.mem.swap(i128, &ta, &tb); + std.mem.swap(Pt, &pa, &pb); + } + var prev = pa; + var prev_t = ta; + for (cuts.items) |cut| { + if (cut.t <= prev_t) continue; + if (cut.t >= tb) break; + try out.append(sa, .{ .a = prev, .b = cut.p }); + prev = cut.p; + prev_t = cut.t; + } + try out.append(sa, .{ .a = prev, .b = pb }); + } +} + +/// Reduce an edge multiset to its even-odd boundary: group collinear edges, +/// split each group to one common subdivision (splitCollinearGroup), and cancel +/// exactly coincident pieces modulo 2 — a doubled edge flips membership twice +/// and is no boundary. Output is deterministically sorted (a function of the +/// input SET alone). The non-overlapping common case stays cheap: one key sort, +/// no splitting. Result is allocated in `sa`. +fn reduceEdges(sa: Allocator, raw: []const HEdge) ![]HEdge { + const keys = try sa.alloc(LineKey, raw.len); + for (raw, 0..) |e, i| keys[i] = lineKeyOf(e.a, e.b); + const idx = try sa.alloc(usize, raw.len); + for (idx, 0..) |*v, i| v.* = i; + const Ctx = struct { keys: []const LineKey, raw: []const HEdge }; + std.mem.sort(usize, idx, Ctx{ .keys = keys, .raw = raw }, struct { + fn lt(ctx: Ctx, x: usize, y: usize) bool { + const a = ctx.keys[x]; + const b = ctx.keys[y]; + if (a.dx != b.dx) return a.dx < b.dx; + if (a.dy != b.dy) return a.dy < b.dy; + if (a.c != b.c) return a.c < b.c; + const ea = canonEdge(ctx.raw[x].a, ctx.raw[x].b); + const eb = canonEdge(ctx.raw[y].a, ctx.raw[y].b); + if (ea.ax != eb.ax) return ea.ax < eb.ax; + if (ea.ay != eb.ay) return ea.ay < eb.ay; + if (ea.bx != eb.bx) return ea.bx < eb.bx; + return ea.by < eb.by; + } + }.lt); + + var out = std.ArrayList(HEdge).empty; + var grp = std.ArrayList(HEdge).empty; + var pieces = std.ArrayList(HEdge).empty; + var i: usize = 0; + while (i < idx.len) { + var j = i + 1; + while (j < idx.len and std.meta.eql(keys[idx[i]], keys[idx[j]])) j += 1; + if (j - i == 1) { + try out.append(sa, raw[idx[i]]); + i = j; + continue; + } + grp.clearRetainingCapacity(); + for (idx[i..j]) |ri| try grp.append(sa, raw[ri]); + pieces.clearRetainingCapacity(); + try splitCollinearGroup(sa, grp.items, &pieces); + // Cancel coincident pieces mod 2: sort canonically, keep odd-count runs. + std.mem.sort(HEdge, pieces.items, {}, struct { + fn lt(_: void, x: HEdge, y: HEdge) bool { + const ea = canonEdge(x.a, x.b); + const eb = canonEdge(y.a, y.b); + if (ea.ax != eb.ax) return ea.ax < eb.ax; + if (ea.ay != eb.ay) return ea.ay < eb.ay; + if (ea.bx != eb.bx) return ea.bx < eb.bx; + return ea.by < eb.by; + } + }.lt); + var p: usize = 0; + while (p < pieces.items.len) { + var q = p + 1; + const kp = canonEdge(pieces.items[p].a, pieces.items[p].b); + while (q < pieces.items.len and std.meta.eql(kp, canonEdge(pieces.items[q].a, pieces.items[q].b))) q += 1; + if ((q - p) % 2 == 1) try out.append(sa, pieces.items[p]); + p = q; + } + i = j; + } + return out.items; +} + const HEdge = struct { a: Pt, b: Pt, used: bool = false }; fn connectEdges(gpa: Allocator, all: []const *SweepEvent) ![][]Pt { @@ -495,35 +655,20 @@ fn connectEdges(gpa: Allocator, all: []const *SweepEvent) ![][]Pt { defer scratch.deinit(); const sa = scratch.allocator(); - // (1) Reduce result edges modulo 2. - var parity = std.AutoHashMap(EdgeKey, void).init(sa); + // (1) Collect the result edges and reduce them to the even-odd boundary: + // reduceEdges splits collinear bundles to one common subdivision and cancels + // coincident pieces modulo 2 (the doubled edges an even-odd operand + // contributes at a shared seam). Its deterministically sorted output also + // fixes the trace order (ring order / start vertex) as a function of + // geometry alone — the byte-stability the bake==live contract needs. + var raw = std.ArrayList(HEdge).empty; for (all) |e| { if (!(e.left and e.in_result)) continue; if (e.p.eql(e.other.p)) continue; - const key = canonEdge(e.p, e.other.p); - if (parity.contains(key)) { - _ = parity.remove(key); - } else { - try parity.put(key, {}); - } + try raw.append(sa, .{ .a = e.p, .b = e.other.p }); } - var edges = std.ArrayList(HEdge).empty; - var it = parity.keyIterator(); - while (it.next()) |k| { - try edges.append(sa, .{ .a = .{ .x = k.ax, .y = k.ay }, .b = .{ .x = k.bx, .y = k.by } }); - } - // Sort so the trace order (and hence ring order / start vertex) is a function - // of geometry alone, not of hash-map iteration — the byte-stability the - // bake==live contract needs. - std.mem.sort(HEdge, edges.items, {}, struct { - fn lt(_: void, x: HEdge, y: HEdge) bool { - if (x.a.x != y.a.x) return x.a.x < y.a.x; - if (x.a.y != y.a.y) return x.a.y < y.a.y; - if (x.b.x != y.b.x) return x.b.x < y.b.x; - return x.b.y < y.b.y; - } - }.lt); + try edges.appendSlice(sa, try reduceEdges(sa, raw.items)); var adj = std.AutoHashMap(Pt, std.ArrayList(usize)).init(sa); for (edges.items, 0..) |e, idx| { @@ -904,6 +1049,55 @@ test "fuzz: even-odd(result) == even-odd(A) op even-odd(B), A/B cleaned via unio } } +test "fuzz: raw even-odd operands — self-overlapping rings within one operand" { + // Operands are the boxes THEMSELVES (no unionAll cleaning): rings of one + // operand may overlap in area, cross, and share collinear runs — the shape a + // baked MVT polygon presents when several of its rings run along one tile + // clip edge. Even-odd oracle: a point is inside an operand iff it is in an + // odd number of that operand's boxes. + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + + var prng = std.Random.DefaultPrng.init(0xDEADFA11); + const rnd = prng.random(); + const grid: i64 = 64; + const ops = [_]Op{ .unite, .intersect, .diff, .sym_diff }; + + var trial: usize = 0; + while (trial < 1500) : (trial += 1) { + const bagA = randBoxBag(rnd, grid); + const bagB = randBoxBag(rnd, grid); + var backA: [3][1][]const Pt = undefined; + var backB: [3][1][]const Pt = undefined; + const psA = bagA.polys(&backA); + const psB = bagB.polys(&backB); + // Flatten each bag's rings into ONE even-odd operand, uncleaned. + var ringsA = std.ArrayList([]const Pt).empty; + for (psA[0..bagA.n]) |p| try ringsA.appendSlice(a, p); + var ringsB = std.ArrayList([]const Pt).empty; + for (psB[0..bagB.n]) |p| try ringsB.appendSlice(a, p); + for (ops) |op| { + const r = try compute(a, ringsA.items, ringsB.items, op); + var qy: i64 = -9 * grid; + while (qy <= 12 * grid) : (qy += 17) { + var qx: i64 = -9 * grid; + while (qx <= 12 * grid) : (qx += 17) { + if (pointOnEdge(ringsA.items, qx, qy) or pointOnEdge(ringsB.items, qx, qy) or pointOnEdge(r, qx, qy)) continue; + const want = combine(op, pointInEvenOdd(ringsA.items, qx, qy), pointInEvenOdd(ringsB.items, qx, qy)); + const got = pointInEvenOdd(r, qx, qy); + if (want != got) { + std.debug.print("RAW MISMATCH trial={} op={} at ({},{}): want={} got={}\n", .{ trial, op, qx, qy, want, got }); + std.debug.print(" A={any}\n B={any}\n R={any}\n", .{ ringsA.items, ringsB.items, r }); + return error.BooleanMismatch; + } + } + } + } + _ = arena_state.reset(.retain_capacity); + } +} + test "fuzz: union is commutative and idempotent (region equality)" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); diff --git a/src/geometry/boolean_repro_test.zig b/src/geometry/boolean_repro_test.zig new file mode 100644 index 0000000..1587886 --- /dev/null +++ b/src/geometry/boolean_repro_test.zig @@ -0,0 +1,50 @@ +// Regression fixture: the exact operands of the US2EC03M DEPVS clip in tile z8/73/98 +// (compose of US2EC03M+US3EC08M) that produced an open-ring wedge artifact. +const std = @import("std"); +const boolean = @import("boolean.zig"); +const Pt = boolean.Pt; +const S0 = [_]Pt{ .{ .x = 2179, .y = 4160 }, .{ .x = 2170, .y = 4096 }, .{ .x = 2142, .y = 4065 }, .{ .x = 2099, .y = 4049 }, .{ .x = 2043, .y = 4013 }, .{ .x = 2008, .y = 4004 }, .{ .x = 1924, .y = 4006 }, .{ .x = 1924, .y = 3715 }, .{ .x = 1990, .y = 3768 }, .{ .x = 2015, .y = 3808 }, .{ .x = 2042, .y = 3782 }, .{ .x = 2072, .y = 3789 }, .{ .x = 2085, .y = 3761 }, .{ .x = 2111, .y = 3768 }, .{ .x = 2113, .y = 3782 }, .{ .x = 2093, .y = 3778 }, .{ .x = 2086, .y = 3784 }, .{ .x = 2083, .y = 3817 }, .{ .x = 2064, .y = 3831 }, .{ .x = 2065, .y = 3843 }, .{ .x = 2109, .y = 3854 }, .{ .x = 2162, .y = 3898 }, .{ .x = 2204, .y = 3904 }, .{ .x = 2242, .y = 3973 }, .{ .x = 2253, .y = 4031 }, .{ .x = 2278, .y = 4059 }, .{ .x = 2289, .y = 4094 }, .{ .x = 2325, .y = 4128 }, .{ .x = 2342, .y = 4160 }, .{ .x = 2502, .y = 4160 }, .{ .x = 2483, .y = 4128 }, .{ .x = 2422, .y = 4085 }, .{ .x = 2426, .y = 4073 }, .{ .x = 2443, .y = 4076 }, .{ .x = 2490, .y = 4106 }, .{ .x = 2523, .y = 4150 }, .{ .x = 2539, .y = 4152 }, .{ .x = 2562, .y = 4134 }, .{ .x = 2573, .y = 4135 }, .{ .x = 2575, .y = 4149 }, .{ .x = 2566, .y = 4160 }, .{ .x = 3034, .y = 4160 }, .{ .x = 3005, .y = 4096 }, .{ .x = 2970, .y = 4068 }, .{ .x = 2955, .y = 4013 }, .{ .x = 2960, .y = 4004 }, .{ .x = 2966, .y = 4005 }, .{ .x = 2991, .y = 4031 }, .{ .x = 3020, .y = 4047 }, .{ .x = 3016, .y = 4010 }, .{ .x = 2997, .y = 3986 }, .{ .x = 3009, .y = 3973 }, .{ .x = 3010, .y = 3961 }, .{ .x = 2998, .y = 3931 }, .{ .x = 3020, .y = 3920 }, .{ .x = 3016, .y = 3895 }, .{ .x = 3022, .y = 3885 }, .{ .x = 3056, .y = 3864 }, .{ .x = 3080, .y = 3873 }, .{ .x = 3084, .y = 3858 }, .{ .x = 3073, .y = 3845 }, .{ .x = 3047, .y = 3848 }, .{ .x = 3029, .y = 3840 }, .{ .x = 3016, .y = 3844 }, .{ .x = 3006, .y = 3858 }, .{ .x = 2998, .y = 3855 }, .{ .x = 2992, .y = 3824 }, .{ .x = 3008, .y = 3819 }, .{ .x = 3018, .y = 3800 }, .{ .x = 3020, .y = 3769 }, .{ .x = 3014, .y = 3756 }, .{ .x = 2984, .y = 3742 }, .{ .x = 2988, .y = 3736 }, .{ .x = 3010, .y = 3733 }, .{ .x = 3022, .y = 3716 }, .{ .x = 2994, .y = 3717 }, .{ .x = 2954, .y = 3625 }, .{ .x = 2940, .y = 3620 }, .{ .x = 2920, .y = 3631 }, .{ .x = 2893, .y = 3633 }, .{ .x = 2878, .y = 3622 }, .{ .x = 2845, .y = 3628 }, .{ .x = 2839, .y = 3617 }, .{ .x = 2864, .y = 3594 }, .{ .x = 2902, .y = 3609 }, .{ .x = 2945, .y = 3589 }, .{ .x = 2959, .y = 3592 }, .{ .x = 2998, .y = 3651 }, .{ .x = 2997, .y = 3680 }, .{ .x = 3019, .y = 3671 }, .{ .x = 3049, .y = 3671 }, .{ .x = 3056, .y = 3678 }, .{ .x = 3064, .y = 3711 }, .{ .x = 3096, .y = 3715 }, .{ .x = 3096, .y = 3746 }, .{ .x = 3121, .y = 3744 }, .{ .x = 3135, .y = 3733 }, .{ .x = 3186, .y = 3662 }, .{ .x = 3188, .y = 3611 }, .{ .x = 3198, .y = 3590 }, .{ .x = 3228, .y = 3466 }, .{ .x = 3223, .y = 3457 }, .{ .x = 3191, .y = 3440 }, .{ .x = 3155, .y = 3394 }, .{ .x = 3135, .y = 3378 }, .{ .x = 2905, .y = 3248 }, .{ .x = 2738, .y = 3199 }, .{ .x = 2711, .y = 3186 }, .{ .x = 2669, .y = 3144 }, .{ .x = 2643, .y = 3137 }, .{ .x = 2632, .y = 3115 }, .{ .x = 2623, .y = 3111 }, .{ .x = 2608, .y = 3106 }, .{ .x = 2566, .y = 3110 }, .{ .x = 2565, .y = 3118 }, .{ .x = 2583, .y = 3137 }, .{ .x = 2582, .y = 3174 }, .{ .x = 2573, .y = 3185 }, .{ .x = 2538, .y = 3182 }, .{ .x = 2537, .y = 3170 }, .{ .x = 2556, .y = 3157 }, .{ .x = 2563, .y = 3144 }, .{ .x = 2538, .y = 3098 }, .{ .x = 2488, .y = 3106 }, .{ .x = 2475, .y = 3106 }, .{ .x = 2470, .y = 3100 }, .{ .x = 2483, .y = 3087 }, .{ .x = 2539, .y = 3081 }, .{ .x = 2543, .y = 3070 }, .{ .x = 2516, .y = 3066 }, .{ .x = 2529, .y = 3053 }, .{ .x = 2539, .y = 3052 }, .{ .x = 2562, .y = 3068 }, .{ .x = 2571, .y = 3068 }, .{ .x = 2567, .y = 3048 }, .{ .x = 2536, .y = 3024 }, .{ .x = 2539, .y = 3009 }, .{ .x = 2533, .y = 3003 }, .{ .x = 2489, .y = 2994 }, .{ .x = 2410, .y = 2961 }, .{ .x = 2366, .y = 2962 }, .{ .x = 2352, .y = 2976 }, .{ .x = 2348, .y = 3047 }, .{ .x = 2338, .y = 3056 }, .{ .x = 2334, .y = 3037 }, .{ .x = 2320, .y = 3025 }, .{ .x = 2334, .y = 2999 }, .{ .x = 2332, .y = 2976 }, .{ .x = 2310, .y = 2966 }, .{ .x = 2236, .y = 2965 }, .{ .x = 2232, .y = 2957 }, .{ .x = 2244, .y = 2948 }, .{ .x = 2308, .y = 2946 }, .{ .x = 2307, .y = 2918 }, .{ .x = 2313, .y = 2910 }, .{ .x = 2333, .y = 2916 }, .{ .x = 2361, .y = 2898 }, .{ .x = 2394, .y = 2895 }, .{ .x = 2397, .y = 2885 }, .{ .x = 2370, .y = 2838 }, .{ .x = 2352, .y = 2780 }, .{ .x = 2214, .y = 2695 }, .{ .x = 2179, .y = 2664 }, .{ .x = 2135, .y = 2511 }, .{ .x = 2071, .y = 2492 }, .{ .x = 2047, .y = 2504 }, .{ .x = 2043, .y = 2517 }, .{ .x = 2061, .y = 2546 }, .{ .x = 2062, .y = 2600 }, .{ .x = 2055, .y = 2649 }, .{ .x = 2042, .y = 2663 }, .{ .x = 2038, .y = 2654 }, .{ .x = 2046, .y = 2616 }, .{ .x = 2040, .y = 2595 }, .{ .x = 1982, .y = 2519 }, .{ .x = 1924, .y = 2490 }, .{ .x = 1924, .y = 2190 }, .{ .x = 1941, .y = 2189 }, .{ .x = 1978, .y = 2197 }, .{ .x = 2036, .y = 2225 }, .{ .x = 2097, .y = 2229 }, .{ .x = 2175, .y = 2259 }, .{ .x = 2226, .y = 2293 }, .{ .x = 2294, .y = 2352 }, .{ .x = 2312, .y = 2379 }, .{ .x = 2325, .y = 2445 }, .{ .x = 2359, .y = 2517 }, .{ .x = 2367, .y = 2559 }, .{ .x = 2374, .y = 2561 }, .{ .x = 2389, .y = 2534 }, .{ .x = 2450, .y = 2544 }, .{ .x = 2457, .y = 2539 }, .{ .x = 2426, .y = 2483 }, .{ .x = 2399, .y = 2463 }, .{ .x = 2400, .y = 2453 }, .{ .x = 2409, .y = 2451 }, .{ .x = 2479, .y = 2506 }, .{ .x = 2482, .y = 2511 }, .{ .x = 2473, .y = 2528 }, .{ .x = 2524, .y = 2556 }, .{ .x = 2552, .y = 2562 }, .{ .x = 2551, .y = 2576 }, .{ .x = 2562, .y = 2580 }, .{ .x = 2572, .y = 2561 }, .{ .x = 2571, .y = 2546 }, .{ .x = 2546, .y = 2503 }, .{ .x = 2546, .y = 2492 }, .{ .x = 2584, .y = 2481 }, .{ .x = 2599, .y = 2468 }, .{ .x = 2577, .y = 2443 }, .{ .x = 2572, .y = 2428 }, .{ .x = 2600, .y = 2408 }, .{ .x = 2624, .y = 2352 }, .{ .x = 2620, .y = 2346 }, .{ .x = 2595, .y = 2343 }, .{ .x = 2587, .y = 2311 }, .{ .x = 2557, .y = 2290 }, .{ .x = 2523, .y = 2254 }, .{ .x = 2522, .y = 2244 }, .{ .x = 2529, .y = 2242 }, .{ .x = 2567, .y = 2274 }, .{ .x = 2613, .y = 2297 }, .{ .x = 2621, .y = 2329 }, .{ .x = 2652, .y = 2326 }, .{ .x = 2664, .y = 2338 }, .{ .x = 2660, .y = 2352 }, .{ .x = 2640, .y = 2366 }, .{ .x = 2647, .y = 2384 }, .{ .x = 2625, .y = 2429 }, .{ .x = 2638, .y = 2462 }, .{ .x = 2654, .y = 2463 }, .{ .x = 2669, .y = 2455 }, .{ .x = 2673, .y = 2460 }, .{ .x = 2670, .y = 2469 }, .{ .x = 2627, .y = 2508 }, .{ .x = 2642, .y = 2524 }, .{ .x = 2644, .y = 2566 }, .{ .x = 2690, .y = 2671 }, .{ .x = 2698, .y = 2674 }, .{ .x = 2706, .y = 2668 }, .{ .x = 2712, .y = 2643 }, .{ .x = 2710, .y = 2611 }, .{ .x = 2699, .y = 2581 }, .{ .x = 2718, .y = 2583 }, .{ .x = 2730, .y = 2627 }, .{ .x = 2748, .y = 2601 }, .{ .x = 2758, .y = 2602 }, .{ .x = 2764, .y = 2607 }, .{ .x = 2760, .y = 2621 }, .{ .x = 2731, .y = 2643 }, .{ .x = 2727, .y = 2658 }, .{ .x = 2733, .y = 2665 }, .{ .x = 2771, .y = 2677 }, .{ .x = 2772, .y = 2709 }, .{ .x = 2806, .y = 2735 }, .{ .x = 2870, .y = 2852 }, .{ .x = 2891, .y = 2862 }, .{ .x = 2943, .y = 2868 }, .{ .x = 2954, .y = 2878 }, .{ .x = 2971, .y = 2915 }, .{ .x = 2983, .y = 2910 }, .{ .x = 2986, .y = 2900 }, .{ .x = 2985, .y = 2876 }, .{ .x = 2955, .y = 2789 }, .{ .x = 2948, .y = 2678 }, .{ .x = 2928, .y = 2639 }, .{ .x = 2874, .y = 2594 }, .{ .x = 2889, .y = 2544 }, .{ .x = 2895, .y = 2543 }, .{ .x = 2902, .y = 2550 }, .{ .x = 2917, .y = 2600 }, .{ .x = 2930, .y = 2607 }, .{ .x = 2972, .y = 2560 }, .{ .x = 2978, .y = 2539 }, .{ .x = 2941, .y = 2464 }, .{ .x = 2792, .y = 2250 }, .{ .x = 2755, .y = 2112 }, .{ .x = 2761, .y = 2041 }, .{ .x = 2771, .y = 2020 }, .{ .x = 2815, .y = 1969 }, .{ .x = 2822, .y = 1946 }, .{ .x = 2767, .y = 1916 }, .{ .x = 2745, .y = 1912 }, .{ .x = 2724, .y = 1924 }, .{ .x = 2686, .y = 1928 }, .{ .x = 2664, .y = 1962 }, .{ .x = 2646, .y = 1971 }, .{ .x = 2604, .y = 1974 }, .{ .x = 2567, .y = 1958 }, .{ .x = 2543, .y = 1910 }, .{ .x = 2522, .y = 1892 }, .{ .x = 2528, .y = 1859 }, .{ .x = 2477, .y = 1861 }, .{ .x = 2484, .y = 1822 }, .{ .x = 2439, .y = 1775 }, .{ .x = 2442, .y = 1767 }, .{ .x = 2461, .y = 1778 }, .{ .x = 2467, .y = 1771 }, .{ .x = 2444, .y = 1718 }, .{ .x = 2412, .y = 1700 }, .{ .x = 2340, .y = 1627 }, .{ .x = 2253, .y = 1607 }, .{ .x = 2197, .y = 1573 }, .{ .x = 2041, .y = 1438 }, .{ .x = 2032, .y = 1394 }, .{ .x = 1964, .y = 1336 }, .{ .x = 1964, .y = 1329 }, .{ .x = 1987, .y = 1328 }, .{ .x = 1993, .y = 1318 }, .{ .x = 1974, .y = 1273 }, .{ .x = 1951, .y = 1268 }, .{ .x = 1944, .y = 1259 }, .{ .x = 1960, .y = 1232 }, .{ .x = 1924, .y = 1215 }, .{ .x = 1924, .y = 1190 }, .{ .x = 1950, .y = 1158 }, .{ .x = 1947, .y = 1131 }, .{ .x = 1952, .y = 1107 }, .{ .x = 1946, .y = 1094 }, .{ .x = 1924, .y = 1083 }, .{ .x = 1924, .y = 855 }, .{ .x = 1942, .y = 835 }, .{ .x = 1944, .y = 801 }, .{ .x = 1952, .y = 798 }, .{ .x = 1962, .y = 814 }, .{ .x = 1949, .y = 908 }, .{ .x = 1950, .y = 953 }, .{ .x = 1962, .y = 975 }, .{ .x = 1994, .y = 970 }, .{ .x = 2002, .y = 978 }, .{ .x = 1991, .y = 994 }, .{ .x = 1988, .y = 1023 }, .{ .x = 1967, .y = 1048 }, .{ .x = 1966, .y = 1060 }, .{ .x = 2003, .y = 1084 }, .{ .x = 2010, .y = 1101 }, .{ .x = 2006, .y = 1130 }, .{ .x = 1974, .y = 1166 }, .{ .x = 1975, .y = 1185 }, .{ .x = 1981, .y = 1203 }, .{ .x = 2019, .y = 1237 }, .{ .x = 2028, .y = 1277 }, .{ .x = 2042, .y = 1291 }, .{ .x = 2031, .y = 1325 }, .{ .x = 2124, .y = 1363 }, .{ .x = 2136, .y = 1381 }, .{ .x = 2138, .y = 1400 }, .{ .x = 2146, .y = 1403 }, .{ .x = 2164, .y = 1355 }, .{ .x = 2166, .y = 1306 }, .{ .x = 2169, .y = 1300 }, .{ .x = 2180, .y = 1300 }, .{ .x = 2189, .y = 1322 }, .{ .x = 2189, .y = 1342 }, .{ .x = 2183, .y = 1369 }, .{ .x = 2154, .y = 1430 }, .{ .x = 2154, .y = 1447 }, .{ .x = 2164, .y = 1454 }, .{ .x = 2199, .y = 1453 }, .{ .x = 2250, .y = 1491 }, .{ .x = 2299, .y = 1501 }, .{ .x = 2305, .y = 1520 }, .{ .x = 2294, .y = 1554 }, .{ .x = 2305, .y = 1566 }, .{ .x = 2328, .y = 1541 }, .{ .x = 2348, .y = 1549 }, .{ .x = 2384, .y = 1525 }, .{ .x = 2398, .y = 1550 }, .{ .x = 2413, .y = 1559 }, .{ .x = 2423, .y = 1605 }, .{ .x = 2436, .y = 1614 }, .{ .x = 2483, .y = 1585 }, .{ .x = 2495, .y = 1547 }, .{ .x = 2508, .y = 1548 }, .{ .x = 2508, .y = 1575 }, .{ .x = 2499, .y = 1596 }, .{ .x = 2462, .y = 1618 }, .{ .x = 2487, .y = 1660 }, .{ .x = 2505, .y = 1716 }, .{ .x = 2538, .y = 1762 }, .{ .x = 2540, .y = 1776 }, .{ .x = 2527, .y = 1803 }, .{ .x = 2523, .y = 1830 }, .{ .x = 2566, .y = 1856 }, .{ .x = 2583, .y = 1884 }, .{ .x = 2591, .y = 1885 }, .{ .x = 2600, .y = 1868 }, .{ .x = 2631, .y = 1853 }, .{ .x = 2679, .y = 1872 }, .{ .x = 2693, .y = 1869 }, .{ .x = 2725, .y = 1809 }, .{ .x = 2779, .y = 1732 }, .{ .x = 2786, .y = 1701 }, .{ .x = 2776, .y = 1663 }, .{ .x = 2800, .y = 1637 }, .{ .x = 2802, .y = 1622 }, .{ .x = 2751, .y = 1604 }, .{ .x = 2743, .y = 1596 }, .{ .x = 2700, .y = 1516 }, .{ .x = 2583, .y = 1378 }, .{ .x = 2486, .y = 1275 }, .{ .x = 2427, .y = 1149 }, .{ .x = 2406, .y = 1059 }, .{ .x = 2414, .y = 990 }, .{ .x = 2409, .y = 873 }, .{ .x = 2424, .y = 779 }, .{ .x = 2376, .y = 604 }, .{ .x = 2365, .y = 534 }, .{ .x = 2361, .y = 458 }, .{ .x = 2382, .y = 369 }, .{ .x = 2377, .y = 356 }, .{ .x = 2328, .y = 350 }, .{ .x = 2306, .y = 334 }, .{ .x = 2292, .y = 295 }, .{ .x = 2300, .y = 262 }, .{ .x = 2284, .y = 226 }, .{ .x = 2290, .y = 201 }, .{ .x = 2358, .y = 165 }, .{ .x = 2389, .y = 106 }, .{ .x = 2432, .y = 75 }, .{ .x = 2457, .y = 43 }, .{ .x = 2487, .y = -64 }, .{ .x = 2371, .y = -64 }, .{ .x = 2383, .y = -46 }, .{ .x = 2372, .y = -36 }, .{ .x = 2353, .y = -44 }, .{ .x = 2347, .y = -64 }, .{ .x = 3335, .y = -64 }, .{ .x = 3349, .y = -15 }, .{ .x = 3329, .y = 66 }, .{ .x = 3396, .y = 187 }, .{ .x = 3409, .y = 198 }, .{ .x = 3476, .y = 165 }, .{ .x = 3479, .y = 173 }, .{ .x = 3447, .y = 200 }, .{ .x = 3425, .y = 249 }, .{ .x = 3418, .y = 255 }, .{ .x = 3395, .y = 250 }, .{ .x = 3362, .y = 197 }, .{ .x = 3296, .y = 143 }, .{ .x = 3285, .y = 126 }, .{ .x = 3298, .y = 64 }, .{ .x = 3264, .y = 17 }, .{ .x = 3238, .y = 5 }, .{ .x = 3201, .y = 7 }, .{ .x = 3193, .y = -4 }, .{ .x = 3190, .y = -28 }, .{ .x = 3168, .y = -64 }, .{ .x = 3092, .y = -64 }, .{ .x = 3095, .y = -39 }, .{ .x = 3090, .y = -30 }, .{ .x = 3049, .y = -26 }, .{ .x = 3033, .y = -16 }, .{ .x = 3020, .y = 10 }, .{ .x = 2990, .y = 120 }, .{ .x = 2938, .y = 188 }, .{ .x = 2953, .y = 205 }, .{ .x = 2954, .y = 215 }, .{ .x = 2925, .y = 236 }, .{ .x = 2920, .y = 246 }, .{ .x = 2915, .y = 286 }, .{ .x = 2923, .y = 372 }, .{ .x = 2907, .y = 430 }, .{ .x = 2902, .y = 480 }, .{ .x = 2918, .y = 567 }, .{ .x = 2929, .y = 564 }, .{ .x = 2941, .y = 540 }, .{ .x = 2982, .y = 528 }, .{ .x = 2981, .y = 503 }, .{ .x = 2952, .y = 456 }, .{ .x = 2955, .y = 426 }, .{ .x = 2975, .y = 371 }, .{ .x = 3004, .y = 340 }, .{ .x = 3003, .y = 273 }, .{ .x = 2996, .y = 237 }, .{ .x = 3000, .y = 225 }, .{ .x = 3022, .y = 206 }, .{ .x = 3048, .y = 196 }, .{ .x = 3056, .y = 200 }, .{ .x = 3026, .y = 268 }, .{ .x = 3046, .y = 312 }, .{ .x = 3039, .y = 373 }, .{ .x = 3050, .y = 392 }, .{ .x = 3093, .y = 398 }, .{ .x = 3113, .y = 425 }, .{ .x = 3123, .y = 426 }, .{ .x = 3131, .y = 369 }, .{ .x = 3172, .y = 318 }, .{ .x = 3154, .y = 285 }, .{ .x = 3164, .y = 273 }, .{ .x = 3201, .y = 275 }, .{ .x = 3219, .y = 255 }, .{ .x = 3232, .y = 270 }, .{ .x = 3292, .y = 298 }, .{ .x = 3312, .y = 316 }, .{ .x = 3313, .y = 327 }, .{ .x = 3303, .y = 338 }, .{ .x = 3231, .y = 307 }, .{ .x = 3215, .y = 316 }, .{ .x = 3217, .y = 408 }, .{ .x = 3232, .y = 442 }, .{ .x = 3267, .y = 492 }, .{ .x = 3324, .y = 558 }, .{ .x = 3334, .y = 565 }, .{ .x = 3343, .y = 560 }, .{ .x = 3401, .y = 433 }, .{ .x = 3414, .y = 427 }, .{ .x = 3446, .y = 442 }, .{ .x = 3470, .y = 445 }, .{ .x = 3466, .y = 457 }, .{ .x = 3405, .y = 472 }, .{ .x = 3389, .y = 505 }, .{ .x = 3396, .y = 549 }, .{ .x = 3446, .y = 620 }, .{ .x = 3471, .y = 684 }, .{ .x = 3500, .y = 707 }, .{ .x = 3550, .y = 721 }, .{ .x = 3586, .y = 785 }, .{ .x = 3603, .y = 787 }, .{ .x = 3633, .y = 763 }, .{ .x = 3732, .y = 803 }, .{ .x = 3769, .y = 869 }, .{ .x = 3783, .y = 875 }, .{ .x = 3808, .y = 874 }, .{ .x = 3819, .y = 904 }, .{ .x = 3831, .y = 911 }, .{ .x = 3842, .y = 907 }, .{ .x = 3861, .y = 864 }, .{ .x = 3923, .y = 841 }, .{ .x = 3948, .y = 811 }, .{ .x = 3953, .y = 792 }, .{ .x = 3938, .y = 724 }, .{ .x = 3971, .y = 686 }, .{ .x = 4027, .y = 649 }, .{ .x = 4042, .y = 576 }, .{ .x = 4051, .y = 561 }, .{ .x = 4058, .y = 563 }, .{ .x = 4052, .y = 632 }, .{ .x = 4044, .y = 656 }, .{ .x = 4027, .y = 696 }, .{ .x = 4001, .y = 713 }, .{ .x = 3986, .y = 748 }, .{ .x = 3987, .y = 768 }, .{ .x = 4000, .y = 792 }, .{ .x = 3982, .y = 816 }, .{ .x = 3974, .y = 885 }, .{ .x = 3947, .y = 899 }, .{ .x = 3913, .y = 891 }, .{ .x = 3869, .y = 930 }, .{ .x = 3857, .y = 949 }, .{ .x = 3806, .y = 964 }, .{ .x = 3779, .y = 961 }, .{ .x = 3731, .y = 937 }, .{ .x = 3707, .y = 909 }, .{ .x = 3688, .y = 873 }, .{ .x = 3664, .y = 854 }, .{ .x = 3636, .y = 863 }, .{ .x = 3607, .y = 894 }, .{ .x = 3596, .y = 898 }, .{ .x = 3579, .y = 895 }, .{ .x = 3552, .y = 869 }, .{ .x = 3533, .y = 836 }, .{ .x = 3518, .y = 827 }, .{ .x = 3497, .y = 830 }, .{ .x = 3458, .y = 861 }, .{ .x = 3447, .y = 860 }, .{ .x = 3421, .y = 830 }, .{ .x = 3406, .y = 823 }, .{ .x = 3422, .y = 789 }, .{ .x = 3392, .y = 760 }, .{ .x = 3380, .y = 758 }, .{ .x = 3369, .y = 764 }, .{ .x = 3322, .y = 819 }, .{ .x = 3310, .y = 828 }, .{ .x = 3297, .y = 828 }, .{ .x = 3275, .y = 805 }, .{ .x = 3234, .y = 800 }, .{ .x = 3231, .y = 793 }, .{ .x = 3248, .y = 751 }, .{ .x = 3239, .y = 725 }, .{ .x = 3227, .y = 725 }, .{ .x = 3188, .y = 746 }, .{ .x = 3166, .y = 798 }, .{ .x = 3156, .y = 801 }, .{ .x = 3142, .y = 796 }, .{ .x = 3123, .y = 755 }, .{ .x = 3103, .y = 731 }, .{ .x = 3085, .y = 722 }, .{ .x = 3083, .y = 733 }, .{ .x = 3113, .y = 773 }, .{ .x = 3123, .y = 814 }, .{ .x = 3157, .y = 832 }, .{ .x = 3136, .y = 885 }, .{ .x = 3127, .y = 894 }, .{ .x = 3115, .y = 903 }, .{ .x = 3090, .y = 892 }, .{ .x = 3075, .y = 894 }, .{ .x = 3058, .y = 917 }, .{ .x = 3060, .y = 938 }, .{ .x = 3090, .y = 976 }, .{ .x = 3107, .y = 1011 }, .{ .x = 3112, .y = 1078 }, .{ .x = 3126, .y = 1064 }, .{ .x = 3134, .y = 965 }, .{ .x = 3139, .y = 958 }, .{ .x = 3149, .y = 965 }, .{ .x = 3151, .y = 1042 }, .{ .x = 3160, .y = 1057 }, .{ .x = 3171, .y = 1047 }, .{ .x = 3183, .y = 972 }, .{ .x = 3189, .y = 964 }, .{ .x = 3201, .y = 963 }, .{ .x = 3203, .y = 971 }, .{ .x = 3198, .y = 1044 }, .{ .x = 3273, .y = 992 }, .{ .x = 3288, .y = 935 }, .{ .x = 3312, .y = 939 }, .{ .x = 3316, .y = 946 }, .{ .x = 3306, .y = 975 }, .{ .x = 3316, .y = 980 }, .{ .x = 3368, .y = 965 }, .{ .x = 3383, .y = 955 }, .{ .x = 3399, .y = 929 }, .{ .x = 3406, .y = 929 }, .{ .x = 3413, .y = 939 }, .{ .x = 3397, .y = 960 }, .{ .x = 3363, .y = 993 }, .{ .x = 3323, .y = 1015 }, .{ .x = 3320, .y = 1029 }, .{ .x = 3348, .y = 1080 }, .{ .x = 3372, .y = 1097 }, .{ .x = 3445, .y = 1097 }, .{ .x = 3417, .y = 1112 }, .{ .x = 3379, .y = 1117 }, .{ .x = 3336, .y = 1095 }, .{ .x = 3312, .y = 1069 }, .{ .x = 3300, .y = 1069 }, .{ .x = 3320, .y = 1130 }, .{ .x = 3323, .y = 1165 }, .{ .x = 3299, .y = 1180 }, .{ .x = 3280, .y = 1179 }, .{ .x = 3275, .y = 1162 }, .{ .x = 3286, .y = 1119 }, .{ .x = 3278, .y = 1113 }, .{ .x = 3268, .y = 1111 }, .{ .x = 3257, .y = 1124 }, .{ .x = 3252, .y = 1160 }, .{ .x = 3234, .y = 1134 }, .{ .x = 3216, .y = 1128 }, .{ .x = 3205, .y = 1154 }, .{ .x = 3203, .y = 1218 }, .{ .x = 3186, .y = 1225 }, .{ .x = 3163, .y = 1203 }, .{ .x = 3148, .y = 1203 }, .{ .x = 3127, .y = 1254 }, .{ .x = 3098, .y = 1298 }, .{ .x = 3090, .y = 1336 }, .{ .x = 3075, .y = 1331 }, .{ .x = 3075, .y = 1315 }, .{ .x = 3082, .y = 1291 }, .{ .x = 3109, .y = 1249 }, .{ .x = 3114, .y = 1230 }, .{ .x = 3109, .y = 1209 }, .{ .x = 3076, .y = 1199 }, .{ .x = 3046, .y = 1216 }, .{ .x = 3023, .y = 1218 }, .{ .x = 2999, .y = 1193 }, .{ .x = 2986, .y = 1188 }, .{ .x = 2978, .y = 1196 }, .{ .x = 2983, .y = 1238 }, .{ .x = 2978, .y = 1247 }, .{ .x = 2963, .y = 1250 }, .{ .x = 2948, .y = 1232 }, .{ .x = 2936, .y = 1131 }, .{ .x = 2928, .y = 1113 }, .{ .x = 2939, .y = 1107 }, .{ .x = 2952, .y = 1122 }, .{ .x = 2957, .y = 1109 }, .{ .x = 2946, .y = 1087 }, .{ .x = 2927, .y = 1081 }, .{ .x = 2914, .y = 1086 }, .{ .x = 2911, .y = 1105 }, .{ .x = 2916, .y = 1156 }, .{ .x = 2941, .y = 1269 }, .{ .x = 2975, .y = 1339 }, .{ .x = 3006, .y = 1377 }, .{ .x = 3049, .y = 1455 }, .{ .x = 3098, .y = 1567 }, .{ .x = 3121, .y = 1638 }, .{ .x = 3153, .y = 1661 }, .{ .x = 3210, .y = 1742 }, .{ .x = 3234, .y = 1753 }, .{ .x = 3244, .y = 1749 }, .{ .x = 3256, .y = 1727 }, .{ .x = 3237, .y = 1662 }, .{ .x = 3251, .y = 1636 }, .{ .x = 3253, .y = 1614 }, .{ .x = 3272, .y = 1601 }, .{ .x = 3296, .y = 1608 }, .{ .x = 3306, .y = 1645 }, .{ .x = 3295, .y = 1714 }, .{ .x = 3325, .y = 1716 }, .{ .x = 3379, .y = 1755 }, .{ .x = 3395, .y = 1781 }, .{ .x = 3401, .y = 1810 }, .{ .x = 3424, .y = 1820 }, .{ .x = 3434, .y = 1833 }, .{ .x = 3435, .y = 1847 }, .{ .x = 3428, .y = 1849 }, .{ .x = 3378, .y = 1820 }, .{ .x = 3355, .y = 1820 }, .{ .x = 3354, .y = 1843 }, .{ .x = 3331, .y = 1846 }, .{ .x = 3339, .y = 1874 }, .{ .x = 3352, .y = 1884 }, .{ .x = 3386, .y = 1874 }, .{ .x = 3515, .y = 1920 }, .{ .x = 3518, .y = 1929 }, .{ .x = 3504, .y = 1949 }, .{ .x = 3501, .y = 1983 }, .{ .x = 3476, .y = 2021 }, .{ .x = 3480, .y = 2035 }, .{ .x = 3542, .y = 2035 }, .{ .x = 3566, .y = 2049 }, .{ .x = 3572, .y = 2047 }, .{ .x = 3535, .y = 1993 }, .{ .x = 3531, .y = 1967 }, .{ .x = 3535, .y = 1957 }, .{ .x = 3559, .y = 1940 }, .{ .x = 3592, .y = 1947 }, .{ .x = 3579, .y = 1971 }, .{ .x = 3582, .y = 1985 }, .{ .x = 3619, .y = 1982 }, .{ .x = 3626, .y = 1994 }, .{ .x = 3620, .y = 2019 }, .{ .x = 3648, .y = 2021 }, .{ .x = 3657, .y = 2038 }, .{ .x = 3682, .y = 2035 }, .{ .x = 3673, .y = 2062 }, .{ .x = 3689, .y = 2083 }, .{ .x = 3686, .y = 2096 }, .{ .x = 3658, .y = 2111 }, .{ .x = 3622, .y = 2103 }, .{ .x = 3626, .y = 2120 }, .{ .x = 3651, .y = 2133 }, .{ .x = 3698, .y = 2137 }, .{ .x = 3721, .y = 2152 }, .{ .x = 3751, .y = 2204 }, .{ .x = 3782, .y = 2224 }, .{ .x = 3815, .y = 2271 }, .{ .x = 3825, .y = 2264 }, .{ .x = 3813, .y = 2181 }, .{ .x = 3780, .y = 2131 }, .{ .x = 3818, .y = 2122 }, .{ .x = 3838, .y = 2096 }, .{ .x = 3838, .y = 2086 }, .{ .x = 3810, .y = 2062 }, .{ .x = 3829, .y = 2049 }, .{ .x = 3840, .y = 2017 }, .{ .x = 3813, .y = 1967 }, .{ .x = 3799, .y = 1956 }, .{ .x = 3761, .y = 1945 }, .{ .x = 3759, .y = 1935 }, .{ .x = 3786, .y = 1897 }, .{ .x = 3857, .y = 1833 }, .{ .x = 3873, .y = 1807 }, .{ .x = 3887, .y = 1773 }, .{ .x = 3883, .y = 1711 }, .{ .x = 3890, .y = 1697 }, .{ .x = 3897, .y = 1689 }, .{ .x = 3911, .y = 1688 }, .{ .x = 3999, .y = 1724 }, .{ .x = 4019, .y = 1764 }, .{ .x = 4036, .y = 1780 }, .{ .x = 3999, .y = 1852 }, .{ .x = 3965, .y = 1891 }, .{ .x = 3930, .y = 1907 }, .{ .x = 3881, .y = 1912 }, .{ .x = 3862, .y = 1924 }, .{ .x = 3973, .y = 2015 }, .{ .x = 3974, .y = 2067 }, .{ .x = 4014, .y = 2113 }, .{ .x = 4018, .y = 2129 }, .{ .x = 4006, .y = 2167 }, .{ .x = 4007, .y = 2183 }, .{ .x = 4016, .y = 2183 }, .{ .x = 4067, .y = 2137 }, .{ .x = 4055, .y = 2081 }, .{ .x = 4081, .y = 2069 }, .{ .x = 4074, .y = 2040 }, .{ .x = 4076, .y = 1962 }, .{ .x = 4089, .y = 1935 }, .{ .x = 4160, .y = 1856 }, .{ .x = 4160, .y = 2327 }, .{ .x = 4127, .y = 2355 }, .{ .x = 4100, .y = 2348 }, .{ .x = 4078, .y = 2351 }, .{ .x = 4059, .y = 2361 }, .{ .x = 4046, .y = 2382 }, .{ .x = 4060, .y = 2411 }, .{ .x = 4103, .y = 2444 }, .{ .x = 4102, .y = 2451 }, .{ .x = 4072, .y = 2441 }, .{ .x = 4057, .y = 2451 }, .{ .x = 4048, .y = 2496 }, .{ .x = 4048, .y = 2546 }, .{ .x = 4055, .y = 2561 }, .{ .x = 4071, .y = 2569 }, .{ .x = 4100, .y = 2567 }, .{ .x = 4126, .y = 2573 }, .{ .x = 4131, .y = 2566 }, .{ .x = 4121, .y = 2517 }, .{ .x = 4128, .y = 2513 }, .{ .x = 4160, .y = 2520 }, .{ .x = 4160, .y = 4160 } }; +const S1 = [_]Pt{ .{ .x = 3921, .y = 3609 }, .{ .x = 3911, .y = 3660 }, .{ .x = 3918, .y = 3742 }, .{ .x = 3934, .y = 3768 }, .{ .x = 3969, .y = 3784 }, .{ .x = 3973, .y = 3769 }, .{ .x = 3943, .y = 3749 }, .{ .x = 3944, .y = 3738 }, .{ .x = 3962, .y = 3712 }, .{ .x = 3945, .y = 3680 }, .{ .x = 3963, .y = 3659 }, .{ .x = 3938, .y = 3619 }, .{ .x = 3921, .y = 3609 } }; +const S2 = [_]Pt{ .{ .x = 3973, .y = 3700 }, .{ .x = 3993, .y = 3684 }, .{ .x = 3985, .y = 3664 }, .{ .x = 3968, .y = 3680 }, .{ .x = 3966, .y = 3690 }, .{ .x = 3973, .y = 3700 } }; +const S3 = [_]Pt{ .{ .x = 3921, .y = 3577 }, .{ .x = 3934, .y = 3576 }, .{ .x = 3935, .y = 3562 }, .{ .x = 3907, .y = 3529 }, .{ .x = 3888, .y = 3521 }, .{ .x = 3884, .y = 3528 }, .{ .x = 3894, .y = 3552 }, .{ .x = 3921, .y = 3577 } }; +const S4 = [_]Pt{ .{ .x = 3849, .y = 3440 }, .{ .x = 3839, .y = 3409 }, .{ .x = 3822, .y = 3409 }, .{ .x = 3823, .y = 3425 }, .{ .x = 3840, .y = 3458 }, .{ .x = 3857, .y = 3460 }, .{ .x = 3849, .y = 3440 } }; +const S5 = [_]Pt{ .{ .x = 3859, .y = 3268 }, .{ .x = 3864, .y = 3226 }, .{ .x = 3838, .y = 3201 }, .{ .x = 3829, .y = 3182 }, .{ .x = 3843, .y = 3155 }, .{ .x = 3849, .y = 3090 }, .{ .x = 3829, .y = 3059 }, .{ .x = 3806, .y = 3051 }, .{ .x = 3792, .y = 3054 }, .{ .x = 3773, .y = 3068 }, .{ .x = 3768, .y = 3083 }, .{ .x = 3773, .y = 3197 }, .{ .x = 3782, .y = 3248 }, .{ .x = 3791, .y = 3254 }, .{ .x = 3803, .y = 3247 }, .{ .x = 3803, .y = 3220 }, .{ .x = 3786, .y = 3178 }, .{ .x = 3796, .y = 3171 }, .{ .x = 3805, .y = 3176 }, .{ .x = 3830, .y = 3250 }, .{ .x = 3831, .y = 3279 }, .{ .x = 3816, .y = 3311 }, .{ .x = 3826, .y = 3348 }, .{ .x = 3840, .y = 3346 }, .{ .x = 3847, .y = 3328 }, .{ .x = 3866, .y = 3321 }, .{ .x = 3859, .y = 3268 } }; +const S6 = [_]Pt{ .{ .x = 3892, .y = 3092 }, .{ .x = 3906, .y = 3141 }, .{ .x = 3919, .y = 3155 }, .{ .x = 3932, .y = 3157 }, .{ .x = 3942, .y = 3122 }, .{ .x = 3972, .y = 3088 }, .{ .x = 3969, .y = 3029 }, .{ .x = 3958, .y = 3004 }, .{ .x = 3954, .y = 2968 }, .{ .x = 3904, .y = 2963 }, .{ .x = 3881, .y = 2953 }, .{ .x = 3878, .y = 2937 }, .{ .x = 3893, .y = 2913 }, .{ .x = 3880, .y = 2904 }, .{ .x = 3865, .y = 2905 }, .{ .x = 3795, .y = 2940 }, .{ .x = 3778, .y = 2970 }, .{ .x = 3771, .y = 3028 }, .{ .x = 3778, .y = 3046 }, .{ .x = 3783, .y = 3046 }, .{ .x = 3805, .y = 3023 }, .{ .x = 3852, .y = 3016 }, .{ .x = 3870, .y = 3041 }, .{ .x = 3875, .y = 3070 }, .{ .x = 3892, .y = 3092 } }; +const S7 = [_]Pt{ .{ .x = 3917, .y = 3174 }, .{ .x = 3891, .y = 3144 }, .{ .x = 3880, .y = 3099 }, .{ .x = 3867, .y = 3094 }, .{ .x = 3859, .y = 3167 }, .{ .x = 3903, .y = 3248 }, .{ .x = 3912, .y = 3260 }, .{ .x = 3920, .y = 3260 }, .{ .x = 3944, .y = 3202 }, .{ .x = 3942, .y = 3192 }, .{ .x = 3917, .y = 3174 } }; +const S8 = [_]Pt{ .{ .x = 3841, .y = 2752 }, .{ .x = 3858, .y = 2765 }, .{ .x = 3867, .y = 2790 }, .{ .x = 3879, .y = 2789 }, .{ .x = 3895, .y = 2769 }, .{ .x = 3890, .y = 2714 }, .{ .x = 3873, .y = 2683 }, .{ .x = 3861, .y = 2641 }, .{ .x = 3881, .y = 2581 }, .{ .x = 3878, .y = 2571 }, .{ .x = 3860, .y = 2565 }, .{ .x = 3848, .y = 2566 }, .{ .x = 3821, .y = 2589 }, .{ .x = 3775, .y = 2641 }, .{ .x = 3773, .y = 2655 }, .{ .x = 3809, .y = 2698 }, .{ .x = 3804, .y = 2714 }, .{ .x = 3786, .y = 2718 }, .{ .x = 3744, .y = 2700 }, .{ .x = 3736, .y = 2708 }, .{ .x = 3815, .y = 2777 }, .{ .x = 3827, .y = 2773 }, .{ .x = 3828, .y = 2751 }, .{ .x = 3841, .y = 2752 } }; +const S9 = [_]Pt{ .{ .x = 3662, .y = 2576 }, .{ .x = 3649, .y = 2556 }, .{ .x = 3640, .y = 2562 }, .{ .x = 3644, .y = 2616 }, .{ .x = 3654, .y = 2627 }, .{ .x = 3673, .y = 2620 }, .{ .x = 3674, .y = 2602 }, .{ .x = 3662, .y = 2576 } }; +const S10 = [_]Pt{ .{ .x = 4085, .y = 2656 }, .{ .x = 4096, .y = 2655 }, .{ .x = 4097, .y = 2639 }, .{ .x = 4089, .y = 2610 }, .{ .x = 4081, .y = 2604 }, .{ .x = 4070, .y = 2618 }, .{ .x = 4085, .y = 2656 } }; +const S11 = [_]Pt{ .{ .x = 3668, .y = 2517 }, .{ .x = 3666, .y = 2494 }, .{ .x = 3654, .y = 2487 }, .{ .x = 3646, .y = 2496 }, .{ .x = 3647, .y = 2528 }, .{ .x = 3661, .y = 2547 }, .{ .x = 3667, .y = 2547 }, .{ .x = 3668, .y = 2517 } }; +const S12 = [_]Pt{ .{ .x = 3788, .y = 2513 }, .{ .x = 3813, .y = 2512 }, .{ .x = 3828, .y = 2499 }, .{ .x = 3828, .y = 2480 }, .{ .x = 3809, .y = 2457 }, .{ .x = 3810, .y = 2446 }, .{ .x = 3847, .y = 2415 }, .{ .x = 3846, .y = 2394 }, .{ .x = 3823, .y = 2374 }, .{ .x = 3787, .y = 2371 }, .{ .x = 3789, .y = 2362 }, .{ .x = 3803, .y = 2353 }, .{ .x = 3803, .y = 2342 }, .{ .x = 3769, .y = 2302 }, .{ .x = 3711, .y = 2299 }, .{ .x = 3707, .y = 2311 }, .{ .x = 3746, .y = 2329 }, .{ .x = 3749, .y = 2342 }, .{ .x = 3719, .y = 2358 }, .{ .x = 3656, .y = 2344 }, .{ .x = 3653, .y = 2353 }, .{ .x = 3668, .y = 2378 }, .{ .x = 3669, .y = 2393 }, .{ .x = 3657, .y = 2427 }, .{ .x = 3659, .y = 2462 }, .{ .x = 3669, .y = 2476 }, .{ .x = 3687, .y = 2475 }, .{ .x = 3723, .y = 2431 }, .{ .x = 3733, .y = 2430 }, .{ .x = 3740, .y = 2439 }, .{ .x = 3753, .y = 2502 }, .{ .x = 3767, .y = 2511 }, .{ .x = 3788, .y = 2513 } }; +const S13 = [_]Pt{ .{ .x = 3435, .y = 2113 }, .{ .x = 3406, .y = 2067 }, .{ .x = 3400, .y = 2035 }, .{ .x = 3430, .y = 1984 }, .{ .x = 3429, .y = 1971 }, .{ .x = 3415, .y = 1959 }, .{ .x = 3383, .y = 1969 }, .{ .x = 3333, .y = 1971 }, .{ .x = 3332, .y = 1986 }, .{ .x = 3365, .y = 2051 }, .{ .x = 3388, .y = 2124 }, .{ .x = 3398, .y = 2137 }, .{ .x = 3469, .y = 2171 }, .{ .x = 3507, .y = 2213 }, .{ .x = 3529, .y = 2212 }, .{ .x = 3538, .y = 2204 }, .{ .x = 3540, .y = 2180 }, .{ .x = 3507, .y = 2154 }, .{ .x = 3460, .y = 2142 }, .{ .x = 3435, .y = 2113 } }; +const S14 = [_]Pt{ .{ .x = 3283, .y = 1910 }, .{ .x = 3259, .y = 1865 }, .{ .x = 3259, .y = 1856 }, .{ .x = 3280, .y = 1833 }, .{ .x = 3276, .y = 1804 }, .{ .x = 3251, .y = 1776 }, .{ .x = 3230, .y = 1775 }, .{ .x = 3221, .y = 1800 }, .{ .x = 3232, .y = 1853 }, .{ .x = 3246, .y = 1888 }, .{ .x = 3279, .y = 1936 }, .{ .x = 3288, .y = 1946 }, .{ .x = 3299, .y = 1946 }, .{ .x = 3300, .y = 1941 }, .{ .x = 3283, .y = 1910 } }; +const S15 = [_]Pt{ .{ .x = 3186, .y = 1885 }, .{ .x = 3191, .y = 1876 }, .{ .x = 3181, .y = 1820 }, .{ .x = 3166, .y = 1761 }, .{ .x = 3150, .y = 1734 }, .{ .x = 3144, .y = 1746 }, .{ .x = 3153, .y = 1840 }, .{ .x = 3167, .y = 1876 }, .{ .x = 3186, .y = 1885 } }; +const S16 = [_]Pt{ .{ .x = 2833, .y = 208 }, .{ .x = 2832, .y = 186 }, .{ .x = 2853, .y = 198 }, .{ .x = 2862, .y = 188 }, .{ .x = 2869, .y = 114 }, .{ .x = 2851, .y = 98 }, .{ .x = 2840, .y = 102 }, .{ .x = 2835, .y = 130 }, .{ .x = 2806, .y = 160 }, .{ .x = 2777, .y = 208 }, .{ .x = 2780, .y = 258 }, .{ .x = 2804, .y = 297 }, .{ .x = 2813, .y = 299 }, .{ .x = 2839, .y = 294 }, .{ .x = 2854, .y = 284 }, .{ .x = 2856, .y = 276 }, .{ .x = 2851, .y = 256 }, .{ .x = 2818, .y = 248 }, .{ .x = 2810, .y = 236 }, .{ .x = 2809, .y = 229 }, .{ .x = 2831, .y = 218 }, .{ .x = 2833, .y = 208 } }; +const S17 = [_]Pt{ .{ .x = 2815, .y = -64 }, .{ .x = 2836, .y = -52 }, .{ .x = 2844, .y = -53 }, .{ .x = 2849, .y = -64 } }; +const S18 = [_]Pt{ .{ .x = 2541, .y = 2675 }, .{ .x = 2553, .y = 2671 }, .{ .x = 2558, .y = 2657 }, .{ .x = 2548, .y = 2623 }, .{ .x = 2490, .y = 2584 }, .{ .x = 2476, .y = 2564 }, .{ .x = 2461, .y = 2562 }, .{ .x = 2510, .y = 2623 }, .{ .x = 2526, .y = 2669 }, .{ .x = 2541, .y = 2675 } }; +const F0 = [_]Pt{ .{ .x = 4160, .y = -64 }, .{ .x = 4160, .y = 4160 }, .{ .x = 1924, .y = 4160 }, .{ .x = 1924, .y = 4006 }, .{ .x = 1924, .y = 3715 }, .{ .x = 1924, .y = 3035 }, .{ .x = 1924, .y = 2490 }, .{ .x = 1924, .y = 2190 }, .{ .x = 1924, .y = 1215 }, .{ .x = 1924, .y = 1191 }, .{ .x = 1924, .y = 1083 }, .{ .x = 1924, .y = 855 }, .{ .x = 1924, .y = -64 } }; +const SUBJECT = [_][]const Pt{ &S0, &S1, &S2, &S3, &S4, &S5, &S6, &S7, &S8, &S9, &S10, &S11, &S12, &S13, &S14, &S15, &S16, &S17, &S18 }; +const FACE = [_][]const Pt{ &F0 }; + +test "compose clip repro: wedge points stay inside subject-and-face intersection" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const r = try boolean.compute(a, &SUBJECT, &FACE, .intersect); + // Points in the open bay water (inside S0, inside no hole, inside the face box): + // these MUST be in the intersection. The wedge artifact excluded them. + const probes = [_]Pt{ + .{ .x = 2794, .y = 45 }, + .{ .x = 2740, .y = 150 }, + .{ .x = 2600, .y = 300 }, + .{ .x = 2900, .y = 200 }, + }; + for (probes) |q| { + try std.testing.expect(boolean.pointInEvenOdd(&SUBJECT, q.x, q.y)); // sanity: in subject + try std.testing.expect(boolean.pointInEvenOdd(&FACE, q.x, q.y)); // sanity: in face + if (!boolean.pointInEvenOdd(r, q.x, q.y)) { + std.debug.print("wedge point ({d},{d}) missing from intersection\n", .{ q.x, q.y }); + return error.WedgeExcluded; + } + } +} diff --git a/src/geometry/geometry.zig b/src/geometry/geometry.zig index ecd2e2d..9190c75 100644 --- a/src/geometry/geometry.zig +++ b/src/geometry/geometry.zig @@ -18,4 +18,5 @@ test { _ = boolean; _ = plane; _ = partition; + _ = @import("boolean_repro_test.zig"); } From 82a7822efd0420d2d8490cde16764e3072c119b0 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 18:24:50 -0400 Subject: [PATCH 118/140] fix(geometry): order T-touches in the sweep status by the touching edge's heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/geometry/boolean.zig | 11 ++++++++++- src/geometry/boolean_repro_test.zig | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/geometry/boolean.zig b/src/geometry/boolean.zig index cd32969..fd77e92 100644 --- a/src/geometry/boolean.zig +++ b/src/geometry/boolean.zig @@ -134,7 +134,16 @@ fn segBelow(e1: *SweepEvent, e2: *SweepEvent) bool { if (a1 != 0 or a2 != 0) { // Not collinear. if (e1.p.eql(e2.p)) return e1.below(e2.other.p); // share left endpoint - if (queueBefore(e2, e1)) return e2.above(e1.p); // e2 inserted first + // A T-touch: one edge's left endpoint lies exactly ON the other segment + // (a subject vertex on a coincident seam edge is the common case). The + // on-segment point says nothing about order — decide by where the + // touching edge HEADS, else the toucher sorts below the touched edge + // and inherits its sweep flags from the wrong neighbour. + if (a1 == 0) return e1.below(e2.other.p); // e2 starts on e1 + if (queueBefore(e2, e1)) { + if (signedArea(e2.p, e2.other.p, e1.p) == 0) return e2.above(e1.other.p); // e1 starts on e2 + return e2.above(e1.p); // e2 inserted first + } return e1.below(e2.p); } // Collinear: subject below clip, then stable id — consistent with queueBefore. diff --git a/src/geometry/boolean_repro_test.zig b/src/geometry/boolean_repro_test.zig index 1587886..2880347 100644 --- a/src/geometry/boolean_repro_test.zig +++ b/src/geometry/boolean_repro_test.zig @@ -48,3 +48,24 @@ test "compose clip repro: wedge points stay inside subject-and-face intersection } } } + +const LI_S0 = [_]Pt{ .{ .x = 4030, .y = -64 }, .{ .x = 4030, .y = -42 }, .{ .x = 4022, .y = -44 }, .{ .x = 4009, .y = -64 }, .{ .x = 3621, .y = -64 }, .{ .x = 3615, .y = -59 }, .{ .x = 3565, .y = -64 }, .{ .x = 3572, .y = -53 }, .{ .x = 3594, .y = -46 }, .{ .x = 3594, .y = -39 }, .{ .x = 3561, .y = -18 }, .{ .x = 3514, .y = -17 }, .{ .x = 3494, .y = 2 }, .{ .x = 3486, .y = 2 }, .{ .x = 3476, .y = -17 }, .{ .x = 3469, .y = -17 }, .{ .x = 3463, .y = 10 }, .{ .x = 3407, .y = 86 }, .{ .x = 3371, .y = 160 }, .{ .x = 3337, .y = 197 }, .{ .x = 3306, .y = 246 }, .{ .x = 3271, .y = 254 }, .{ .x = 3254, .y = 269 }, .{ .x = 3237, .y = 315 }, .{ .x = 3217, .y = 337 }, .{ .x = 3205, .y = 343 }, .{ .x = 3148, .y = 348 }, .{ .x = 3123, .y = 371 }, .{ .x = 3115, .y = 371 }, .{ .x = 3054, .y = 343 }, .{ .x = 3030, .y = 358 }, .{ .x = 3016, .y = 359 }, .{ .x = 3012, .y = 319 }, .{ .x = 3004, .y = 318 }, .{ .x = 2985, .y = 375 }, .{ .x = 2975, .y = 377 }, .{ .x = 2967, .y = 336 }, .{ .x = 2956, .y = 343 }, .{ .x = 2959, .y = 380 }, .{ .x = 2951, .y = 368 }, .{ .x = 2909, .y = 366 }, .{ .x = 2907, .y = 372 }, .{ .x = 2920, .y = 384 }, .{ .x = 2965, .y = 397 }, .{ .x = 2965, .y = 405 }, .{ .x = 2941, .y = 430 }, .{ .x = 2906, .y = 431 }, .{ .x = 2858, .y = 471 }, .{ .x = 2746, .y = 469 }, .{ .x = 2721, .y = 479 }, .{ .x = 2761, .y = 484 }, .{ .x = 2811, .y = 475 }, .{ .x = 2859, .y = 485 }, .{ .x = 2923, .y = 457 }, .{ .x = 2937, .y = 459 }, .{ .x = 2943, .y = 482 }, .{ .x = 2910, .y = 514 }, .{ .x = 2948, .y = 529 }, .{ .x = 2957, .y = 558 }, .{ .x = 2965, .y = 564 }, .{ .x = 2986, .y = 541 }, .{ .x = 2994, .y = 503 }, .{ .x = 3029, .y = 491 }, .{ .x = 3047, .y = 495 }, .{ .x = 3057, .y = 479 }, .{ .x = 3069, .y = 489 }, .{ .x = 3076, .y = 524 }, .{ .x = 3098, .y = 546 }, .{ .x = 3106, .y = 566 }, .{ .x = 3119, .y = 568 }, .{ .x = 3112, .y = 531 }, .{ .x = 3118, .y = 522 }, .{ .x = 3129, .y = 527 }, .{ .x = 3137, .y = 543 }, .{ .x = 3160, .y = 553 }, .{ .x = 3179, .y = 575 }, .{ .x = 3187, .y = 550 }, .{ .x = 3219, .y = 548 }, .{ .x = 3239, .y = 539 }, .{ .x = 3263, .y = 514 }, .{ .x = 3265, .y = 489 }, .{ .x = 3256, .y = 470 }, .{ .x = 3261, .y = 468 }, .{ .x = 3304, .y = 493 }, .{ .x = 3346, .y = 500 }, .{ .x = 3545, .y = 621 }, .{ .x = 3598, .y = 640 }, .{ .x = 3623, .y = 637 }, .{ .x = 3606, .y = 651 }, .{ .x = 3609, .y = 723 }, .{ .x = 3622, .y = 781 }, .{ .x = 3643, .y = 806 }, .{ .x = 3679, .y = 794 }, .{ .x = 3682, .y = 810 }, .{ .x = 3637, .y = 907 }, .{ .x = 3608, .y = 1003 }, .{ .x = 3599, .y = 1004 }, .{ .x = 3571, .y = 978 }, .{ .x = 3508, .y = 966 }, .{ .x = 3491, .y = 942 }, .{ .x = 3487, .y = 917 }, .{ .x = 3480, .y = 910 }, .{ .x = 3487, .y = 966 }, .{ .x = 3504, .y = 1008 }, .{ .x = 3429, .y = 988 }, .{ .x = 3427, .y = 967 }, .{ .x = 3454, .y = 941 }, .{ .x = 3462, .y = 922 }, .{ .x = 3452, .y = 904 }, .{ .x = 3391, .y = 849 }, .{ .x = 3387, .y = 852 }, .{ .x = 3394, .y = 913 }, .{ .x = 3381, .y = 940 }, .{ .x = 3354, .y = 969 }, .{ .x = 3345, .y = 992 }, .{ .x = 3336, .y = 1062 }, .{ .x = 3268, .y = 1062 }, .{ .x = 3242, .y = 1050 }, .{ .x = 3217, .y = 1016 }, .{ .x = 3207, .y = 1011 }, .{ .x = 3204, .y = 1017 }, .{ .x = 3215, .y = 1032 }, .{ .x = 3216, .y = 1047 }, .{ .x = 3211, .y = 1075 }, .{ .x = 3220, .y = 1087 }, .{ .x = 3206, .y = 1127 }, .{ .x = 3196, .y = 1131 }, .{ .x = 3164, .y = 1119 }, .{ .x = 3128, .y = 1131 }, .{ .x = 3155, .y = 1141 }, .{ .x = 3162, .y = 1150 }, .{ .x = 3167, .y = 1181 }, .{ .x = 3160, .y = 1207 }, .{ .x = 3134, .y = 1214 }, .{ .x = 3080, .y = 1199 }, .{ .x = 3084, .y = 1208 }, .{ .x = 3130, .y = 1228 }, .{ .x = 3139, .y = 1240 }, .{ .x = 3151, .y = 1242 }, .{ .x = 3187, .y = 1212 }, .{ .x = 3193, .y = 1212 }, .{ .x = 3194, .y = 1230 }, .{ .x = 3213, .y = 1225 }, .{ .x = 3231, .y = 1232 }, .{ .x = 3234, .y = 1218 }, .{ .x = 3218, .y = 1208 }, .{ .x = 3220, .y = 1200 }, .{ .x = 3287, .y = 1210 }, .{ .x = 3274, .y = 1181 }, .{ .x = 3313, .y = 1198 }, .{ .x = 3307, .y = 1182 }, .{ .x = 3313, .y = 1173 }, .{ .x = 3386, .y = 1178 }, .{ .x = 3418, .y = 1169 }, .{ .x = 3440, .y = 1149 }, .{ .x = 3470, .y = 1148 }, .{ .x = 3494, .y = 1132 }, .{ .x = 3519, .y = 1129 }, .{ .x = 3536, .y = 1109 }, .{ .x = 3574, .y = 1101 }, .{ .x = 3586, .y = 1084 }, .{ .x = 3635, .y = 1075 }, .{ .x = 3642, .y = 1042 }, .{ .x = 3689, .y = 1059 }, .{ .x = 3717, .y = 1059 }, .{ .x = 3755, .y = 1046 }, .{ .x = 3776, .y = 1059 }, .{ .x = 3775, .y = 1075 }, .{ .x = 3768, .y = 1079 }, .{ .x = 3740, .y = 1080 }, .{ .x = 3701, .y = 1105 }, .{ .x = 3609, .y = 1131 }, .{ .x = 3359, .y = 1232 }, .{ .x = 2947, .y = 1386 }, .{ .x = 2831, .y = 1386 }, .{ .x = 2831, .y = 1371 }, .{ .x = 2834, .y = 1367 }, .{ .x = 2853, .y = 1377 }, .{ .x = 2877, .y = 1372 }, .{ .x = 2911, .y = 1353 }, .{ .x = 2951, .y = 1343 }, .{ .x = 2997, .y = 1313 }, .{ .x = 2987, .y = 1295 }, .{ .x = 2971, .y = 1288 }, .{ .x = 2955, .y = 1292 }, .{ .x = 2937, .y = 1282 }, .{ .x = 2932, .y = 1272 }, .{ .x = 2936, .y = 1228 }, .{ .x = 2950, .y = 1193 }, .{ .x = 2948, .y = 1142 }, .{ .x = 2943, .y = 1146 }, .{ .x = 2940, .y = 1185 }, .{ .x = 2920, .y = 1201 }, .{ .x = 2904, .y = 1244 }, .{ .x = 2904, .y = 1281 }, .{ .x = 2886, .y = 1283 }, .{ .x = 2870, .y = 1277 }, .{ .x = 2844, .y = 1232 }, .{ .x = 2845, .y = 1264 }, .{ .x = 2852, .y = 1282 }, .{ .x = 2895, .y = 1310 }, .{ .x = 2899, .y = 1333 }, .{ .x = 2870, .y = 1360 }, .{ .x = 2851, .y = 1360 }, .{ .x = 2842, .y = 1343 }, .{ .x = 2814, .y = 1352 }, .{ .x = 2796, .y = 1386 }, .{ .x = 2736, .y = 1381 }, .{ .x = 2711, .y = 1359 }, .{ .x = 2695, .y = 1353 }, .{ .x = 2691, .y = 1333 }, .{ .x = 2672, .y = 1310 }, .{ .x = 2659, .y = 1314 }, .{ .x = 2653, .y = 1338 }, .{ .x = 2628, .y = 1343 }, .{ .x = 2618, .y = 1353 }, .{ .x = 2592, .y = 1353 }, .{ .x = 2569, .y = 1342 }, .{ .x = 2542, .y = 1346 }, .{ .x = 2527, .y = 1256 }, .{ .x = 2522, .y = 1260 }, .{ .x = 2520, .y = 1337 }, .{ .x = 2512, .y = 1379 }, .{ .x = 2438, .y = 1386 }, .{ .x = 2408, .y = 1378 }, .{ .x = 2372, .y = 1359 }, .{ .x = 2326, .y = 1292 }, .{ .x = 2333, .y = 1245 }, .{ .x = 2373, .y = 1220 }, .{ .x = 2347, .y = 1223 }, .{ .x = 2300, .y = 1261 }, .{ .x = 2299, .y = 1203 }, .{ .x = 2291, .y = 1211 }, .{ .x = 2279, .y = 1249 }, .{ .x = 2250, .y = 1224 }, .{ .x = 2246, .y = 1227 }, .{ .x = 2257, .y = 1251 }, .{ .x = 2289, .y = 1280 }, .{ .x = 2279, .y = 1312 }, .{ .x = 2281, .y = 1366 }, .{ .x = 2273, .y = 1373 }, .{ .x = 2229, .y = 1374 }, .{ .x = 2203, .y = 1352 }, .{ .x = 2168, .y = 1353 }, .{ .x = 2138, .y = 1325 }, .{ .x = 2134, .y = 1332 }, .{ .x = 2147, .y = 1359 }, .{ .x = 2151, .y = 1386 }, .{ .x = 1712, .y = 1386 }, .{ .x = 1709, .y = 1358 }, .{ .x = 1701, .y = 1362 }, .{ .x = 1700, .y = 1386 }, .{ .x = -64, .y = 1386 }, .{ .x = -64, .y = 128 }, .{ .x = -52, .y = 110 }, .{ .x = -53, .y = 101 }, .{ .x = -64, .y = 102 }, .{ .x = -64, .y = 90 }, .{ .x = -39, .y = 79 }, .{ .x = 3, .y = 45 }, .{ .x = 34, .y = 45 }, .{ .x = 41, .y = 65 }, .{ .x = 34, .y = 93 }, .{ .x = 26, .y = 107 }, .{ .x = -8, .y = 133 }, .{ .x = -20, .y = 156 }, .{ .x = -7, .y = 197 }, .{ .x = 27, .y = 200 }, .{ .x = 43, .y = 232 }, .{ .x = 53, .y = 176 }, .{ .x = 44, .y = 110 }, .{ .x = 65, .y = 68 }, .{ .x = 88, .y = 82 }, .{ .x = 98, .y = 97 }, .{ .x = 105, .y = 129 }, .{ .x = 131, .y = 138 }, .{ .x = 142, .y = 152 }, .{ .x = 134, .y = 174 }, .{ .x = 134, .y = 205 }, .{ .x = 113, .y = 212 }, .{ .x = 74, .y = 202 }, .{ .x = 68, .y = 228 }, .{ .x = 100, .y = 236 }, .{ .x = 118, .y = 266 }, .{ .x = 126, .y = 268 }, .{ .x = 135, .y = 267 }, .{ .x = 141, .y = 232 }, .{ .x = 176, .y = 222 }, .{ .x = 176, .y = 213 }, .{ .x = 168, .y = 208 }, .{ .x = 162, .y = 169 }, .{ .x = 170, .y = 160 }, .{ .x = 184, .y = 171 }, .{ .x = 218, .y = 179 }, .{ .x = 242, .y = 204 }, .{ .x = 260, .y = 206 }, .{ .x = 302, .y = 250 }, .{ .x = 324, .y = 246 }, .{ .x = 332, .y = 234 }, .{ .x = 340, .y = 204 }, .{ .x = 313, .y = 177 }, .{ .x = 272, .y = 97 }, .{ .x = 272, .y = 70 }, .{ .x = 251, .y = 62 }, .{ .x = 241, .y = 77 }, .{ .x = 225, .y = 66 }, .{ .x = 205, .y = 78 }, .{ .x = 198, .y = 63 }, .{ .x = 198, .y = 58 }, .{ .x = 209, .y = 61 }, .{ .x = 231, .y = 47 }, .{ .x = 248, .y = 44 }, .{ .x = 285, .y = 53 }, .{ .x = 369, .y = 99 }, .{ .x = 435, .y = 115 }, .{ .x = 463, .y = 128 }, .{ .x = 491, .y = 160 }, .{ .x = 506, .y = 168 }, .{ .x = 509, .y = 184 }, .{ .x = 537, .y = 210 }, .{ .x = 550, .y = 197 }, .{ .x = 573, .y = 191 }, .{ .x = 591, .y = 156 }, .{ .x = 526, .y = 160 }, .{ .x = 515, .y = 143 }, .{ .x = 587, .y = 140 }, .{ .x = 594, .y = 136 }, .{ .x = 595, .y = 124 }, .{ .x = 488, .y = 123 }, .{ .x = 479, .y = 120 }, .{ .x = 480, .y = 108 }, .{ .x = 726, .y = 104 }, .{ .x = 794, .y = 109 }, .{ .x = 886, .y = 98 }, .{ .x = 1001, .y = 103 }, .{ .x = 1107, .y = 126 }, .{ .x = 1330, .y = 143 }, .{ .x = 1448, .y = 130 }, .{ .x = 1496, .y = 117 }, .{ .x = 1515, .y = 117 }, .{ .x = 1519, .y = 135 }, .{ .x = 1531, .y = 137 }, .{ .x = 1547, .y = 130 }, .{ .x = 1530, .y = 125 }, .{ .x = 1531, .y = 113 }, .{ .x = 1538, .y = 109 }, .{ .x = 1604, .y = 92 }, .{ .x = 1721, .y = 77 }, .{ .x = 1849, .y = 90 }, .{ .x = 1885, .y = 103 }, .{ .x = 1944, .y = 100 }, .{ .x = 2019, .y = 113 }, .{ .x = 2107, .y = 104 }, .{ .x = 2332, .y = 51 }, .{ .x = 2486, .y = -4 }, .{ .x = 2566, .y = -7 }, .{ .x = 2658, .y = 5 }, .{ .x = 2732, .y = -6 }, .{ .x = 2836, .y = -11 }, .{ .x = 2881, .y = -24 }, .{ .x = 2958, .y = -64 } }; +const LI_F0 = [_]Pt{ .{ .x = -64, .y = 1386 }, .{ .x = -64, .y = -64 }, .{ .x = 4160, .y = -64 }, .{ .x = 4160, .y = 1386 }, .{ .x = 4038, .y = 1386 }, .{ .x = 4032, .y = 1386 }, .{ .x = 3457, .y = 1386 }, .{ .x = 2947, .y = 1386 }, .{ .x = 2831, .y = 1386 }, .{ .x = 2788, .y = 1386 }, .{ .x = 2755, .y = 1386 }, .{ .x = 2151, .y = 1386 }, .{ .x = 2108, .y = 1386 }, .{ .x = 2086, .y = 1386 }, .{ .x = 1991, .y = 1386 }, .{ .x = 1959, .y = 1386 }, .{ .x = 1712, .y = 1386 }, .{ .x = 1700, .y = 1386 }, .{ .x = 1677, .y = 1386 }, .{ .x = 1597, .y = 1386 } }; +const LI_SUBJECT = [_][]const Pt{ &LI_S0 }; +const LI_FACE = [_][]const Pt{ &LI_F0 }; + +test "compose clip repro: Long Island land ring vs coverage-edge face" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const r = try boolean.compute(a, &LI_SUBJECT, &LI_FACE, .intersect); + const probes = [_]Pt{ .{ .x = 3320, .y = 100 }, .{ .x = 3380, .y = 100 }, .{ .x = 3320, .y = 160 }, .{ .x = 3320, .y = 220 }, .{ .x = 3320, .y = 520 }, .{ .x = 3320, .y = 580 } }; + for (probes) |q| { + try std.testing.expect(boolean.pointInEvenOdd(&LI_SUBJECT, q.x, q.y)); + try std.testing.expect(boolean.pointInEvenOdd(&LI_FACE, q.x, q.y)); + if (!boolean.pointInEvenOdd(r, q.x, q.y)) { + std.debug.print("land point ({d},{d}) missing from intersection\n", .{ q.x, q.y }); + return error.LandExcluded; + } + } +} From 600efeac1084889e2dce9c2a59964acbde29b1d0 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 19:04:41 -0400 Subject: [PATCH 119/140] =?UTF-8?q?docs:=20intro=20once-over=20=E2=80=94?= =?UTF-8?q?=20plain-prose=20opening,=20best-effort=20S-57=E2=86=92S-101=20?= =?UTF-8?q?stated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/docs/architecture.md | 2 +- docs/docs/intro.md | 45 ++++++++++++++++++++++++++------------- docs/docs/limitations.md | 21 +++++++++++++++--- docs/docs/zig-api.md | 5 +++-- 4 files changed, 52 insertions(+), 21 deletions(-) diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 0fbbe3e..cd4bcd0 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -48,7 +48,7 @@ render Surface src/render/surface.zig primitives: filled polygons, stroked lines, symbols, patterns, soundings, text. 5. **Generate the scene.** Each primitive is projected to web-mercator tile coordinates, clipped (extent 4096, buffer 64), and emitted as *draw calls* on a - **Surface** — the backend seam described below. + **Surface** — the backend contract described below. ### The Surface contract diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 95639f0..585260b 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -10,28 +10,37 @@ sidebar_position: 1 :::warning Not for navigation This project is coded almost entirely with AI (Claude) and human-reviewed. It is -an experiment in building a large, complex specification (IHO S-101) with AI — -not a certified or tested navigation product. **Do not rely on it for real-world -navigation.** See [Known limitations](./limitations.md). +an experiment in using AI to implement a large, complex specification (IHO +S-101) — not a certified or tested navigation product. **Do not rely on it for +real-world navigation.** See [Known limitations](./limitations.md). ::: -**tile57** is an **S-57/S-101 → vector-tile + S-52 style engine**, embeddable -from **Zig**, **C**, or **Go**, targeting native and WASM. It decodes IHO/NOAA **S-57** ENC cells and generates **vector tiles** -by `(z, x, y)` — [MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) -(MLT, the default) or Mapbox Vector Tiles (MVT) — running the official IHO -**S-101 Portrayal Catalogue** in embedded Lua to produce S-52 nautical -portrayal. Alongside the tiles it emits a **MapLibre GL style** and the -portrayal **assets** it references — colour tables, line styles, and the sprite -+ area-fill pattern atlases — so a renderer such as -[MapLibre](https://github.com/maplibre/maplibre-native) can draw a chart directly -from tile57's output. +**tile57** is a nautical chart engine. It reads IHO **S-57** ENC cells — the +electronic navigational charts published by NOAA and other hydrographic +offices — converts their features to the newer **S-101** data model, and runs +the official IHO **S-101 Portrayal Catalogue** in embedded Lua to decide how +each feature is drawn, producing standard **S-52** chart symbology. S-57 has +no perfect S-101 translation, so the conversion is best effort — see +[Known limitations](./limitations.md). + +The primary output is **vector tiles**, fetched by `(z, x, y)`: +[MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) (MLT, the +default) or Mapbox Vector Tiles (MVT). Alongside the tiles, tile57 emits the +matching **MapLibre GL style** and the portrayal **assets** it references — +colour tables, line styles, and the sprite and area-fill pattern atlases — so +a renderer such as [MapLibre](https://github.com/maplibre/maplibre-native) +draws a complete chart from tile57's output alone. tile57 also contains a [native S-52 rendering engine](./rendering.md): the same scene that becomes tiles can be drawn straight to **PNG**, deterministic vector **PDF**, or a **terminal** (Unicode grid / kitty graphics), with the mariner's display settings evaluated live — no browser or GPU involved. +The whole engine is one **Zig** library behind a **C ABI**, with **Go** +bindings in the repo; it compiles natively (Linux, macOS, Windows) or to +**WASM**. + ## Goals tile57 is an experiment in building a real, spec-faithful nautical chart engine @@ -39,8 +48,8 @@ almost entirely with AI assistance. A few specific goals shape its design: - **AI-written, human-reviewed.** Every significant piece of this codebase was generated by Claude and reviewed by a human. The project tests how far AI can - carry the heavy lifting of spec interpretation, test coverage, and correctness - on a non-trivial domain. + carry the heavy lifting of spec interpretation, test coverage, and + implementation correctness on a non-trivial domain. - **Spec adherence first.** The goal is to implement S-57 decoding, S-101 portrayal, and S-52 display as faithfully as possible, using the actual IHO @@ -80,6 +89,12 @@ render Surface ──► MVT / MLT tiles + PMTiles (src/tiles/) + MapLibre sty └───► PNG raster / vector PDF / terminal text (src/render/) ``` +This is the flow for one cell. The organizing principle is **bake, then +compose**: each source cell bakes once to its own PMTiles archive, and every +output — a served tile, a PNG, a query — is produced from baked archives, with +multi-cell charts stitched on demand by the +[runtime compositor](./architecture.md). + ## The memory model The engine holds only its working set: diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 3a2c4a5..628b4a3 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -22,6 +22,21 @@ See the warning on the [introduction](./intro.md). NOAA ENC charts are U.S. publ domain and not for navigation; this renderer adds its own gaps on top. ::: +## S-57 → S-101 conversion + +tile57's input is S-57 but its portrayal rules are S-101, so every cell passes +through an S-57 → S-101 adapter (`src/s101/adapter.zig`) before the rules run. +S-57 has no perfect S-101 translation; the adapter follows the IHO S-65 +conversion guidance, and the result is **best effort**: + +- **Missing S-101 content stays missing.** S-101 attributes and features with + no S-57 source are never invented; rules that test them take their fallback + branch. +- **Unconvertible objects fall back or drop.** An S-57 object class with no + S-101 equivalent portrays as the S-52 question mark (QUESMRK1) — or, where + the S-65 guidance says the object is simply not carried into S-101 (e.g. an + area-geometry recommended track), it is dropped. + ## Portrayal gaps - **Ignored portrayal-instruction keys.** The instruction translator @@ -38,9 +53,9 @@ domain and not for navigation; this renderer adds its own gaps on top. drawn to the light's nominal range) is not emitted, so the `show_full_sector_lines` setting currently has nothing to act on. - **Sector figures can stop at a tile boundary beyond the owning cell.** The - compositor keeps a light's sector legs and arcs WHOLE across ownership seams - (they are fixed-size decorations anchored at the light, exempt from face - clipping). The remaining gap: a figure reaching into a tile where the owning + compositor keeps a light's sector legs and arcs WHOLE across ownership + boundaries (they are fixed-size decorations anchored at the light, exempt + from face clipping). The remaining gap: a figure reaching into a tile where the owning cell holds no ground at all is absent there — the compositor never consults that cell for the tile — so a figure within roughly one tile of the cell's owned ground can cut at that tile's edge (directional ground-length legs can diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index 1857f2e..2d7855d 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -104,7 +104,8 @@ library), and `tile57.CellReadFn` (the reader callback). Multi-cell input for The runtime compositor stitches per-cell archives into one seamless chart: any `(z, x, y)` tile on demand through the ownership partition (cells never -double-draw at a seam), and the same view outputs as a single chart, composed. +double-draw where they meet), and the same view outputs as a single chart, +composed. ```zig // Open the compositor over the archives + partition, then compose tiles. @@ -134,7 +135,7 @@ var src = (try tile57.compose.openComposeSourceCharts(gpa, &archives, null)).?; | `ComposeSource.tile(gpa, z, x, y)` | compose one tile on demand (raw MLT + the ownership flag). | | `tile57.compose.renderView(src, ...)` | the composed view render — PNG, PDF, or a callback canvas. | | `tile57.compose.renderSurfaceView(src, ...)` | the composed world-space surface stream. | -| `tile57.compose.queryPoint(src, lon, lat, zoom, cb)` | the composed cursor pick (seams included). | +| `tile57.compose.queryPoint(src, lon, lat, zoom, cb)` | the composed cursor pick, across cell boundaries. | | `tile57.compose.composeTile(...)` | the stateless core `ComposeSource.tile` uses. | | `tile57.partition` | the ownership partition and its `.tpart` sidecar (serialize / deserialize). | From b9e0feec05b203542d31c46a57dd46d851681c8c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 19:05:23 -0400 Subject: [PATCH 120/140] =?UTF-8?q?docs(readme):=20tagline=20says=20vector?= =?UTF-8?q?=20tiles=20(MLT=20or=20MVT)=20=E2=80=94=20MLT=20is=20the=20defa?= =?UTF-8?q?ult,=20not=20Mapbox-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index edb1688..742cce5 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

⚓ An S-57/S-101 chart engine: vector tiles, S-52 styles, PNG and PDF.
- tile57 turns IHO S-57 ENC cells into Mapbox Vector Tiles with a matching MapLibre S-52 - style, or renders finished charts directly to PNG and PDF — one Zig library with a + tile57 turns IHO S-57 ENC cells into vector tiles (MLT or MVT) with a matching MapLibre + S-52 style, or renders finished charts directly to PNG and PDF — one Zig library with a C ABI, compiled natively or to WASM.

From b36ca8a13b4a4612731e32d8f517833e4bd437d8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 19:08:28 -0400 Subject: [PATCH 121/140] =?UTF-8?q?docs:=20the=20portrayal=20produces=20S-?= =?UTF-8?q?101=20symbology;=20S-57=E2=86=92S-101=20adapter=20is=20interim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/docs/intro.md | 8 +++++--- docs/docs/limitations.md | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 585260b..0e67a1f 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -20,9 +20,11 @@ real-world navigation.** See [Known limitations](./limitations.md). electronic navigational charts published by NOAA and other hydrographic offices — converts their features to the newer **S-101** data model, and runs the official IHO **S-101 Portrayal Catalogue** in embedded Lua to decide how -each feature is drawn, producing standard **S-52** chart symbology. S-57 has -no perfect S-101 translation, so the conversion is best effort — see -[Known limitations](./limitations.md). +each feature is drawn, producing **S-101 symbology** (the successor to S-52). +The conversion is an interim step, and a best-effort one — S-57 has no perfect +S-101 translation (see [Known limitations](./limitations.md)); the goal is +S-101 throughout, reading native S-101 cells directly as hydrographic offices +publish them. The primary output is **vector tiles**, fetched by `(z, x, y)`: [MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) (MLT, the diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 628b4a3..18b6e2e 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -26,8 +26,10 @@ domain and not for navigation; this renderer adds its own gaps on top. tile57's input is S-57 but its portrayal rules are S-101, so every cell passes through an S-57 → S-101 adapter (`src/s101/adapter.zig`) before the rules run. -S-57 has no perfect S-101 translation; the adapter follows the IHO S-65 -conversion guidance, and the result is **best effort**: +The adapter is an interim solution — the goal is S-101 throughout, reading +native S-101 cells directly as hydrographic offices publish them. S-57 has no +perfect S-101 translation; the adapter follows the IHO S-65 conversion +guidance, and the result is **best effort**: - **Missing S-101 content stays missing.** S-101 attributes and features with no S-57 source are never invented; rules that test them take their fallback From 69d2753ef701cde9e1dc16c2bc223c1a580a12fd Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 19:09:19 -0400 Subject: [PATCH 122/140] =?UTF-8?q?docs(intro):=20goals=20become=20a=20com?= =?UTF-8?q?pact=20:::note=20=E2=80=94=20one-liners,=20no=20section=20headi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/docs/intro.md | 45 +++++++++++++++++---------------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 0e67a1f..0a616c9 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -43,34 +43,23 @@ The whole engine is one **Zig** library behind a **C ABI**, with **Go** bindings in the repo; it compiles natively (Linux, macOS, Windows) or to **WASM**. -## Goals - -tile57 is an experiment in building a real, spec-faithful nautical chart engine -almost entirely with AI assistance. A few specific goals shape its design: - -- **AI-written, human-reviewed.** Every significant piece of this codebase was - generated by Claude and reviewed by a human. The project tests how far AI can - carry the heavy lifting of spec interpretation, test coverage, and - implementation correctness on a non-trivial domain. - -- **Spec adherence first.** The goal is to implement S-57 decoding, S-101 - portrayal, and S-52 display as faithfully as possible, using the actual IHO - spec documents and the official Portrayal Catalogue — not approximations. - -- **Cross-platform via Zig.** Zig's build system and cross-compilation support let - the same core compile to native (Linux, macOS, Windows) and **WASM** without - code changes. Go and Zig were chosen specifically because both have excellent - build systems and first-class WASM targets, making the engine usable in desktop - apps, servers, and browsers from one codebase. - -- **Coupled tile + style.** The engine emits vector tiles (MLT or MVT) *and* a - matching MapLibre GL style together. The same style works for MapLibre Native - and MapLibre GL JS, so native and web renderers share one chart look without - separate style maintenance. - -- **Language-agnostic embedding.** A thin C ABI (`libtile57.a`) bridges the Zig - core to any language with C FFI. Go bindings ship in the repo; others are - straightforward additions. +:::note Goals + +tile57 is an experiment in building a real, spec-faithful nautical chart +engine almost entirely with AI assistance: + +- **AI-written, human-reviewed** — every significant piece was generated by + Claude and reviewed by a human. +- **Spec adherence first** — the actual IHO documents and the official + Portrayal Catalogue, not approximations. +- **Cross-platform via Zig** — one core compiles to native (Linux, macOS, + Windows) and WASM without code changes. +- **Coupled tile + style** — tiles and the MapLibre style that draws them ship + together, so native and web renderers share one chart look. +- **Language-agnostic embedding** — a thin C ABI bridges the Zig core to any + language with C FFI; Go bindings ship in the repo. + +::: ## The pipeline From 7f22c1a76d93edfce4b641400a7fc3b26778eb82 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 19:11:18 -0400 Subject: [PATCH 123/140] =?UTF-8?q?fix(geometry):=20self-healing=20result?= =?UTF-8?q?=20extraction=20=E2=80=94=20region-sampled=20retry,=20seam=20pa?= =?UTF-8?q?rity=20repair,=20certified=20stitching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --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 --- build.zig | 1 + src/geometry/boolean.zig | 307 +++++++++++++++++++++++++++- src/geometry/boolean_repro_test.zig | 144 +++++++++++++ tools/compose_tile.zig | 71 ++++++- 4 files changed, 517 insertions(+), 6 deletions(-) diff --git a/build.zig b/build.zig index e862a3e..4ca0992 100644 --- a/build.zig +++ b/build.zig @@ -490,6 +490,7 @@ pub fn build(b: *std.Build) void { .{ .name = "catalog", .module = catalog_embed }, .{ .name = "bundle", .module = bundle_mod }, .{ .name = "compose", .module = compose_mod }, // compose-tile CLI + .{ .name = "geometry", .module = geometry_mod }, // compose-tile --scan reads the boolean diagnostics .{ .name = "render", .module = render_mod }, // renderpng pixel path .{ .name = "chart", .module = chart_mod }, // ENC_ROOT view renders }, diff --git a/src/geometry/boolean.zig b/src/geometry/boolean.zig index fd77e92..680b1fc 100644 --- a/src/geometry/boolean.zig +++ b/src/geometry/boolean.zig @@ -50,6 +50,25 @@ pub const Polygon = []const []const Pt; pub const Op = enum { intersect, unite, diff, sym_diff }; +/// Diagnostic: how many result-ring walks failed to close on a real edge since +/// process start, AFTER the robust retry — an increase across a compute() call +/// means that call's result still contains an open chain whose implicit +/// closing chord slices across the region (the visible "wedge" artifact). On a +/// parity-correct survivor graph a dead end can never happen, so any increase +/// marks a boolean bug at the exact tile being composed. Callers snapshot +/// before/after (single-threaded per compute). +pub var open_chain_walks: u64 = 0; +/// Diagnostic: how many compute() calls fell back to the region-sampled +/// in_result recomputation because the sweep-flag pass dead-ended a walk. +pub var robust_retries: u64 = 0; +/// Diagnostic: how many micro-stitch edges closed a snap-divergence gap (two +/// crossings of near-parallel edges snapping to adjacent lattice points leave +/// the survivor graph with odd-degree vertex pairs a few units apart). +pub var stitched_gaps: u64 = 0; +/// Diagnostic: open chains whose implicit closing chord exceeds 32 units — the +/// visible-artifact subset of open_chain_walks (micro chords are sub-pixel). +pub var large_open_chains: u64 = 0; + // --------------------------------------------------------------------------- // Exact predicates (i128). // --------------------------------------------------------------------------- @@ -86,6 +105,10 @@ const SweepEvent = struct { other_in_out: bool = false, edge_type: EdgeType = .normal, in_result: bool = false, + /// The robust retry could not decide this edge by region sampling (its + /// midpoint lies ON the other operand's boundary — an undetected collinear + /// overlap); the parity repair may toggle it alongside the typed seams. + undecided: bool = false, inline fn vertical(self: *const SweepEvent) bool { return self.p.x == self.other.p.x; @@ -474,7 +497,44 @@ pub fn compute(gpa: Allocator, subject: Polygon, clip: Polygon, op: Op) ![][]Pt } } - return connectEdges(gpa, sw.processed.items); + // First attempt: the sweep's transition-typed in_result flags. On a correct + // flag set every result-ring walk closes; a dead-ended walk (open chain) + // means some flag was corrupted by a degenerate interleaving the pairwise + // machinery mishandled (snapped crossing landing on a vertex, vertical at + // a seam, ...). The fallback recomputes every NORMAL edge's membership by + // exact region sampling against the ORIGINAL operands — the sweep has + // already subdivided all crossings and overlaps, so an edge's interior + // lies strictly on one side of the other operand's boundary and the + // midpoint test is unambiguous (coincident seam edges keep their typing, + // which the sampling cannot decide and the sweep handles well). Both + // passes are pure functions of the input geometry, so determinism holds. + const chains_before = open_chain_walks; + const large_before = large_open_chains; + const first = try connectEdges(gpa, sw.processed.items, false); + if (open_chain_walks == chains_before) return first; + freePolygon(gpa, first); + robust_retries += 1; + open_chain_walks = chains_before; // the retry's own walk re-judges health + large_open_chains = large_before; + for (sw.processed.items) |e| { + if (!(e.left and e.edge_type == .normal)) continue; + const other: Polygon = if (e.subject) clip else subject; + // Midpoint at doubled scale (exact); on-boundary keeps the sweep's call. + const mx = e.p.x + e.other.p.x; + const my = e.p.y + e.other.p.y; + if (pointOnEdgeScaled2(other, mx, my)) { + e.undecided = true; + continue; + } + const inside_other = pointInEvenOddScaled2(other, mx, my); + e.in_result = switch (op) { + .intersect => inside_other, + .unite => !inside_other, + .diff => if (e.subject) !inside_other else inside_other, + .sym_diff => true, + }; + } + return connectEdges(gpa, sw.processed.items, true); } /// Add one operand's rings to the sweep with its self-overlapping collinear @@ -659,7 +719,26 @@ fn reduceEdges(sa: Allocator, raw: []const HEdge) ![]HEdge { const HEdge = struct { a: Pt, b: Pt, used: bool = false }; -fn connectEdges(gpa: Allocator, all: []const *SweepEvent) ![][]Pt { +/// Is `v` within ~2 units of segment a-b (perpendicular distance ≤ 2 with the +/// projection inside the segment, or within ~2.8 of an endpoint)? Exact i128. +fn nearSegment(v: Pt, a: Pt, b: Pt) bool { + const da: i128 = (@as(i128, v.x) - a.x) * (@as(i128, v.x) - a.x) + (@as(i128, v.y) - a.y) * (@as(i128, v.y) - a.y); + if (da <= 8) return true; + const db: i128 = (@as(i128, v.x) - b.x) * (@as(i128, v.x) - b.x) + (@as(i128, v.y) - b.y) * (@as(i128, v.y) - b.y); + if (db <= 8) return true; + const abx: i128 = @as(i128, b.x) - a.x; + const aby: i128 = @as(i128, b.y) - a.y; + const len2 = abx * abx + aby * aby; + if (len2 == 0) return false; + const avx: i128 = @as(i128, v.x) - a.x; + const avy: i128 = @as(i128, v.y) - a.y; + const cross = abx * avy - aby * avx; + if (cross * cross > 4 * len2) return false; + const dot = abx * avx + aby * avy; + return dot >= 0 and dot <= len2; +} + +fn connectEdges(gpa: Allocator, all: []const *SweepEvent, stitch: bool) ![][]Pt { var scratch = std.heap.ArenaAllocator.init(gpa); defer scratch.deinit(); const sa = scratch.allocator(); @@ -688,6 +767,169 @@ fn connectEdges(gpa: Allocator, all: []const *SweepEvent) ![][]Pt { } } + // (2a) Seam parity repair — FINAL pass only. The robust retry decides every + // NORMAL edge exactly by region sampling, but coincident cross-operand seam + // edges keep the sweep's transition typing, which snap debris on a diagonal + // seam can corrupt. Parity pins them down: on a correct boundary every + // vertex has even degree, so a seam edge whose BOTH endpoints are odd is + // included/excluded wrongly — toggling it flips both endpoint parities + // (monotone: odd count strictly drops by two per toggle, so this + // terminates). Seam edges not incident to odd vertices keep their typing. + if (stitch) { + var present = std.AutoHashMap(EdgeKey, usize).init(sa); + for (edges.items, 0..) |e, idx| try present.put(canonEdge(e.a, e.b), idx); + var deg = std.AutoHashMap(Pt, u32).init(sa); + for (edges.items) |e| { + for ([_]Pt{ e.a, e.b }) |pp| { + const gop = try deg.getOrPut(pp); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += 1; + } + } + var seams = std.ArrayList(HEdge).empty; + { + var seen = std.AutoHashMap(EdgeKey, void).init(sa); + for (all) |e| { + if (!e.left) continue; + if (e.edge_type == .normal and !e.undecided) continue; + if (e.p.eql(e.other.p)) continue; + const k = canonEdge(e.p, e.other.p); + if (seen.contains(k)) continue; + try seen.put(k, {}); + try seams.append(sa, .{ .a = e.p, .b = e.other.p }); + } + } + std.mem.sort(HEdge, seams.items, {}, struct { + fn lt(_: void, x: HEdge, y: HEdge) bool { + const ka = canonEdge(x.a, x.b); + const kb = canonEdge(y.a, y.b); + if (ka.ax != kb.ax) return ka.ax < kb.ax; + if (ka.ay != kb.ay) return ka.ay < kb.ay; + if (ka.bx != kb.bx) return ka.bx < kb.bx; + return ka.by < kb.by; + } + }.lt); + var changed = true; + while (changed) { + changed = false; + for (seams.items) |se| { + const da = deg.get(se.a) orelse 0; + const db = deg.get(se.b) orelse 0; + if (da % 2 == 0 or db % 2 == 0) continue; + const k = canonEdge(se.a, se.b); + if (present.get(k)) |idx| { + // Included wrongly: remove (swap-remove keeps indices dense). + _ = present.remove(k); + const last = edges.items.len - 1; + if (idx != last) { + edges.items[idx] = edges.items[last]; + try present.put(canonEdge(edges.items[idx].a, edges.items[idx].b), idx); + } + edges.items.len = last; + deg.getPtr(se.a).?.* -= 1; + deg.getPtr(se.b).?.* -= 1; + } else { + try edges.append(sa, .{ .a = se.a, .b = se.b }); + try present.put(k, edges.items.len - 1); + for ([_]Pt{ se.a, se.b }) |pp| { + const gop = try deg.getOrPut(pp); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += 1; + } + } + changed = true; + } + } + // Rebuild adjacency over the repaired edge set (sorted for determinism). + std.mem.sort(HEdge, edges.items, {}, struct { + fn lt(_: void, x: HEdge, y: HEdge) bool { + if (x.a.x != y.a.x) return x.a.x < y.a.x; + if (x.a.y != y.a.y) return x.a.y < y.a.y; + if (x.b.x != y.b.x) return x.b.x < y.b.x; + return x.b.y < y.b.y; + } + }.lt); + adj.clearRetainingCapacity(); + for (edges.items, 0..) |e, idx| { + for ([_]Pt{ e.a, e.b }) |pp| { + const gop = try adj.getOrPut(pp); + if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(usize).empty; + try gop.value_ptr.append(sa, idx); + } + } + } + + // (2) Micro-stitch snap-divergence gaps — FINAL pass only (the flag-driven + // first pass must dead-end loudly so the robust retry can recompute its + // memberships; stitching there would paper over flag corruption). Crossings + // of near-parallel edges can snap to ADJACENT lattice points, leaving stub + // edges whose endpoints miss their partners by a few units — odd-degree + // vertices in close pairs. Pairing each with its nearest odd peer within + // the snap radius and adding the connecting micro-edge closes the walk with + // bounded (sub-pixel) error; anything farther apart is a real defect and + // stays a countable dead end. + if (stitch) { + var odd = std.ArrayList(Pt).empty; + var deg_it = adj.iterator(); + while (deg_it.next()) |entry| { + if (entry.value_ptr.items.len % 2 != 0) try odd.append(sa, entry.key_ptr.*); + } + std.mem.sort(Pt, odd.items, {}, struct { + fn lt(_: void, x: Pt, y: Pt) bool { + if (x.x != y.x) return x.x < y.x; + return x.y < y.y; + } + }.lt); + const micro_r2: i128 = 16 * 16; + const cert_r2: i128 = 1024 * 1024; + var paired = try sa.alloc(bool, odd.items.len); + @memset(paired, false); + for (odd.items, 0..) |v, i| { + if (paired[i]) continue; + var best: ?usize = null; + var best_d2: i128 = cert_r2 + 1; + for (odd.items[i + 1 ..], i + 1..) |w, j| { + if (paired[j]) continue; + const dx: i128 = w.x - v.x; + const dy: i128 = w.y - v.y; + const d2 = dx * dx + dy * dy; + if (d2 >= best_d2) continue; + if (d2 > micro_r2) { + // Beyond the blind micro radius a stitch is allowed only + // along real boundary geometry: each endpoint AND the + // stitch midpoint must lie on (subdivided) input segments, + // so the stitch tracks an actual edge run — possibly across + // a polyline vertex — rather than inventing a chord. + const m: Pt = .{ .x = @divTrunc(v.x + w.x, 2), .y = @divTrunc(v.y + w.y, 2) }; + var okv = false; + var okw = false; + var okm = false; + for (all) |ev| { + if (!ev.left) continue; + if (!okv and nearSegment(v, ev.p, ev.other.p)) okv = true; + if (!okw and nearSegment(w, ev.p, ev.other.p)) okw = true; + if (!okm and nearSegment(m, ev.p, ev.other.p)) okm = true; + if (okv and okw and okm) break; + } + if (!(okv and okw and okm)) continue; + } + best_d2 = d2; + best = j; + } + const j = best orelse continue; + paired[i] = true; + paired[j] = true; + stitched_gaps += 1; + const idx = edges.items.len; + try edges.append(sa, .{ .a = v, .b = odd.items[j] }); + for ([_]Pt{ v, odd.items[j] }) |pp| { + const gop = try adj.getOrPut(pp); + if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(usize).empty; + try gop.value_ptr.append(sa, idx); + } + } + } + // (2) Walk closed loops. var out = std.ArrayList([]Pt).empty; errdefer { @@ -712,7 +954,13 @@ fn connectEdges(gpa: Allocator, all: []const *SweepEvent) ![][]Pt { break; } } - const ei = picked orelse break; + const ei = picked orelse { + open_chain_walks += 1; // dead end: the emitted chain closes on a chord + const cdx: i128 = cur.x - loop_start.x; + const cdy: i128 = cur.y - loop_start.y; + if (cdx * cdx + cdy * cdy > 32 * 32) large_open_chains += 1; + break; + }; edges.items[ei].used = true; cur = if (edges.items[ei].a.eql(cur)) edges.items[ei].b else edges.items[ei].a; if (cur.eql(loop_start)) break; // closed on a real edge @@ -784,6 +1032,59 @@ pub fn pointInEvenOdd(rings: []const []const Pt, x: i64, y: i64) bool { return inside; } +/// Even-odd containment of the DOUBLED-scale point (x2,y2) in a ring-set whose +/// coordinates are at 1× — i.e. containment of the exact rational midpoint +/// (x2/2, y2/2). Ring coordinates are doubled in the arithmetic (×2 stays in +/// i64; the crossing products promote to i128 as everywhere else). This is the +/// robust-fallback membership test: exact, no rounding. +fn pointInEvenOddScaled2(rings: []const []const Pt, x2: i64, y2: i64) bool { + var inside = false; + for (rings) |ring| { + if (ring.len < 3) continue; + var j = ring.len - 1; + for (ring, 0..) |pi, i| { + const pj = ring[j]; + j = i; + const yi = 2 * pi.y; + const yj = 2 * pj.y; + if ((yi > y2) != (yj > y2)) { + const dy: i128 = @as(i128, yj) - yi; + const lhs: i128 = (@as(i128, x2) - 2 * pi.x) * dy; + const rhs: i128 = (@as(i128, y2) - yi) * (@as(i128, 2 * pj.x) - 2 * pi.x); + if (dy > 0) { + if (lhs < rhs) inside = !inside; + } else { + if (lhs > rhs) inside = !inside; + } + } + } + } + return inside; +} + +/// True if the DOUBLED-scale point (x2,y2) lies exactly on an edge of the +/// 1×-coordinate ring-set (pointOnEdge at the rational midpoint). +fn pointOnEdgeScaled2(rings: []const []const Pt, x2: i64, y2: i64) bool { + for (rings) |ring| { + if (ring.len < 2) continue; + var j = ring.len - 1; + for (ring, 0..) |pi, i| { + const pj = ring[j]; + j = i; + const ax = 2 * pj.x; + const ay = 2 * pj.y; + const bx = 2 * pi.x; + const by = 2 * pi.y; + const cross: i128 = (@as(i128, bx) - ax) * (@as(i128, y2) - ay) - (@as(i128, by) - ay) * (@as(i128, x2) - ax); + if (cross != 0) continue; + if (x2 < @min(ax, bx) or x2 > @max(ax, bx)) continue; + if (y2 < @min(ay, by) or y2 > @max(ay, by)) continue; + return true; + } + } + return false; +} + /// True if (x,y) lies exactly on any edge of the ring-set. Used by tests to /// skip ambiguous on-boundary samples. pub fn pointOnEdge(rings: []const []const Pt, x: i64, y: i64) bool { diff --git a/src/geometry/boolean_repro_test.zig b/src/geometry/boolean_repro_test.zig index 2880347..fedb840 100644 --- a/src/geometry/boolean_repro_test.zig +++ b/src/geometry/boolean_repro_test.zig @@ -69,3 +69,147 @@ test "compose clip repro: Long Island land ring vs coverage-edge face" { } } } + +const SM_S0 = [_]Pt{ .{ .x = 2075, .y = 1078 }, .{ .x = 2075, .y = 1205 }, .{ .x = 1966, .y = 1205 }, .{ .x = 1966, .y = 1066 }, .{ .x = 1981, .y = 1076 }, .{ .x = 2002, .y = 1067 }, .{ .x = 2017, .y = 1081 }, .{ .x = 2039, .y = 1066 }, .{ .x = 2058, .y = 1066 }, .{ .x = 2075, .y = 1078 } }; +const SM_F0 = [_]Pt{ .{ .x = 1966, .y = 1205 }, .{ .x = 1966, .y = 1066 }, .{ .x = 1967, .y = 1066 }, .{ .x = 1968, .y = 1066 }, .{ .x = 1969, .y = 1066 }, .{ .x = 1970, .y = 1066 }, .{ .x = 1970, .y = 1066 }, .{ .x = 1970, .y = 1066 }, .{ .x = 1971, .y = 1066 }, .{ .x = 1974, .y = 1066 }, .{ .x = 1994, .y = 1066 }, .{ .x = 1994, .y = 1066 }, .{ .x = 1994, .y = 1066 }, .{ .x = 1996, .y = 1066 }, .{ .x = 2001, .y = 1066 }, .{ .x = 2002, .y = 1066 }, .{ .x = 2005, .y = 1066 }, .{ .x = 2015, .y = 1066 }, .{ .x = 2015, .y = 1076 }, .{ .x = 2015, .y = 1079 }, .{ .x = 2015, .y = 1205 } }; +const SM_SUBJECT = [_][]const Pt{ &SM_S0 }; +const SM_FACE = [_][]const Pt{ &SM_F0 }; + +test "compose clip repro: micro-subdivided seam edge with duplicate vertices" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const before = boolean.open_chain_walks; + const r = try boolean.compute(a, &SM_SUBJECT, &SM_FACE, .intersect); + try std.testing.expectEqual(before, boolean.open_chain_walks); // every walk must close + // Interior of subject ∩ face must be present. + const probes = [_]Pt{ .{ .x = 1990, .y = 1100 }, .{ .x = 2005, .y = 1150 }, .{ .x = 1975, .y = 1190 } }; + for (probes) |q| { + try std.testing.expect(boolean.pointInEvenOdd(&SM_SUBJECT, q.x, q.y)); + try std.testing.expect(boolean.pointInEvenOdd(&SM_FACE, q.x, q.y)); + try std.testing.expect(boolean.pointInEvenOdd(r, q.x, q.y)); + } +} + +const C2_S0 = [_]Pt{ .{ .x = 3088, .y = 2392 }, .{ .x = 3077, .y = 2398 }, .{ .x = 3090, .y = 2394 }, .{ .x = 3097, .y = 2402 }, .{ .x = 3083, .y = 2400 }, .{ .x = 3087, .y = 2404 }, .{ .x = 3079, .y = 2411 }, .{ .x = 3071, .y = 2406 }, .{ .x = 3063, .y = 2420 }, .{ .x = 3055, .y = 2421 }, .{ .x = 3045, .y = 2435 }, .{ .x = 3025, .y = 2440 }, .{ .x = 3012, .y = 2456 }, .{ .x = 2978, .y = 2459 }, .{ .x = 2983, .y = 2464 }, .{ .x = 2969, .y = 2466 }, .{ .x = 2972, .y = 2476 }, .{ .x = 2978, .y = 2476 }, .{ .x = 2973, .y = 2478 }, .{ .x = 2974, .y = 2501 }, .{ .x = 2982, .y = 2511 }, .{ .x = 2993, .y = 2509 }, .{ .x = 3001, .y = 2517 }, .{ .x = 2990, .y = 2528 }, .{ .x = 2983, .y = 2524 }, .{ .x = 2970, .y = 2541 }, .{ .x = 2982, .y = 2523 }, .{ .x = 2999, .y = 2516 }, .{ .x = 2979, .y = 2516 }, .{ .x = 2968, .y = 2500 }, .{ .x = 2959, .y = 2506 }, .{ .x = 2959, .y = 2490 }, .{ .x = 2949, .y = 2492 }, .{ .x = 2955, .y = 2459 }, .{ .x = 2932, .y = 2458 }, .{ .x = 2933, .y = 2446 }, .{ .x = 2917, .y = 2457 }, .{ .x = 2917, .y = 2462 }, .{ .x = 2928, .y = 2465 }, .{ .x = 2916, .y = 2474 }, .{ .x = 2918, .y = 2488 }, .{ .x = 2912, .y = 2496 }, .{ .x = 2904, .y = 2472 }, .{ .x = 2894, .y = 2470 }, .{ .x = 2887, .y = 2485 }, .{ .x = 2876, .y = 2483 }, .{ .x = 2876, .y = 2474 }, .{ .x = 2871, .y = 2478 }, .{ .x = 2871, .y = 2500 }, .{ .x = 2857, .y = 2505 }, .{ .x = 2857, .y = 2515 }, .{ .x = 2845, .y = 2528 }, .{ .x = 2854, .y = 2533 }, .{ .x = 2857, .y = 2550 }, .{ .x = 2845, .y = 2555 }, .{ .x = 2844, .y = 2579 }, .{ .x = 2833, .y = 2580 }, .{ .x = 2827, .y = 2597 }, .{ .x = 2814, .y = 2591 }, .{ .x = 2803, .y = 2604 }, .{ .x = 2805, .y = 2618 }, .{ .x = 2813, .y = 2614 }, .{ .x = 2822, .y = 2622 }, .{ .x = 2810, .y = 2643 }, .{ .x = 2810, .y = 2658 }, .{ .x = 2791, .y = 2674 }, .{ .x = 2807, .y = 2680 }, .{ .x = 2817, .y = 2723 }, .{ .x = 2826, .y = 2718 }, .{ .x = 2841, .y = 2722 }, .{ .x = 2839, .y = 2737 }, .{ .x = 2830, .y = 2745 }, .{ .x = 2833, .y = 2755 }, .{ .x = 2853, .y = 2738 }, .{ .x = 2881, .y = 2731 }, .{ .x = 2837, .y = 2763 }, .{ .x = 2839, .y = 2772 }, .{ .x = 2858, .y = 2767 }, .{ .x = 2879, .y = 2770 }, .{ .x = 2884, .y = 2783 }, .{ .x = 2874, .y = 2787 }, .{ .x = 2878, .y = 2800 }, .{ .x = 2887, .y = 2802 }, .{ .x = 2878, .y = 2809 }, .{ .x = 2889, .y = 2812 }, .{ .x = 2892, .y = 2795 }, .{ .x = 2907, .y = 2799 }, .{ .x = 2914, .y = 2782 }, .{ .x = 2902, .y = 2786 }, .{ .x = 2892, .y = 2779 }, .{ .x = 2900, .y = 2753 }, .{ .x = 2909, .y = 2747 }, .{ .x = 2917, .y = 2751 }, .{ .x = 2917, .y = 2741 }, .{ .x = 2924, .y = 2736 }, .{ .x = 2941, .y = 2735 }, .{ .x = 2956, .y = 2711 }, .{ .x = 2953, .y = 2701 }, .{ .x = 2960, .y = 2710 }, .{ .x = 2974, .y = 2698 }, .{ .x = 2956, .y = 2712 }, .{ .x = 2965, .y = 2725 }, .{ .x = 2969, .y = 2755 }, .{ .x = 2957, .y = 2760 }, .{ .x = 2950, .y = 2773 }, .{ .x = 2958, .y = 2786 }, .{ .x = 2974, .y = 2792 }, .{ .x = 2984, .y = 2761 }, .{ .x = 3019, .y = 2767 }, .{ .x = 3046, .y = 2726 }, .{ .x = 3055, .y = 2731 }, .{ .x = 3062, .y = 2726 }, .{ .x = 3067, .y = 2709 }, .{ .x = 3098, .y = 2708 }, .{ .x = 3094, .y = 2697 }, .{ .x = 3105, .y = 2693 }, .{ .x = 3107, .y = 2685 }, .{ .x = 3091, .y = 2670 }, .{ .x = 3093, .y = 2659 }, .{ .x = 3082, .y = 2660 }, .{ .x = 3098, .y = 2641 }, .{ .x = 3096, .y = 2634 }, .{ .x = 3088, .y = 2637 }, .{ .x = 3099, .y = 2631 }, .{ .x = 3096, .y = 2625 }, .{ .x = 3101, .y = 2626 }, .{ .x = 3104, .y = 2649 }, .{ .x = 3134, .y = 2635 }, .{ .x = 3138, .y = 2627 }, .{ .x = 3155, .y = 2639 }, .{ .x = 3173, .y = 2625 }, .{ .x = 3174, .y = 2641 }, .{ .x = 3177, .y = 2633 }, .{ .x = 3204, .y = 2616 }, .{ .x = 3205, .y = 2608 }, .{ .x = 3197, .y = 2607 }, .{ .x = 3211, .y = 2599 }, .{ .x = 3218, .y = 2600 }, .{ .x = 3216, .y = 2607 }, .{ .x = 3225, .y = 2605 }, .{ .x = 3263, .y = 2567 }, .{ .x = 3298, .y = 2602 }, .{ .x = 3296, .y = 2614 }, .{ .x = 3306, .y = 2619 }, .{ .x = 3305, .y = 2626 }, .{ .x = 3318, .y = 2622 }, .{ .x = 3404, .y = 2707 }, .{ .x = 3369, .y = 2726 }, .{ .x = 3341, .y = 2757 }, .{ .x = 3372, .y = 2757 }, .{ .x = 3421, .y = 2726 }, .{ .x = 3443, .y = 2748 }, .{ .x = 3424, .y = 2760 }, .{ .x = 3423, .y = 2770 }, .{ .x = 3408, .y = 2773 }, .{ .x = 3399, .y = 2792 }, .{ .x = 3407, .y = 2768 }, .{ .x = 3380, .y = 2782 }, .{ .x = 3383, .y = 2786 }, .{ .x = 3375, .y = 2782 }, .{ .x = 3372, .y = 2790 }, .{ .x = 3362, .y = 2788 }, .{ .x = 3367, .y = 2804 }, .{ .x = 3360, .y = 2823 }, .{ .x = 3348, .y = 2831 }, .{ .x = 3336, .y = 2829 }, .{ .x = 3323, .y = 2844 }, .{ .x = 3324, .y = 2855 }, .{ .x = 3319, .y = 2856 }, .{ .x = 3315, .y = 2839 }, .{ .x = 3313, .y = 2861 }, .{ .x = 3312, .y = 2839 }, .{ .x = 3323, .y = 2830 }, .{ .x = 3311, .y = 2825 }, .{ .x = 3272, .y = 2867 }, .{ .x = 3272, .y = 2829 }, .{ .x = 3264, .y = 2845 }, .{ .x = 3243, .y = 2826 }, .{ .x = 3207, .y = 2842 }, .{ .x = 3204, .y = 2854 }, .{ .x = 3218, .y = 2866 }, .{ .x = 3206, .y = 2870 }, .{ .x = 3216, .y = 2908 }, .{ .x = 3212, .y = 2920 }, .{ .x = 3230, .y = 2909 }, .{ .x = 3219, .y = 2934 }, .{ .x = 3222, .y = 2943 }, .{ .x = 3233, .y = 2943 }, .{ .x = 3221, .y = 2947 }, .{ .x = 3215, .y = 2926 }, .{ .x = 3215, .y = 2949 }, .{ .x = 3211, .y = 2947 }, .{ .x = 3222, .y = 2954 }, .{ .x = 3209, .y = 2947 }, .{ .x = 3217, .y = 2955 }, .{ .x = 3204, .y = 2957 }, .{ .x = 3192, .y = 2987 }, .{ .x = 3184, .y = 2987 }, .{ .x = 3172, .y = 3002 }, .{ .x = 3147, .y = 3008 }, .{ .x = 3147, .y = 3020 }, .{ .x = 3134, .y = 3020 }, .{ .x = 3143, .y = 3017 }, .{ .x = 3146, .y = 3006 }, .{ .x = 3172, .y = 2997 }, .{ .x = 3172, .y = 2984 }, .{ .x = 3155, .y = 2973 }, .{ .x = 3164, .y = 2976 }, .{ .x = 3190, .y = 2959 }, .{ .x = 3198, .y = 2960 }, .{ .x = 3203, .y = 2944 }, .{ .x = 3193, .y = 2945 }, .{ .x = 3202, .y = 2945 }, .{ .x = 3214, .y = 2922 }, .{ .x = 3210, .y = 2917 }, .{ .x = 3175, .y = 2917 }, .{ .x = 3177, .y = 2909 }, .{ .x = 3163, .y = 2907 }, .{ .x = 3154, .y = 2897 }, .{ .x = 3132, .y = 2916 }, .{ .x = 3129, .y = 2912 }, .{ .x = 3144, .y = 2902 }, .{ .x = 3130, .y = 2906 }, .{ .x = 3130, .y = 2911 }, .{ .x = 3128, .y = 2905 }, .{ .x = 3149, .y = 2897 }, .{ .x = 3149, .y = 2889 }, .{ .x = 3125, .y = 2881 }, .{ .x = 3115, .y = 2886 }, .{ .x = 3121, .y = 2879 }, .{ .x = 3103, .y = 2864 }, .{ .x = 3091, .y = 2877 }, .{ .x = 3090, .y = 2893 }, .{ .x = 3079, .y = 2895 }, .{ .x = 3065, .y = 2941 }, .{ .x = 3032, .y = 2970 }, .{ .x = 2977, .y = 2997 }, .{ .x = 2946, .y = 3001 }, .{ .x = 2937, .y = 2988 }, .{ .x = 2900, .y = 3005 }, .{ .x = 2891, .y = 3015 }, .{ .x = 2858, .y = 3026 }, .{ .x = 2854, .y = 3017 }, .{ .x = 2843, .y = 3016 }, .{ .x = 2827, .y = 3025 }, .{ .x = 2799, .y = 3028 }, .{ .x = 2741, .y = 3071 }, .{ .x = 2648, .y = 2978 }, .{ .x = 2775, .y = 2860 }, .{ .x = 2789, .y = 2527 }, .{ .x = 2968, .y = 2400 }, .{ .x = 2978, .y = 2410 }, .{ .x = 3043, .y = 2411 }, .{ .x = 3080, .y = 2386 }, .{ .x = 3088, .y = 2392 } }; +const C2_S1 = [_]Pt{ .{ .x = 2972, .y = 2408 }, .{ .x = 2971, .y = 2408 }, .{ .x = 2971, .y = 2409 }, .{ .x = 2969, .y = 2409 }, .{ .x = 2969, .y = 2410 }, .{ .x = 2968, .y = 2411 }, .{ .x = 2968, .y = 2412 }, .{ .x = 2967, .y = 2413 }, .{ .x = 2967, .y = 2416 }, .{ .x = 2973, .y = 2410 }, .{ .x = 2973, .y = 2409 }, .{ .x = 2972, .y = 2409 }, .{ .x = 2972, .y = 2408 } }; +const C2_S2 = [_]Pt{ .{ .x = 2973, .y = 2414 }, .{ .x = 2974, .y = 2414 }, .{ .x = 2974, .y = 2413 }, .{ .x = 2973, .y = 2413 }, .{ .x = 2973, .y = 2414 } }; +const C2_S3 = [_]Pt{ .{ .x = 3054, .y = 2416 }, .{ .x = 3052, .y = 2416 }, .{ .x = 3051, .y = 2417 }, .{ .x = 3051, .y = 2420 }, .{ .x = 3052, .y = 2420 }, .{ .x = 3054, .y = 2418 }, .{ .x = 3054, .y = 2417 }, .{ .x = 3056, .y = 2417 }, .{ .x = 3056, .y = 2415 }, .{ .x = 3055, .y = 2415 }, .{ .x = 3054, .y = 2416 } }; +const C2_S4 = [_]Pt{ .{ .x = 3022, .y = 2420 }, .{ .x = 3021, .y = 2420 }, .{ .x = 3021, .y = 2421 }, .{ .x = 3022, .y = 2421 }, .{ .x = 3022, .y = 2420 } }; +const C2_S5 = [_]Pt{ .{ .x = 2950, .y = 2425 }, .{ .x = 2955, .y = 2421 }, .{ .x = 2950, .y = 2420 }, .{ .x = 2939, .y = 2429 }, .{ .x = 2942, .y = 2435 }, .{ .x = 2933, .y = 2439 }, .{ .x = 2935, .y = 2443 }, .{ .x = 2962, .y = 2426 }, .{ .x = 2960, .y = 2422 }, .{ .x = 2947, .y = 2431 }, .{ .x = 2950, .y = 2425 } }; +const C2_S6 = [_]Pt{ .{ .x = 3040, .y = 2433 }, .{ .x = 3041, .y = 2433 }, .{ .x = 3041, .y = 2432 }, .{ .x = 3040, .y = 2432 }, .{ .x = 3040, .y = 2433 } }; +const C2_S7 = [_]Pt{ .{ .x = 2975, .y = 2435 }, .{ .x = 2974, .y = 2435 }, .{ .x = 2974, .y = 2434 }, .{ .x = 2973, .y = 2434 }, .{ .x = 2972, .y = 2435 }, .{ .x = 2971, .y = 2435 }, .{ .x = 2971, .y = 2438 }, .{ .x = 2972, .y = 2438 }, .{ .x = 2975, .y = 2435 } }; +const C2_S8 = [_]Pt{ .{ .x = 2966, .y = 2445 }, .{ .x = 2972, .y = 2440 }, .{ .x = 2964, .y = 2440 }, .{ .x = 2966, .y = 2445 } }; +const C2_S9 = [_]Pt{ .{ .x = 2950, .y = 2440 }, .{ .x = 2950, .y = 2441 }, .{ .x = 2951, .y = 2441 }, .{ .x = 2950, .y = 2440 } }; +const C2_S10 = [_]Pt{ .{ .x = 2931, .y = 2439 }, .{ .x = 2931, .y = 2438 }, .{ .x = 2932, .y = 2438 }, .{ .x = 2932, .y = 2437 }, .{ .x = 2930, .y = 2437 }, .{ .x = 2930, .y = 2436 }, .{ .x = 2928, .y = 2436 }, .{ .x = 2928, .y = 2437 }, .{ .x = 2927, .y = 2437 }, .{ .x = 2926, .y = 2438 }, .{ .x = 2926, .y = 2439 }, .{ .x = 2925, .y = 2440 }, .{ .x = 2925, .y = 2441 }, .{ .x = 2929, .y = 2441 }, .{ .x = 2929, .y = 2440 }, .{ .x = 2930, .y = 2440 }, .{ .x = 2930, .y = 2439 }, .{ .x = 2931, .y = 2439 } }; +const C2_S11 = [_]Pt{ .{ .x = 3020, .y = 2416 }, .{ .x = 3019, .y = 2416 }, .{ .x = 3019, .y = 2417 }, .{ .x = 3018, .y = 2417 }, .{ .x = 3018, .y = 2418 }, .{ .x = 3019, .y = 2418 }, .{ .x = 3019, .y = 2417 }, .{ .x = 3020, .y = 2416 } }; +const C2_S12 = [_]Pt{ .{ .x = 2863, .y = 2485 }, .{ .x = 2864, .y = 2485 }, .{ .x = 2864, .y = 2484 }, .{ .x = 2863, .y = 2484 }, .{ .x = 2863, .y = 2485 } }; +const C2_S13 = [_]Pt{ .{ .x = 2853, .y = 2496 }, .{ .x = 2854, .y = 2495 }, .{ .x = 2849, .y = 2495 }, .{ .x = 2849, .y = 2497 }, .{ .x = 2847, .y = 2499 }, .{ .x = 2847, .y = 2502 }, .{ .x = 2849, .y = 2502 }, .{ .x = 2850, .y = 2500 }, .{ .x = 2852, .y = 2500 }, .{ .x = 2852, .y = 2499 }, .{ .x = 2853, .y = 2499 }, .{ .x = 2853, .y = 2498 }, .{ .x = 2854, .y = 2498 }, .{ .x = 2854, .y = 2497 }, .{ .x = 2853, .y = 2497 }, .{ .x = 2853, .y = 2496 } }; +const C2_S14 = [_]Pt{ .{ .x = 2854, .y = 2500 }, .{ .x = 2854, .y = 2499 }, .{ .x = 2853, .y = 2499 }, .{ .x = 2853, .y = 2500 }, .{ .x = 2852, .y = 2500 }, .{ .x = 2852, .y = 2501 }, .{ .x = 2853, .y = 2501 }, .{ .x = 2853, .y = 2500 }, .{ .x = 2854, .y = 2500 } }; +const C2_S15 = [_]Pt{ .{ .x = 2834, .y = 2527 }, .{ .x = 2835, .y = 2527 }, .{ .x = 2835, .y = 2524 }, .{ .x = 2834, .y = 2524 }, .{ .x = 2833, .y = 2525 }, .{ .x = 2833, .y = 2526 }, .{ .x = 2834, .y = 2527 } }; +const C2_S16 = [_]Pt{ .{ .x = 2837, .y = 2527 }, .{ .x = 2837, .y = 2528 }, .{ .x = 2836, .y = 2528 }, .{ .x = 2837, .y = 2529 }, .{ .x = 2837, .y = 2531 }, .{ .x = 2839, .y = 2531 }, .{ .x = 2840, .y = 2530 }, .{ .x = 2840, .y = 2527 }, .{ .x = 2841, .y = 2527 }, .{ .x = 2840, .y = 2526 }, .{ .x = 2839, .y = 2526 }, .{ .x = 2839, .y = 2525 }, .{ .x = 2838, .y = 2525 }, .{ .x = 2838, .y = 2526 }, .{ .x = 2837, .y = 2527 } }; +const C2_S17 = [_]Pt{ .{ .x = 2813, .y = 2530 }, .{ .x = 2815, .y = 2528 }, .{ .x = 2812, .y = 2528 }, .{ .x = 2812, .y = 2530 }, .{ .x = 2813, .y = 2530 } }; +const C2_S18 = [_]Pt{ .{ .x = 2838, .y = 2574 }, .{ .x = 2838, .y = 2573 }, .{ .x = 2837, .y = 2574 }, .{ .x = 2838, .y = 2574 } }; +const C2_S19 = [_]Pt{ .{ .x = 2828, .y = 2575 }, .{ .x = 2827, .y = 2575 }, .{ .x = 2827, .y = 2576 }, .{ .x = 2828, .y = 2577 }, .{ .x = 2830, .y = 2575 }, .{ .x = 2828, .y = 2575 } }; +const C2_S20 = [_]Pt{ .{ .x = 2812, .y = 2586 }, .{ .x = 2812, .y = 2585 }, .{ .x = 2811, .y = 2585 }, .{ .x = 2811, .y = 2586 }, .{ .x = 2812, .y = 2586 } }; +const C2_S21 = [_]Pt{ .{ .x = 2828, .y = 2591 }, .{ .x = 2829, .y = 2591 }, .{ .x = 2829, .y = 2590 }, .{ .x = 2828, .y = 2590 }, .{ .x = 2828, .y = 2591 } }; +const C2_S22 = [_]Pt{ .{ .x = 2800, .y = 2596 }, .{ .x = 2801, .y = 2596 }, .{ .x = 2801, .y = 2595 }, .{ .x = 2802, .y = 2595 }, .{ .x = 2802, .y = 2596 }, .{ .x = 2803, .y = 2596 }, .{ .x = 2803, .y = 2595 }, .{ .x = 2804, .y = 2595 }, .{ .x = 2804, .y = 2594 }, .{ .x = 2800, .y = 2594 }, .{ .x = 2800, .y = 2595 }, .{ .x = 2799, .y = 2596 }, .{ .x = 2799, .y = 2597 }, .{ .x = 2800, .y = 2597 }, .{ .x = 2800, .y = 2596 } }; +const C2_S23 = [_]Pt{ .{ .x = 2796, .y = 2602 }, .{ .x = 2797, .y = 2602 }, .{ .x = 2797, .y = 2601 }, .{ .x = 2796, .y = 2601 }, .{ .x = 2796, .y = 2602 } }; +const C2_S24 = [_]Pt{ .{ .x = 2795, .y = 2604 }, .{ .x = 2796, .y = 2604 }, .{ .x = 2796, .y = 2602 }, .{ .x = 2795, .y = 2602 }, .{ .x = 2795, .y = 2604 } }; +const C2_S25 = [_]Pt{ .{ .x = 2796, .y = 2608 }, .{ .x = 2794, .y = 2608 }, .{ .x = 2794, .y = 2609 }, .{ .x = 2793, .y = 2609 }, .{ .x = 2793, .y = 2611 }, .{ .x = 2794, .y = 2611 }, .{ .x = 2794, .y = 2610 }, .{ .x = 2795, .y = 2610 }, .{ .x = 2795, .y = 2611 }, .{ .x = 2796, .y = 2610 }, .{ .x = 2797, .y = 2610 }, .{ .x = 2797, .y = 2607 }, .{ .x = 2795, .y = 2607 }, .{ .x = 2795, .y = 2608 }, .{ .x = 2796, .y = 2608 } }; +const C2_S26 = [_]Pt{ .{ .x = 2800, .y = 2610 }, .{ .x = 2801, .y = 2610 }, .{ .x = 2801, .y = 2609 }, .{ .x = 2802, .y = 2608 }, .{ .x = 2801, .y = 2608 }, .{ .x = 2800, .y = 2609 }, .{ .x = 2800, .y = 2610 } }; +const C2_S27 = [_]Pt{ .{ .x = 2803, .y = 2612 }, .{ .x = 2802, .y = 2612 }, .{ .x = 2802, .y = 2613 }, .{ .x = 2803, .y = 2613 }, .{ .x = 2803, .y = 2612 } }; +const C2_S28 = [_]Pt{ .{ .x = 2795, .y = 2612 }, .{ .x = 2794, .y = 2612 }, .{ .x = 2793, .y = 2613 }, .{ .x = 2793, .y = 2615 }, .{ .x = 2794, .y = 2615 }, .{ .x = 2795, .y = 2614 }, .{ .x = 2795, .y = 2612 } }; +const C2_S29 = [_]Pt{ .{ .x = 2801, .y = 2615 }, .{ .x = 2801, .y = 2616 }, .{ .x = 2800, .y = 2616 }, .{ .x = 2800, .y = 2617 }, .{ .x = 2802, .y = 2617 }, .{ .x = 2802, .y = 2616 }, .{ .x = 2801, .y = 2615 } }; +const C2_S30 = [_]Pt{ .{ .x = 2797, .y = 2616 }, .{ .x = 2796, .y = 2617 }, .{ .x = 2797, .y = 2617 }, .{ .x = 2797, .y = 2616 } }; +const C2_S31 = [_]Pt{ .{ .x = 2810, .y = 2619 }, .{ .x = 2809, .y = 2619 }, .{ .x = 2809, .y = 2620 }, .{ .x = 2808, .y = 2620 }, .{ .x = 2808, .y = 2621 }, .{ .x = 2809, .y = 2621 }, .{ .x = 2810, .y = 2621 }, .{ .x = 2810, .y = 2620 }, .{ .x = 2811, .y = 2619 }, .{ .x = 2810, .y = 2619 } }; +const C2_S32 = [_]Pt{ .{ .x = 2812, .y = 2635 }, .{ .x = 2811, .y = 2635 }, .{ .x = 2811, .y = 2636 }, .{ .x = 2812, .y = 2636 }, .{ .x = 2812, .y = 2635 } }; +const C2_S33 = [_]Pt{ .{ .x = 2808, .y = 2640 }, .{ .x = 2809, .y = 2640 }, .{ .x = 2809, .y = 2639 }, .{ .x = 2808, .y = 2639 }, .{ .x = 2808, .y = 2640 } }; +const C2_S34 = [_]Pt{ .{ .x = 2798, .y = 2651 }, .{ .x = 2797, .y = 2652 }, .{ .x = 2796, .y = 2652 }, .{ .x = 2796, .y = 2653 }, .{ .x = 2795, .y = 2654 }, .{ .x = 2794, .y = 2654 }, .{ .x = 2794, .y = 2656 }, .{ .x = 2795, .y = 2656 }, .{ .x = 2795, .y = 2657 }, .{ .x = 2796, .y = 2656 }, .{ .x = 2797, .y = 2656 }, .{ .x = 2797, .y = 2655 }, .{ .x = 2798, .y = 2654 }, .{ .x = 2799, .y = 2654 }, .{ .x = 2801, .y = 2652 }, .{ .x = 2801, .y = 2649 }, .{ .x = 2800, .y = 2650 }, .{ .x = 2798, .y = 2651 } }; +const C2_S35 = [_]Pt{ .{ .x = 2792, .y = 2651 }, .{ .x = 2791, .y = 2652 }, .{ .x = 2790, .y = 2652 }, .{ .x = 2790, .y = 2653 }, .{ .x = 2792, .y = 2653 }, .{ .x = 2792, .y = 2651 } }; +const C2_S36 = [_]Pt{ .{ .x = 2797, .y = 2710 }, .{ .x = 2798, .y = 2710 }, .{ .x = 2799, .y = 2709 }, .{ .x = 2800, .y = 2709 }, .{ .x = 2801, .y = 2707 }, .{ .x = 2802, .y = 2707 }, .{ .x = 2802, .y = 2706 }, .{ .x = 2801, .y = 2706 }, .{ .x = 2801, .y = 2707 }, .{ .x = 2800, .y = 2708 }, .{ .x = 2798, .y = 2708 }, .{ .x = 2798, .y = 2707 }, .{ .x = 2799, .y = 2706 }, .{ .x = 2797, .y = 2706 }, .{ .x = 2797, .y = 2707 }, .{ .x = 2796, .y = 2708 }, .{ .x = 2796, .y = 2710 }, .{ .x = 2797, .y = 2710 } }; +const C2_S37 = [_]Pt{ .{ .x = 2814, .y = 2724 }, .{ .x = 2815, .y = 2724 }, .{ .x = 2815, .y = 2722 }, .{ .x = 2814, .y = 2723 }, .{ .x = 2813, .y = 2723 }, .{ .x = 2813, .y = 2724 }, .{ .x = 2814, .y = 2724 } }; +const C2_S38 = [_]Pt{ .{ .x = 2839, .y = 2775 }, .{ .x = 2839, .y = 2773 }, .{ .x = 2838, .y = 2773 }, .{ .x = 2838, .y = 2772 }, .{ .x = 2834, .y = 2772 }, .{ .x = 2834, .y = 2774 }, .{ .x = 2833, .y = 2774 }, .{ .x = 2833, .y = 2775 }, .{ .x = 2834, .y = 2776 }, .{ .x = 2834, .y = 2777 }, .{ .x = 2835, .y = 2777 }, .{ .x = 2835, .y = 2778 }, .{ .x = 2837, .y = 2778 }, .{ .x = 2838, .y = 2777 }, .{ .x = 2838, .y = 2776 }, .{ .x = 2839, .y = 2776 }, .{ .x = 2839, .y = 2775 } }; +const C2_S39 = [_]Pt{ .{ .x = 2847, .y = 2798 }, .{ .x = 2841, .y = 2794 }, .{ .x = 2832, .y = 2806 }, .{ .x = 2847, .y = 2798 } }; +const C2_S40 = [_]Pt{ .{ .x = 2862, .y = 2800 }, .{ .x = 2864, .y = 2800 }, .{ .x = 2863, .y = 2799 }, .{ .x = 2863, .y = 2798 }, .{ .x = 2864, .y = 2797 }, .{ .x = 2860, .y = 2797 }, .{ .x = 2859, .y = 2798 }, .{ .x = 2859, .y = 2799 }, .{ .x = 2860, .y = 2800 }, .{ .x = 2862, .y = 2800 } }; +const C2_S41 = [_]Pt{ .{ .x = 2878, .y = 2810 }, .{ .x = 2878, .y = 2811 }, .{ .x = 2879, .y = 2810 }, .{ .x = 2878, .y = 2810 } }; +const C2_S42 = [_]Pt{ .{ .x = 2877, .y = 2810 }, .{ .x = 2876, .y = 2810 }, .{ .x = 2876, .y = 2813 }, .{ .x = 2877, .y = 2812 }, .{ .x = 2877, .y = 2810 } }; +const C2_S43 = [_]Pt{ .{ .x = 3271, .y = 2821 }, .{ .x = 3272, .y = 2828 }, .{ .x = 3286, .y = 2811 }, .{ .x = 3261, .y = 2808 }, .{ .x = 3271, .y = 2821 } }; +const C2_S44 = [_]Pt{ .{ .x = 3318, .y = 2817 }, .{ .x = 3319, .y = 2817 }, .{ .x = 3319, .y = 2816 }, .{ .x = 3317, .y = 2816 }, .{ .x = 3316, .y = 2817 }, .{ .x = 3316, .y = 2819 }, .{ .x = 3315, .y = 2819 }, .{ .x = 3315, .y = 2820 }, .{ .x = 3317, .y = 2820 }, .{ .x = 3317, .y = 2819 }, .{ .x = 3318, .y = 2819 }, .{ .x = 3318, .y = 2817 } }; +const C2_S45 = [_]Pt{ .{ .x = 2859, .y = 2837 }, .{ .x = 2859, .y = 2836 }, .{ .x = 2858, .y = 2836 }, .{ .x = 2858, .y = 2837 }, .{ .x = 2859, .y = 2837 } }; +const C2_S46 = [_]Pt{ .{ .x = 2862, .y = 2839 }, .{ .x = 2862, .y = 2838 }, .{ .x = 2861, .y = 2838 }, .{ .x = 2861, .y = 2839 }, .{ .x = 2862, .y = 2839 } }; +const C2_S47 = [_]Pt{ .{ .x = 2856, .y = 2892 }, .{ .x = 2856, .y = 2893 }, .{ .x = 2855, .y = 2893 }, .{ .x = 2855, .y = 2894 }, .{ .x = 2858, .y = 2894 }, .{ .x = 2859, .y = 2893 }, .{ .x = 2860, .y = 2893 }, .{ .x = 2860, .y = 2890 }, .{ .x = 2862, .y = 2888 }, .{ .x = 2864, .y = 2887 }, .{ .x = 2864, .y = 2885 }, .{ .x = 2863, .y = 2885 }, .{ .x = 2862, .y = 2886 }, .{ .x = 2862, .y = 2887 }, .{ .x = 2858, .y = 2891 }, .{ .x = 2856, .y = 2892 } }; +const C2_S48 = [_]Pt{ .{ .x = 2792, .y = 2898 }, .{ .x = 2793, .y = 2898 }, .{ .x = 2793, .y = 2896 }, .{ .x = 2792, .y = 2895 }, .{ .x = 2791, .y = 2895 }, .{ .x = 2791, .y = 2896 }, .{ .x = 2792, .y = 2897 }, .{ .x = 2792, .y = 2898 } }; +const C2_S49 = [_]Pt{ .{ .x = 2796, .y = 2898 }, .{ .x = 2796, .y = 2899 }, .{ .x = 2797, .y = 2898 }, .{ .x = 2797, .y = 2897 }, .{ .x = 2795, .y = 2897 }, .{ .x = 2795, .y = 2898 }, .{ .x = 2796, .y = 2898 } }; +const C2_S50 = [_]Pt{ .{ .x = 3205, .y = 2893 }, .{ .x = 3208, .y = 2898 }, .{ .x = 3208, .y = 2897 }, .{ .x = 3210, .y = 2896 }, .{ .x = 3207, .y = 2892 }, .{ .x = 3206, .y = 2890 }, .{ .x = 3205, .y = 2890 }, .{ .x = 3205, .y = 2891 }, .{ .x = 3204, .y = 2891 }, .{ .x = 3205, .y = 2893 } }; +const C2_S51 = [_]Pt{ .{ .x = 3204, .y = 2894 }, .{ .x = 3203, .y = 2892 }, .{ .x = 3202, .y = 2893 }, .{ .x = 3201, .y = 2893 }, .{ .x = 3200, .y = 2894 }, .{ .x = 3204, .y = 2900 }, .{ .x = 3206, .y = 2899 }, .{ .x = 3206, .y = 2898 }, .{ .x = 3204, .y = 2894 } }; +const C2_S52 = [_]Pt{ .{ .x = 3305, .y = 2829 }, .{ .x = 3305, .y = 2830 }, .{ .x = 3305, .y = 2831 }, .{ .x = 3306, .y = 2831 }, .{ .x = 3306, .y = 2830 }, .{ .x = 3305, .y = 2830 }, .{ .x = 3305, .y = 2829 } }; +const C2_S53 = [_]Pt{ .{ .x = 3270, .y = 2804 }, .{ .x = 3269, .y = 2804 }, .{ .x = 3269, .y = 2805 }, .{ .x = 3270, .y = 2805 }, .{ .x = 3270, .y = 2804 } }; +const C2_S54 = [_]Pt{ .{ .x = 3168, .y = 2793 }, .{ .x = 3166, .y = 2792 }, .{ .x = 3166, .y = 2793 }, .{ .x = 3162, .y = 2793 }, .{ .x = 3161, .y = 2794 }, .{ .x = 3160, .y = 2794 }, .{ .x = 3158, .y = 2796 }, .{ .x = 3157, .y = 2796 }, .{ .x = 3157, .y = 2797 }, .{ .x = 3156, .y = 2798 }, .{ .x = 3156, .y = 2800 }, .{ .x = 3157, .y = 2801 }, .{ .x = 3158, .y = 2801 }, .{ .x = 3158, .y = 2802 }, .{ .x = 3159, .y = 2801 }, .{ .x = 3161, .y = 2800 }, .{ .x = 3162, .y = 2800 }, .{ .x = 3165, .y = 2797 }, .{ .x = 3165, .y = 2796 }, .{ .x = 3167, .y = 2794 }, .{ .x = 3169, .y = 2794 }, .{ .x = 3169, .y = 2793 }, .{ .x = 3168, .y = 2793 } }; +const C2_S55 = [_]Pt{ .{ .x = 3357, .y = 2791 }, .{ .x = 3356, .y = 2790 }, .{ .x = 3356, .y = 2791 }, .{ .x = 3357, .y = 2791 } }; +const C2_S56 = [_]Pt{ .{ .x = 3188, .y = 2788 }, .{ .x = 3188, .y = 2787 }, .{ .x = 3188, .y = 2788 } }; +const C2_S57 = [_]Pt{ .{ .x = 3190, .y = 2787 }, .{ .x = 3192, .y = 2787 }, .{ .x = 3193, .y = 2786 }, .{ .x = 3194, .y = 2786 }, .{ .x = 3194, .y = 2785 }, .{ .x = 3192, .y = 2785 }, .{ .x = 3191, .y = 2786 }, .{ .x = 3190, .y = 2786 }, .{ .x = 3189, .y = 2787 }, .{ .x = 3190, .y = 2787 } }; +const C2_S58 = [_]Pt{ .{ .x = 3185, .y = 2775 }, .{ .x = 3190, .y = 2763 }, .{ .x = 3177, .y = 2776 }, .{ .x = 3174, .y = 2773 }, .{ .x = 3182, .y = 2767 }, .{ .x = 3176, .y = 2766 }, .{ .x = 3173, .y = 2783 }, .{ .x = 3178, .y = 2786 }, .{ .x = 3185, .y = 2775 } }; +const C2_S59 = [_]Pt{ .{ .x = 3124, .y = 2781 }, .{ .x = 3123, .y = 2781 }, .{ .x = 3123, .y = 2782 }, .{ .x = 3122, .y = 2782 }, .{ .x = 3122, .y = 2784 }, .{ .x = 3121, .y = 2784 }, .{ .x = 3121, .y = 2786 }, .{ .x = 3123, .y = 2784 }, .{ .x = 3124, .y = 2784 }, .{ .x = 3124, .y = 2781 } }; +const C2_S60 = [_]Pt{ .{ .x = 3198, .y = 2774 }, .{ .x = 3199, .y = 2774 }, .{ .x = 3199, .y = 2772 }, .{ .x = 3198, .y = 2772 }, .{ .x = 3197, .y = 2773 }, .{ .x = 3197, .y = 2774 }, .{ .x = 3198, .y = 2774 } }; +const C2_S61 = [_]Pt{ .{ .x = 3428, .y = 2757 }, .{ .x = 3427, .y = 2757 }, .{ .x = 3427, .y = 2758 }, .{ .x = 3428, .y = 2758 }, .{ .x = 3428, .y = 2757 } }; +const C2_S62 = [_]Pt{ .{ .x = 3128, .y = 2757 }, .{ .x = 3129, .y = 2756 }, .{ .x = 3129, .y = 2755 }, .{ .x = 3128, .y = 2755 }, .{ .x = 3128, .y = 2756 }, .{ .x = 3127, .y = 2757 }, .{ .x = 3126, .y = 2757 }, .{ .x = 3127, .y = 2758 }, .{ .x = 3127, .y = 2757 }, .{ .x = 3128, .y = 2757 } }; +const C2_S63 = [_]Pt{ .{ .x = 3138, .y = 2750 }, .{ .x = 3137, .y = 2750 }, .{ .x = 3137, .y = 2751 }, .{ .x = 3138, .y = 2751 }, .{ .x = 3138, .y = 2750 } }; +const C2_S64 = [_]Pt{ .{ .x = 3134, .y = 2750 }, .{ .x = 3134, .y = 2749 }, .{ .x = 3136, .y = 2747 }, .{ .x = 3135, .y = 2747 }, .{ .x = 3135, .y = 2748 }, .{ .x = 3134, .y = 2748 }, .{ .x = 3133, .y = 2748 }, .{ .x = 3133, .y = 2749 }, .{ .x = 3132, .y = 2749 }, .{ .x = 3132, .y = 2748 }, .{ .x = 3131, .y = 2748 }, .{ .x = 3131, .y = 2751 }, .{ .x = 3132, .y = 2751 }, .{ .x = 3134, .y = 2750 } }; +const C2_S65 = [_]Pt{ .{ .x = 3135, .y = 2746 }, .{ .x = 3134, .y = 2746 }, .{ .x = 3134, .y = 2747 }, .{ .x = 3135, .y = 2747 }, .{ .x = 3135, .y = 2746 } }; +const C2_S66 = [_]Pt{ .{ .x = 3138, .y = 2744 }, .{ .x = 3139, .y = 2743 }, .{ .x = 3140, .y = 2743 }, .{ .x = 3140, .y = 2742 }, .{ .x = 3139, .y = 2742 }, .{ .x = 3137, .y = 2744 }, .{ .x = 3137, .y = 2745 }, .{ .x = 3138, .y = 2745 }, .{ .x = 3138, .y = 2744 } }; +const C2_S67 = [_]Pt{ .{ .x = 3050, .y = 2731 }, .{ .x = 3050, .y = 2732 }, .{ .x = 3048, .y = 2732 }, .{ .x = 3048, .y = 2734 }, .{ .x = 3049, .y = 2734 }, .{ .x = 3050, .y = 2734 }, .{ .x = 3050, .y = 2731 } }; +const C2_S68 = [_]Pt{ .{ .x = 3127, .y = 2719 }, .{ .x = 3128, .y = 2718 }, .{ .x = 3126, .y = 2718 }, .{ .x = 3126, .y = 2719 }, .{ .x = 3127, .y = 2719 } }; +const C2_S69 = [_]Pt{ .{ .x = 3310, .y = 2716 }, .{ .x = 3308, .y = 2716 }, .{ .x = 3308, .y = 2717 }, .{ .x = 3309, .y = 2717 }, .{ .x = 3310, .y = 2716 } }; +const C2_S70 = [_]Pt{ .{ .x = 3370, .y = 2716 }, .{ .x = 3384, .y = 2708 }, .{ .x = 3382, .y = 2698 }, .{ .x = 3366, .y = 2704 }, .{ .x = 3370, .y = 2716 } }; +const C2_S71 = [_]Pt{ .{ .x = 3092, .y = 2711 }, .{ .x = 3091, .y = 2711 }, .{ .x = 3090, .y = 2712 }, .{ .x = 3090, .y = 2714 }, .{ .x = 3091, .y = 2714 }, .{ .x = 3091, .y = 2713 }, .{ .x = 3092, .y = 2713 }, .{ .x = 3092, .y = 2711 } }; +const C2_S72 = [_]Pt{ .{ .x = 3082, .y = 2712 }, .{ .x = 3081, .y = 2712 }, .{ .x = 3081, .y = 2713 }, .{ .x = 3083, .y = 2713 }, .{ .x = 3083, .y = 2712 }, .{ .x = 3082, .y = 2712 } }; +const C2_S73 = [_]Pt{ .{ .x = 3359, .y = 2703 }, .{ .x = 3359, .y = 2702 }, .{ .x = 3358, .y = 2702 }, .{ .x = 3358, .y = 2701 }, .{ .x = 3356, .y = 2701 }, .{ .x = 3356, .y = 2702 }, .{ .x = 3355, .y = 2702 }, .{ .x = 3353, .y = 2703 }, .{ .x = 3352, .y = 2703 }, .{ .x = 3349, .y = 2706 }, .{ .x = 3348, .y = 2706 }, .{ .x = 3346, .y = 2708 }, .{ .x = 3346, .y = 2709 }, .{ .x = 3348, .y = 2709 }, .{ .x = 3349, .y = 2710 }, .{ .x = 3350, .y = 2710 }, .{ .x = 3351, .y = 2711 }, .{ .x = 3354, .y = 2708 }, .{ .x = 3355, .y = 2708 }, .{ .x = 3357, .y = 2707 }, .{ .x = 3359, .y = 2705 }, .{ .x = 3359, .y = 2704 }, .{ .x = 3359, .y = 2703 } }; +const C2_S74 = [_]Pt{ .{ .x = 3271, .y = 2701 }, .{ .x = 3271, .y = 2700 }, .{ .x = 3272, .y = 2699 }, .{ .x = 3272, .y = 2700 }, .{ .x = 3271, .y = 2700 }, .{ .x = 3271, .y = 2699 }, .{ .x = 3270, .y = 2700 }, .{ .x = 3269, .y = 2700 }, .{ .x = 3268, .y = 2700 }, .{ .x = 3268, .y = 2701 }, .{ .x = 3269, .y = 2702 }, .{ .x = 3270, .y = 2702 }, .{ .x = 3271, .y = 2701 } }; +const C2_S75 = [_]Pt{ .{ .x = 3107, .y = 2700 }, .{ .x = 3107, .y = 2699 }, .{ .x = 3106, .y = 2699 }, .{ .x = 3106, .y = 2698 }, .{ .x = 3107, .y = 2698 }, .{ .x = 3107, .y = 2697 }, .{ .x = 3103, .y = 2697 }, .{ .x = 3101, .y = 2697 }, .{ .x = 3101, .y = 2698 }, .{ .x = 3100, .y = 2698 }, .{ .x = 3100, .y = 2699 }, .{ .x = 3101, .y = 2699 }, .{ .x = 3101, .y = 2700 }, .{ .x = 3103, .y = 2702 }, .{ .x = 3104, .y = 2702 }, .{ .x = 3105, .y = 2701 }, .{ .x = 3106, .y = 2701 }, .{ .x = 3106, .y = 2700 }, .{ .x = 3107, .y = 2700 } }; +const C2_S76 = [_]Pt{ .{ .x = 3366, .y = 2697 }, .{ .x = 3365, .y = 2697 }, .{ .x = 3365, .y = 2698 }, .{ .x = 3364, .y = 2698 }, .{ .x = 3364, .y = 2699 }, .{ .x = 3366, .y = 2699 }, .{ .x = 3366, .y = 2698 }, .{ .x = 3367, .y = 2698 }, .{ .x = 3367, .y = 2697 }, .{ .x = 3366, .y = 2697 } }; +const C2_S77 = [_]Pt{ .{ .x = 3362, .y = 2699 }, .{ .x = 3361, .y = 2700 }, .{ .x = 3361, .y = 2702 }, .{ .x = 3359, .y = 2702 }, .{ .x = 3360, .y = 2703 }, .{ .x = 3362, .y = 2703 }, .{ .x = 3363, .y = 2702 }, .{ .x = 3363, .y = 2700 }, .{ .x = 3362, .y = 2699 } }; +const C2_S78 = [_]Pt{ .{ .x = 3288, .y = 2675 }, .{ .x = 3273, .y = 2682 }, .{ .x = 3274, .y = 2695 }, .{ .x = 3287, .y = 2687 }, .{ .x = 3288, .y = 2675 } }; +const C2_S79 = [_]Pt{ .{ .x = 3329, .y = 2661 }, .{ .x = 3329, .y = 2660 }, .{ .x = 3328, .y = 2660 }, .{ .x = 3327, .y = 2661 }, .{ .x = 3327, .y = 2663 }, .{ .x = 3329, .y = 2663 }, .{ .x = 3329, .y = 2661 } }; +const C2_S80 = [_]Pt{ .{ .x = 3098, .y = 2659 }, .{ .x = 3099, .y = 2658 }, .{ .x = 3098, .y = 2657 }, .{ .x = 3096, .y = 2659 }, .{ .x = 3097, .y = 2659 }, .{ .x = 3097, .y = 2660 }, .{ .x = 3098, .y = 2659 } }; +const C2_S81 = [_]Pt{ .{ .x = 3277, .y = 2600 }, .{ .x = 3276, .y = 2599 }, .{ .x = 3276, .y = 2600 }, .{ .x = 3275, .y = 2600 }, .{ .x = 3275, .y = 2601 }, .{ .x = 3274, .y = 2601 }, .{ .x = 3274, .y = 2603 }, .{ .x = 3276, .y = 2603 }, .{ .x = 3277, .y = 2602 }, .{ .x = 3278, .y = 2602 }, .{ .x = 3278, .y = 2600 }, .{ .x = 3277, .y = 2600 } }; +const C2_S82 = [_]Pt{ .{ .x = 2926, .y = 2781 }, .{ .x = 2934, .y = 2773 }, .{ .x = 2919, .y = 2777 }, .{ .x = 2916, .y = 2785 }, .{ .x = 2926, .y = 2781 } }; +const C2_S83 = [_]Pt{ .{ .x = 2913, .y = 2779 }, .{ .x = 2914, .y = 2779 }, .{ .x = 2914, .y = 2777 }, .{ .x = 2912, .y = 2777 }, .{ .x = 2912, .y = 2778 }, .{ .x = 2913, .y = 2779 } }; +const C2_S84 = [_]Pt{ .{ .x = 2916, .y = 2773 }, .{ .x = 2916, .y = 2772 }, .{ .x = 2915, .y = 2772 }, .{ .x = 2914, .y = 2771 }, .{ .x = 2912, .y = 2771 }, .{ .x = 2912, .y = 2775 }, .{ .x = 2915, .y = 2775 }, .{ .x = 2916, .y = 2774 }, .{ .x = 2916, .y = 2773 } }; +const C2_S85 = [_]Pt{ .{ .x = 2912, .y = 2767 }, .{ .x = 2908, .y = 2762 }, .{ .x = 2907, .y = 2768 }, .{ .x = 2912, .y = 2767 } }; +const C2_S86 = [_]Pt{ .{ .x = 2949, .y = 2762 }, .{ .x = 2954, .y = 2763 }, .{ .x = 2950, .y = 2755 }, .{ .x = 2949, .y = 2762 } }; +const C2_S87 = [_]Pt{ .{ .x = 2946, .y = 2758 }, .{ .x = 2945, .y = 2758 }, .{ .x = 2945, .y = 2760 }, .{ .x = 2946, .y = 2760 }, .{ .x = 2946, .y = 2758 } }; +const C2_S88 = [_]Pt{ .{ .x = 2949, .y = 2753 }, .{ .x = 2948, .y = 2753 }, .{ .x = 2948, .y = 2754 }, .{ .x = 2949, .y = 2754 }, .{ .x = 2949, .y = 2753 } }; +const C2_S89 = [_]Pt{ .{ .x = 2923, .y = 2739 }, .{ .x = 2922, .y = 2739 }, .{ .x = 2921, .y = 2739 }, .{ .x = 2921, .y = 2740 }, .{ .x = 2922, .y = 2741 }, .{ .x = 2923, .y = 2741 }, .{ .x = 2923, .y = 2740 }, .{ .x = 2924, .y = 2740 }, .{ .x = 2923, .y = 2739 } }; +const C2_S90 = [_]Pt{ .{ .x = 2975, .y = 2460 }, .{ .x = 2975, .y = 2461 }, .{ .x = 2976, .y = 2460 }, .{ .x = 2975, .y = 2460 } }; +const C2_S91 = [_]Pt{ .{ .x = 2974, .y = 2502 }, .{ .x = 2974, .y = 2503 }, .{ .x = 2975, .y = 2503 }, .{ .x = 2975, .y = 2502 }, .{ .x = 2974, .y = 2502 } }; +const C2_S92 = [_]Pt{ .{ .x = 3077, .y = 2408 }, .{ .x = 3076, .y = 2408 }, .{ .x = 3076, .y = 2409 }, .{ .x = 3077, .y = 2408 } }; +const C2_S93 = [_]Pt{ .{ .x = 3256, .y = 2841 }, .{ .x = 3256, .y = 2840 }, .{ .x = 3258, .y = 2838 }, .{ .x = 3259, .y = 2839 }, .{ .x = 3257, .y = 2842 }, .{ .x = 3256, .y = 2841 } }; +const C2_S94 = [_]Pt{ .{ .x = 2918, .y = 2473 }, .{ .x = 2917, .y = 2473 }, .{ .x = 2917, .y = 2472 }, .{ .x = 2918, .y = 2472 }, .{ .x = 2918, .y = 2473 } }; +const C2_F0 = [_]Pt{ .{ .x = 2260, .y = 2591 }, .{ .x = 2300, .y = 2631 }, .{ .x = 2302, .y = 2633 }, .{ .x = 2303, .y = 2634 }, .{ .x = 2305, .y = 2636 }, .{ .x = 2391, .y = 2722 }, .{ .x = 2538, .y = 2868 }, .{ .x = 2578, .y = 2908 }, .{ .x = 2579, .y = 2909 }, .{ .x = 2580, .y = 2910 }, .{ .x = 2581, .y = 2911 }, .{ .x = 2637, .y = 2966 }, .{ .x = 2641, .y = 2970 }, .{ .x = 2642, .y = 2972 }, .{ .x = 2643, .y = 2972 }, .{ .x = 2648, .y = 2978 }, .{ .x = 2648, .y = 2978 }, .{ .x = 2740, .y = 3069 }, .{ .x = 2741, .y = 3070 }, .{ .x = 2741, .y = 3071 }, .{ .x = 2951, .y = 3279 }, .{ .x = 3450, .y = 2776 }, .{ .x = 3461, .y = 2765 }, .{ .x = 3443, .y = 2747 }, .{ .x = 3443, .y = 2747 }, .{ .x = 3439, .y = 2743 }, .{ .x = 3439, .y = 2743 }, .{ .x = 3437, .y = 2741 }, .{ .x = 3437, .y = 2741 }, .{ .x = 3435, .y = 2739 }, .{ .x = 3435, .y = 2738 }, .{ .x = 3430, .y = 2734 }, .{ .x = 3430, .y = 2733 }, .{ .x = 3428, .y = 2731 }, .{ .x = 3425, .y = 2729 }, .{ .x = 3425, .y = 2728 }, .{ .x = 3422, .y = 2726 }, .{ .x = 3404, .y = 2708 }, .{ .x = 3404, .y = 2707 }, .{ .x = 3403, .y = 2707 }, .{ .x = 3400, .y = 2704 }, .{ .x = 3390, .y = 2694 }, .{ .x = 3379, .y = 2683 }, .{ .x = 3378, .y = 2682 }, .{ .x = 3371, .y = 2674 }, .{ .x = 3369, .y = 2672 }, .{ .x = 3324, .y = 2628 }, .{ .x = 3322, .y = 2626 }, .{ .x = 3320, .y = 2624 }, .{ .x = 3319, .y = 2623 }, .{ .x = 3319, .y = 2623 }, .{ .x = 3318, .y = 2622 }, .{ .x = 3318, .y = 2622 }, .{ .x = 3298, .y = 2602 }, .{ .x = 3297, .y = 2601 }, .{ .x = 3295, .y = 2599 }, .{ .x = 3294, .y = 2598 }, .{ .x = 3293, .y = 2597 }, .{ .x = 3292, .y = 2596 }, .{ .x = 3292, .y = 2596 }, .{ .x = 3291, .y = 2595 }, .{ .x = 3291, .y = 2595 }, .{ .x = 3265, .y = 2569 }, .{ .x = 3264, .y = 2568 }, .{ .x = 3264, .y = 2568 }, .{ .x = 3263, .y = 2567 }, .{ .x = 3261, .y = 2565 }, .{ .x = 3259, .y = 2563 }, .{ .x = 3258, .y = 2562 }, .{ .x = 3239, .y = 2544 }, .{ .x = 3239, .y = 2543 }, .{ .x = 3097, .y = 2401 }, .{ .x = 3096, .y = 2401 }, .{ .x = 3090, .y = 2394 }, .{ .x = 3088, .y = 2393 }, .{ .x = 3088, .y = 2392 }, .{ .x = 3087, .y = 2391 }, .{ .x = 3086, .y = 2391 }, .{ .x = 3086, .y = 2390 }, .{ .x = 3085, .y = 2390 }, .{ .x = 3081, .y = 2386 }, .{ .x = 3078, .y = 2383 }, .{ .x = 3077, .y = 2382 }, .{ .x = 3077, .y = 2381 }, .{ .x = 3077, .y = 2381 }, .{ .x = 3076, .y = 2381 }, .{ .x = 3076, .y = 2381 }, .{ .x = 3075, .y = 2380 }, .{ .x = 3072, .y = 2377 }, .{ .x = 3072, .y = 2376 }, .{ .x = 3071, .y = 2376 }, .{ .x = 3071, .y = 2375 }, .{ .x = 3071, .y = 2375 }, .{ .x = 3070, .y = 2375 }, .{ .x = 3070, .y = 2374 }, .{ .x = 3054, .y = 2358 }, .{ .x = 3052, .y = 2356 }, .{ .x = 3051, .y = 2355 }, .{ .x = 3050, .y = 2355 }, .{ .x = 3050, .y = 2354 }, .{ .x = 3048, .y = 2353 }, .{ .x = 3048, .y = 2352 }, .{ .x = 3047, .y = 2352 }, .{ .x = 3047, .y = 2351 }, .{ .x = 3046, .y = 2350 }, .{ .x = 3045, .y = 2350 }, .{ .x = 3045, .y = 2349 }, .{ .x = 3045, .y = 2349 }, .{ .x = 3043, .y = 2347 }, .{ .x = 3042, .y = 2347 }, .{ .x = 3041, .y = 2346 }, .{ .x = 3041, .y = 2345 }, .{ .x = 3040, .y = 2344 }, .{ .x = 3040, .y = 2344 }, .{ .x = 3033, .y = 2337 }, .{ .x = 3031, .y = 2335 }, .{ .x = 3030, .y = 2335 }, .{ .x = 3030, .y = 2334 }, .{ .x = 3029, .y = 2333 }, .{ .x = 3028, .y = 2333 }, .{ .x = 2979, .y = 2284 }, .{ .x = 2967, .y = 2272 }, .{ .x = 2967, .y = 2271 }, .{ .x = 2956, .y = 2260 }, .{ .x = 2955, .y = 2260 }, .{ .x = 2920, .y = 2225 }, .{ .x = 2918, .y = 2223 }, .{ .x = 2917, .y = 2221 }, .{ .x = 2916, .y = 2221 }, .{ .x = 2909, .y = 2214 }, .{ .x = 2908, .y = 2213 }, .{ .x = 2884, .y = 2189 }, .{ .x = 2883, .y = 2187 }, .{ .x = 2882, .y = 2186 }, .{ .x = 2871, .y = 2176 }, .{ .x = 2860, .y = 2165 }, .{ .x = 2859, .y = 2164 }, .{ .x = 2845, .y = 2150 }, .{ .x = 2844, .y = 2149 }, .{ .x = 2843, .y = 2147 }, .{ .x = 2828, .y = 2133 }, .{ .x = 2770, .y = 2075 }, .{ .x = 2753, .y = 2093 }, .{ .x = 2749, .y = 2096 }, .{ .x = 2749, .y = 2097 }, .{ .x = 2749, .y = 2097 }, .{ .x = 2717, .y = 2129 }, .{ .x = 2573, .y = 2275 }, .{ .x = 2565, .y = 2283 }, .{ .x = 2560, .y = 2288 }, .{ .x = 2553, .y = 2295 }, .{ .x = 2551, .y = 2297 }, .{ .x = 2533, .y = 2315 }, .{ .x = 2532, .y = 2316 }, .{ .x = 2531, .y = 2317 }, .{ .x = 2519, .y = 2330 }, .{ .x = 2518, .y = 2330 } }; +const C2_F1 = [_]Pt{ .{ .x = 2753, .y = 2214 }, .{ .x = 2753, .y = 2209 }, .{ .x = 2753, .y = 2207 }, .{ .x = 2753, .y = 2204 }, .{ .x = 2753, .y = 2203 }, .{ .x = 2753, .y = 2201 }, .{ .x = 2753, .y = 2196 }, .{ .x = 2753, .y = 2187 }, .{ .x = 2753, .y = 2176 }, .{ .x = 2753, .y = 2175 }, .{ .x = 2753, .y = 2142 }, .{ .x = 2758, .y = 2142 }, .{ .x = 2764, .y = 2142 }, .{ .x = 2765, .y = 2142 }, .{ .x = 2767, .y = 2142 }, .{ .x = 2768, .y = 2142 }, .{ .x = 2835, .y = 2142 }, .{ .x = 2835, .y = 2155 }, .{ .x = 2835, .y = 2162 }, .{ .x = 2835, .y = 2168 }, .{ .x = 2835, .y = 2174 }, .{ .x = 2835, .y = 2176 }, .{ .x = 2835, .y = 2203 }, .{ .x = 2835, .y = 2207 }, .{ .x = 2835, .y = 2209 }, .{ .x = 2835, .y = 2214 }, .{ .x = 2821, .y = 2214 }, .{ .x = 2818, .y = 2214 }, .{ .x = 2795, .y = 2214 }, .{ .x = 2779, .y = 2214 } }; +const C2_SUBJECT = [_][]const Pt{ &C2_S0, &C2_S1, &C2_S2, &C2_S3, &C2_S4, &C2_S5, &C2_S6, &C2_S7, &C2_S8, &C2_S9, &C2_S10, &C2_S11, &C2_S12, &C2_S13, &C2_S14, &C2_S15, &C2_S16, &C2_S17, &C2_S18, &C2_S19, &C2_S20, &C2_S21, &C2_S22, &C2_S23, &C2_S24, &C2_S25, &C2_S26, &C2_S27, &C2_S28, &C2_S29, &C2_S30, &C2_S31, &C2_S32, &C2_S33, &C2_S34, &C2_S35, &C2_S36, &C2_S37, &C2_S38, &C2_S39, &C2_S40, &C2_S41, &C2_S42, &C2_S43, &C2_S44, &C2_S45, &C2_S46, &C2_S47, &C2_S48, &C2_S49, &C2_S50, &C2_S51, &C2_S52, &C2_S53, &C2_S54, &C2_S55, &C2_S56, &C2_S57, &C2_S58, &C2_S59, &C2_S60, &C2_S61, &C2_S62, &C2_S63, &C2_S64, &C2_S65, &C2_S66, &C2_S67, &C2_S68, &C2_S69, &C2_S70, &C2_S71, &C2_S72, &C2_S73, &C2_S74, &C2_S75, &C2_S76, &C2_S77, &C2_S78, &C2_S79, &C2_S80, &C2_S81, &C2_S82, &C2_S83, &C2_S84, &C2_S85, &C2_S86, &C2_S87, &C2_S88, &C2_S89, &C2_S90, &C2_S91, &C2_S92, &C2_S93, &C2_S94 }; +const C2_FACE = [_][]const Pt{ &C2_F0, &C2_F1 }; + +test "compose clip repro: dense sounding-hole DEPARE vs jagged face" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const before = boolean.open_chain_walks; + _ = try boolean.compute(a, &C2_SUBJECT, &C2_FACE, .intersect); + try std.testing.expectEqual(before, boolean.open_chain_walks); +} + +const OB_S0 = [_]Pt{ .{ .x = 2135, .y = -64 }, .{ .x = 2130, .y = 2 }, .{ .x = 2078, .y = 230 }, .{ .x = 2092, .y = 269 }, .{ .x = 2116, .y = 300 }, .{ .x = 2229, .y = 359 }, .{ .x = 2262, .y = 401 }, .{ .x = 2289, .y = 517 }, .{ .x = 2322, .y = 712 }, .{ .x = 2339, .y = 754 }, .{ .x = 2351, .y = 808 }, .{ .x = 2346, .y = 836 }, .{ .x = 2261, .y = 961 }, .{ .x = 2214, .y = 1121 }, .{ .x = 2202, .y = 1259 }, .{ .x = 2185, .y = 1331 }, .{ .x = 2127, .y = 1389 }, .{ .x = 2114, .y = 1419 }, .{ .x = 2109, .y = 1487 }, .{ .x = 2134, .y = 1582 }, .{ .x = 2155, .y = 1633 }, .{ .x = 2166, .y = 1642 }, .{ .x = 2190, .y = 1610 }, .{ .x = 2242, .y = 1564 }, .{ .x = 2343, .y = 1423 }, .{ .x = 2377, .y = 1357 }, .{ .x = 2403, .y = 1263 }, .{ .x = 2514, .y = 1064 }, .{ .x = 2525, .y = 1026 }, .{ .x = 2595, .y = 897 }, .{ .x = 2651, .y = 817 }, .{ .x = 2725, .y = 732 }, .{ .x = 2754, .y = 690 }, .{ .x = 2777, .y = 642 }, .{ .x = 2868, .y = 540 }, .{ .x = 2889, .y = 463 }, .{ .x = 3087, .y = 157 }, .{ .x = 3141, .y = 91 }, .{ .x = 3309, .y = -64 }, .{ .x = 3393, .y = -64 }, .{ .x = 3165, .y = 263 }, .{ .x = 3052, .y = 443 }, .{ .x = 2889, .y = 633 }, .{ .x = 2835, .y = 706 }, .{ .x = 2841, .y = 724 }, .{ .x = 2829, .y = 748 }, .{ .x = 2661, .y = 970 }, .{ .x = 2657, .y = 994 }, .{ .x = 2638, .y = 1021 }, .{ .x = 2621, .y = 1070 }, .{ .x = 2620, .y = 1094 }, .{ .x = 2629, .y = 1107 }, .{ .x = 2599, .y = 1143 }, .{ .x = 2561, .y = 1214 }, .{ .x = 2540, .y = 1287 }, .{ .x = 2470, .y = 1427 }, .{ .x = 2297, .y = 1578 }, .{ .x = 2176, .y = 1745 }, .{ .x = 2176, .y = 1764 }, .{ .x = 2210, .y = 1805 }, .{ .x = 2264, .y = 1936 }, .{ .x = 2282, .y = 1959 }, .{ .x = 2316, .y = 1968 }, .{ .x = 2346, .y = 1965 }, .{ .x = 2367, .y = 1954 }, .{ .x = 2399, .y = 1908 }, .{ .x = 2436, .y = 1877 }, .{ .x = 2490, .y = 1863 }, .{ .x = 2521, .y = 1867 }, .{ .x = 2560, .y = 1887 }, .{ .x = 2613, .y = 1942 }, .{ .x = 2646, .y = 1986 }, .{ .x = 2651, .y = 2012 }, .{ .x = 2631, .y = 2050 }, .{ .x = 2569, .y = 2107 }, .{ .x = 2541, .y = 2147 }, .{ .x = 2537, .y = 2188 }, .{ .x = 2555, .y = 2222 }, .{ .x = 2684, .y = 2299 }, .{ .x = 2727, .y = 2313 }, .{ .x = 2886, .y = 2333 }, .{ .x = 3015, .y = 2329 }, .{ .x = 3096, .y = 2316 }, .{ .x = 3202, .y = 2277 }, .{ .x = 3251, .y = 2247 }, .{ .x = 3276, .y = 2222 }, .{ .x = 3308, .y = 2162 }, .{ .x = 3321, .y = 2079 }, .{ .x = 3323, .y = 2006 }, .{ .x = 3289, .y = 1812 }, .{ .x = 3287, .y = 1749 }, .{ .x = 3291, .y = 1706 }, .{ .x = 3332, .y = 1530 }, .{ .x = 3361, .y = 1466 }, .{ .x = 3499, .y = 1215 }, .{ .x = 3578, .y = 1167 }, .{ .x = 3659, .y = 1149 }, .{ .x = 3781, .y = 1138 }, .{ .x = 3848, .y = 1152 }, .{ .x = 3913, .y = 1184 }, .{ .x = 3980, .y = 1241 }, .{ .x = 4124, .y = 1500 }, .{ .x = 4160, .y = 1526 }, .{ .x = 4160, .y = 2516 }, .{ .x = 3293, .y = 3252 }, .{ .x = 1897, .y = 1610 }, .{ .x = 1894, .y = 1573 }, .{ .x = 1808, .y = 1334 }, .{ .x = 1794, .y = 1225 }, .{ .x = 1769, .y = 1174 }, .{ .x = 1777, .y = 1092 }, .{ .x = 1768, .y = 1022 }, .{ .x = 1700, .y = 819 }, .{ .x = 1628, .y = 572 }, .{ .x = 1523, .y = -64 }, .{ .x = 1066, .y = -64 }, .{ .x = 1076, .y = -6 }, .{ .x = 1070, .y = 20 }, .{ .x = 940, .y = 230 }, .{ .x = 871, .y = 390 }, .{ .x = 490, .y = -64 } }; +const OB_F0 = [_]Pt{ .{ .x = 4160, .y = -64 }, .{ .x = 4160, .y = 2516 }, .{ .x = 3427, .y = 3139 }, .{ .x = 3293, .y = 3252 }, .{ .x = 3221, .y = 3167 }, .{ .x = 3206, .y = 3150 }, .{ .x = 3203, .y = 3145 }, .{ .x = 2458, .y = 2270 }, .{ .x = 2369, .y = 2166 }, .{ .x = 2191, .y = 1956 }, .{ .x = 2125, .y = 1879 }, .{ .x = 2109, .y = 1859 }, .{ .x = 2102, .y = 1851 }, .{ .x = 2099, .y = 1848 }, .{ .x = 2098, .y = 1846 }, .{ .x = 2095, .y = 1843 }, .{ .x = 2094, .y = 1842 }, .{ .x = 2092, .y = 1839 }, .{ .x = 2088, .y = 1835 }, .{ .x = 2062, .y = 1804 }, .{ .x = 2059, .y = 1801 }, .{ .x = 2058, .y = 1799 }, .{ .x = 2057, .y = 1798 }, .{ .x = 2057, .y = 1798 }, .{ .x = 2056, .y = 1797 }, .{ .x = 2050, .y = 1790 }, .{ .x = 2045, .y = 1784 }, .{ .x = 2044, .y = 1783 }, .{ .x = 2043, .y = 1782 }, .{ .x = 2043, .y = 1782 }, .{ .x = 2042, .y = 1781 }, .{ .x = 2018, .y = 1753 }, .{ .x = 1897, .y = 1610 }, .{ .x = 1851, .y = 1556 }, .{ .x = 1851, .y = 1556 }, .{ .x = 1829, .y = 1530 }, .{ .x = 1812, .y = 1510 }, .{ .x = 1669, .y = 1340 }, .{ .x = 871, .y = 390 }, .{ .x = 871, .y = 390 }, .{ .x = 490, .y = -64 } }; +const OB_SUBJECT = [_][]const Pt{ &OB_S0 }; +const OB_FACE = [_][]const Pt{ &OB_F0 }; + +test "compose clip repro: Pamlico DEPARE blob (retry-resistant)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const before = boolean.open_chain_walks; + _ = try boolean.compute(a, &OB_SUBJECT, &OB_FACE, .intersect); + try std.testing.expectEqual(before, boolean.open_chain_walks); +} diff --git a/tools/compose_tile.zig b/tools/compose_tile.zig index efe641d..cecf953 100644 --- a/tools/compose_tile.zig +++ b/tools/compose_tile.zig @@ -2,10 +2,16 @@ //! composed tile ON DEMAND from a resident ownership partition + mmap'd per-cell archives (the //! runtime-compositor path), byte-identical to the batch. `--bench N` then serves an N×N tile block //! around (x,y) to report per-tile serving latency, amortising the one-time open. +//! +//! `compose-tile --scan Z0[..Z1] [--load-partition FILE]` — compose EVERY tile in the +//! source bounds at those zooms and report each tile whose clip left an open ring walk (the +//! geometry boolean's wedge-artifact diagnostic): on correct booleans the count is always zero, so +//! any hit names a broken tile exactly. The artifact sweep to run after touching the boolean. const std = @import("std"); const engine = @import("engine"); const compose = @import("compose"); +const geometry = @import("geometry"); const common = @import("common.zig"); const Flags = common.Flags; const usageErr = common.usageErr; @@ -25,6 +31,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var out: ?[]const u8 = null; var load_partition: ?[]const u8 = null; var bench: u32 = 0; + var scan: ?[]const u8 = null; var f = Flags{ .args = args }; while (f.next()) |arg| { @@ -34,6 +41,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { load_partition = f.val(arg) orelse return; } else if (std.mem.eql(u8, arg, "--bench")) { bench = f.int(u32, arg) orelse return; + } else if (std.mem.eql(u8, arg, "--scan")) { + scan = f.val(arg) orelse return; } else if (std.mem.startsWith(u8, arg, "-")) { return usageErr("unknown flag"); } else if (dir_path == null) { @@ -47,9 +56,22 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } else return usageErr("unexpected argument"); } const dir = dir_path orelse return usageErr("missing "); - const z = std.fmt.parseInt(u8, zs orelse return usageErr("missing z"), 10) catch return usageErr("bad z"); - const tx = std.fmt.parseInt(u32, xs orelse return usageErr("missing x"), 10) catch return usageErr("bad x"); - const ty = std.fmt.parseInt(u32, ys orelse return usageErr("missing y"), 10) catch return usageErr("bad y"); + // --scan Z0[..Z1] needs no positional tile address. + var scan_z0: u8 = 0; + var scan_z1: u8 = 0; + if (scan) |sv| { + if (std.mem.indexOf(u8, sv, "..")) |dots| { + scan_z0 = std.fmt.parseInt(u8, sv[0..dots], 10) catch return usageErr("bad --scan zoom"); + scan_z1 = std.fmt.parseInt(u8, sv[dots + 2 ..], 10) catch return usageErr("bad --scan zoom"); + } else { + scan_z0 = std.fmt.parseInt(u8, sv, 10) catch return usageErr("bad --scan zoom"); + scan_z1 = scan_z0; + } + if (scan_z1 < scan_z0) return usageErr("bad --scan zoom range"); + } + const z = if (scan != null) 0 else std.fmt.parseInt(u8, zs orelse return usageErr("missing z"), 10) catch return usageErr("bad z"); + const tx = if (scan != null) 0 else std.fmt.parseInt(u32, xs orelse return usageErr("missing x"), 10) catch return usageErr("bad x"); + const ty = if (scan != null) 0 else std.fmt.parseInt(u32, ys orelse return usageErr("missing y"), 10) catch return usageErr("bad y"); // Enumerate the per-cell archives (sorted, like `compose --from-archives`). var paths = std.ArrayList([]const u8).empty; @@ -97,6 +119,49 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const open_ms = @as(f64, @floatFromInt(nowNs() - open_t0)) / 1e6; std.debug.print("opened {d} cell(s), partition {s}, in {d:.1} ms (serve z {d}..{d})\n", .{ src.readers.len, if (load_bytes != null) "loaded" else "built", open_ms, src.minz, src.loop_max }); + // Artifact sweep: compose every in-bounds tile at the scan zooms and report + // each one whose clip dead-ended a ring walk (open chain ⇒ chord artifact). + if (scan != null) { + var arena_state = std.heap.ArenaAllocator.init(a); + defer arena_state.deinit(); + var bad_tiles: usize = 0; + var total: usize = 0; + var sz = scan_z0; + while (sz <= scan_z1) : (sz += 1) { + const scale: f64 = @floatFromInt(@as(u32, 1) << @intCast(sz)); + const tl = engine.tile.lonLatToWorld(src.bounds[0], src.bounds[3]); + const br = engine.tile.lonLatToWorld(src.bounds[2], src.bounds[1]); + const x0 = compose.worldAxisToTile(tl[0], scale); + const x1 = compose.worldAxisToTile(br[0], scale); + const y0 = compose.worldAxisToTile(tl[1], scale); + const y1 = compose.worldAxisToTile(br[1], scale); + var zx = x0; + while (zx <= x1) : (zx += 1) { + var zy = y0; + while (zy <= y1) : (zy += 1) { + const before = geometry.boolean.open_chain_walks; + const big_before = geometry.boolean.large_open_chains; + const aa = arena_state.allocator(); + const r = src.tile(aa, sz, zx, zy) catch |err| { + std.debug.print("z{d}/{d}/{d}: ERROR {s}\n", .{ sz, zx, zy, @errorName(err) }); + continue; + }; + if (r.tile != null) total += 1; + const chains = geometry.boolean.open_chain_walks - before; + const big = geometry.boolean.large_open_chains - big_before; + if (chains > 0) { + bad_tiles += 1; + std.debug.print("z{d}/{d}/{d}: {d} open chain(s), {d} large\n", .{ sz, zx, zy, chains, big }); + } + _ = arena_state.reset(.retain_capacity); + } + } + std.debug.print("scan z{d} done ({d} composed so far, {d} bad)\n", .{ sz, total, bad_tiles }); + } + std.debug.print("scan complete: {d} tiles composed, {d} with open chains\n", .{ total, bad_tiles }); + return; + } + // Serve the requested tile. const serve_t0 = nowNs(); const res = try src.tile(a, z, tx, ty); From 7d1a50b928e8bebb43be90c9c9ef9c969dd41ac7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 19:19:58 -0400 Subject: [PATCH 124/140] =?UTF-8?q?docs:=20one=20word=20for=20the=20unit?= =?UTF-8?q?=20=E2=80=94=20chart;=20cell=20survives=20only=20as=20spec/API?= =?UTF-8?q?=20vocabulary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/docs/architecture.md | 70 +++++++++++++++++++----------------- docs/docs/c-api.md | 42 +++++++++++----------- docs/docs/getting-started.md | 32 +++++++++-------- docs/docs/installation.md | 4 +-- docs/docs/intro.md | 39 ++++++++++---------- docs/docs/limitations.md | 24 ++++++------- docs/docs/rendering.md | 14 ++++---- docs/docs/tile-schema.md | 10 +++--- docs/docs/zig-api.md | 51 +++++++++++++------------- 9 files changed, 148 insertions(+), 138 deletions(-) diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index cd4bcd0..b99cd6a 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -6,12 +6,13 @@ sidebar_position: 6 # Architecture -This page explains how tile57 turns an S-57 chart cell into vector tiles, how the -codebase is layered, and the memory design that keeps it small. +This page explains how tile57 turns an S-57 chart — an ENC *cell*, in the +spec's vocabulary — into vector tiles, how the codebase is layered, and the +memory design that keeps it small. ## The pipeline -A chart cell flows through these stages, all inside the engine: +A chart flows through these stages, all inside the engine: ``` S-57 ENC cell (.000) @@ -34,7 +35,7 @@ render Surface src/render/surface.zig └─► pixel surfaces: PNG raster · vector PDF · terminal text (src/render/) ``` -1. **Decode (ISO 8211).** S-57 cells use the ISO 8211 binary container format; +1. **Decode (ISO 8211).** S-57 charts use the ISO 8211 binary container format; the decoder reads its raw records and fields. 2. **Build the S-57 model.** Features (depth areas, buoys, coastlines, …), their attributes, and their geometry (assembled from the vector topology) become a @@ -74,21 +75,22 @@ host hints its renderer correctly. ### Band handoff (coverage-clipped ownership) -Overlapping cells of different compilation scales are resolved per tile to the -best band. Rather than *carry* a coarser cell's features down into a finer band's -tiles (the old `smax`-tagged carry-down), the compositor clips each cell to the -**ownership partition**: at every tile the finer cell's M_COVR coverage wins the -ground it holds, and a coarser cell renders only where no finer cell covers it. +Overlapping charts of different compilation scales are resolved per tile to +the best band. Rather than *carry* a coarser chart's features down into a finer +band's tiles (the old `smax`-tagged carry-down), the compositor clips each +chart to the **ownership partition**: at every tile the finer chart's M_COVR +coverage wins the ground it holds, and a coarser chart renders only where no +finer chart covers it. Band boundaries hand off without holes or double-draws, and there is no per-feature handoff tag for the style to gate. ### Overscale indication (`oscl`) -Per S-52 §10.1.10, every contributing cell's coverage polygon is baked as an -`OVERSC01` vertical-line hatch tagged `oscl` = the cell's compilation-scale +Per S-52 §10.1.10, every contributing chart's coverage polygon is baked as an +`OVERSC01` vertical-line hatch tagged `oscl` = the chart's compilation-scale denominator. The hatch shows only while the display is *finer* than `1:oscl`, and the style sandwiches it between the overscaled and at-scale fill passes so -finer opaque data occludes a coarser cell's hatch. The `show_overscale` +finer opaque data occludes a coarser chart's hatch. The `show_overscale` mariner toggle (default on) drives its visibility. ## The Zig modules @@ -99,7 +101,7 @@ libc/Lua) and target-agnostic: | Module | Role | |--------|------| | `iso8211` | the ISO/IEC 8211 container reader (the bottom layer; std-only) | -| `s57` | S-57 ENC cell parser + geometry model (reads 8211 records through `iso8211`) | +| `s57` | the S-57 chart parser + geometry model (reads 8211 records through `iso8211`) | | `s101` | the S-101 catalogue, the S-57 → S-101 adapter, and the portrayal instruction stream | | `portray` | the embedded-Lua S-101 runner (links libc) | | `tiles` | MVT + MLT encoders, gzip, the PMTiles container, web-mercator tile math | @@ -124,7 +126,7 @@ the same Zig API as the `tile57` module. The public surface composes the packages into high-level entry points: - **`Chart`** — ONE open chart, no composition. Open a baked **PMTiles** archive - (`openPmtilesPath`, mmap'd; `openBytes`) or a live cell (`openBytes` on `.000` + (`openPmtilesPath`, mmap'd; `openBytes`) or a live chart (`openBytes` on `.000` bytes), then take its outputs: view renders (`renderView` — PNG, PDF, or a callback canvas; `renderSurfaceView` — world-space GPU callbacks), the cursor pick (`queryPoint`), the metadata getters, and — for an archive — its stored @@ -133,13 +135,13 @@ The public surface composes the packages into high-level entry points: `tile57_query`. A streaming ENC_ROOT open (`openPath` / `openCells` / `openCellsStreaming`) is the metadata + extraction view of raw source data (the C `tile57_enc_*` readers); it serves no tiles or renders. -- **Tile production** — bake each cell to its own PMTiles at its compilation scale +- **Tile production** — bake each chart to its own PMTiles at its compilation scale (`tile57_bake_cell_bytes`, which runs the banded bake engine `scene/bake_enc.zig` - on a single cell), then a runtime **compositor** stitches the overlapping cells + on a single chart), then a runtime **compositor** stitches the overlapping charts through an ownership partition and offers the SAME outputs as a chart, composed: `tile57_compose_tile` for any `(z, x, y)` on demand, `tile57_compose_png` / `_pdf` / `_canvas` / `_surface` / `_query` for composed views and the composed - pick. Baking is strictly per-cell: one cell, one archive. + pick. Baking is strictly per-chart: one chart, one archive. - **`style.build`** (`style/maplibre.zig`) + **`style`** / **`sprite`** — generate the MapLibre style and the portrayal assets it references (`tile57_style_build` / `tile57_bake_assets` in the C ABI). @@ -148,22 +150,24 @@ The public surface composes the packages into high-level entry points: tile57 is built to hold only its working set: -- **Lazy per-cell reads.** An ENC_ROOT is opened by scanning each cell's header - for a cheap spatial index (band + bbox). A cell's bytes are read and parsed +- **Lazy per-chart reads.** An ENC_ROOT is opened by scanning each chart's + header for a cheap spatial index (band + bbox). A chart's bytes are read and + parsed only when a metadata or feature query needs them, then released under an LRU bound. -- **Ownership, not overlays.** Overlapping cells of different compilation scales - are resolved by the precomputed ownership partition — each tile's ground - belongs to exactly one cell per band, so composing never loads every - overlapping cell. +- **Ownership, not overlays.** Overlapping charts of different compilation + scales are resolved by the precomputed ownership partition — each tile's + ground belongs to exactly one chart per band, so composing never loads every + overlapping chart. - **Streaming open.** `openCellsStreaming` (and its on-disk driver `openPath`, - which backs the C `tile57_enc_*` readers) take per-cell metadata (bbox + scale) - plus a reader; a cell's bytes are read only on demand and freed on eviction. A + which backs the C `tile57_enc_*` readers) take per-chart metadata (bbox + + scale) plus a reader; a chart's bytes are read only on demand and freed on + eviction. A host then holds only the working set's bytes, not the whole catalogue — the right choice for a large ENC_ROOT. -- **Per-cell bakes.** Each cell bakes independently at its own compilation scale, - so a bake holds a single cell's parsed data at a time — memory doesn't grow with - the size of the catalogue. +- **Per-chart bakes.** Each chart bakes independently at its own compilation + scale, so a bake holds a single chart's parsed data at a time — memory + doesn't grow with the size of the catalogue. - **Tile cache.** Generated/decoded tiles are memoized per chart (keyed `z<<48 | x<<24 | y`) and released with the chart, so a long-running host bounds memory by closing charts it no longer renders. @@ -171,19 +175,19 @@ tile57 is built to hold only its working set: ## The live-composite bake One `bake` command (`tile57 bake -o out/`) writes the -live-composite structure — per-cell tiles plus the ownership partition a runtime +live-composite structure — per-chart tiles plus the ownership partition a runtime compositor serves them from: ``` out/ - tiles/US5MD1MC.pmtiles one PMTiles per cell, baked at its compilation scale + tiles/US5MD1MC.pmtiles one PMTiles per chart, baked at its compilation scale tiles/US4MD81M.pmtiles (M_COVR coverage embedded in each archive's metadata) - partition.tpart the ownership partition: which cell renders which ground + partition.tpart the ownership partition: which chart renders which ground ``` There is no merged archive: any `(z, x, y)` tile is composed from the overlapping -cells on demand (`tile57_compose_tile`), so re-baking one cell doesn't rewrite the -whole ENC_ROOT. The portrayal assets are generated separately (`tile57 assets` / +charts on demand (`tile57_compose_tile`), so re-baking one chart doesn't +rewrite the whole ENC_ROOT. The portrayal assets are generated separately (`tile57 assets` / `style`); the tiles carry S-52 colour **tokens**, never RGB, and both halves come from the *same* S-101 catalogue, so they cannot drift. The tile-schema vocabulary (`tile57/2`) is the contract a renderer checks. diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 4a5efc8..37a2f43 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -13,22 +13,23 @@ the [Zig API](./zig-api.md); the two stay in lock-step. The pipeline is three stages, and the header (and this page) is organised the same way: -- **Bake** — ENC source data in, per-cell PMTiles out. Each cell bakes to its - own archive at its own compilation scale, with its M_COVR coverage + scale - embedded in the archive metadata. The bake section also carries the raw-source - readers (cell inventory, feature extraction, exchange-set catalogue). +- **Bake** — ENC source data in, per-chart PMTiles out. Each chart bakes to + its own archive at its own compilation scale, with its M_COVR coverage + + scale embedded in the archive metadata. The bake section also carries the + raw-source readers (chart inventory, feature extraction, exchange-set + catalogue). - **Render** — a **`tile57`** chart handle opens ONE baked archive and answers for it with NO composition: metadata (info / SCAMIN / coverage), its stored tiles verbatim (`tile57_tile` — the primitive for writing your own compositor), the S-52 cursor pick, and view outputs (`tile57_png` / `_pdf` / `_canvas` / `_surface`). - **Compose** — a **`tile57_compose`** handle stitches MANY open charts through - the cell-ownership partition and offers the SAME output set, composed: + the ownership partition and offers the SAME output set, composed: `tile57_compose_tile` (what a live tile server hands its HTTP layer — the bytes are MapLibre Tiles), `_png`, `_pdf`, `_canvas`, `_surface`, `_query`. -Everything is **bake, then compose** (or bake, then render): source cells bake -once to per-cell archives; every output is produced from baked archives. +Everything is **bake, then compose** (or bake, then render): source charts +bake once to per-chart archives; every output is produced from baked archives. Style + portrayal-asset generation rounds out the surface: the mariner's S-52 display options become a concrete MapLibre style JSON plus the colortables and @@ -82,14 +83,15 @@ return bytes allocate `*out`; free it with `tile57_free(ptr)`. Input bytes are copied, so the caller may free them right after the call. ::: -## Bake: ENC cells → per-cell archives +## Bake: ENC charts → per-chart archives -Tile production is a two-step composite model. First bake each ENC cell to its -own PMTiles at that cell's compilation scale; the per-cell archive embeds the -cell's M_COVR coverage, compilation scale, and identity in its metadata. Then -open a **compositor** over the archives (as charts) and serve any `(z, x, y)` -tile on demand — the compositor stitches the overlapping cells through an -ownership partition, handling cross-band zoom. +Tile production is a two-step composite model. First bake each chart to its +own PMTiles at its compilation scale; the archive embeds the chart's M_COVR +coverage, compilation scale, and identity in its metadata. Then open a +**compositor** over the archives and serve any `(z, x, y)` tile on demand — +the compositor stitches the overlapping charts through an ownership partition, +handling cross-band zoom. (The bake and enc APIs keep S-57's own word for a +chart — a *cell*: `tile57_bake_cell_bytes`, `tile57_enc_cells`.) ```c /* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over its @@ -125,11 +127,11 @@ tile57_status tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, ``` Every baked feature carries the pick-report properties `class` (object-class -acronym), `cell` (source cell stem), and `s57` (the full S-57 attribute set as a +acronym), `cell` (source chart stem), and `s57` (the full S-57 attribute set as a JSON object) — what `tile57_query` and a host inspector read back. The `tile57 bake -o out/` CLI produces this structure -directly: `out/tiles/.pmtiles` per cell plus `out/partition.tpart`. +directly: `out/tiles/.pmtiles` per chart plus `out/partition.tpart`. ### Read raw S-57 source data @@ -234,7 +236,7 @@ The S-52 cursor pick. Given a lon/lat and the current view `zoom`, tile57 replay the tile at that zoom and reports every feature the point falls in — an area you are inside, or a line or point symbol within a small radius. Each hit calls you back with the S-57 object-class acronym, the attribute JSON (acronym to value), -and the source cell name. This is what a chart application shows when you tap a +and the source chart name. This is what a chart application shows when you tap a feature to see what it is. Passing the view zoom matters: the query reports the features actually DISPLAYED @@ -362,12 +364,12 @@ encode. Both callback forms have composed twins (`tile57_compose_canvas` / ## Compose: many charts, one chart -The compositor builds (or loads) the cell-ownership partition over its charts' +The compositor builds (or loads) the ownership partition over its charts' embedded coverage, then offers the SAME output set as a single chart, composed: any tile on demand for the cost of a classify plus one decompress or one decode/clip, plus the composed view outputs and the composed cursor pick. It **borrows** the charts — their mmap'd archives and decoded coverage — so the -cell set is never fully resident and the charts must outlive the compositor. +chart set is never fully resident and the charts must outlive the compositor. Open once, serve many, close. ```c @@ -467,7 +469,7 @@ void tile57_assets_free(tile57_assets *out); `tile57_bake_sprite_mln` is a focused variant that fills only the `sprite_json` / `sprite_png` fields with a MapLibre **sprite-mln** atlas: every S-101 symbol packed -into one PNG, each cell centered on its symbol's pivot, plus a JSON index of +into one PNG, each atlas cell centered on its symbol's pivot, plus a JSON index of `{name: {x, y, width, height, pixelRatio}}`. A GPU host loads this atlas once and draws point symbols and area patterns as textured quads by name — the atlas the [host-surface `draw_sprite`/`draw_pattern` callbacks](#render-to-a-host-surface-vector-callbacks) diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 1472a24..5bbac93 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -12,15 +12,15 @@ uses the engine from Zig and C. It assumes you have finished ## 1. Bake a chart with the CLI -The `tile57` binary is the offline tool. It bakes ENC cells to PMTiles and emits +The `tile57` binary is the offline tool. It bakes ENC charts to PMTiles and emits the portrayal assets a renderer needs: ```sh zig build # builds zig-out/bin/tile57 T=zig-out/bin/tile57 -# Bake a single cell OR a whole ENC_ROOT to the live-composite structure: -# per-cell tiles/.pmtiles (native band scale, M_COVR embedded) + partition.tpart +# Bake a single chart OR a whole ENC_ROOT to the live-composite structure: +# per-chart tiles/.pmtiles (native band scale, M_COVR embedded) + partition.tpart $T bake CELL.000 -o out/ $T bake /path/to/ENC_ROOT -o out/ @@ -37,15 +37,16 @@ $T png CELL.000 --view -76.48,38.974,15 --size 1600x1200 -o chart.png $T pdf CELL.000 --view -76.48,38.974,15 --size 1600x1200 -o chart.pdf # Inspect / summarise -$T inspect out/tiles/US5MD1MC.pmtiles # zoom range + tile counts for one cell -$T cell CELL.000 # summarise an S-57 cell +$T inspect out/tiles/US5MD1MC.pmtiles # zoom range + tile counts for one chart +$T cell CELL.000 # summarise an S-57 chart $T version ``` Run `tile57 help` for usage. The full subcommand list: `bake`, `compose-tile`, `assets`, `sprite`, `pattern`, `sprite-mln`, `style`, `png`, `pdf`, `ascii`, `explore`, `cells`, `catalog`, `features`, `inspect`, `cell`, `objlcount`, -`version`, `help`. +`version`, `help`. (S-57's own word for a chart is *cell* — the `cell` / +`cells` commands keep it.) :::info Tiles are MLT by default Bakes encode [MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) @@ -62,18 +63,19 @@ real S-52 pixels inline on kitty-graphics terminals like Ghostty or Kitty): $T ascii CELL.000 --view -76.48,38.974,13 --ansi --tui ``` -`tile57 bake` writes a **live-composite structure** — per-cell tiles plus an +`tile57 bake` writes a **live-composite structure** — per-chart tiles plus an ownership partition — that a runtime compositor serves tiles from on demand: ``` out/ - tiles/US5MD1MC.pmtiles one PMTiles per cell, baked at its compilation scale + tiles/US5MD1MC.pmtiles one PMTiles per chart, baked at its compilation scale tiles/US4MD81M.pmtiles (M_COVR coverage embedded in each archive's metadata) - partition.tpart the ownership partition: which cell renders which ground + partition.tpart the ownership partition: which chart renders which ground ``` There is no merged archive: any `(z, x, y)` tile is composed from the overlapping -cells on demand, so a re-bake of one cell doesn't rewrite the whole ENC_ROOT. The +charts on demand, so a re-bake of one chart doesn't rewrite the whole +ENC_ROOT. The portrayal assets travel separately (`tile57 assets` / `style`); the tiles carry S-52 colour **tokens**, never RGB, so one set of tiles renders in any palette. @@ -97,7 +99,7 @@ tile57_compose_open(&chart, 1, "out/partition.tpart", &src, &err); uint8_t *tile; size_t n; bool owned; if (tile57_compose_tile(src, z, x, y, &tile, &n, &owned, &err) == TILE57_OK) { if (tile) { /* … serve the decompressed MLT tile … */ tile57_free(tile); } - else if (owned) { /* owned but empty — a cell's bake is still in flight */ } + else if (owned) { /* owned but empty — a chart's bake is still in flight */ } else { /* not owned — open ocean; cache as blank */ } } tile57_compose_close(src); /* the compositor borrows its charts… */ @@ -109,7 +111,7 @@ compositor load the ownership partition instead of rebuilding it. The chart and the compositor offer the same outputs — `tile57_png(chart, …)` renders one chart alone; `tile57_compose_png(src, …)` renders the composed set; `tile57_tile` hands back one chart's stored tiles verbatim for anyone writing their own -compositor. Raw S-57 reading (cell inventory, feature extraction) is +compositor. Raw S-57 reading (chart inventory, feature extraction) is handle-free via `tile57_enc_*`. See the [C API](./c-api.md). ## 3. Use the engine from Zig @@ -138,10 +140,10 @@ package. See the [Zig API](./zig-api.md). ## ENC_ROOT and updates -Open an ENC_ROOT (many cells, each with its sequential `.001`, `.002` … updates) +Open an ENC_ROOT (many charts, each with its sequential `.001`, `.002` … updates) with `Chart.openPath` (Zig) or scan it with `tile57_enc_cells` (C) — a single -call that walks a whole on-disk catalogue, applying each cell's updates. Baking -(`tile57 bake` / `tile57_bake_tree`) turns it into the per-cell archives the +call that walks a whole on-disk catalogue, applying each chart's updates. Baking +(`tile57 bake` / `tile57_bake_tree`) turns it into the per-chart archives the compositor serves. See the [**Architecture**](./architecture.md) page for how the engine fits diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 855f124..11d68b5 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -41,7 +41,7 @@ zig build test # runs the test suite | Target | What it is | |--------|-----------| -| `tile57` (`zig-out/bin/tile57`) | the offline CLI: bake cells/ENC_ROOTs to PMTiles or a chart bundle, and emit portrayal assets. | +| `tile57` (`zig-out/bin/tile57`) | the offline CLI: bake charts/ENC_ROOTs to PMTiles or a chart bundle, and emit portrayal assets. | | `libtile57.a` | the static library behind the [C ABI](./c-api.md) (`include/tile57.h`). | The engine is also a Zig package named `tile57` (v0.2.0); a Zig consumer @@ -66,7 +66,7 @@ build. Until that's fixed, use a path dependency. ## Runtime knob -- `TILE57_S101_RULES=` — S-101 portrayal rules directory for raw S-57 cells. +- `TILE57_S101_RULES=` — S-101 portrayal rules directory for raw S-57 charts. An **override**: the rules are embedded in the binary by default, so this is only needed to portray against a different on-disk catalogue (it applies when a caller passes `NULL`/`null` for the `rules_dir` argument). The CLI accepts the diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 0a616c9..23240b2 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -16,15 +16,15 @@ real-world navigation.** See [Known limitations](./limitations.md). ::: -**tile57** is a nautical chart engine. It reads IHO **S-57** ENC cells — the -electronic navigational charts published by NOAA and other hydrographic -offices — converts their features to the newer **S-101** data model, and runs -the official IHO **S-101 Portrayal Catalogue** in embedded Lua to decide how -each feature is drawn, producing **S-101 symbology** (the successor to S-52). -The conversion is an interim step, and a best-effort one — S-57 has no perfect -S-101 translation (see [Known limitations](./limitations.md)); the goal is -S-101 throughout, reading native S-101 cells directly as hydrographic offices -publish them. +**tile57** is a nautical chart engine. It reads IHO **S-57** ENC cells — each +cell is one electronic navigational chart, compiled for one scale; NOAA and +other hydrographic offices publish thousands — converts each chart's features +to the newer **S-101** data model, and runs the official IHO **S-101 +Portrayal Catalogue** in embedded Lua to decide how each feature is drawn, +producing **S-101 symbology** (the successor to S-52). The conversion is an +interim step, and a best-effort one — S-57 has no perfect S-101 translation +(see [Known limitations](./limitations.md)); the goal is S-101 throughout, +reading native S-101 charts directly as hydrographic offices publish them. The primary output is **vector tiles**, fetched by `(z, x, y)`: [MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) (MLT, the @@ -80,24 +80,25 @@ render Surface ──► MVT / MLT tiles + PMTiles (src/tiles/) + MapLibre sty └───► PNG raster / vector PDF / terminal text (src/render/) ``` -This is the flow for one cell. The organizing principle is **bake, then -compose**: each source cell bakes once to its own PMTiles archive, and every +This is the flow for one chart. The organizing principle is **bake, then +compose**: each source chart bakes once to its own PMTiles archive, and every output — a served tile, a PNG, a query — is produced from baked archives, with -multi-cell charts stitched on demand by the +overlapping charts stitched into one on demand by the [runtime compositor](./architecture.md). ## The memory model The engine holds only its working set: -- **Lazy, per-cell reads.** A multi-cell ENC_ROOT is indexed cheaply (band + - bbox); a **streaming** open reads a cell's bytes only when a metadata or +- **Lazy, per-chart reads.** A multi-chart ENC_ROOT is indexed cheaply (band + + bbox); a **streaming** open reads a chart's bytes only when a metadata or feature query needs them (and frees them on eviction), so a host holds only the working set — not the whole catalogue. -- **Per-cell bakes, composed on demand.** Each cell bakes to its own PMTiles at - its compilation scale, so a bake holds one cell at a time; the runtime - compositor mmaps the archives and stitches the cells by `(z, x, y)` on demand - through an ownership partition, so serving never loads the cell set either. +- **Per-chart bakes, composed on demand.** Each chart bakes to its own + PMTiles at its compilation scale, so a bake holds one chart at a time; the + runtime compositor mmaps the archives and stitches the charts by `(z, x, y)` + on demand through an ownership partition, so serving never loads the chart + set either. - **Pure-Zig core.** The foundational format/encode modules (`iso8211`, `s57`, `s101`, `tiles`, `render`, `scene`, `style`) have no libc; only the Lua portrayal (`portray`) and the sprite rasterizer (`sprite`) pull in C. @@ -112,7 +113,7 @@ hex, so a renderer can switch palette without regenerating tiles. ## Where to go next - [**Installation**](./installation.md) — Zig 0.16, submodules, `zig build`. -- [**Getting Started**](./getting-started.md) — bake cells and fetch a tile +- [**Getting Started**](./getting-started.md) — bake charts and fetch a tile from Zig or C. - [**Zig API**](./zig-api.md) — the `@import("tile57")` surface. - [**C API**](./c-api.md) — the `tile57_*` C ABI (`include/tile57.h`). diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 18b6e2e..78c401f 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -7,7 +7,7 @@ sidebar_position: 8 # Known Limitations tile57 runs the official IHO S-101 Portrayal Catalogue, and on the -test cells (`US4MD81M.000`, `US5MD1MC.000` + updates) **every feature portrays +test charts (`US4MD81M.000`, `US5MD1MC.000` + updates) **every feature portrays with zero rule errors** — depth areas and contours, soundings, coastline and land, buoys and beacons, lights (including sector legs and arcs), dangers (obstructions / wrecks / rocks), data-quality zones, restricted/anchorage areas, @@ -24,10 +24,10 @@ domain and not for navigation; this renderer adds its own gaps on top. ## S-57 → S-101 conversion -tile57's input is S-57 but its portrayal rules are S-101, so every cell passes +tile57's input is S-57 but its portrayal rules are S-101, so every chart passes through an S-57 → S-101 adapter (`src/s101/adapter.zig`) before the rules run. The adapter is an interim solution — the goal is S-101 throughout, reading -native S-101 cells directly as hydrographic offices publish them. S-57 has no +native S-101 charts directly as hydrographic offices publish them. S-57 has no perfect S-101 translation; the adapter follows the IHO S-65 conversion guidance, and the result is **best effort**: @@ -54,16 +54,16 @@ guidance, and the result is **best effort**: S-52 display length; the mariner's *full-length sector line* variant (legs drawn to the light's nominal range) is not emitted, so the `show_full_sector_lines` setting currently has nothing to act on. -- **Sector figures can stop at a tile boundary beyond the owning cell.** The +- **Sector figures can stop at a tile boundary beyond the owning chart.** The compositor keeps a light's sector legs and arcs WHOLE across ownership boundaries (they are fixed-size decorations anchored at the light, exempt from face clipping). The remaining gap: a figure reaching into a tile where the owning - cell holds no ground at all is absent there — the compositor never consults - that cell for the tile — so a figure within roughly one tile of the cell's + chart holds no ground at all is absent there — the compositor never consults + that chart for the tile — so a figure within roughly one tile of the chart's owned ground can cut at that tile's edge (directional ground-length legs can reach further). - **Single-primitive rules vs. non-conformant geometry.** Some S-101 rules - handle only one primitive (e.g. RecommendedTrack is Curve-only); a cell that + handle only one primitive (e.g. RecommendedTrack is Curve-only); a chart that encodes the feature with another primitive (an area-encoded recommended track) errors in the rule and the feature is suppressed. - **Low-accuracy sounding ring via spatial QUAPOS.** SNDFRM04's low-accuracy @@ -82,7 +82,7 @@ guidance, and the result is **best effort**: indication (`OVERSC01`, see [architecture](./architecture.md)) is gated correctly everywhere, but only the generated MapLibre style sandwiches the hatch under finer at-scale fills. The PNG/PDF/ASCII surfaces draw in S-52 - priority order without that sandwich, so where a finer cell overlaps a + priority order without that sandwich, so where a finer chart overlaps a coarser one the hatch can show through fills that should occlude it. - **SCAMIN gating snaps to integer zooms outside bucket mode.** With a SCAMIN manifest, the style builds per-value layers with exact fractional native @@ -99,17 +99,17 @@ has its own short list of deliberate gaps — see ## ENC_ROOT loading Opening an ENC_ROOT (`Chart.openPath` / `openCellsStreaming`) builds a cheap -spatial index (band + bbox per cell) and reads a cell's bytes only when a +spatial index (band + bbox per chart) and reads a chart's bytes only when a metadata or feature query needs them — the catalogue opens in seconds and memory stays bounded. It serves metadata and extraction only: tiles and views -always come from a bake (bake each cell once, then compose on demand). +always come from a bake (bake each chart once, then compose on demand). Caveats: - **Baking is the import step.** The whole NOAA catalogue is a multi-minute one-time bake (`tile57 bake` / `tile57_bake_tree`); a region is far quicker, - and re-runs are incremental — only cells whose source changed re-bake. + and re-runs are incremental — only charts whose source changed re-bake. - **Index-scan cost.** Opening the whole catalogue as a streaming chart pays a - one-time index scan (a few seconds, parsing every cell header). + one-time index scan (a few seconds, parsing every chart header). - **Low zoom is style-gated.** The generated style's vector-source `minzoom` is the bake's tile floor (default 8), and MapLibre never requests tiles below a source's minzoom — nothing draws below it regardless of the data. diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index 6fc7807..4d3c072 100644 --- a/docs/docs/rendering.md +++ b/docs/docs/rendering.md @@ -1,13 +1,13 @@ # The Rendering Engine tile57 contains a full **native S-52 rendering engine**: it draws a finished -chart — raster PNG or vector PDF — straight from ENC cells, with no MapLibre, +chart — raster PNG or vector PDF — straight from ENC charts, with no MapLibre, browser, or GPU involved. This page explains how it works, how to use it, and how to extend it. ## The one-paragraph version -Charts are turned into **draw calls**. The *scene generator* reads cells, runs +Charts are turned into **draw calls**. The *scene generator* reads charts, runs the official S-101 portrayal rules, and calls methods like `fillArea("DEPVS", rings)` or `drawSymbol("BOYLAT13", point)` on a **Surface**. What happens next depends on which Surface is listening: the tile surface *serializes* those @@ -20,7 +20,7 @@ print. One engine, pluggable outputs. ``` ┌──────────────────────────────────────────────┐ - ENC cells ─►│ scene generator (src/scene/) │ + ENC chart ─►│ scene generator (src/scene/) │ │ parse → S-101 portrayal (embedded Lua) → │ │ project → clip → draw calls, per tile/view │ └───────────────────┬──────────────────────────┘ @@ -88,7 +88,7 @@ archive can't re-run Lua per user), and papers over it with swappable properties the style toggles at runtime. The pixel path evaluates the mariner's display gates — palette, display category, SCAMIN, viewing groups, text groups, size scale — live at render time for any source. Rendering a -live cell (the CLI on a `.000`, or `Chart.openBytes` in Zig) goes further: +live chart (the CLI on a `.000`, or `Chart.openBytes` in Zig) goes further: the S-101 rules themselves run with the mariner's *actual* safety contour, boundary style, and point-symbol style — what you see is what the rules decided for *your* settings, the ECDIS-faithful path. Rendering a baked @@ -100,16 +100,16 @@ swappable parts re-evaluate. ### From the command line ```sh -# One tile of a cell, as a 512px PNG +# One tile of a chart, as a 512px PNG tile57 png US5MD1MC.000 14 4712 6280 -o tile.png --size 512 -# A view (any centre, fractional zoom, any size) from a single cell +# A view (any centre, fractional zoom, any size) from a single chart tile57 png US5MD1MC.000 --view -76.48,38.974,15.1 --size 1600x1200 -o annapolis.png # The same view as a vector PDF with selectable text tile57 pdf US5MD1MC.000 --view -76.48,38.974,15.1 --size 1600x1200 -o annapolis.pdf -# From a baked PMTiles bundle instead of source cells (tile replay) +# From a baked PMTiles bundle instead of the source chart (tile replay) tile57 png chart.pmtiles --view -76.48,38.974,15.1 --size 1024x768 -o out.png # Mariner settings diff --git a/docs/docs/tile-schema.md b/docs/docs/tile-schema.md index 01c940f..e0755b8 100644 --- a/docs/docs/tile-schema.md +++ b/docs/docs/tile-schema.md @@ -20,7 +20,7 @@ Every tile uses an extent of **4096** and a buffer of **64**. :::note Live path coverage Whether the tiles come from a pre-baked PMTiles archive or are generated live from -a raw S-57 cell, the schema is the same. The live path (`src/scene/`) +a raw S-57 chart, the schema is the same. The live path (`src/scene/`) emits `areas`, `area_patterns`, `lines`, `point_symbols`, `soundings`, and `text`. Features carrying SCAMIN (attr 133) keep it as the per-feature `scamin` property, and every feature has a `draw_prio` for S-52 fill ordering. DEPCNT lines @@ -38,9 +38,9 @@ regenerating tiles. ## Zoom levels and navigational bands -A nautical chart is not one map at one scale. NOAA compiles each ENC cell for a +A nautical chart is not one map at one scale. NOAA compiles each chart (an ENC *cell*) for a **navigational purpose** — from a wide overview to a close-in berthing plan — and -the right cell to show depends on how far you are zoomed in. Each cell carries a +the right chart to show depends on how far you are zoomed in. Each chart carries a compilation scale (`CSCL`, a `1:N` denominator) that maps to a band, and each band covers the Web-Mercator zoom range that matches its scale: @@ -69,7 +69,7 @@ regardless of layer: | Field | Type | Meaning | | --- | --- | --- | | `class` | string | S-57 object-class acronym. | -| `cell` | string | Source cell stem. | +| `cell` | string | Source chart stem. | | `s57` | string | The feature's full S-57 attribute set as a JSON object (the pick report). | | `draw_prio` | int | S-52 draw priority (fill/stroke ordering). | | `cat` | int | Display category (base / standard / other gating). | @@ -87,7 +87,7 @@ Filled polygons, such as depth areas and land. | --- | --- | --- | | `color_token` | string | Fill color name. | | `drval1`, `drval2` | number | Depth-range min/max for depth areas (DEPARE/DRGARE). | -| `oscl` | int | Overscale denominator, present when the cell shows finer than its compilation scale. | +| `oscl` | int | Overscale denominator, present when the chart shows finer than its compilation scale. | ### area_patterns diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index 2d7855d..4490f25 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -10,8 +10,8 @@ The engine is a Zig package named **`tile57`** (v0.2.0, requires Zig 0.16). Add it as a dependency and `@import("tile57")` for the curated public surface (`src/tile57.zig`). The [C ABI](./c-api.md) is a thin shim over this same API, and both share the same shape: **bake, then compose** (or bake, then render) — -source cells bake once to per-cell archives, and every output is produced from -baked archives. +source charts bake once to per-chart archives, and every output is produced +from baked archives. :::note Add it as a **path dependency** on a local clone (submodules initialised) — @@ -22,24 +22,25 @@ Add it as a **path dependency** on a local clone (submodules initialised) — ## Bake -Bake each ENC cell to its own PMTiles at its compilation scale — the input the -compositor serves from. Free any returned bytes with `tile57.freeBytes`. +Bake each chart to its own PMTiles at its compilation scale — the input the +compositor serves from (the bake APIs keep S-57's own word for a chart: a +*cell*). Free any returned bytes with `tile57.freeBytes`. ```zig -// Bake an ENC_ROOT: each cell -> /tiles/.pmtiles + /partition.tpart. +// Bake an ENC_ROOT: each chart -> /tiles/.pmtiles + /partition.tpart. // Incremental: an archive already newer than its whole input is skipped. const n = try tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null); ``` | Surface | What it does | |---------|--------------| -| `tile57.bake.cellBytes(path, rules)` | bake one cell (+ updates) to PMTiles bytes. | -| `tile57.bake.cellsParallel(...)` / `bake.cellsToFiles(...)` | bake many cells in parallel, to memory / to files. | -| `tile57.bake.tree(io, in, out, ...)` | walk an ENC_ROOT, bake each cell to a mirrored path (incremental). | +| `tile57.bake.cellBytes(path, rules)` | bake one chart (+ updates) to PMTiles bytes. | +| `tile57.bake.cellsParallel(...)` / `bake.cellsToFiles(...)` | bake many charts in parallel, to memory / to files. | +| `tile57.bake.tree(io, in, out, ...)` | walk an ENC_ROOT, bake each chart to a mirrored path (incremental). | | `tile57.bake.pmtilesMetadata(a, bytes)` | read an archive's metadata JSON (embedded coverage + scamin). | | `tile57.bake.Progress` | the optional progress-callback type. | -Reading raw S-57 source data (a cell inventory, GeoJSON feature extraction) +Reading raw S-57 source data (a chart inventory, GeoJSON feature extraction) goes through a streaming `Chart` — see `openPath` below. ## Render: the `Chart` @@ -65,9 +66,9 @@ What a chart can do depends on how it was opened: |------|---------|--------| | `openPmtilesPath(io, path)` | baked archive, mmap'd | metadata (embedded coverage + scale), query, view renders (tile replay), raw tiles via `pmtilesReader()`. | | `openBytes(bytes, .pmtiles, …)` | baked archive, copied | the same, from memory. | -| `openBytes(cell_bytes, .auto, rules_dir)` | ONE live S-57 cell, fully portrayed | metadata, query, view renders with the S-101 rules evaluated live. | -| `openPath(path, rules_dir, pick_attrs)` | streaming ENC_ROOT (or a single `.000`) | metadata + extraction ONLY: `cellsJson`, `featuresJson`, `scamin`, bounds. Cells are enumerated up front and parsed on demand, so a whole catalogue opens instantly. No view renders, no tiles. | -| `openCells(cells, …)` / `openCellsStreaming(metas, reader, …)` | the same, from in-memory cells / a host reader callback | as `openPath`. | +| `openBytes(cell_bytes, .auto, rules_dir)` | ONE live S-57 chart, fully portrayed | metadata, query, view renders with the S-101 rules evaluated live. | +| `openPath(path, rules_dir, pick_attrs)` | streaming ENC_ROOT (or a single `.000`) | metadata + extraction ONLY: `cellsJson`, `featuresJson`, `scamin`, bounds. Charts are enumerated up front and parsed on demand, so a whole catalogue opens instantly. No view renders, no tiles. | +| `openCells(cells, …)` / `openCellsStreaming(metas, reader, …)` | the same, from in-memory charts / a host reader callback | as `openPath`. | `Chart` methods: @@ -77,33 +78,33 @@ What a chart can do depends on how it was opened: | `renderSurfaceView(lon, lat, zoom, w, h, palette, settings, cb)` | drive world-space surface callbacks (the GPU vector twin). | | `renderAscii(lon, lat, zoom, cols, rows, palette, settings, ansi)` | the same view as a terminal text grid. | | `queryPoint(lon, lat, zoom, cb)` | the S-52 cursor pick — features under a point at the view zoom. | -| `cellsJson()` / `featuresJson(classes)` | per-cell metadata / GeoJSON feature extraction (the `cells` / `features` CLI). | -| `coverage()` | the M_COVR data-coverage rings (a live cell, or the copy a per-cell bake embeds in its archive metadata). | +| `cellsJson()` / `featuresJson(classes)` | per-chart metadata / GeoJSON feature extraction (the `cells` / `features` CLI). | +| `coverage()` | the M_COVR data-coverage rings (a live chart, or the copy a per-chart bake embeds in its archive metadata). | | `bounds() -> ?[4]f64` | geographic extent `[w, s, e, n]`, if known. | | `anchor()` | a good initial camera (lat, lon, zoom) on real data. | | `bands() -> u32` | bitmask of navigational bands present. | | `zoomRange()` | the min/max zoom the chart covers. | -| `nativeScale() -> i32` | the compilation scale 1:N (a live cell or an archive's embedded metadata; 0 if unknown). | +| `nativeScale() -> i32` | the compilation scale 1:N (a live chart or an archive's embedded metadata; 0 if unknown). | | `scamin() -> ![]u32` | the distinct SCAMIN denominators present (the live SCAMIN manifest). | | `tileType()` | the tile encoding the chart's tiles use (MVT/MLT). | | `format() -> Format` | the resolved backend (after `.auto`). | -| `pmtilesReader()` / `cellCoverage()` | the archive reader (raw per-archive tiles — the primitive for writing your own compositor) + the decoded per-cell coverage; what the built-in compositor borrows. | +| `pmtilesReader()` / `cellCoverage()` | the archive reader (raw per-archive tiles — the primitive for writing your own compositor) + the decoded per-chart coverage; what the built-in compositor borrows. | | `deinit()` | release the chart and its cached tiles. | `tile57.Format` is `.auto` / `.pmtiles` / `.s57_cell`. `rules_dir` is the S-101 -portrayal rules directory for live S-57 cells; `null` (or `""`) uses the rules +portrayal rules directory for live S-57 charts; `null` (or `""`) uses the rules embedded in the binary (or `TILE57_S101_RULES` if set), so no on-disk catalogue is required; a path overrides with an on-disk catalogue. The streaming open uses the extern types `tile57.CellMeta` (bbox + `cscl`), -`tile57.CellBytes` (the cell's base + updates, ownership transferred to the -library), and `tile57.CellReadFn` (the reader callback). Multi-cell input for +`tile57.CellBytes` (the chart's base + updates, ownership transferred to the +library), and `tile57.CellReadFn` (the reader callback). Multi-chart input for `openCells` is `tile57.CellInput`. ## Compose -The runtime compositor stitches per-cell archives into one seamless chart: any -`(z, x, y)` tile on demand through the ownership partition (cells never +The runtime compositor stitches per-chart archives into one seamless chart: +any `(z, x, y)` tile on demand through the ownership partition (charts never double-draw where they meet), and the same view outputs as a single chart, composed. @@ -135,7 +136,7 @@ var src = (try tile57.compose.openComposeSourceCharts(gpa, &archives, null)).?; | `ComposeSource.tile(gpa, z, x, y)` | compose one tile on demand (raw MLT + the ownership flag). | | `tile57.compose.renderView(src, ...)` | the composed view render — PNG, PDF, or a callback canvas. | | `tile57.compose.renderSurfaceView(src, ...)` | the composed world-space surface stream. | -| `tile57.compose.queryPoint(src, lon, lat, zoom, cb)` | the composed cursor pick, across cell boundaries. | +| `tile57.compose.queryPoint(src, lon, lat, zoom, cb)` | the composed cursor pick, across chart boundaries. | | `tile57.compose.composeTile(...)` | the stateless core `ComposeSource.tile` uses. | | `tile57.partition` | the ownership partition and its `.tpart` sidecar (serialize / deserialize). | @@ -163,7 +164,7 @@ The mid-level packages, for callers that compose their own pipeline: | `tile57.tile` | web-mercator tiling + clipping | | `tile57.pmtiles` | PMTiles read/write | | `tile57.band` | compilation-scale → zoom-range mapping | -| `tile57.bake_enc` | banded multi-cell ENC_ROOT → PMTiles | +| `tile57.bake_enc` | banded multi-chart ENC_ROOT → PMTiles | | `tile57.scene` | S-57 feature → tile-surface scene generation | | `tile57.render` | the Surface/Canvas rendering path (PNG, PDF, ASCII, callbacks) | @@ -174,9 +175,9 @@ The pure-Zig foundational parsers under `tile57.formats`: | Module | Role | |--------|------| | `tile57.formats.iso8211` | ISO/IEC 8211 records | -| `tile57.formats.s57` | S-57 ENC cell parser + geometry | +| `tile57.formats.s57` | the S-57 chart parser + geometry | | `tile57.formats.s101` | the S-101 catalogue, adapter, and instruction stream | -`tile57.coverage` is the per-cell M_COVR coverage sidecar (carried in an +`tile57.coverage` is the per-chart M_COVR coverage sidecar (carried in an archive's PMTiles metadata). `tile57.version` is the package version string (`"0.2.0"`), matching `build.zig.zon` and `tile57_version()`. From 5d714243f977844acb5577db5ce4470cd937e221 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 19:22:08 -0400 Subject: [PATCH 125/140] docs(cli): the tile57 CLI gets its own reference page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/docs/architecture.md | 2 +- docs/docs/c-api.md | 2 +- docs/docs/cli.md | 179 +++++++++++++++++++++++++++++++++++ docs/docs/getting-started.md | 7 +- docs/docs/intro.md | 1 + docs/docs/limitations.md | 2 +- docs/docs/tile-schema.md | 2 +- docs/docs/zig-api.md | 2 +- docs/sidebars.js | 1 + 9 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 docs/docs/cli.md diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index b99cd6a..313736a 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -1,7 +1,7 @@ --- id: architecture title: Architecture -sidebar_position: 6 +sidebar_position: 7 --- # Architecture diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 37a2f43..7a0fd07 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -1,7 +1,7 @@ --- id: c-api title: C API -sidebar_position: 5 +sidebar_position: 6 --- # C API diff --git a/docs/docs/cli.md b/docs/docs/cli.md new file mode 100644 index 0000000..9fe0b1c --- /dev/null +++ b/docs/docs/cli.md @@ -0,0 +1,179 @@ +--- +id: cli +title: The CLI +sidebar_position: 4 +--- + +# The CLI + +`tile57` (built to `zig-out/bin/tile57` by `zig build`) is the offline +command-line tool over the engine: it bakes charts, serves and inspects tiles, +renders finished views, and emits the portrayal assets. The S-101 Portrayal +Catalogue — rules and assets — is embedded in the binary, so no command needs +an on-disk catalogue; `--rules ` (portrayal) or a positional catalogue +path (the asset commands) overrides the embedded copy. + +Usage lines keep S-57's own word for a chart: a *cell*. `` is one +chart's base file (its sequential `.001`, `.002` … updates are discovered +automatically); `ENC_ROOT` is a whole catalogue directory. + +## Bake and serve + +### `bake` + +``` +tile57 bake -o [--rules DIR] [--from-archives] +``` + +Produces the **live-composite structure** every other output is served from: +each chart bakes at its native band scale to its own +`/tiles/.pmtiles` (M_COVR coverage embedded in the archive +metadata), and the ownership partition is written to `/partition.tpart`. +There is no merged archive — a runtime compositor serves any tile on demand — +and re-runs are incremental: an archive already newer than its whole input +(`.000` + update chain) is skipped. + +`--from-archives` treats the input as an existing directory of per-chart +archives and only (re)builds `partition.tpart` over them. + +### `compose-tile` + +``` +tile57 compose-tile [--load-partition FILE] [-o out] [--bench N] +``` + +Serves ONE composed tile on demand from a live-composite structure — the +runtime compositor as a command. `--load-partition` reuses the baked +`partition.tpart` instead of rebuilding the partition; `--bench N` times an +N×N block of tiles around `(x, y)`. + +## Render + +### `png` / `pdf` + +``` +tile57 png|pdf -o [--size N] +tile57 png|pdf --view --size WxH -o +``` + +Render one tile, or a view (any centre, fractional zoom, any pixel size), +through the [native S-52 pixel path](./rendering.md): PNG raster or +deterministic vector PDF with real text objects. An S-57 chart renders with +the S-101 rules evaluated live; a baked `.pmtiles` bundle renders by tile +replay. `--palette day|dusk|night` picks the colour scheme, `--dq` overlays +data quality, `--scale F` multiplies physical symbol size, and the mariner +settings flags (`--safety`, `--feet`, `--no-names`, …) are shown in +[The Rendering Engine](./rendering.md#from-the-command-line). + +### `ascii` + +``` +tile57 ascii --view [--size COLSxROWS] [--ansi] [--tui] [--kitty] +``` + +The chart in your terminal as a Unicode grid (default size: the terminal). +`--ansi` adds xterm-256 colour, `--tui` opens an interactive pan/zoom loop, +and `--kitty` paints real S-52 pixels inline on kitty-graphics terminals +(Ghostty, Kitty). + +## Portrayal assets and style + +Each of these takes an optional positional catalogue directory; without one it +uses the catalogue embedded in the binary. + +### `assets` + +``` +tile57 assets [portrayal-catalog-dir] -o +``` + +Emits every portrayal asset a renderer needs: `colortables.json` (S-52 token → +Day/Dusk/Night hex), `linestyles.json`, the sprite atlas (`sprite.json` + +`sprite.png`), and the area-fill pattern atlas (`patterns.json` + +`patterns.png`). + +### `style` + +``` +tile57 style [portrayal-catalog-dir] --scheme day|dusk|night -o +``` + +Emits one concrete MapLibre `style.json`, with colours resolved from the +catalogue (or `--colortables FILE`). `--source-tiles` / `--pmtiles-url` pick +the tile source, `--sprite` / `--glyphs` enable the symbol and text layers, +and `--minzoom` / `--maxzoom` bound the source. + +### `sprite` / `pattern` / `sprite-mln` + +``` +tile57 sprite|pattern|sprite-mln [portrayal-catalog-dir] -o +``` + +The focused atlas emitters: `sprite` writes the S-101 symbol atlas, `pattern` +the area-fill pattern atlas, and `sprite-mln` the MapLibre sprite sheet (every +symbol packed into one PNG, each atlas cell centered on its symbol's pivot, +plus the JSON index). + +## Inspect + +### `explore` + +``` +tile57 explore [--class ACR[,ACR..]] [--object FOID|RCID|INDEX] +``` + +The portrayal microscope: dumps, per feature, the raw S-57 (class + +attributes), the S-101 portrayal instruction stream (raw and parsed), and the +resolved Surface draw calls. Takes a single chart, or an ENC_ROOT with +`--view LON,LAT,ZOOM` (or a `…/#v=LON,LAT,ZOOM` share URL) to pull just the +charts under that viewport. `--zoom N` picks the resolving tile, `--json` +emits machine-readable output, `--no-resolve` skips the draw-call pass, and +`--tui` opens a two-pane explorer (`--kitty` adds inline render thumbnails +and a live chart map that frames the selection). + +### `cells` / `cell` / `features` / `catalog` + +``` +tile57 cells +tile57 cell +tile57 features +tile57 catalog +``` + +The raw S-57 readers (the CLI face of the `tile57_enc_*` C calls). `cells` +prints per-chart metadata for a chart or a whole catalogue — name, scale, +edition, update, issue date, agency, bbox. `cell` summarises one chart. +`features` extracts the named object classes (e.g. `DEPARE,DRGARE`) as a +GeoJSON FeatureCollection. `catalog` decodes an exchange-set catalogue into +its entries (file, title, bbox). + +### `inspect` / `tiledump` + +``` +tile57 inspect [z x y] +tile57 tiledump [--geom CLASS [--coords]] +``` + +`inspect` summarises a baked archive — zoom range and tile counts — and, given +`z x y`, one stored tile (`-o` dumps its raw decompressed bytes). `tiledump` +decodes one raw tile and summarises it: per-layer feature counts by geometry +type plus value histograms of the portrayal properties (`class`, +`symbol_name`, `ls`). `--geom CLASS` switches to per-feature geometry detail +for one class (`--coords` lists the coordinates) — for hunting degenerate +geometry. + +### `objlcount` + +``` +tile57 objlcount [prim] +``` + +Counts features of one S-57 object-class code (optionally one geometric +primitive) — a corpus-scan helper for finding charts that exercise a class. + +## `version` / `help` + +`tile57 version` prints the engine version; `tile57 help` prints the usage +summary. A few extra subcommands (`partdbg-png`, `zoomsizes`, `audit-holes`, +`audit-pairs`) are engine-development diagnostics and may change without +notice. diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 5bbac93..64df424 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -42,11 +42,8 @@ $T cell CELL.000 # summarise an S-57 chart $T version ``` -Run `tile57 help` for usage. The full subcommand list: `bake`, `compose-tile`, -`assets`, `sprite`, `pattern`, `sprite-mln`, `style`, `png`, `pdf`, `ascii`, -`explore`, `cells`, `catalog`, `features`, `inspect`, `cell`, `objlcount`, -`version`, `help`. (S-57's own word for a chart is *cell* — the `cell` / -`cells` commands keep it.) +Run `tile57 help` for usage, or see [**The CLI**](./cli.md) for the full +command reference. :::info Tiles are MLT by default Bakes encode [MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 23240b2..915cb2c 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -115,6 +115,7 @@ hex, so a renderer can switch palette without regenerating tiles. - [**Installation**](./installation.md) — Zig 0.16, submodules, `zig build`. - [**Getting Started**](./getting-started.md) — bake charts and fetch a tile from Zig or C. +- [**The CLI**](./cli.md) — `tile57` bake / render / inspect commands. - [**Zig API**](./zig-api.md) — the `@import("tile57")` surface. - [**C API**](./c-api.md) — the `tile57_*` C ABI (`include/tile57.h`). - [**Architecture**](./architecture.md) — the pipeline and the Zig packages. diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 78c401f..95e4bc1 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -1,7 +1,7 @@ --- id: limitations title: Known Limitations -sidebar_position: 8 +sidebar_position: 9 --- # Known Limitations diff --git a/docs/docs/tile-schema.md b/docs/docs/tile-schema.md index e0755b8..a9ea7e0 100644 --- a/docs/docs/tile-schema.md +++ b/docs/docs/tile-schema.md @@ -1,7 +1,7 @@ --- id: tile-schema title: Tile Schema -sidebar_position: 7 +sidebar_position: 8 --- # Tile Schema diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index 4490f25..3e1cfbd 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -1,7 +1,7 @@ --- id: zig-api title: Zig API -sidebar_position: 4 +sidebar_position: 5 --- # Zig API diff --git a/docs/sidebars.js b/docs/sidebars.js index f52a014..33ed45e 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -6,6 +6,7 @@ const sidebars = { 'intro', 'installation', 'getting-started', + 'cli', 'zig-api', 'c-api', 'architecture', From 12e423945250910914d587fc83837a297b526455 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 19:41:26 -0400 Subject: [PATCH 126/140] fix(scene): bake the masked M_NSYS boundary complement for the meta-bounds view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/render/resolve.zig | 7 ++++++ src/render/surface.zig | 5 ++++ src/s57/s57.zig | 57 ++++++++++++++++++++++++++++++++++++++++++ src/scene/replay.zig | 1 + src/scene/scene.zig | 41 ++++++++++++++++++++++++++++-- 5 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/render/resolve.zig b/src/render/resolve.zig index 355355d..3c992d8 100644 --- a/src/render/resolve.zig +++ b/src/render/resolve.zig @@ -104,6 +104,13 @@ fn collectItems(a: Allocator, block: []const u8, map: *std.StringHashMapUnmanage /// is on (then regardless of category), hidden otherwise. pub fn categoryVisible(cat: ?i64, class: []const u8, symbol_name: ?[]const u8, m: *const Settings) bool { if (std.mem.eql(u8, class, "M_QUAL")) return m.data_quality; + // Meta-object boundaries (mirrors mariner.commonChartFilters): hidden unless + // the meta-bounds inspection view is on; when on, category still applies. + if (!m.show_meta_bounds) { + for ([_][]const u8{ "M_NPUB", "M_NSYS", "M_COVR", "M_CSCL" }) |mc| { + if (std.mem.eql(u8, class, mc)) return false; + } + } var c = cat orelse 1; if (symbol_name) |sn| { if (std.mem.eql(u8, sn, "ISODGR01")) c = if (m.show_isolated_dangers_shallow) 1 else 0; diff --git a/src/render/surface.zig b/src/render/surface.zig index 979ea03..17b0f46 100644 --- a/src/render/surface.zig +++ b/src/render/surface.zig @@ -99,6 +99,11 @@ pub const FeatureMeta = struct { // 0/1 = plain/symbolized boundary or paper/simplified point pass. bnd: i64 = 2, pts: i64 = 2, + // S-52 §8.6.2 suppressed boundary piece: geometry the producer masked as a + // cell-limit edge (MASK/USAG), baked anyway so the meta-bounds inspection + // view can outline meta objects; the standard display never shows it (the + // meta classes are filtered out entirely unless meta-bounds is on). + masked: bool = false, }; /// The render engine Surface vtable. diff --git a/src/s57/s57.zig b/src/s57/s57.zig index f8d7d46..ccd5fc6 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -895,6 +895,63 @@ pub const Cell = struct { return parts.items; } + /// The complement of drawableLineParts: ONLY the edges §8.6.2 masking drops — + /// MASK==1, USAG==3, or coast-coincident on a non-coast-definer area. These are + /// the cell-limit stretches of a boundary; the meta-bounds inspection view bakes + /// them (tagged) so a meta object can be outlined even where its whole boundary + /// is the cell limit. Kept edges are chained into continuous parts like the + /// drawable walk; a drawn (or degenerate) edge breaks the chain. + pub fn maskedLineParts(self: Cell, a: Allocator, f: Feature) ![][]LonLat { + const mask_coast = f.prim == 3 and !isCoastDefiner(f.objl); + var parts = std.ArrayList([]LonLat).empty; + var cur = std.ArrayList(LonLat).empty; + var broken = false; + for (f.refs) |ref| { + if (ref.name.rcnm != RCNM_VE) continue; + const masked = ref.mask == 1 or ref.usag == 3 or (mask_coast and self.coast_edges.contains(ref.name.rcid)); + if (!masked) { + if (cur.items.len > 0) { + try parts.append(a, cur.items); + cur = std.ArrayList(LonLat).empty; + } + broken = true; // a drawn edge separates two masked stretches + continue; + } + const edge = try self.edgeCoordsRaw(a, ref.name.rcid, ref.ornt); + if (edge.len == 0) { + if (cur.items.len > 0) { + try parts.append(a, cur.items); + cur = std.ArrayList(LonLat).empty; + } + broken = true; + continue; + } + if (cur.items.len == 0 or broken) { + if (cur.items.len > 0) { + try parts.append(a, cur.items); + cur = std.ArrayList(LonLat).empty; + } + try cur.appendSlice(a, edge); + broken = false; + continue; + } + const tail = cur.items[cur.items.len - 1]; + const last = edge[edge.len - 1]; + if (tail.lon_e7 == edge[0].lon_e7 and tail.lat_e7 == edge[0].lat_e7) { + try cur.appendSlice(a, edge[1..]); + } else if (tail.lon_e7 == last.lon_e7 and tail.lat_e7 == last.lat_e7) { + std.mem.reverse(LonLat, edge); + try cur.appendSlice(a, edge[1..]); + } else { + try parts.append(a, cur.items); + cur = std.ArrayList(LonLat).empty; + try cur.appendSlice(a, edge); + } + } + if (cur.items.len > 0) try parts.append(a, cur.items); + return parts.items; + } + /// True when a feature's DRAWN boundary differs from its full geometry — so the /// caller must take the drawableLineParts subset instead of stroking the full /// ring. Either it carries explicit MASK/USAG edge flags, OR derived coast masking diff --git a/src/scene/replay.zig b/src/scene/replay.zig index a8393a0..4b7c376 100644 --- a/src/scene/replay.zig +++ b/src/scene/replay.zig @@ -68,6 +68,7 @@ fn metaFromProps(props: []const mvt.Prop) rs.FeatureMeta { .band = @intCast(std.math.clamp(propInt(props, "band", 0), 0, 255)), .bnd = propInt(props, "bnd", 2), .pts = propInt(props, "pts", 2), + .masked = propInt(props, "masked", 0) != 0, .date_start = propStr(props, "date_start"), .date_end = propStr(props, "date_end"), }; diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 48723a6..5e90846 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -506,6 +506,7 @@ pub const TileSurface = struct { .s57 = meta.s57_json, .cell = meta.cell_name, .band = meta.band, + .masked = meta.masked, .date_start = meta.date_start, .date_end = meta.date_end, .bnd = meta.bnd, @@ -770,6 +771,7 @@ const Meta = struct { // (unknown cell name, or pick attributes disabled). From s57.Cell.name. cell: []const u8 = "", band: u8 = 0, // NOAA band rank (0 finest … 5 coarsest) + masked: bool = false, // S-52 §8.6.2 suppressed boundary piece (meta-bounds view only) date_start: []const u8 = "", date_end: []const u8 = "", // S-52 boundary symbolization (§8.6.1) and point-symbol style (§11.2.2) tags @@ -1001,6 +1003,9 @@ fn appendMeta(a: Allocator, props: *std.ArrayList(mvt.Prop), m: Meta) !void { // unvarying feature's tile footprint unchanged. if (m.bnd != 2) try props.append(a, .{ .key = "bnd", .value = .{ .int = m.bnd } }); if (m.pts != 2) try props.append(a, .{ .key = "pts", .value = .{ .int = m.pts } }); + // §8.6.2-suppressed boundary piece — present only on the meta-bounds extras, + // so every normally-drawn feature's tile footprint is unchanged. + if (m.masked) try props.append(a, .{ .key = "masked", .value = .{ .int = 1 } }); // Date-dependent display (S-52 §10.4.1.1): recurring iff a "--" month-day prefix, // stripped so the client compares MMDD (recurring) / YYYYMMDD (fixed). if (m.date_start.len > 0 or m.date_end.len > 0) { @@ -1709,11 +1714,11 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize // producer flags the M_NSYS edges that coincide with the cell limit (MASK/USAG), // so the system boundary strokes only where a REAL division runs — not along // every cell junction of a uniform IALA region. The fill is unaffected. - const geo_parts = if (cell.needsDrawableBoundary(f)) + const masked_boundary = cell.needsDrawableBoundary(f); + const geo_parts = if (masked_boundary) cell.drawableLineParts(a, f) catch return else featureParts(a, cell, geo, fi, f) catch return; - if (geo_parts.len == 0) return; const fmeta = rs.FeatureMeta{ .draw_prio = 12, @@ -1728,6 +1733,11 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize // IALA A/B system boundary (MARSYS51). Both stroke in CHGRD. const boundary: []const u8 = if (f.attr(s57.ATTR_ORIENT) != null) "NAVARE51" else "MARSYS51"; + // The masked complement (cell-limit stretches) rides along tagged, for the + // meta-bounds view; the standard display filters the class out anyway. + if (masked_boundary) try emitMaskedBoundary(a, cell, f, fmeta, z, x, y, tb, box, opts, surf); + if (geo_parts.len == 0) return; + // Best-available: cut the covered stretches before tessellating/stroking. const nav_parts = if (opts.cover_clip) |cc| clipGeoPartsOutsideCover(a, geo_parts, cc, z, x, y) else geo_parts; @@ -1752,6 +1762,33 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize try surf.endFeature(); } +/// The §8.6.2-masked complement of a boundary, baked as a `masked`-tagged plain +/// dashed line so the meta-bounds inspection view can OUTLINE the meta object — +/// a whole-cell M_NSYS has its entire boundary flagged as the cell limit, and +/// masking alone leaves the toggle nothing to show. The standard display never +/// renders it (the meta classes are filtered out unless meta-bounds is on), and +/// the tag keeps the masked pieces letter-free in the styled view too. +fn emitMaskedBoundary(a: Allocator, cell: s57.Cell, f: s57.Feature, base: rs.FeatureMeta, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { + const masked_parts = cell.maskedLineParts(a, f) catch return; + if (masked_parts.len == 0) return; + var fmeta = base; + fmeta.masked = true; + const parts_cov = if (opts.cover_clip) |cc| clipGeoPartsOutsideCover(a, masked_parts, cc, z, x, y) else masked_parts; + try surf.beginFeature(&fmeta); + for (parts_cov) |gp| { + if (gp.len < 2) continue; + if (!overlaps(geomBounds(gp), tb)) continue; + const proj = try a.alloc(mvt.Point, gp.len); + for (gp, 0..) |pt, i| proj[i] = tile.project(pt.lon(), pt.lat(), z, x, y, tile.EXTENT); + const sub = try tile.clipSimplifyLine(a, proj, box); + if (sub.len == 0) continue; + const parts = try a.alloc([]const mvt.Point, sub.len); + for (sub, 0..) |s, i| parts[i] = s; + try surf.strokeLine("CHGRD", 1, .dashed, parts, null); + } + try surf.endFeature(); +} + /// Native S-52 fallback for NEWOBJ (objl 163). NEWOBJ features map to S-101 classes /// (e.g. VirtualAISAidToNavigation) whose rule may not portray the encoded geometry /// (wrong primitive, unofficial stub, …); when portrayal yields nothing or errors, From 31836558f789b573e72497ac489602528042720f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 20:05:07 -0400 Subject: [PATCH 127/140] =?UTF-8?q?feat(api)!:=20every=20symbol=20in=20a?= =?UTF-8?q?=20family=20=E2=80=94=20tile57=5Fchart=20handle,=20chart=20voca?= =?UTF-8?q?bulary;=20v0.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- CLAUDE.md | 55 +++++++----- build.zig.zon | 2 +- include/tile57.h | 171 +++++++++++++++++++------------------- src/capi.zig | 66 +++++++-------- src/chart.zig | 78 ++++++++--------- src/compose/compose.zig | 17 ++-- src/coverage/coverage.zig | 14 ++-- src/tile57.zig | 72 ++++++++-------- src/tiles/tile.zig | 2 +- tools/bake.zig | 20 ++--- tools/cells.zig | 2 +- tools/common.zig | 27 +++--- tools/compose_tile.zig | 2 +- tools/main.zig | 4 +- 14 files changed, 278 insertions(+), 254 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bfdefd1..9064e36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,39 +6,54 @@ The public surface (include/tile57.h, src/tile57.zig, bindings/go) follows one style. Hold every new or renamed symbol against it. **The pipeline is the organizing principle.** Everything is bake, then compose -(or bake, then render): source cells bake once to per-cell archives; every +(or bake, then render): source charts bake once to per-chart archives; every output is produced from baked archives. Sections, in order: Version, Errors, Bake, Render (the chart), Compose, Style, Util. The header, the Zig public root, and the docs all follow that same section order. -**Three kinds of prefix, and only three.** +**The unit is a chart, never "cell" in prose.** An ENC cell IS one chart at one +compilation scale. "Cell" survives only as spec-facing vocabulary: one +first-use definition ("a *cell*, in the spec's vocabulary"), the `cell` tile +property and other wire-format names, `` CLI placeholders and the +`cell`/`cells` CLI commands, and the s57/geometry module internals (the domain +that parses cells). -1. *The chart is the bare namespace.* `tile57` is one open baked archive (the - sqlite3 pattern: the library's name is its central handle), so functions on - it carry no extra prefix: `tile57_open` / `tile57_close` / - `tile57_get_info` / `tile57_tile` / `tile57_png` / `tile57_query`. This is - why there is no `render_` prefix — the header's Render section IS the chart - handle, and its outputs are named bare. -2. *Every other handle namespaces under its noun* and mirrors the chart's - shape: `tile57_compose_open` / `_close` / `_tile` / `_png` / `_query` on - `tile57_compose`. -3. *Handle-free families lead with their section token, then what the call +**Everything is in a family, and the family token always leads.** The prefix +names what a call runs against; never bury the family token mid-name. + +1. *Handles namespace under their noun and mirror each other.* `tile57_chart` + (one open baked archive) and `tile57_compose` (the runtime compositor) are + peers: `tile57_chart_open` / `_close` / `_get_info` / `_tile` / `_png` / + `_query` ↔ `tile57_compose_open` / `_close` / `_get_meta` / `_tile` / + `_png` / `_query`. A noun prefix means the first parameter is that handle — + the C spelling of a method. +2. *Handle-free families lead with their section token, then what the call acts on or yields* — the token's part of speech follows the section, so the name reads naturally after it. `tile57_bake_*` is a verb family - (`bake_cell_bytes`, `bake_tree` — "bake the X"); `tile57_enc_*` is a domain + (`bake_chart_bytes`, `bake_tree` — "bake the X"); `tile57_enc_*` is a domain family (the raw S-57 source readers — ENC is the ONE vocabulary for raw source data, never s57_/source_/scan_ variants); `tile57_style_*` is a product family (`style_build` / `style_diff` / `style_template`, never - `build_style` — "the style's build/diff/template"). Never bury the family - token mid-name. + `build_style` — "the style's build/diff/template"). Small POD/domain noun + families follow the same shape: `pmtiles_metadata`, `status_str`, + `mariner_defaults`, `colortables_default`, `assets_free`. +3. *Bare `tile57_` is the library itself* — process/library-level plumbing + only: `tile57_version`, `tile57_free`, `tile57_warmup`. No handle method is + ever bare. + +In Zig the namespace is the family, with the same two consequences: a member +never repeats its namespace (`ComposeSource.openFiles`, never +`compose.openComposeSourceFiles`; `compose.tile`, never `compose.composeTile`), +and the two handles mirror (`Chart.openPmtilesPath` ↔ `ComposeSource.open` / +`.openFiles`). **Outputs are named by what you get, never by how it's produced or served.** `tile`, `png`, `pdf`, `canvas`, `surface`, `query` — not serve/render/build/ generate. The chart and the compositor offer the SAME output set, so the names -mirror exactly: `tile57_png` ↔ `tile57_compose_png`, `tile57_tile` ↔ -`tile57_compose_tile`, and so on. The compositor is "many charts in, one chart -out"; keep that symmetry when adding an output — it goes on both handles or -has a stated reason not to. +mirror exactly: `tile57_chart_png` ↔ `tile57_compose_png`, `tile57_chart_tile` +↔ `tile57_compose_tile`, and so on. The compositor is "many charts in, one +chart out"; keep that symmetry when adding an output — it goes on both handles +or has a stated reason not to. **The status model is universal.** Every call that can fail returns `tile57_status` (never a bare int, count, or bool) and takes an optional @@ -67,7 +82,7 @@ still surfaces it under the name it belongs to (`tile57.compose.renderView`). Public surface trumps internal layering. **Go bindings track shape, not spelling.** Idiomatic Go names over the same -structure: `Open`/`OpenBytes` on the archive, package-level `Cells`/`Features`/ +structure: `Open`/`OpenBytes` on the archive, package-level `Charts`/`Features`/ `FeaturesBytes`/`CatalogEntries` for the enc readers, `ComposeSource.Tile` mirroring `tile57_compose_tile`, errors as wrapped sentinels (`errors.Is(err, tile57.ErrParse)`). diff --git a/build.zig.zon b/build.zig.zon index 65877c8..317aafa 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,7 +9,7 @@ .name = .tile57, // This is a [Semantic Version](https://semver.org/). // In a future version of Zig it will be used for package deduplication. - .version = "0.2.0", + .version = "0.3.0", // Together with name, this represents a globally unique package // identifier. This field is generated by the Zig toolchain when the // package is first created, and then *never changes*. This allows diff --git a/include/tile57.h b/include/tile57.h index bc90287..f5657b7 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -1,30 +1,31 @@ /* tile57.h — public C ABI for libtile57. * - * libtile57 is an embeddable nautical-chart engine. It reads IHO S-57 ENC cells, - * bakes them to per-cell PMTiles archives, serves composed vector tiles from + * libtile57 is an embeddable nautical-chart engine. It reads IHO S-57 ENC + * charts (each one a *cell*, in the spec's vocabulary), bakes them to per-chart + * PMTiles archives, serves composed vector tiles from * those archives on demand, renders finished charts to pixels / PDF / a callback * surface, and generates the matching S-52 MapLibre style + portrayal assets. * - * Everything is BAKE, THEN COMPOSE (or bake, then render): source cells bake - * once to per-cell archives; every output — a tile, a PNG, a PDF, a callback + * Everything is BAKE, THEN COMPOSE (or bake, then render): source charts bake + * once to per-chart archives; every output — a tile, a PNG, a PDF, a callback * stream — is produced from baked archives. The header is organised the same * way: * - * 3. BAKE ENC source data in, per-cell PMTiles out. Each cell bakes to its - * own archive at its own compilation scale, with its M_COVR + * 3. BAKE ENC source data in, per-chart PMTiles out. Each chart bakes to + * its own archive at its own compilation scale, with its M_COVR * coverage + scale embedded in the archive metadata. This is the * import step; the section also carries the raw-source readers - * (cell inventory, feature extraction, exchange-set catalogue). + * (chart inventory, feature extraction, exchange-set catalogue). * - * 4. RENDER a `tile57` chart handle opens ONE baked archive (mmap'd from a + * 4. RENDER a `tile57_chart` handle opens ONE baked archive (mmap'd from a * path, or copied from bytes) and answers for it with NO * composition: metadata (info / scamin / coverage), its stored - * tiles verbatim (tile57_tile — the primitive for writing your + * tiles verbatim (tile57_chart_tile — the primitive for writing your * own compositor), the cursor pick (query), and view outputs * (png / pdf / canvas / surface). * * 5. COMPOSE a `tile57_compose` handle stitches MANY open charts through the - * cell-ownership partition and offers the SAME output set, + * ownership partition and offers the SAME output set, * composed: tile (what a live tile server hands its HTTP layer), * png, pdf, canvas, surface, query. * @@ -38,7 +39,7 @@ * an optional caller-owned tile57_error* it fills on failure — see section 2. * Out-parameters are always defined on return: the result on TILE57_OK, * NULL/0 otherwise. "Nothing produced" is NOT a failure — a call that finds - * nothing (a cell that bakes no tiles, a tile nobody owns, a chart with no + * nothing (a chart that bakes no tiles, a tile nobody owns, a chart with no * SCAMIN values) returns TILE57_OK with a NULL/zero out. * * Lifetime: a handle must OUTLIVE every borrower still holding it: a compositor @@ -56,7 +57,7 @@ * * Memory: calls that return bytes allocate *out; release it with * tile57_free(ptr) — buffers are length-prefixed at allocation, so the - * pointer is all it needs. All pointers are POD across the seam. + * pointer is all it needs. All pointers are POD across the ABI. * * The S-101 portrayal self-test / bring-up entry points live in a separate * header, tile57_diag.h (developer tooling, not part of the embedding API). @@ -76,9 +77,9 @@ extern "C" { * 1. Version * ======================================================================== */ -/* Library version. tile57_version() returns the string form, e.g. "0.2.0". */ +/* Library version. tile57_version() returns the string form, e.g. "0.3.0". */ #define TILE57_VERSION_MAJOR 0 -#define TILE57_VERSION_MINOR 2 +#define TILE57_VERSION_MINOR 3 #define TILE57_VERSION_PATCH 0 const char *tile57_version(void); @@ -101,7 +102,7 @@ typedef enum { TILE57_OK = 0, /* success */ TILE57_ERR_BADARG, /* a NULL or out-of-range argument */ TILE57_ERR_IO, /* a file/directory could not be opened, read, or written */ - TILE57_ERR_PARSE, /* malformed input (S-57 cell, PMTiles, partition, JSON) */ + TILE57_ERR_PARSE, /* malformed input (S-57 chart, PMTiles, partition, JSON) */ TILE57_ERR_NOMEM, /* an allocation failed */ TILE57_ERR_UNSUPPORTED, /* valid but unusable input (e.g. no coverage-carrying chart) */ TILE57_ERR_RENDER, /* tile generation or rendering failed */ @@ -120,55 +121,55 @@ typedef struct { /* ======================================================================== * * 3. Bake * - * ENC source data in, per-cell PMTiles archives out. The composite model bakes - * each cell to its own archive over its NATIVE band zoom range at its own + * ENC source data in, per-chart PMTiles archives out. The composite model bakes + * each chart to its own archive over its NATIVE band zoom range at its own * compilation scale; the compositor (section 5) stitches them on demand, so - * baking never composes and re-importing one cell never re-bakes the rest. + * baking never composes and re-importing one chart never re-bakes the rest. * - * A per-cell archive's metadata embeds the cell's M_COVR coverage, compilation + * A per-chart archive's metadata embeds the chart's M_COVR coverage, compilation * scale, and identity — everything a chart handle or compositor needs to place * it, with no .000 re-parse (read it back with tile57_pmtiles_metadata). * * Every emitted vector-tile feature carries the per-feature pick/inspector * properties used by the S-52 §10.8 pick report: `class` (object-class - * acronym), `cell` (source cell name), and `s57` (a JSON object of the - * feature's full S-57 attribute set, acronym -> value). tile57_query and a + * acronym), `cell` (source chart name), and `s57` (a JSON object of the + * feature's full S-57 attribute set, acronym -> value). tile57_chart_query and a * host inspector read these back. * * The section also carries the raw-source readers a host's import UI needs: - * the cell inventory of a path, GeoJSON feature extraction, and the + * the chart inventory of a path, GeoJSON feature extraction, and the * exchange-set catalogue decode. * ======================================================================== */ /* ---- reading ENC source data --------------------------------------------- */ -/* The per-cell metadata of the S-57 data at `path` — ONE cell (a .000 file, +/* The per-chart metadata of the S-57 data at `path` — ONE chart (a .000 file, * with its .001.. update chain auto-read from the same directory) or a whole - * ENC_ROOT directory — as a JSON array, one object per cell: + * ENC_ROOT directory — as a JSON array, one object per chart: * [{"name":"US5MD1MC","scale":12000,"edition":"13","update":"3", * "issueDate":"20240105","agency":550,"bbox":[west,south,east,north]}, ...] * `name` is the DSNM stem; `scale` is DSPM CSCL; edition/update/issueDate/ * agency are DSID EDTN/UPDN/ISDT/AGEN after the update chain is applied; * `bbox` is omitted when none parses. For a host's chart-database scan. * TILE57_OK with the JSON in *out / *out_len (free with tile57_free). */ -tile57_status tile57_enc_cells(const char *path, uint8_t **out, size_t *out_len, +tile57_status tile57_enc_charts(const char *path, uint8_t **out, size_t *out_len, tile57_error *err); -/* The features of the S-57 data at `path` (one cell or a whole ENC_ROOT) for +/* The features of the S-57 data at `path` (one chart or a whole ENC_ROOT) for * the given object classes (comma-separated acronyms, e.g. "DEPARE,DRGARE") as * a GeoJSON FeatureCollection: geometry in lon/lat (Polygon rings * largest-first, MultiPoint with depths for soundings, LineString/Point as * encoded), properties = {"class":"DEPARE", ...the feature's full S-57 * acronym->value attribute map}. Parsed without portrayal; a whole-ENC_ROOT - * extraction walks every cell — the caller owns that cost. TILE57_OK with the + * extraction walks every chart — the caller owns that cost. TILE57_OK with the * JSON in *out / *out_len (free with tile57_free); NULL/0 when nothing * matched. */ tile57_status tile57_enc_features(const char *path, const char *classes, uint8_t **out, size_t *out_len, tile57_error *err); -/* tile57_enc_features over in-memory base-cell bytes (a .000 read from a zip - * member, say) instead of a path. No update chain is applied. */ +/* tile57_enc_features over in-memory base .000 bytes (read from a zip member, + * say) instead of a path. No update chain is applied. */ tile57_status tile57_enc_features_bytes(const uint8_t *base, size_t len, const char *classes, uint8_t **out, size_t *out_len, @@ -187,44 +188,44 @@ tile57_status tile57_enc_catalog(const uint8_t *catalog_031, size_t len, uint8_t **out, size_t *out_len, tile57_error *err); -/* ---- baking cells to per-cell archives ------------------------------------ */ +/* ---- baking charts to per-chart archives ---------------------------------- */ -/* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over +/* Bake ONE chart (+ its .001.. updates, read from disk) to PMTiles bytes over * its NATIVE band zoom range and nothing else. Returned in *out / *out_len - * (free with tile57_free); NULL/0 when the cell produced no tiles. For a host - * persisting a per-cell tile cache to disk — open the archives as charts + * (free with tile57_free); NULL/0 when the chart produced no tiles. For a host + * persisting a per-chart tile cache to disk — open the archives as charts * (section 4) and compose them (section 5). */ -tile57_status tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len, +tile57_status tile57_bake_chart_bytes(const char *path, uint8_t **out, size_t *out_len, tile57_error *err); -/* Bake `n` cells (each a .000 path; updates auto-read) to per-cell PMTiles +/* Bake `n` charts (each a .000 path; updates auto-read) to per-chart PMTiles * bytes IN PARALLEL across up to `workers` threads. The engine returns BYTES * only — it never writes an output directory; the host writes each archive - * into the cache it manages. out_bytes[i] / out_lens[i] receive cell i's - * archive (free each with tile57_free) or NULL/0 when that cell produced + * into the cache it manages. out_bytes[i] / out_lens[i] receive chart i's + * archive (free each with tile57_free) or NULL/0 when that chart produced * nothing; both arrays are caller-allocated, length n. *out_baked (NULL to - * ignore) receives the number of cells that produced bytes. `workers` is a - * MEMORY bound — each concurrent bake holds a whole cell's + * ignore) receives the number of charts that produced bytes. `workers` is a + * MEMORY bound — each concurrent bake holds a whole chart's * parse+portray+raster working set, so pass a small count (not a core count). * Warms up the process globals internally, so concurrent baking is race-free. */ -tile57_status tile57_bake_cells(const char *const *paths, size_t n, uint32_t workers, +tile57_status tile57_bake_charts(const char *const *paths, size_t n, uint32_t workers, uint8_t **out_bytes, size_t *out_lens, size_t *out_baked, tile57_error *err); -/* Progress callback for tile57_bake_tree: fires after each cell with (done, +/* Progress callback for tile57_bake_tree: fires after each chart with (done, * total). May be called CONCURRENTLY from worker threads — make it * thread-safe. */ typedef void (*tile57_bake_progress)(void *ctx, uint32_t done, uint32_t total); -/* Walk `in_dir` for S-57 base cells (*.000) and bake each IN PARALLEL to the +/* Walk `in_dir` for S-57 base charts (*.000) and bake each IN PARALLEL to the * SAME relative path under `out_dir` with a .pmtiles extension * (in_dir/d1/US4CT1AA.000 -> out_dir/d1/US4CT1AA.pmtiles), plus an .sha * content-hash sidecar; output subdirs are created as needed. The engine * writes and frees each archive as it goes, so the host never holds N archives - * in memory (peak ~ workers). INCREMENTAL: a cell whose mirrored archive is + * in memory (peak ~ workers). INCREMENTAL: a chart whose mirrored archive is * already at least as new as its whole input (.000 + update chain) is skipped, * so a re-run over an unchanged tree bakes nothing — *out_baked (NULL to - * ignore) counts the cells baked THIS run, and 0 over a warm cache is success, + * ignore) counts the charts baked THIS run, and 0 over a warm cache is success, * not failure. `in_dir` is the source ENC data; `out_dir` is the caller's OWN * cache — it owns the location + layout, so distinct library consumers each * keep their own chart library without clashing. `workers` is a MEMORY bound — @@ -235,7 +236,7 @@ tile57_status tile57_bake_tree(const char *in_dir, const char *out_dir, uint32_t /* Read a PMTiles archive's metadata JSON blob (decompressed) into *out / * *out_len (free with tile57_free); NULL/0 when the archive carries none. A - * per-cell bake embeds the cell's M_COVR coverage + cscl + date/name under a + * per-chart bake embeds the chart's M_COVR coverage + cscl + date/name under a * "coverage" key. */ tile57_status tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, uint8_t **out, size_t *out_len, @@ -243,22 +244,22 @@ tile57_status tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, /* Bake the ownership-partition DEBUG tiles from an ENC_ROOT (on-disk path) * into a single PMTiles at out_path: the composited ownership faces (which - * cell renders which ground at each band), one polygon per owning cell tagged + * chart renders which ground at each band), one polygon per owning chart tagged * with the properties cell/cscl/band/tier/oi/color, and NO portrayed chart * content — for building a partition-debug UI. band < 0 emits the band * GOVERNING each zoom (the natural view); 0..5 (berthing..overview) emits one * band's own map at every zoom. minzoom/maxzoom bound the tiles (harbor-level * detail needs maxzoom >= 13; coarser bands are much cheaper). TILE57_OK with - * *out_cell_count (NULL to ignore) = the covered-cell count; 0 with no file + * *out_chart_count (NULL to ignore) = the covered-chart count; 0 with no file * written when nothing is covered. */ tile57_status 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, tile57_error *err); + uint32_t *out_chart_count, tile57_error *err); /* ======================================================================== * * 4. Render * - * A `tile57` is a chart: ONE baked PMTiles archive, opened for metadata and + * A `tile57_chart` is ONE baked PMTiles archive, opened for metadata and * output — with NO composition (the compositor, section 5, offers the same * outputs across many charts). Open it from a path (mmap'd — a whole chart * library can be open without being resident) or from bytes (copied). It @@ -271,24 +272,24 @@ tile57_status tile57_bake_partition_debug(const char *enc_root, const char *out_ * * Rendering REPLAYS baked tiles through the native S-52 pixel path: one whole * scene across every covering tile, labels decluttered over the full canvas - * (no tile seams), symbols replayed as vectors from the catalogue. The + * (no tile boundaries), symbols replayed as vectors from the catalogue. The * mariner's live-swappable settings — colour scheme, safety contour * danger/sounding swaps, category/SCAMIN/text gates, size scale — re-evaluate * at render time; the rest of the portrayal context was fixed at bake time. * ======================================================================== */ /* Opaque chart handle: one open baked archive. */ -typedef struct tile57 tile57; +typedef struct tile57_chart tile57_chart; /* Open a baked PMTiles archive from a file path, mmap'd (never fully * resident). The file must stay in place while the chart is open. TILE57_OK - * with *out set (close with tile57_close). */ -tile57_status tile57_open(const char *path, tile57 **out, tile57_error *err); + * with *out set (close with tile57_chart_close). */ +tile57_status tile57_chart_open(const char *path, tile57_chart **out, tile57_error *err); /* Open a baked PMTiles archive from in-memory bytes (e.g. straight from - * tile57_bake_cell_bytes, before any file exists). Bytes are copied. */ -tile57_status tile57_open_bytes(const uint8_t *pmtiles, size_t len, - tile57 **out, tile57_error *err); + * tile57_bake_chart_bytes, before any file exists). Bytes are copied. */ +tile57_status tile57_chart_open_bytes(const uint8_t *pmtiles, size_t len, + tile57_chart **out, tile57_error *err); /* Vector-tile encodings an archive can store (reported in * tile57_info.tile_type; the engine bakes MLT). */ @@ -311,7 +312,7 @@ typedef struct { uint8_t tile_type; /* tile57_tile_type */ int32_t native_scale; } tile57_info; -void tile57_get_info(tile57 *chart, tile57_info *out); +void tile57_chart_get_info(tile57_chart *chart, tile57_info *out); /* The distinct SCAMIN denominators present in the chart (read from the archive * metadata), ascending — the host publishes these so its style builds one @@ -319,12 +320,12 @@ void tile57_get_info(tile57 *chart, tile57_info *out); * min-display-scale at zero per-zoom cost). TILE57_OK with *out pointing at * *out_len int32 values, or NULL/0 when the chart has none. Free *out with * tile57_free. */ -tile57_status tile57_scamin(tile57 *chart, int32_t **out, size_t *out_len, +tile57_status tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len, tile57_error *err); /* The chart's M_COVR(CATCOV=1) data-coverage polygons, from the coverage the * bake embedded in the archive metadata — the real coverage a host reports so - * a quilt fills gaps to coarser cells (vs. the bounding box). ring() is called + * a quilt fills gaps to coarser charts (vs. the bounding box). ring() is called * once per polygon with its exterior ring as `npts` interleaved lon,lat * doubles (valid only during the call). A chart whose archive embeds no * coverage (a composed/foreign archive) is TILE57_OK with no calls. */ @@ -332,7 +333,7 @@ typedef struct { void *ctx; void (*ring)(void *ctx, const double *lonlat, size_t npts); } tile57_coverage_cb; -tile57_status tile57_coverage(tile57 *chart, const tile57_coverage_cb *cb, +tile57_status tile57_chart_coverage(tile57_chart *chart, const tile57_coverage_cb *cb, tile57_error *err); /* The chart's own stored tile at (z,x,y), decompressed (MLT or MVT per @@ -340,24 +341,24 @@ tile57_status tile57_coverage(tile57 *chart, const tile57_coverage_cb *cb, * an embedder writing its own compositor. TILE57_OK with the bytes in *out / * *out_len (free with tile57_free); NULL/0 when the archive has no tile * there. */ -tile57_status tile57_tile(tile57 *chart, uint8_t z, uint32_t x, uint32_t y, +tile57_status tile57_chart_tile(tile57_chart *chart, uint8_t z, uint32_t x, uint32_t y, uint8_t **out, size_t *out_len, tile57_error *err); /* Cursor object-query (S-52 §10.8 pick): feature() is invoked once per feature * the point (lon,lat) falls in — area point-in-polygon, line/point within a * small radius — with the S-57 object-class acronym, the attribute JSON - * (acronym -> value), and the source cell name. Pointers are valid only for + * (acronym -> value), and the source chart name. Pointers are valid only for * the duration of the call. */ typedef struct { void *ctx; void (*feature)(void *ctx, const char *cls, size_t cls_len, const char *s57, size_t s57_len, - const char *cell, size_t cell_len); + const char *chart, size_t chart_len); } tile57_query_cb; /* `zoom` is the current view's web-mercator zoom: the query reads the tile at * that zoom, so it reports the features actually DISPLAYED there * (SCAMIN-bucketed) and the pick tolerance tracks on-screen distance. */ -tile57_status tile57_query(tile57 *chart, double lon, double lat, double zoom, +tile57_status tile57_chart_query(tile57_chart *chart, double lon, double lat, double zoom, const tile57_query_cb *cb, tile57_error *err); /* ---- mariner settings ------------------------------------------------------ @@ -416,7 +417,7 @@ void tile57_mariner_defaults(tile57_mariner *m); * TILE57_ERR_BADARG. */ /* The view as a PNG, in *out / *out_len (free with tile57_free). */ -tile57_status tile57_png(tile57 *chart, double lon, double lat, double zoom, +tile57_status tile57_chart_png(tile57_chart *chart, double lon, double lat, double zoom, uint32_t width, uint32_t height, const tile57_mariner *m, uint8_t **out, size_t *out_len, tile57_error *err); @@ -424,7 +425,7 @@ tile57_status tile57_png(tile57 *chart, double lon, double lat, double zoom, /* The view as a deterministic single-page vector PDF (1 px = 1 pt, 72 dpi; * vector fills + native strokes + glyph-outline text), in *out / *out_len * (free with tile57_free). */ -tile57_status tile57_pdf(tile57 *chart, double lon, double lat, double zoom, +tile57_status tile57_chart_pdf(tile57_chart *chart, double lon, double lat, double zoom, uint32_t width, uint32_t height, const tile57_mariner *m, uint8_t **out, size_t *out_len, tile57_error *err); @@ -459,7 +460,7 @@ typedef struct { void (*draw_glyphs) (void *ctx, const tile57_rings *outline, tile57_rgba color, tile57_rgba halo, float halo_px); } tile57_canvas_cb; -tile57_status tile57_canvas(tile57 *chart, double lon, double lat, double zoom, +tile57_status tile57_chart_canvas(tile57_chart *chart, double lon, double lat, double zoom, uint32_t width, uint32_t height, const tile57_mariner *m, const tile57_canvas_cb *canvas, tile57_error *err); @@ -526,20 +527,20 @@ typedef struct { void (*draw_text_str)(void *ctx, const tile57_feature *f, tile57_world_point anchor, float ox_px, float oy_px, const char *text, size_t text_len, float size_px, tile57_rgba color, tile57_rgba halo); } tile57_surface_cb; -tile57_status tile57_surface(tile57 *chart, double lon, double lat, double zoom, +tile57_status tile57_chart_surface(tile57_chart *chart, double lon, double lat, double zoom, uint32_t width, uint32_t height, const tile57_mariner *m, const tile57_surface_cb *surface, tile57_error *err); /* Release a chart and all cached tiles. Must not be called while any borrower * (a compositor, a renderer thread) may still read from it. */ -void tile57_close(tile57 *chart); +void tile57_chart_close(tile57_chart *chart); /* ======================================================================== * * 5. Compose * * The runtime compositor: MANY open charts in, one seamless chart out. It - * builds (or loads) the cell-ownership partition over the charts' embedded + * builds (or loads) the ownership partition over the charts' embedded * coverage, then offers the SAME output set as a single chart, composed: * any (z, x, y) tile on demand for the cost of a classify plus one decompress * or one decode/clip (what a live tile server hands its HTTP layer), plus the @@ -554,23 +555,23 @@ void tile57_close(tile57 *chart); /* Opaque runtime-compositor handle. */ typedef struct tile57_compose tile57_compose; -/* Coverage/zoom summary filled by tile57_compose_meta_get. */ +/* Coverage/zoom summary filled by tile57_compose_get_meta. */ typedef struct { uint8_t min_zoom; uint8_t max_zoom; /* deepest zoom served (native windows + one fill-up overscale zoom) */ - uint32_t cells; /* coverage-carrying charts held */ + uint32_t charts; /* coverage-carrying charts held */ double west, south, east, north; /* union coverage bounds, degrees */ } tile57_compose_meta; -/* Open a compositor over `n` open charts (each a per-cell archive from the - * bake, opened with tile57_open). Charts whose archives embed no coverage are +/* Open a compositor over `n` open charts (each a per-chart archive from the + * bake, opened with tile57_chart_open). Charts whose archives embed no coverage are * skipped — they can own no ground; if none carries coverage the open is * TILE57_ERR_UNSUPPORTED. `partition_path` (NULL to skip) names a partition * sidecar — written by tile57_compose_save_partition (the `tile57 bake` CLI * emits one as partition.tpart) — to load and skip the build; a missing/stale * one falls back to building. TILE57_OK with *out set (close with * tile57_compose_close — BEFORE closing the charts). */ -tile57_status tile57_compose_open(tile57 *const *charts, size_t n, +tile57_status tile57_compose_open(tile57_chart *const *charts, size_t n, const char *partition_path, tile57_compose **out, tile57_error *err); @@ -578,17 +579,17 @@ tile57_status tile57_compose_open(tile57 *const *charts, size_t n, * *out_len (free with tile57_free) — the HTTP layer gzips on the wire. NULL/0 * out with TILE57_OK means no bytes for this tile; *out_owned (NULL to ignore) * then distinguishes the two empties: - * owned = false: no cell owns this ground — true empty ocean, safe to cache. - * owned = true: a cell owns this ground but produced nothing — transient - * while its per-cell bake is still running; suspect once + * owned = false: no chart owns this ground — true empty ocean, safe to cache. + * owned = true: a chart owns this ground but produced nothing — transient + * while its per-chart bake is still running; suspect once * bakes are done. */ tile57_status tile57_compose_tile(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, uint8_t **out, size_t *out_len, bool *out_owned, tile57_error *err); -/* The composed view outputs — tile57_png / tile57_pdf / tile57_canvas / - * tile57_surface across the WHOLE composed set: every covering tile is - * composed on demand (seams stitched through the ownership partition) and +/* The composed view outputs — tile57_chart_png / tile57_chart_pdf / tile57_chart_canvas / + * tile57_chart_surface across the WHOLE composed set: every covering tile is + * composed on demand (stitched through the ownership partition) and * replayed through the native S-52 pixel path. Same parameters, limits, and * ownership as the single-chart forms in section 4. */ tile57_status tile57_compose_png(tile57_compose *c, double lon, double lat, double zoom, @@ -608,13 +609,13 @@ tile57_status tile57_compose_surface(tile57_compose *c, double lon, double lat, const tile57_mariner *m, const tile57_surface_cb *surface, tile57_error *err); -/* The composed cursor pick (S-52 §10.8, seams included): tile57_query across +/* The composed cursor pick (S-52 §10.8, across chart boundaries): tile57_chart_query across * the whole composed set. */ tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, double zoom, const tile57_query_cb *cb, tile57_error *err); /* Fill *out with the compositor's zoom range + union coverage bounds. */ -void tile57_compose_meta_get(tile57_compose *c, tile57_compose_meta *out); +void tile57_compose_get_meta(tile57_compose *c, tile57_compose_meta *out); /* Serialize the compositor's ownership partition to the file `path` (a sidecar * a later tile57_compose_open can load to skip the build). */ @@ -681,7 +682,7 @@ void tile57_assets_free(tile57_assets *out); * enabled_bands: NULL = no band filter (show all); else only features whose `band` * rank is in the array (count entries) are shown. * scamin: the distinct SCAMIN denominators present in the source (e.g. from - * tile57_scamin / the TileJSON). When non-NULL with scamin_count>0 the `_scamin` + * tile57_chart_scamin / the TileJSON). When non-NULL with scamin_count>0 the `_scamin` * source-layers are split into one per-value bucket layer with a native * fractional minzoom = scaminDisplayZoom(value, scamin_lat). NULL / count 0 -> the * `_scamin` layers stay a single ungated layer (features render, but SCAMIN does @@ -753,7 +754,7 @@ tile57_status tile57_style_template(tile57_scheme scheme, const char *source_til /* Populate the process-global read-only registries (the S-100 feature catalogue and * the complex-linestyle table) on the calling thread. Both are idempotent lazy-init * and thereafter read-only. Call this ONCE on your main thread before opening or - * baking cells from worker threads, so those globals are fully populated first and + * baking charts from worker threads, so those globals are fully populated first and * concurrent bake/render is race-free (the allocator is thread-safe and the portrayal * context is thread-local). Cheap and safe to call more than once. */ void tile57_warmup(void); diff --git a/src/capi.zig b/src/capi.zig index 95f3a19..655c3ac 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -182,12 +182,12 @@ export fn tile57_version() callconv(.c) [*:0]const u8 { /// The per-cell metadata of the S-57 data at `path` (one .000 or a whole ENC_ROOT) /// as a JSON array — name/scale/edition/update/issueDate/agency/bbox per cell, /// DSID fields reflecting the applied update chain. See tile57.h. -export fn tile57_enc_cells(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { +export fn tile57_enc_charts(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); const c = Chart.openPath(p, null, false) catch |e| return failCtx(err, e, p); defer c.deinit(); - const bytes = (c.cellsJson() catch |e| return fail(err, e)) orelse return OK; + const bytes = (c.chartsJson() catch |e| return fail(err, e)) orelse return OK; return exportOut(err, o, n, bytes); } @@ -211,8 +211,8 @@ export fn tile57_enc_features_bytes(base: ?[*]const u8, len: usize, classes: ?[* const b = base orelse return failWith(err, .badarg, "base must not be null"); if (len == 0) return failWith(err, .badarg, "len must not be zero"); const cls = spanOpt(classes) orelse return failWith(err, .badarg, "classes must not be null"); - const cells = [_]chart.CellInput{.{ .base = b[0..len] }}; - const c = Chart.openCells(&cells, null, false) catch |e| return fail(err, e); + const charts_in = [_]chart.ChartInput{.{ .base = b[0..len] }}; + const c = Chart.openCharts(&charts_in, null, false) catch |e| return fail(err, e); defer c.deinit(); const bytes = (c.featuresJson(cls) catch |e| return fail(err, e)) orelse return OK; return exportOut(err, o, n, bytes); @@ -267,19 +267,19 @@ fn jsonStr(a: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) !void { /// Bake ONE cell (+ its updates, read from disk) to PMTiles bytes over its NATIVE /// band zoom range, into *out / *out_len (free with tile57_free); NULL/0 when the /// cell produced no tiles. See tile57.h. -export fn tile57_bake_cell_bytes(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { +export fn tile57_bake_chart_bytes(path: ?[*:0]const u8, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); - const archive = chart.bakeCellBytes(p, null) catch |e| return failCtx(err, e, p); + const archive = chart.bakeChartBytes(p, null) catch |e| return failCtx(err, e, p); if (archive) |a| return exportOut(err, o, n, a); return OK; } -/// Bake `n` cells to per-cell PMTiles bytes IN PARALLEL across up to `workers` +/// Bake `n` charts to per-chart PMTiles bytes IN PARALLEL across up to `workers` /// threads; out_bytes[i]/out_lens[i] get cell i's archive (free each with /// tile57_free) or NULL/0 when it produced nothing. *out_baked (NULL to ignore) = /// the count that produced bytes. `workers` is a MEMORY bound. See tile57.h. -export fn tile57_bake_cells( +export fn tile57_bake_charts( paths: ?[*]const ?[*:0]const u8, n: usize, workers: u32, @@ -302,7 +302,7 @@ export fn tile57_bake_cells( for (0..n) |i| list[i] = spanOpt(ps[i]) orelse return failWith(err, .badarg, "a path in paths is null"); const results = gpa.alloc(?[]u8, n) catch |e| return fail(err, e); defer gpa.free(results); - chart.bakeCellsParallel(list, null, workers, results); + chart.bakeChartsParallel(list, null, workers, results); var baked: usize = 0; var oom = false; for (0..n) |i| { @@ -330,7 +330,7 @@ export fn tile57_bake_cells( return OK; } -/// Walk `in_dir` for S-57 base cells (*.000) and bake each IN PARALLEL to the SAME +/// Walk `in_dir` for S-57 base charts (*.000) and bake each IN PARALLEL to the SAME /// relative path under `out_dir` with a .pmtiles extension (+ an .sha sidecar), /// creating subdirs as needed. INCREMENTAL: an archive already at least as new as /// its whole input is skipped, so *out_baked (NULL to ignore) counts THIS run only. @@ -402,7 +402,7 @@ export fn tile57_bake_partition_debug( /// Open a baked PMTiles archive from a file path, mmap'd (never fully resident; /// the file must stay in place while the chart is open). See tile57.h. -export fn tile57_open(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { +export fn tile57_chart_open(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { const o = out orelse return failWith(err, .badarg, "out must not be null"); o.* = null; const p = spanOpt(path) orelse return failWith(err, .badarg, "path must not be null"); @@ -413,7 +413,7 @@ export fn tile57_open(path: ?[*:0]const u8, out: ?*?*Chart, err: ?*CError) callc } /// Open a baked PMTiles archive from in-memory bytes (copied). See tile57.h. -export fn tile57_open_bytes(pmtiles_ptr: ?[*]const u8, len: usize, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { +export fn tile57_chart_open_bytes(pmtiles_ptr: ?[*]const u8, len: usize, out: ?*?*Chart, err: ?*CError) callconv(.c) c_int { const o = out orelse return failWith(err, .badarg, "out must not be null"); o.* = null; const b = pmtiles_ptr orelse return failWith(err, .badarg, "pmtiles must not be null"); @@ -446,7 +446,7 @@ const TILE_TYPE_MLT: u8 = 2; /// Fill *out with the chart's fixed metadata (zoom range, bands, bounds, anchor, /// tile encoding, embedded compilation scale). See tile57.h. -export fn tile57_get_info(src: ?*Chart, out: ?*CInfo) callconv(.c) void { +export fn tile57_chart_get_info(src: ?*Chart, out: ?*CInfo) callconv(.c) void { const o = out orelse return; o.* = std.mem.zeroes(CInfo); const s = src orelse return; @@ -477,7 +477,7 @@ export fn tile57_get_info(src: ?*Chart, out: ?*CInfo) callconv(.c) void { /// The distinct SCAMIN denominators present in the chart (ascending, from the /// archive metadata); NULL/0 out when there are none. Free *out with /// tile57_free. See tile57.h. -export fn tile57_scamin(handle: ?*Chart, out: ?*?[*]i32, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { +export fn tile57_chart_scamin(handle: ?*Chart, out: ?*?[*]i32, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o = out orelse return failWith(err, .badarg, bad_out); const n = out_len orelse return failWith(err, .badarg, bad_out); o.* = null; @@ -503,7 +503,7 @@ const CCoverageCb = extern struct { /// in the archive metadata: cb->ring is called once per polygon with its exterior /// ring as interleaved lon,lat doubles. OK with no calls when the archive embeds /// none. See tile57.h. -export fn tile57_coverage(handle: ?*Chart, cb: ?*const CCoverageCb, err: ?*CError) callconv(.c) c_int { +export fn tile57_chart_coverage(handle: ?*Chart, cb: ?*const CCoverageCb, err: ?*CError) callconv(.c) c_int { const self = handle orelse return failWith(err, .badarg, "chart must not be null"); const cbp = cb orelse return failWith(err, .badarg, "cb must not be null"); const polys = self.coverage() orelse return OK; @@ -528,7 +528,7 @@ export fn tile57_coverage(handle: ?*Chart, cb: ?*const CCoverageCb, err: ?*CErro /// tile57_info.tile_type), with NO composition — the per-archive primitive an /// embedder's own compositor consumes. NULL/0 out when the archive has no tile /// there. See tile57.h. -export fn tile57_tile(handle: ?*Chart, z: u8, x: u32, y: u32, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { +export fn tile57_chart_tile(handle: ?*Chart, z: u8, x: u32, y: u32, out: ?*?[*]u8, out_len: ?*usize, err: ?*CError) callconv(.c) c_int { const o, const n = bytesOut(out, out_len) catch return failWith(err, .badarg, bad_out); const c = handle orelse return failWith(err, .badarg, "chart must not be null"); const rd = c.pmtilesReader() orelse return failWith(err, .badarg, "chart is not archive-backed"); @@ -541,7 +541,7 @@ const CQueryCb = @import("render").query.QueryCb; /// Cursor object-query at (lon,lat) for the view `zoom` (web-mercator): invokes /// cb->feature once per displayed feature the point falls in, with its S-57 class, /// attribute JSON, and source cell. See tile57.h. -export fn tile57_query(handle: ?*Chart, lon: f64, lat: f64, zoom: f64, cb: ?*const CQueryCb, err: ?*CError) callconv(.c) c_int { +export fn tile57_chart_query(handle: ?*Chart, lon: f64, lat: f64, zoom: f64, cb: ?*const CQueryCb, err: ?*CError) callconv(.c) c_int { const self = handle orelse return failWith(err, .badarg, "chart must not be null"); const cbp = cb orelse return failWith(err, .badarg, "cb must not be null"); self.queryPoint(lon, lat, zoom, cbp) catch |e| return fail(err, e); @@ -567,7 +567,7 @@ const bad_size = "width/height must be 1..16384"; /// one scene across every covering tile, labels decluttered over the whole /// canvas. No composition (see tile57_compose_png for the composed twin). /// `m` NULL = defaults. See tile57.h. -export fn tile57_png( +export fn tile57_chart_png( handle: ?*Chart, lon: f64, lat: f64, @@ -588,9 +588,9 @@ export fn tile57_png( return exportOut(err, o, n, bytes); } -/// tile57_png's vector twin: the SAME scene as a deterministic single-page PDF +/// tile57_chart_png's vector twin: the SAME scene as a deterministic single-page PDF /// (1 px = 1 pt, 72 dpi; vector fills, native strokes, glyph-outline text). -export fn tile57_pdf( +export fn tile57_chart_pdf( handle: ?*Chart, lon: f64, lat: f64, @@ -613,10 +613,10 @@ export fn tile57_pdf( const CbCanvas = @import("render").cb_canvas.CCanvas; -/// tile57_png's callback twin: the SAME view painted through the C callback +/// tile57_chart_png's callback twin: the SAME view painted through the C callback /// table `canvas` (see tile57.h) instead of rasterising. Geometry in canvas /// PIXEL space (y down), paint order, palette-resolved colours. -export fn tile57_canvas( +export fn tile57_chart_canvas( handle: ?*Chart, lon: f64, lat: f64, @@ -643,7 +643,7 @@ const CSurface = @import("render").vector.CSurface; /// (areas/lines in web-mercator [0,1]; symbols/text as a world anchor + local /// reference-px outline; per-feature class + SCAMIN) to the C surface callback /// `surface` (see tile57.h). Pan/zoom re-portray nothing on the host. -export fn tile57_surface( +export fn tile57_chart_surface( handle: ?*Chart, lon: f64, lat: f64, @@ -665,7 +665,7 @@ export fn tile57_surface( /// Release a chart and all cached tiles. Must not be called while any borrower /// (a compositor, a renderer) may still read from it. See tile57.h. -export fn tile57_close(handle: ?*Chart) callconv(.c) void { +export fn tile57_chart_close(handle: ?*Chart) callconv(.c) void { if (handle) |s| s.deinit(); } @@ -673,11 +673,11 @@ export fn tile57_close(handle: ?*Chart) callconv(.c) void { // 5. Compose — the runtime compositor over open charts (see tile57.h) // =========================================================================== -/// Coverage/zoom summary of a compositor, filled by tile57_compose_meta_get. +/// Coverage/zoom summary of a compositor, filled by tile57_compose_get_meta. const CComposeMeta = extern struct { min_zoom: u8, max_zoom: u8, // deepest zoom that can be served (native windows + one fill-up overscale zoom) - cells: u32, // coverage-carrying charts held + charts: u32, // coverage-carrying charts held west: f64, south: f64, east: f64, @@ -720,7 +720,7 @@ export fn tile57_compose_open( for (0..n) |i| { const c = cs[i] orelse return failWith(err, .badarg, "a chart in charts is null"); const rd = c.pmtilesReader() orelse return failWith(err, .badarg, "a chart in charts is not archive-backed"); - const cov = c.cellCoverage() orelse continue; // embeds no coverage: owns no ground + const cov = c.decodedCoverage() orelse continue; // embeds no coverage: owns no ground archives[na] = .{ .reader = rd, .cov = cov }; na += 1; } @@ -737,7 +737,7 @@ export fn tile57_compose_open( } else |_| {} } - o.* = (compose.openComposeSourceCharts(gpa, archives[0..na], owned) catch |e| return fail(err, e)) orelse + o.* = (compose.ComposeSource.open(gpa, archives[0..na], owned) catch |e| return fail(err, e)) orelse return failWith(err, .unsupported, "no chart carries per-cell coverage"); return OK; } @@ -766,7 +766,7 @@ export fn tile57_compose_tile( return OK; } -/// Render a VIEW over the compositor to PNG — the composed twin of tile57_png: +/// Render a VIEW over the compositor to PNG — the composed twin of tile57_chart_png: /// every covering tile is composed on demand (seams stitched through the /// ownership partition) and replayed through the native S-52 pixel path. See /// tile57.h. @@ -838,7 +838,7 @@ export fn tile57_compose_canvas( } /// The composed GPU vector twin: the SAME composed view emitted as a WORLD-SPACE -/// tagged stream to the C surface callback (see tile57.h). See tile57_surface for +/// tagged stream to the C surface callback (see tile57.h). See tile57_chart_surface for /// the single-chart form. export fn tile57_compose_surface( handle: ?*compose.ComposeSource, @@ -872,14 +872,14 @@ export fn tile57_compose_query(handle: ?*compose.ComposeSource, lon: f64, lat: f /// Fill *out with the compositor's zoom range + union coverage bounds (zeroed when /// the handle is NULL). See tile57.h. -export fn tile57_compose_meta_get(handle: ?*compose.ComposeSource, out: ?*CComposeMeta) callconv(.c) void { +export fn tile57_compose_get_meta(handle: ?*compose.ComposeSource, out: ?*CComposeMeta) callconv(.c) void { const o = out orelse return; o.* = std.mem.zeroes(CComposeMeta); const src = handle orelse return; o.* = .{ .min_zoom = src.minz, .max_zoom = src.loop_max, - .cells = @intCast(src.readers.len), + .charts = @intCast(src.readers.len), .west = src.bounds[0], .south = src.bounds[1], .east = src.bounds[2], @@ -1332,7 +1332,7 @@ export fn tile57_mariner_defaults(cm: ?*CMariner) callconv(.c) void { // =========================================================================== /// Populate the process-global read-only registries (S-100 catalogue + linestyles) on -/// the calling thread. Call ONCE on the main thread before opening/baking cells from +/// the calling thread. Call ONCE on the main thread before opening/baking charts from /// worker threads, so concurrent bake/render is race-free. See tile57.h. export fn tile57_warmup() callconv(.c) void { chart.warmup(); diff --git a/src/chart.zig b/src/chart.zig index e881de4..ff457e7 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -13,7 +13,7 @@ //! //! Threading: a Chart is NOT internally synchronized — don't call its render / //! query methods on the same Chart from multiple threads concurrently. Distinct -//! charts are independent. `openCells`/`bakeCellsParallel` parallelize +//! charts are independent. `openCharts`/`bakeChartsParallel` parallelize //! internally over cores. const std = @import("std"); @@ -43,16 +43,16 @@ const gpa = std.heap.smp_allocator; extern fn tg_env_rules() callconv(.c) ?[*:0]const u8; /// Backend / on-disk format. `auto` sniffs PMTiles first, then S-57. -pub const Format = enum { auto, pmtiles, s57_cell }; +pub const Format = enum { auto, pmtiles, s57 }; /// One ENC cell: the base .000 bytes plus its sequential update files (.001…). /// Bytes are borrowed for the duration of the call (copied where retained). -pub const CellInput = struct { +pub const ChartInput = struct { base: []const u8, updates: []const []const u8 = &.{}, /// Source ENC cell name (dataset stem, e.g. "US4MD81M") for the pick report's /// "source cell" badge. "" = unknown (the `cell` prop is omitted). The eager - /// (openCells) path copies it; the bake path borrows it for the call. + /// (openCharts) path copies it; the bake path borrows it for the call. name: []const u8 = "", }; @@ -66,7 +66,7 @@ pub const Progress = ?*const fn (user: ?*anyopaque, stage: u8, done: usize, tota /// Pre-peeked metadata for one cell in a streaming open: its geographic extent /// and compilation scale (1:cscl). The host supplies these (cheap to compute, or /// already known) so the source opens without reading any cell bytes. -pub const CellMeta = extern struct { +pub const ChartMeta = extern struct { west: f64, south: f64, east: f64, @@ -78,7 +78,7 @@ pub const CellMeta = extern struct { /// malloc-allocated buffers (base + each update); the engine frees them with /// libc free() once the cell is parsed. update arrays are parallel, length /// update_count (0 / null for a base-only cell). -pub const CellBytes = extern struct { +pub const ChartBytes = extern struct { base: [*]const u8 = undefined, base_len: usize = 0, updates: ?[*]const [*]const u8 = null, @@ -90,7 +90,7 @@ pub const CellBytes = extern struct { /// engine frees them), returning true on success. Called on demand the first /// time a tile needs the cell (and again after the cell is LRU-evicted), so the /// host holds only the working set's bytes — not the whole ENC_ROOT. -pub const CellReadFn = *const fn (user: ?*anyopaque, index: usize, out: *CellBytes) callconv(.c) bool; +pub const ChartReadFn = *const fn (user: ?*anyopaque, index: usize, out: *ChartBytes) callconv(.c) bool; /// Free bytes returned by the render / bake / JSON entry points (page-allocator owned). pub fn freeBytes(bytes: []u8) void { @@ -161,7 +161,7 @@ const LazySource = struct { tick: u64 = 0, loaded: usize = 0, max_loaded: usize = 256, // LRU budget on parsed+portrayed cells (wide views) - reader: ?CellReadFn = null, // streaming: read a cell's bytes on demand + reader: ?ChartReadFn = null, // streaming: read a cell's bytes on demand reader_user: ?*anyopaque = null, // Path-backed streaming (chart-api.md): when the chart was opened from an on-disk // ENC_ROOT, this owns the retained Io + Dir + per-cell paths and is the @@ -199,7 +199,7 @@ fn bboxOverlap(a_: [4]f64, b_: [4]f64) bool { } // Free the host-malloc'd buffers a streaming reader transferred to us (libc free). -fn freeCellBytes(cb: *CellBytes) void { +fn freeCellBytes(cb: *ChartBytes) void { if (cb.base_len != 0) std.c.free(@ptrCast(@constCast(cb.base))); if (cb.updates) |ups| { var k: usize = 0; @@ -229,7 +229,7 @@ fn cdup(bytes: []const u8) ?[*]u8 { // Peek `relpath`'s bbox+scale; on success append an index-aligned meta + a gpa-owned // copy of the path. Cells that don't read / have no coverage bbox are skipped (both // lists), keeping meta[i] and paths[i] aligned with the streaming cell index. -fn addPathCell(io: std.Io, dir: std.Io.Dir, relpath: []const u8, metas: *std.ArrayList(CellMeta), paths: *std.ArrayList([]u8)) !void { +fn addPathCell(io: std.Io, dir: std.Io.Dir, relpath: []const u8, metas: *std.ArrayList(ChartMeta), paths: *std.ArrayList([]u8)) !void { const bytes = dir.readFileAlloc(io, relpath, gpa, .unlimited) catch return; defer gpa.free(bytes); const m = s57.peekMeta(gpa, bytes) orelse return; @@ -238,10 +238,10 @@ fn addPathCell(io: std.Io, dir: std.Io.Dir, relpath: []const u8, metas: *std.Arr try paths.append(gpa, try gpa.dupe(u8, relpath)); } -// Internal CellReadFn for a path-backed chart: read cell `index`'s base .000 + its +// Internal ChartReadFn for a path-backed chart: read cell `index`'s base .000 + its // sequential .001.. updates from the retained dir into libc-malloc'd buffers (the // engine frees them via freeCellBytes). Mirrors the baker's per-cell load. -fn pathRead(user: ?*anyopaque, index: usize, out: *CellBytes) callconv(.c) bool { +fn pathRead(user: ?*anyopaque, index: usize, out: *ChartBytes) callconv(.c) bool { const ctx: *PathCtx = @ptrCast(@alignCast(user orelse return false)); if (index >= ctx.paths.len) return false; const bpath = ctx.paths[index]; @@ -293,7 +293,7 @@ fn pathRead(user: ?*anyopaque, index: usize, out: *CellBytes) callconv(.c) bool // (freeing the host's originals). Returns false if the reader declines/fails. fn streamRead(ls: *LazySource, lc: *LazyCell) bool { const rd = ls.reader orelse return false; - var cb: CellBytes = .{}; + var cb: ChartBytes = .{}; if (!rd(ls.reader_user, lc.index, &cb)) return false; const base = gpa.dupe(u8, cb.base[0..cb.base_len]) catch { freeCellBytes(&cb); @@ -587,11 +587,11 @@ fn readCellFiles(path: []const u8) !CellFiles { /// /// The archive metadata embeds the cell's own coverage (M_COVR + cscl + date/name), /// so the composite stitcher rebuilds the ownership partition from the baked archives -/// without re-parsing the .000. Read it back with `cellCoverageFromArchive`. -pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 { +/// without re-parsing the .000. Read it back with `decodedCoverageFromArchive`. +pub fn bakeChartBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 { // Populate the read-only portrayal globals (feature catalogue + complex-linestyle table) // before portraying: without them, complex lines fall back to plain geometry and their S-52 - // linestyle is dropped from the tile. Idempotent; in the parallel batch path bakeCellsParallel + // linestyle is dropped from the tile. Idempotent; in the parallel batch path bakeChartsParallel // has already warmed up before spawning workers, so this is a no-op there (and race-free). warmup(); var cf = try readCellFiles(cell_path); @@ -618,7 +618,7 @@ pub fn bakeCellBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 { // coarser zooms where nothing coarser covers — a harbor-only region still // shows land and coast at z4. No overscale above the window. const zr = bake_enc.bandZooms(bake_enc.bandOf(cscl)); - const cell_in = [_]CellInput{.{ .base = cf.base, .updates = cf.updates }}; + const cell_in = [_]ChartInput{.{ .base = cf.base, .updates = cf.updates }}; return bakeArchive(&cell_in, resolveRulesDir(rules_dir), 0, zr.max, .mlt, true, null, null, coverage_json); } @@ -644,7 +644,7 @@ fn bakeCellWorker(ctx: *BakeCtx) void { while (true) { const i = ctx.next.fetchAdd(1, .monotonic); if (i >= ctx.paths.len) return; - ctx.out[i] = bakeCellBytes(ctx.paths[i], ctx.rules_dir) catch null; + ctx.out[i] = bakeChartBytes(ctx.paths[i], ctx.rules_dir) catch null; } } @@ -652,9 +652,9 @@ fn bakeCellWorker(ctx: *BakeCtx) void { /// PMTiles bytes IN PARALLEL across up to `workers` threads, writing cell i's archive to out[i] /// (caller owns it — free each with freeBytes) or leaving it null when that cell produced nothing /// or failed. out.len must equal paths.len. Race-free: warms up the process globals first, then -/// each bakeCellBytes is independent (thread-safe allocator, thread-local portrayal context). +/// each bakeChartBytes is independent (thread-safe allocator, thread-local portrayal context). /// `workers` is clamped to [1, min(paths.len, MAX_BAKE_WORKERS)] and is a MEMORY bound. -pub fn bakeCellsParallel(paths: []const []const u8, rules_dir: ?[]const u8, workers: usize, out: []?[]u8) void { +pub fn bakeChartsParallel(paths: []const []const u8, rules_dir: ?[]const u8, workers: usize, out: []?[]u8) void { std.debug.assert(out.len == paths.len); for (out) |*o| o.* = null; if (paths.len == 0) return; @@ -697,7 +697,7 @@ const BakeFileCtx = struct { }; fn bakeOneToFile(ctx: *BakeFileCtx, i: usize) void { - const arc = (bakeCellBytes(ctx.in_paths[i], ctx.rules_dir) catch null) orelse return; + const arc = (bakeChartBytes(ctx.in_paths[i], ctx.rules_dir) catch null) orelse return; defer freeBytes(arc); std.Io.Dir.cwd().writeFile(ctx.io, .{ .sub_path = ctx.out_paths[i], .data = arc }) catch return; var digest: [32]u8 = undefined; @@ -725,7 +725,7 @@ fn bakeFileWorker(ctx: *BakeFileCtx) void { /// the write — so the host never holds N archives (peak memory ~ the worker count). The app owns /// the cache and names every out_path. `progress(progress_ctx, done, total)` fires (serialised) /// after each cell. Race-free (warms up first; each bake is independent). Returns the count written. -pub fn bakeCellsToFiles(io: std.Io, in_paths: []const []const u8, out_paths: []const []const u8, rules_dir: ?[]const u8, workers: usize, progress: BakeProgress, progress_ctx: ?*anyopaque) usize { +pub fn bakeChartsToFiles(io: std.Io, in_paths: []const []const u8, out_paths: []const []const u8, rules_dir: ?[]const u8, workers: usize, progress: BakeProgress, progress_ctx: ?*anyopaque) usize { std.debug.assert(out_paths.len == in_paths.len); if (in_paths.len == 0) return 0; warmup(); @@ -791,7 +791,7 @@ pub fn bakeTree(io: std.Io, in_dir: []const u8, out_dir: []const u8, rules_dir: out_paths.append(a, out_path) catch continue; } if (in_paths.items.len == 0) return 0; - return bakeCellsToFiles(io, in_paths.items, out_paths.items, rules_dir, workers, progress, progress_ctx); + return bakeChartsToFiles(io, in_paths.items, out_paths.items, rules_dir, workers, progress, progress_ctx); } /// The file's modification time in nanoseconds, or null if it doesn't exist / can't be statted. @@ -971,7 +971,7 @@ pub fn pmtilesMetadata(a: std.mem.Allocator, archive: []const u8) !?[]u8 { /// null if absent. The whole result (rings + strings) is allocated in `a`. The /// composite stitcher calls this over each cell's archive to rebuild the ownership /// partition without re-parsing the source .000. -pub fn cellCoverageFromArchive(a: std.mem.Allocator, archive: []const u8) !?scene.coverage.CellCoverage { +pub fn decodedCoverageFromArchive(a: std.mem.Allocator, archive: []const u8) !?scene.coverage.ChartCoverage { const json = (try pmtilesMetadata(a, archive)) orelse return null; return scene.coverage.decodeFromMetadata(a, json); } @@ -992,7 +992,7 @@ pub fn warmup() void { // Parallel open worker: peek each cell's band + bbox and copy its bytes. const OpenWork = struct { - inputs: []const CellInput, + inputs: []const ChartInput, out: []LazyCell, ok: []bool, @@ -1026,7 +1026,7 @@ const OpenWork = struct { } }; -/// The public chart handle. Open with `openBytes`/`openCells`; release with +/// The public chart handle. Open with `openBytes`/`openCharts`; release with /// `deinit`. pub const Chart = struct { backend: Backend, @@ -1045,7 +1045,7 @@ pub const Chart = struct { // cscl, bbox, M_COVR rings) — read back by coverage()/nativeScale(), and what a // compositor borrows to place this chart in the ownership partition. Storage // lives in coverage_arena (freed in deinit). - cell_cov: ?cell_coverage.CellCoverage = null, + cell_cov: ?cell_coverage.ChartCoverage = null, coverage_arena: ?*std.heap.ArenaAllocator = null, // A path-opened archive is mmap'd rather than copied (never fully resident); // released in deinit. Bytes-opened archives use `data` instead. @@ -1066,7 +1066,7 @@ pub const Chart = struct { /// Open an ENC_ROOT as a multi-cell source: cells are indexed cheaply (band + /// bbox) in parallel and parsed/portrayed lazily per tile. All bytes are /// copied. Errors if no cell's header parses. - pub fn openCells(cells_in: []const CellInput, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart { + pub fn openCharts(cells_in: []const ChartInput, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart { if (cells_in.len == 0) return error.NotFound; const dir = resolveRulesDir(rules_dir); const dir_copy = try gpa.dupe(u8, dir); @@ -1115,7 +1115,7 @@ pub const Chart = struct { /// returns a cell's bytes on demand. Cell bytes are read only when a tile /// needs them and freed on LRU eviction, so the host holds the working set's /// bytes — not the whole ENC_ROOT. No bytes are read at open. Errors if empty. - pub fn openCellsStreaming(metas: []const CellMeta, reader: CellReadFn, user: ?*anyopaque, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart { + pub fn openChartsStreaming(metas: []const ChartMeta, reader: ChartReadFn, user: ?*anyopaque, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart { if (metas.len == 0) return error.NotFound; const dir = resolveRulesDir(rules_dir); const dir_copy = try gpa.dupe(u8, dir); @@ -1162,7 +1162,7 @@ pub const Chart = struct { var dir = try std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }); errdefer dir.close(io); - var metas = std.ArrayList(CellMeta).empty; + var metas = std.ArrayList(ChartMeta).empty; defer metas.deinit(gpa); var paths = std.ArrayList([]u8).empty; errdefer { @@ -1194,7 +1194,7 @@ pub const Chart = struct { // Reuse the streaming backend with the internal file reader; the returned // Chart owns the PathCtx (Io + Dir + paths) via ls.path_ctx, freed in deinit. - const src = try openCellsStreaming(metas.items, pathRead, null, rules_dir, pick_attrs); + const src = try openChartsStreaming(metas.items, pathRead, null, rules_dir, pick_attrs); errdefer src.deinit(); const ctx = try gpa.create(PathCtx); errdefer gpa.destroy(ctx); @@ -1232,7 +1232,7 @@ pub const Chart = struct { pub fn format(self: *Chart) Format { return switch (self.backend) { .reader => .pmtiles, - .cell, .cells => .s57_cell, + .cell, .cells => .s57, }; } @@ -1278,7 +1278,7 @@ pub const Chart = struct { /// The per-cell coverage embedded in the opened archive's metadata (name, date, /// cscl, bbox, M_COVR rings), or null if the archive carries none. Borrows the /// chart's storage. - pub fn cellCoverage(self: *const Chart) ?cell_coverage.CellCoverage { + pub fn decodedCoverage(self: *const Chart) ?cell_coverage.ChartCoverage { return self.cell_cov; } @@ -1626,7 +1626,7 @@ pub const Chart = struct { /// geometry extent, omitted when none parses. Returns null when the chart /// has no cells (a PMTiles chart carries no cell files — its manifest is /// the host-side sidecar). gpa-owned; free with freeBytes. - pub fn cellsJson(self: *Chart) !?[]u8 { + pub fn chartsJson(self: *Chart) !?[]u8 { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); @@ -1667,7 +1667,7 @@ pub const Chart = struct { } else { // Streaming cell: read transiently (collectScaminCells pattern). const rd = ls.reader orelse continue; - var cb: CellBytes = .{}; + var cb: ChartBytes = .{}; if (!rd(ls.reader_user, lc.index, &cb)) continue; defer freeCellBytes(&cb); var ups: []const []const u8 = &.{}; @@ -1743,7 +1743,7 @@ pub const Chart = struct { try appendCellGeoJson(a, &out, &cell, want.items, &n); } else { const rd = ls.reader orelse continue; - var cb: CellBytes = .{}; + var cb: ChartBytes = .{}; if (!rd(ls.reader_user, lc.index, &cb)) continue; defer freeCellBytes(&cb); var ups: []const []const u8 = &.{}; @@ -2104,7 +2104,7 @@ fn collectScaminCells(ls: *LazySource, set: *std.AutoHashMap(u32, void)) void { // Streaming cell with no resident bytes: read them via the host reader for // this scan only, then free (the normal tile path reads them again on demand). const rd = ls.reader orelse continue; - var cb: CellBytes = .{}; + var cb: ChartBytes = .{}; if (!rd(ls.reader_user, lc.index, &cb)) continue; defer freeCellBytes(&cb); var ups: []const []const u8 = &.{}; @@ -2218,7 +2218,7 @@ const BakeWork = struct { } }; -/// Bake an ENC_ROOT (the same cells as `openCells`) into ONE PMTiles archive, +/// Bake an ENC_ROOT (the same cells as `openCharts`) into ONE PMTiles archive, /// zoom-banded per cell by compilation scale. Returns the archive bytes (free /// with `freeBytes`), or null if nothing was covered. Streams band-by-band /// (finest → coarsest, best-band dedup + the scamin-aware band handoff), holding @@ -2227,7 +2227,7 @@ const BakeWork = struct { /// `progress` (nullable) fires during the load+portray phase (stage 0) and the /// tile-bake phase (stage 1). The caller owns the input bytes for the call. pub fn bakeArchive( - cells_in: []const CellInput, + cells_in: []const ChartInput, rules_dir: ?[]const u8, minzoom: u8, maxzoom: u8, diff --git a/src/compose/compose.zig b/src/compose/compose.zig index 49dc485..91e1ccb 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -324,13 +324,20 @@ pub const ComposeSource = struct { self.arena.deinit(); gpa.destroy(self); } + + /// Open over per-chart PMTiles paths (mmap'd; the chart set is never fully + /// resident). Null when no archive carries coverage. + pub const openFiles = openSourceFiles; + /// Open over already-open charts' archives (everything is BORROWED — the + /// charts must outlive this source). Null when no archive carries coverage. + pub const open = openSourceCharts; }; /// Open a resident ComposeSource over per-cell PMTiles at `paths` (mmap'd, so the cell set is never /// fully resident). If `load_partition` is non-null and valid for this cell set the partition is /// loaded (no build); else it is built. Returns null if no archive carries coverage. Free with /// `ComposeSource.deinit`. -pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, load_partition: ?[]const u8) !?*ComposeSource { +fn openSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8, load_partition: ?[]const u8) !?*ComposeSource { const src = try gpa.create(ComposeSource); src.* = .{ .gpa = gpa, .arena = std.heap.ArenaAllocator.init(gpa), .maps = &.{}, .readers = &.{}, .part = undefined, .minz = 0, .maxz = 0, .loop_max = 0, .bounds = .{ 0, 0, 0, 0 } }; errdefer { @@ -399,15 +406,15 @@ pub fn openComposeSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const /// BORROWS both — whatever owns them (a chart handle) must outlive the source. pub const ChartArchive = struct { reader: *pmtiles.Reader, - cov: coverage.CellCoverage, + cov: coverage.ChartCoverage, }; /// Open a resident ComposeSource over already-open charts' archives. Nothing is /// opened or mmap'd here and deinit closes none of it: every archive is borrowed, /// so the charts must OUTLIVE this source. Archives without coverage rings are /// skipped (they can own no ground); returns null if none carries coverage. -/// `load_partition` as in openComposeSourceFiles. -pub fn openComposeSourceCharts(gpa: std.mem.Allocator, archives: []const ChartArchive, load_partition: ?[]const u8) !?*ComposeSource { +/// `load_partition` as in `openFiles`. +fn openSourceCharts(gpa: std.mem.Allocator, archives: []const ChartArchive, load_partition: ?[]const u8) !?*ComposeSource { const src = try gpa.create(ComposeSource); src.* = .{ .gpa = gpa, .arena = std.heap.ArenaAllocator.init(gpa), .maps = &.{}, .readers = &.{}, .part = undefined, .minz = 0, .maxz = 0, .loop_max = 0, .bounds = .{ 0, 0, 0, 0 } }; errdefer { @@ -431,7 +438,7 @@ pub fn openComposeSourceCharts(gpa: std.mem.Allocator, archives: []const ChartAr } // The coverage bbox (integer lon/lat e7) as degree bounds [w, s, e, n]. -fn covDegBounds(cc: coverage.CellCoverage) [4]f64 { +fn covDegBounds(cc: coverage.ChartCoverage) [4]f64 { return .{ @as(f64, @floatFromInt(cc.bbox[0])) / 1e7, @as(f64, @floatFromInt(cc.bbox[1])) / 1e7, @as(f64, @floatFromInt(cc.bbox[2])) / 1e7, @as(f64, @floatFromInt(cc.bbox[3])) / 1e7, diff --git a/src/coverage/coverage.zig b/src/coverage/coverage.zig index 78cea40..5130e98 100644 --- a/src/coverage/coverage.zig +++ b/src/coverage/coverage.zig @@ -21,7 +21,7 @@ const s57 = @import("s57"); /// this cell without re-parsing. Ring geometry is `[feature][ring][point]`, matching /// `s57.Cell.mcovrCoverage`, so a decoded value plugs straight into the partition /// adapter. Slices are owned by whoever built or decoded the value. -pub const CellCoverage = struct { +pub const ChartCoverage = struct { name: []const u8, // file basename stem (verbatim — the ownership tie-break name) date: []const u8, // post-update DSID ISDT (YYYYMMDD), or "" if absent cscl: i32, // compilation scale 1:N (0 = unknown) @@ -52,12 +52,12 @@ pub fn encodeLightReachJson(a: std.mem.Allocator, lr: LightReach) ![]u8 { return std.json.Stringify.valueAlloc(a, lr, .{}); } -/// Build a CellCoverage from a parsed cell. `name` is the caller's identity string +/// Build a ChartCoverage from a parsed cell. `name` is the caller's identity string /// (the file basename stem, matching the coverage loader / ownership oracle) and /// `band` the caller's `bake_enc.bandOf(cscl)` tag; both keep this module free of the /// band + loader policy. Coverage rings are allocated in `a`; `name`/`date` are /// borrowed (encode before the cell is freed). -pub fn fromCell(a: std.mem.Allocator, cell: *const s57.Cell, name: []const u8, band: u8) CellCoverage { +pub fn fromCell(a: std.mem.Allocator, cell: *const s57.Cell, name: []const u8, band: u8) ChartCoverage { const cov1 = cell.mcovrCoverage(a); return .{ .name = name, @@ -108,7 +108,7 @@ const Envelope = struct { coverage: ?Dto = null, light_reach: ?LightReach = null /// Serialize `cov` to the JSON object embedded under the metadata's "coverage" key. /// Caller owns the returned bytes (allocated in `a`). -pub fn encodeJson(a: std.mem.Allocator, cov: CellCoverage) ![]u8 { +pub fn encodeJson(a: std.mem.Allocator, cov: ChartCoverage) ![]u8 { const dto = Dto{ .v = 1, .name = cov.name, @@ -124,11 +124,11 @@ pub fn encodeJson(a: std.mem.Allocator, cov: CellCoverage) ![]u8 { /// Extract the coverage embedded in a PMTiles metadata JSON blob, or null if absent / /// unparseable. The whole result (rings + strings) is allocated in `a`. -pub fn decodeFromMetadata(a: std.mem.Allocator, metadata_json: []const u8) !?CellCoverage { +pub fn decodeFromMetadata(a: std.mem.Allocator, metadata_json: []const u8) !?ChartCoverage { var parsed = std.json.parseFromSlice(Envelope, a, metadata_json, .{ .ignore_unknown_fields = true }) catch return null; defer parsed.deinit(); // frees the DTO's own arena; the copies below live in `a` const dto = parsed.value.coverage orelse return null; - return CellCoverage{ + return ChartCoverage{ .name = try a.dupe(u8, dto.name), .date = try a.dupe(u8, dto.date), .cscl = dto.cscl, @@ -191,7 +191,7 @@ test "coverage round-trips through the metadata envelope, integers exact" { const feat1 = try a.dupe([]const s57.LonLat, &.{ ext, hole }); const cov1 = try a.dupe([]const []const s57.LonLat, &.{ feat0, feat1 }); - const cov = CellCoverage{ + const cov = ChartCoverage{ .name = "US5MD1MC", .date = "20210115", .cscl = 20_000, diff --git a/src/tile57.zig b/src/tile57.zig index 907fdb9..18cd324 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -1,10 +1,11 @@ //! tile57 — the public Zig API for turning S-57 ENC data into vector tiles. //! -//! The pipeline: parse IHO S-57 cells, apply S-101 portrayal, and bake each cell -//! to its own MLT vector tiles in a PMTiles archive at its compilation scale. A -//! runtime compositor then stitches the per-cell archives into one tile for any -//! (z, x, y) on demand, using an ownership partition so cells never double-draw at -//! a seam. The same engine renders a chart to PNG or PDF, and generates the +//! The pipeline: parse IHO S-57 charts (ENC cells), apply S-101 portrayal, and +//! bake each chart to its own MLT vector tiles in a PMTiles archive at its +//! compilation scale. A runtime compositor then stitches the per-chart archives +//! into one tile for any (z, x, y) on demand, using an ownership partition so +//! charts never double-draw where they meet. The same engine renders a chart to +//! PNG or PDF, and generates the //! MapLibre S-52 style plus its portrayal assets (color tables, line styles, //! sprite and pattern atlases). //! @@ -16,8 +17,8 @@ //! //! Surface: //! - Chart: `Chart` — open, then render / query / inspect -//! - Bake: `bake` — a cell (or a tree of cells) to per-cell PMTiles -//! - Compose: `compose` — per-cell archives + `partition` -> tiles on demand +//! - Bake: `bake` — a chart (or a tree of charts) to per-chart PMTiles +//! - Compose: `compose` — per-chart archives + `partition` -> tiles on demand //! - Style: `style` — the MapLibre style; `sprite` — the atlases //! - Tiling: `mvt`, `mlt`, `tile`, `pmtiles`, `gzip`, `band`, `scene` //! - Render: `render` — the Surface/Canvas rendering path @@ -26,61 +27,60 @@ const std = @import("std"); /// Library version (matches build.zig.zon and tile57_version()). -pub const version = "0.2.0"; +pub const version = "0.3.0"; // ---- Chart: open a chart, then render / query / inspect -------------------- const chart = @import("chart.zig"); -/// An embeddable chart source: open from a path, from bytes, or as a multi-cell +/// An embeddable chart source: open from a path, from bytes, or as a multi-chart /// ENC_ROOT (streaming), then render / query / inspect. See chart.zig. pub const Chart = chart.Chart; pub const Format = chart.Format; -pub const CellInput = chart.CellInput; +pub const ChartInput = chart.ChartInput; pub const Progress = chart.Progress; -/// Streaming ENC_ROOT open (read a cell's bytes on demand, low memory): see -/// Chart.openCellsStreaming. -pub const CellMeta = chart.CellMeta; -pub const CellBytes = chart.CellBytes; -pub const CellReadFn = chart.CellReadFn; +/// Streaming ENC_ROOT open (read a chart's bytes on demand, low memory): see +/// Chart.openChartsStreaming. +pub const ChartMeta = chart.ChartMeta; +pub const ChartBytes = chart.ChartBytes; +pub const ChartReadFn = chart.ChartReadFn; /// Free bytes returned by the render / bake entry points. pub const freeBytes = chart.freeBytes; /// Warm the embedded S-101 catalogue + Lua rules once, before serving. pub const warmup = chart.warmup; -// ---- Bake: cells -> per-cell PMTiles archives ------------------------------ -/// Bake each cell to its own PMTiles at its compilation scale — the input the -/// compositor serves from. Strictly one cell, one archive. +// ---- Bake: charts -> per-chart PMTiles archives ---------------------------- +/// Bake each chart to its own PMTiles at its compilation scale — the input the +/// compositor serves from. Strictly one chart, one archive. pub const bake = struct { - pub const cellBytes = chart.bakeCellBytes; // one cell + updates -> PMTiles bytes - pub const cellsParallel = chart.bakeCellsParallel; // N cells -> N archives, threaded - pub const cellsToFiles = chart.bakeCellsToFiles; // N cells -> files under a dir - pub const tree = chart.bakeTree; // walk an ENC_ROOT, bake each cell to a mirrored path + pub const chartBytes = chart.bakeChartBytes; // one chart + updates -> PMTiles bytes + pub const chartsParallel = chart.bakeChartsParallel; // N charts -> N archives, threaded + pub const chartsToFiles = chart.bakeChartsToFiles; // N charts -> files under a dir + pub const tree = chart.bakeTree; // walk an ENC_ROOT, bake each chart to a mirrored path pub const pmtilesMetadata = chart.pmtilesMetadata; // read an archive's TileJSON metadata pub const Progress = chart.BakeProgress; }; -// ---- Compose: per-cell archives + a partition -> any output on demand ------ -/// The runtime compositor: a `ComposeSource` over per-cell archives + a -/// partition offers the SAME outputs as a Chart, composed — `ComposeSource.tile` -/// for one tile, `renderView` / `renderSurfaceView` / `queryPoint` for composed -/// views and the composed pick. (The view calls are implemented beside Chart — -/// the underlying `compose` module is a dependency leaf without the render -/// path — and surfaced here under the compose name they belong to.) +// ---- Compose: per-chart archives + a partition -> any output on demand ----- +/// The runtime compositor: a `ComposeSource` (open with `ComposeSource.open` / +/// `ComposeSource.openFiles`) over per-chart archives + a partition offers the +/// SAME outputs as a Chart, composed — `ComposeSource.tile` for one tile, +/// `renderView` / `renderSurfaceView` / `queryPoint` for composed views and the +/// composed pick. (The view calls are implemented beside Chart — the underlying +/// `compose` module is a dependency leaf without the render path — and surfaced +/// here under the compose name they belong to.) pub const compose = struct { const mod = @import("compose"); pub const ComposeSource = mod.ComposeSource; pub const ChartArchive = mod.ChartArchive; pub const TileResult = mod.TileResult; pub const LoadedCov = mod.LoadedCov; - pub const openComposeSourceFiles = mod.openComposeSourceFiles; - pub const openComposeSourceCharts = mod.openComposeSourceCharts; - pub const composeTile = mod.composeTile; + pub const tile = mod.composeTile; // the stateless core ComposeSource.tile uses pub const toPlaneCells = mod.toPlaneCells; pub const clip = mod.clip; /// The composed view render — PNG, PDF, or a callback canvas. pub const renderView = chart.renderComposeView; /// The composed world-space surface stream (the GPU vector twin). pub const renderSurfaceView = chart.renderComposeSurfaceView; - /// The composed cursor pick (S-52 §10.8, seams included). + /// The composed cursor pick (S-52 §10.8, across chart boundaries). pub const queryPoint = chart.composeQueryPoint; }; /// The ownership partition and its `.tpart` sidecar (serialize / deserialize). @@ -106,7 +106,7 @@ pub const pmtiles = @import("tiles").pmtiles; // PMTiles read/write pub const gzip = @import("tiles").gzip; // gzip (tile payloads, PMTiles internals) pub const band = @import("tiles").band; // compilation-scale -> zoom-range mapping pub const scene = @import("scene"); // S-57 + portrayal -> tile surface -pub const bake_enc = @import("scene").bake_enc; // the banded cell baker +pub const bake_enc = @import("scene").bake_enc; // the banded chart baker // ---- Render surfaces ------------------------------------------------------- /// The Surface/Canvas rendering path: PNG raster, vector PDF, ASCII, and the @@ -116,10 +116,10 @@ pub const render = @import("render"); // ---- Raw formats (advanced) ------------------------------------------------ pub const formats = struct { pub const iso8211 = @import("s57").iso8211; // ISO/IEC 8211 container records - pub const s57 = @import("s57"); // S-57 ENC cell parser + geometry + pub const s57 = @import("s57"); // the S-57 chart parser + geometry pub const s101 = @import("s101"); // S-101 catalogue + adapter + instructions }; -/// Per-cell M_COVR coverage sidecar (carried in an archive's PMTiles metadata). +/// Per-chart M_COVR coverage sidecar (carried in an archive's PMTiles metadata). pub const coverage = @import("coverage"); test { diff --git a/src/tiles/tile.zig b/src/tiles/tile.zig index 215cede..b08f360 100644 --- a/src/tiles/tile.zig +++ b/src/tiles/tile.zig @@ -90,7 +90,7 @@ pub fn tileBoundsLonLat(z: u8, tx: u32, ty: u32) [4]f64 { // figures around the light (plus ground-length directional legs), so they can // cross into tiles the light's cell owns no ground in. Both the baker's tile // addressing (bake_enc buildTileMap) and the runtime compositor's reach ring -// (compose.composeTile) widen by the same bound so the figures survive there. +// (compose.tile) widen by the same bound so the figures survive there. const EARTH_CIRCUM_M: f64 = 40075016.686; diff --git a/tools/bake.zig b/tools/bake.zig index 8065844..f6a47b1 100644 --- a/tools/bake.zig +++ b/tools/bake.zig @@ -1,18 +1,18 @@ //! `bake -o [--rules DIR] [--from-archives]` — produce a -//! LIVE-composite structure on disk. Bake each cell to its OWN native-scale PMTiles under +//! LIVE-composite structure on disk. Bake each chart to its OWN native-scale PMTiles under //! `/tiles/` (with its M_COVR coverage embedded in the metadata), then open a resident //! compositor over them and write the ownership partition to `/partition.tpart`. There is //! NO merged archive: a runtime compositor (`ComposeSource` / the `compose-tile` command / the C -//! ABI `tile57_compose_*`) serves any tile ON DEMAND from this structure, so the per-cell bakes stay +//! ABI `tile57_compose_*`) serves any tile ON DEMAND from this structure, so the per-chart bakes stay //! dumb + cacheable and the partition holds all cross-cell ownership. Native scale only — deeper //! coarse zooms are left to the client camera + MapLibre overzoom. //! -//! `--from-archives`: `` is ALREADY a directory of per-cell archives (*.pmtiles / *.cell.tmp); +//! `--from-archives`: `` is ALREADY a directory of per-chart archives (*.pmtiles / *.cell.tmp); //! skip the bake and only (re)build the partition sidecar into `/partition.tpart` over them //! — the fast re-partition loop over a tiles dir. const std = @import("std"); -const chart = @import("chart"); // per-cell bake (bakeCellBytes) + freeBytes +const chart = @import("chart"); // per-chart bake (bakeChartBytes) + freeBytes const compose = @import("compose"); // openComposeSourceFiles + serializePartition (the resident compositor) const common = @import("common.zig"); const Flags = common.Flags; @@ -45,7 +45,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const out_dir = out orelse return usageErr("missing -o/--output "); const rules_dir = resolveRulesDir(rules); - // The per-cell archive paths that back the compositor. + // The per-chart archive paths that back the compositor. var archive_paths = std.ArrayList([]const u8).empty; if (from_archives) { @@ -62,7 +62,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { if (archive_paths.items.len == 0) return usageErr("no *.pmtiles / *.cell.tmp archives found"); std.debug.print("re-partition: {d} pre-baked archives from {s}\n", .{ archive_paths.items.len, base_path }); } else { - // Bake each cell (dedup by stem — a boundary cell shared by two districts bakes once) + // Bake each chart (dedup by stem — a boundary chart shared by two districts bakes once) // to its own /tiles/.pmtiles. const tiles_dir = try std.fs.path.join(a, &.{ out_dir, "tiles" }); try std.Io.Dir.cwd().createDirPath(io, tiles_dir); @@ -87,7 +87,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { if (cell_paths.items.len == 0) return usageErr("no .000 cells found"); for (cell_paths.items) |cp| { - const arc = (chart.bakeCellBytes(cp, rules_dir) catch |err| { + const arc = (chart.bakeChartBytes(cp, rules_dir) catch |err| { std.debug.print(" warn: bake of {s} failed ({s}); skipping\n", .{ cp, @errorName(err) }); continue; }) orelse continue; // no M_COVR coverage — not a composable cell @@ -113,9 +113,9 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } }.lt); - // Open the resident compositor over the per-cell archives (mmap'd) and serialize its ownership + // Open the resident compositor over the per-chart archives (mmap'd) and serialize its ownership // partition to /partition.tpart — the sidecar a runtime open loads to skip the build. - const src = (compose.openComposeSourceFiles(io, a, archive_paths.items, null) catch |err| { + const src = (compose.ComposeSource.openFiles(io, a, archive_paths.items, null) catch |err| { std.debug.print("error: open compose source failed ({s})\n", .{@errorName(err)}); return; }) orelse return usageErr("no coverage-carrying archives (nothing to compose)"); @@ -129,7 +129,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = part_path, .data = part_bytes }); std.debug.print( - "live structure -> {s}/\n {d} per-cell archive(s){s} + partition.tpart (serve z {d}..{d})\n", + "live structure -> {s}/\n {d} per-chart archive(s){s} + partition.tpart (serve z {d}..{d})\n", .{ out_dir, src.readers.len, if (from_archives) " (in place)" else " under tiles/", src.minz, src.loop_max }, ); } diff --git a/tools/cells.zig b/tools/cells.zig index 7e1c0b7..85570c2 100644 --- a/tools/cells.zig +++ b/tools/cells.zig @@ -15,7 +15,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { return; }; defer c.deinit(); - const json = (c.cellsJson() catch null) orelse { + const json = (c.chartsJson() catch null) orelse { std.debug.print("no cells\n", .{}); return; }; diff --git a/tools/common.zig b/tools/common.zig index 1789ac1..0ef054b 100644 --- a/tools/common.zig +++ b/tools/common.zig @@ -6,7 +6,7 @@ const std = @import("std"); const bundle = @import("bundle"); // chart-bundle pipeline (asset emitters etc.) — the lib owns it -pub const VERSION = "tile57 0.1.0"; +pub const VERSION = "tile57 0.3.0"; // Env access lives in the Lua C shim (Zig 0.16 gates env behind std.Io); // returns the S-101 rules dir from TILE57_S101_RULES or null. Mirrors capi.zig. @@ -143,7 +143,7 @@ pub fn printUsage() void { \\ \\usage: \\ tile57 bake -o [--rules DIR] [--from-archives] - \\ Produce a live-composite structure: bake each cell (a single .000 + + \\ Produce a live-composite structure: bake each chart (a single .000 + \\ its auto-discovered updates, OR every .000 in an ENC_ROOT, at \\ native band scale) to its own /tiles/.pmtiles with M_COVR \\ coverage embedded, then write the ownership partition to @@ -151,15 +151,16 @@ pub fn printUsage() void { \\ serves any tile ON DEMAND from this structure — there is no merged archive. \\ -o, --output DIR output directory (required) \\ --rules DIR S-101 portrayal rules directory (default: embedded) - \\ --from-archives is already a dir of per-cell archives; skip + \\ --from-archives is already a dir of per-chart archives; skip \\ baking, only (re)build partition.tpart over them \\ tile57 compose-tile [--load-partition FILE] [-o out] [--bench N] \\ Serve ONE composed tile on demand from a live-composite structure \\ (the runtime compositor); --bench N times an NxN block around (x,y). - \\ tile57 assets -o - \\ Emit just the portrayal assets (colortables.json today) for a - \\ catalogue, independent of any cell. - \\ tile57 style --scheme day -o + \\ tile57 assets [portrayal-catalog-dir] -o + \\ Emit the portrayal assets — colortables, linestyles, and the sprite + \\ + area-fill pattern atlases — independent of any chart. The + \\ catalogue defaults to the embedded copy. + \\ tile57 style [portrayal-catalog-dir] --scheme day -o \\ Emit one MapLibre style.json (colours from the catalogue, or \\ --colortables FILE). --scheme day|dusk|night; --source-tiles/ \\ --pmtiles-url pick the source; --sprite/--glyphs enable symbol/text @@ -168,7 +169,7 @@ pub fn printUsage() void { \\ tile57 png|pdf --view --size WxH -o \\ Render a tile or a view through the native S-52 pixel path: \\ PNG raster or deterministic vector PDF (real text objects). - \\ Sources: an S-57 cell (single-cell portrayal) or a baked .pmtiles + \\ Sources: an S-57 chart (single-chart portrayal) or a baked .pmtiles \\ bundle (tile replay). --dq data-quality overlay; --scale F \\ physical-size multiplier; --palette day|dusk|night. \\ tile57 ascii --view [--size COLSxROWS (default: terminal size)] [--ansi] [--kitty] @@ -177,19 +178,19 @@ pub fn printUsage() void { \\ tile57 explore [--class ACR[,ACR..]] [--object FOID|RCID|INDEX] \\ Dump, per feature, the RAW S-57 (class + attributes), the S-101 \\ portrayal instruction stream (raw + parsed), and the resolved - \\ Surface draw calls. Takes a SINGLE .000 cell (auto-applying its + \\ Surface draw calls. Takes a SINGLE .000 chart (auto-applying its \\ .001+ updates), or an ENC_ROOT with --view LON,LAT,ZOOM (or a - \\ "…/#v=LON,LAT,ZOOM" share URL) to pull just the cells under that + \\ "…/#v=LON,LAT,ZOOM" share URL) to pull just the charts under that \\ viewport (--viewport WxH overrides the assumed 1280x800 screen). \\ --zoom N picks the resolving tile; --json; --no-resolve skips the draw-call pass; \\ --tui opens the two-pane explorer (arrows select, / filters, q \\ quits); --kitty adds, in console mode, an isolated thumbnail of - \\ each feature's resolved render, and in the TUI a LIVE CELL MAP that - \\ frames the selection (whole cell on a class header, zoomed in to + \\ each feature's resolved render, and in the TUI a LIVE CHART MAP that + \\ frames the selection (whole chart on a class header, zoomed in to \\ frame a feature; m toggles map-only) — for graphics terminals. \\ tile57 inspect [z x y] \\ tile57 cell - \\ tile57 objlcount [prim] (corpus scan: find cells with an object class) + \\ tile57 objlcount [prim] (corpus scan: find charts with an object class) \\ tile57 version \\ tile57 help \\ diff --git a/tools/compose_tile.zig b/tools/compose_tile.zig index cecf953..e9fabad 100644 --- a/tools/compose_tile.zig +++ b/tools/compose_tile.zig @@ -108,7 +108,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // Open the resident source (mmap archives + partition once) — the amortised cost. const open_t0 = nowNs(); - const src = (compose.openComposeSourceFiles(io, a, paths.items, load_bytes) catch |err| { + const src = (compose.ComposeSource.openFiles(io, a, paths.items, load_bytes) catch |err| { std.debug.print("error: open compose source failed ({s})\n", .{@errorName(err)}); return; }) orelse { diff --git a/tools/main.zig b/tools/main.zig index e4269f3..a392f7b 100644 --- a/tools/main.zig +++ b/tools/main.zig @@ -2,14 +2,14 @@ //! //! Subcommands: //! bake -o [--rules DIR] [--from-archives] -//! Bake each cell to /tiles/.pmtiles and write the ownership +//! Bake each chart to /tiles/.pmtiles and write the ownership //! partition to /partition.tpart — the live-composite structure a //! runtime compositor serves tiles from on demand. //! inspect [z x y] //! Parse a PMTiles archive (header + directory) and, if z/x/y is given, //! read+gunzip+decode that tile and list its MVT layers. //! cell -//! Decode + summarise an S-57 cell (record tally, bounds, topology). +//! Decode + summarise an S-57 chart (record tally, bounds, topology). //! version //! Print the baker version. //! help From b27d386b6758d1f1f434591b111ebc955912ffbf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 20:08:46 -0400 Subject: [PATCH 128/140] feat(go)!: bindings track the v0.3.0 family rename 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 --- bindings/go/README.md | 6 +++--- bindings/go/bake.go | 40 +++++++++++++++++------------------ bindings/go/cell_test.go | 4 ++-- bindings/go/compose.go | 23 ++++++++++---------- bindings/go/compose_test.go | 12 +++++------ bindings/go/coverage.go | 6 +++--- bindings/go/errors.go | 2 +- bindings/go/partition_test.go | 2 +- bindings/go/s57meta.go | 32 ++++++++++++++-------------- bindings/go/s57meta_test.go | 10 ++++----- bindings/go/tile57.go | 32 ++++++++++++++-------------- bindings/go/tile57_test.go | 4 ++-- 12 files changed, 87 insertions(+), 86 deletions(-) diff --git a/bindings/go/README.md b/bindings/go/README.md index 879074f..e37a788 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -50,7 +50,7 @@ info := chart.Info() // zoom range, bounds, embedded 1:N scale scamin := chart.Scamin() // []uint32, ascending // Raw S-57 reading is handle-free (cell inventory, feature extraction). -cells, _ := tile57.Cells("/enc/ENC_ROOT") +charts, _ := tile57.Charts("/enc/ENC_ROOT") water, _ := tile57.Features("/enc/ENC_ROOT/US5MD1MC/US5MD1MC.000", "DEPARE", "DRGARE") ``` @@ -58,10 +58,10 @@ water, _ := tile57.Features("/enc/ENC_ROOT/US5MD1MC/US5MD1MC.000", "DEPARE", "DR - **Charts (a baked archive: metadata + query)** — `Open` (path, mmap'd), `OpenBytes`; `Source.Info`, `Meta`, `Scamin`, `Coverage`, `Close`. -- **S-57 source readers (handle-free)** — `Cells` (per-cell metadata of a .000 or +- **S-57 source readers (handle-free)** — `Charts` (per-chart metadata of a .000 or ENC_ROOT), `Features` / `FeaturesBytes` (GeoJSON extraction), `CatalogEntries` (CATALOG.031 decode). -- **Bake** — `BakeCell` (one cell → PMTiles bytes), `BakeTree` (an ENC_ROOT → per-cell +- **Bake** — `BakeChart` (one chart → PMTiles bytes), `BakeTree` (an ENC_ROOT → per-chart archives), `BakeAssets` (portrayal assets in memory). - **Compose** — `OpenCompose` (paths; owns its charts) / `OpenComposeCharts` (borrows yours); `ComposeSource.Serve` (a tile, with an ownership flag), `Meta`, diff --git a/bindings/go/bake.go b/bindings/go/bake.go index 3b0b3d8..a0922eb 100644 --- a/bindings/go/bake.go +++ b/bindings/go/bake.go @@ -24,15 +24,15 @@ func tile57GoBakeProgress(ctx unsafe.Pointer, done, total C.uint32_t) { } } -// BakeCell bakes ONE on-disk cell (a .000 path + its .001.. updates) to a PMTiles -// archive over its NATIVE band zoom range and returns the bytes — the per-cell tile +// BakeChart bakes ONE on-disk chart (a .000 path + its .001.. updates) to a PMTiles +// archive over its NATIVE band zoom range and returns the bytes — the per-chart tile // store the compositor consumes (it handles any cross-band zoom expansion). The -// archive metadata embeds that cell's own coverage (M_COVR + cscl + date/name); +// archive metadata embeds that chart's own coverage (M_COVR + cscl + date/name); // read it back with [PMTilesMetadata], or open the archive with [OpenBytes]. A -// cell that produces no tiles returns ErrNoCoverage. -func BakeCell(path string) ([]byte, error) { +// chart that produces no tiles returns ErrNoCoverage. +func BakeChart(path string) ([]byte, error) { if path == "" { - return nil, fmt.Errorf("tile57: BakeCell needs a cell path: %w", ErrEmptyInput) + return nil, fmt.Errorf("tile57: BakeChart needs a chart path: %w", ErrEmptyInput) } cPath := C.CString(path) defer C.free(unsafe.Pointer(cPath)) @@ -40,22 +40,22 @@ func BakeCell(path string) ([]byte, error) { var out *C.uint8_t var outLen C.size_t var cerr C.tile57_error - if st := C.tile57_bake_cell_bytes(cPath, &out, &outLen, &cerr); st != C.TILE57_OK { + if st := C.tile57_bake_chart_bytes(cPath, &out, &outLen, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } if out == nil { - return nil, fmt.Errorf("tile57: cell bake produced no tiles: %w", ErrNoCoverage) + return nil, fmt.Errorf("tile57: chart bake produced no tiles: %w", ErrNoCoverage) } return tileBytes(out, outLen), nil } -// BakeTree walks inDir for S-57 base cells (*.000) and bakes each IN PARALLEL to the SAME relative +// BakeTree walks inDir for S-57 base charts (*.000) and bakes each IN PARALLEL to the SAME relative // path under outDir with a .pmtiles extension (+ an .sha content-hash sidecar), creating -// subdirs. INCREMENTAL: a cell whose archive is already up to date (newer than its .000 and its +// subdirs. INCREMENTAL: a chart whose archive is already up to date (newer than its .000 and its // update chain) is skipped, so a re-run over an unchanged tree bakes nothing — 0 over a warm cache // is success. The engine writes and frees each archive as it goes, so this never holds N archives // in memory (peak ~ workers). outDir is the caller's OWN cache — it owns the location + layout, so -// distinct consumers don't clash. onProgress(done, total) fires per baked cell (may be called +// distinct consumers don't clash. onProgress(done, total) fires per baked chart (may be called // concurrently from workers, so it must be safe for concurrent use). Returns the number baked // THIS run. func BakeTree(inDir, outDir string, workers int, onProgress func(done, total int)) (int, error) { @@ -87,7 +87,7 @@ func BakeTree(inDir, outDir string, workers int, onProgress func(done, total int } // PMTilesMetadata returns a PMTiles archive's metadata JSON blob (decompressed), or -// nil if the archive carries none. A [BakeCell] archive embeds the cell's coverage +// nil if the archive carries none. A [BakeChart] archive embeds the chart's coverage // under a "coverage" key. The pmtiles bytes are read but not retained. func PMTilesMetadata(pmtiles []byte) ([]byte, error) { if len(pmtiles) == 0 { @@ -123,8 +123,8 @@ const ( ) // BakePartitionDebug bakes the ownership-partition DEBUG tiles from an on-disk -// ENC_ROOT into a single PMTiles at outPath — the composited faces (which cell renders -// which ground at each band), one polygon per owning cell in a layer named "partition" +// ENC_ROOT into a single PMTiles at outPath — the composited faces (which chart renders +// which ground at each band), one polygon per owning chart in a layer named "partition" // with the properties cell/cscl/band/tier/oi/color, and NO portrayed chart content. It // is the raw material for a partition-debug UI: point a MapLibre style (using the // vector_layers metadata) at it and fill by the "color" property. @@ -133,8 +133,8 @@ const ( // [BandBerthing]…[BandOverview] emit that one band's own map at every zoom. minZoom and // maxZoom bound the tiles — the coarse bands are cheap, but harbor-level detail (maxZoom // >= 13) multiplies the tile count ~4× per zoom, so raise it deliberately. Returns the -// cell count; nothing covered returns ErrNoCoverage (no file written). -func BakePartitionDebug(encRoot, outPath string, minZoom, maxZoom uint8, band PartitionBand) (cellCount int, err error) { +// chart count; nothing covered returns ErrNoCoverage (no file written). +func BakePartitionDebug(encRoot, outPath string, minZoom, maxZoom uint8, band PartitionBand) (chartCount int, err error) { if encRoot == "" || outPath == "" { return 0, fmt.Errorf("tile57: BakePartitionDebug needs an ENC root and out path: %w", ErrEmptyInput) } @@ -143,13 +143,13 @@ func BakePartitionDebug(encRoot, outPath string, minZoom, maxZoom uint8, band Pa cOut := C.CString(outPath) defer C.free(unsafe.Pointer(cOut)) - var cells C.uint32_t + var charts C.uint32_t var cerr C.tile57_error - if st := C.tile57_bake_partition_debug(cRoot, cOut, C.uint8_t(minZoom), C.uint8_t(maxZoom), C.int8_t(band), &cells, &cerr); st != C.TILE57_OK { + if st := C.tile57_bake_partition_debug(cRoot, cOut, C.uint8_t(minZoom), C.uint8_t(maxZoom), C.int8_t(band), &charts, &cerr); st != C.TILE57_OK { return 0, statusError(st, &cerr) } - if cells == 0 { + if charts == 0 { return 0, fmt.Errorf("tile57: partition-debug bake covered nothing: %w", ErrNoCoverage) } - return int(cells), nil + return int(charts), nil } diff --git a/bindings/go/cell_test.go b/bindings/go/cell_test.go index a63eff6..5fbeb44 100644 --- a/bindings/go/cell_test.go +++ b/bindings/go/cell_test.go @@ -16,9 +16,9 @@ func TestBakeCellEmbedsCoverage(t *testing.T) { t.Skipf("no test cell %s: %v", testCell, err) } - pm, err := BakeCell(testCell) + pm, err := BakeChart(testCell) if err != nil { - t.Fatalf("BakeCell: %v", err) + t.Fatalf("BakeChart: %v", err) } if len(pm) < 7 || string(pm[:7]) != "PMTiles" { t.Fatalf("not a PMTiles archive (got %d bytes)", len(pm)) diff --git a/bindings/go/compose.go b/bindings/go/compose.go index 882b4ab..effddb1 100644 --- a/bindings/go/compose.go +++ b/bindings/go/compose.go @@ -25,10 +25,11 @@ type ComposeSource struct { owned []*Source // charts OpenCompose opened for us; closed on Close } -// ComposeMeta is a ComposeSource's served zoom range + union coverage bounds (degrees). +// ComposeMeta is a ComposeSource's served zoom range, coverage-carrying chart +// count, and union coverage bounds (degrees). type ComposeMeta struct { MinZoom, MaxZoom uint8 - Cells uint32 + Charts uint32 West, South, East, North float64 } @@ -47,7 +48,7 @@ func OpenComposeCharts(charts []*Source, partitionPath string) (*ComposeSource, var ar cArena defer ar.free() // A C array of chart handles — pointer-free from Go's view (cgocheck-safe). - cc := (**C.tile57)(ar.track(C.malloc(C.size_t(len(charts)) * C.size_t(unsafe.Sizeof((*C.tile57)(nil)))))) + cc := (**C.tile57_chart)(ar.track(C.malloc(C.size_t(len(charts)) * C.size_t(unsafe.Sizeof((*C.tile57_chart)(nil)))))) cv := unsafe.Slice(cc, len(charts)) for i, s := range charts { if s == nil || s.ptr == nil { @@ -72,9 +73,9 @@ func OpenComposeCharts(charts []*Source, partitionPath string) (*ComposeSource, return &ComposeSource{ptr: ptr}, nil } -// OpenCompose opens a resident compositor over the per-cell PMTiles at paths (each -// written by [BakeCell] / [BakeTree]): every path is opened as a chart (mmap'd, so -// the cell set is never fully resident) and the compositor OWNS those charts — +// OpenCompose opens a resident compositor over the per-chart PMTiles at paths +// (each written by [BakeChart] / [BakeTree]): every path is opened as a chart +// (mmap'd, so the chart set is never fully resident) and the compositor OWNS those charts — // Close releases them too. See [OpenComposeCharts] to compose over charts you // keep. partitionPath as in OpenComposeCharts. func OpenCompose(paths []string, partitionPath string) (*ComposeSource, error) { @@ -105,9 +106,9 @@ func OpenCompose(paths []string, partitionPath string) (*ComposeSource, error) { } // Tile composes the tile (z,x,y) on demand, returning raw (decompressed) MLT bytes plus `owned` — -// whether the ownership partition says a cell SHOULD render here. body!=nil → composed (owned). body -// ==nil && owned → a cell owns this ground but produced nothing (transient while its per-cell bake -// runs; an error state once bakes are done). body==nil && !owned → true empty ocean (safe to cache). +// whether the ownership partition says a chart SHOULD render here. body!=nil → composed (owned). +// body==nil && owned → a chart owns this ground but produced nothing (transient while its per-chart +// bake runs; an error state once bakes are done). body==nil && !owned → true empty ocean (safe to cache). // Thread-safe (serialised). func (c *ComposeSource) Tile(z uint8, x, y uint32) (body []byte, owned bool, err error) { c.mu.Lock() @@ -136,11 +137,11 @@ func (c *ComposeSource) Meta() ComposeMeta { return ComposeMeta{} } var m C.tile57_compose_meta - C.tile57_compose_meta_get(c.ptr, &m) + C.tile57_compose_get_meta(c.ptr, &m) return ComposeMeta{ MinZoom: uint8(m.min_zoom), MaxZoom: uint8(m.max_zoom), - Cells: uint32(m.cells), + Charts: uint32(m.charts), West: float64(m.west), South: float64(m.south), East: float64(m.east), diff --git a/bindings/go/compose_test.go b/bindings/go/compose_test.go index 5737aa6..a1ca460 100644 --- a/bindings/go/compose_test.go +++ b/bindings/go/compose_test.go @@ -32,8 +32,8 @@ func TestComposeSingleCell(t *testing.T) { t.Fatalf("OpenComposeCharts: %v", err) } m := src.Meta() - if m.Cells != 1 { - t.Fatalf("compose cells = %d, want 1", m.Cells) + if m.Charts != 1 { + t.Fatalf("compose charts = %d, want 1", m.Charts) } // The fill-up bake serves from z0, but a single harbour cell is sub-pixel at // world zooms (its low-zoom tiles are legitimately empty) — probe a zoom @@ -108,10 +108,10 @@ func TestOpenComposeServe(t *testing.T) { defer src.Close() m := src.Meta() - t.Logf("compose: %d cells, z%d..%d, bounds [%.3f, %.3f, %.3f, %.3f]", - m.Cells, m.MinZoom, m.MaxZoom, m.West, m.South, m.East, m.North) - if m.Cells == 0 { - t.Fatal("no coverage-carrying cells") + t.Logf("compose: %d charts, z%d..%d, bounds [%.3f, %.3f, %.3f, %.3f]", + m.Charts, m.MinZoom, m.MaxZoom, m.West, m.South, m.East, m.North) + if m.Charts == 0 { + t.Fatal("no coverage-carrying charts") } // Serve the tile at the coverage centre, a few zooms into the range — the centre of real diff --git a/bindings/go/coverage.go b/bindings/go/coverage.go index a287ab0..f054d2f 100644 --- a/bindings/go/coverage.go +++ b/bindings/go/coverage.go @@ -10,9 +10,9 @@ package tile57 extern void tile57GoCoverageRing(void *ctx, double *lonlat, size_t npts); // Build the callback table C-side so no C function pointer crosses into Go. -static tile57_status t57CoverageGo(tile57 *chart, void *handle, tile57_error *err) { +static tile57_status t57CoverageGo(tile57_chart *chart, void *handle, tile57_error *err) { tile57_coverage_cb cb = {handle, (void (*)(void *, const double *, size_t))tile57GoCoverageRing}; - return tile57_coverage(chart, &cb, err); + return tile57_chart_coverage(chart, &cb, err); } */ import "C" @@ -39,7 +39,7 @@ func tile57GoCoverageRing(ctx unsafe.Pointer, lonlat *C.double, npts C.size_t) { // Coverage returns the chart's M_COVR data-coverage polygons (one exterior ring // per polygon, lon/lat points) from the coverage the bake embedded in the archive // metadata — the real coverage a host reports so a quilt fills gaps to coarser -// cells. Nil when the archive embeds none (a composed/foreign archive). +// charts. Nil when the archive embeds none (a composed/foreign archive). func (s *Source) Coverage() ([][][2]float64, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/bindings/go/errors.go b/bindings/go/errors.go index 6bc0c3f..c92c732 100644 --- a/bindings/go/errors.go +++ b/bindings/go/errors.go @@ -16,7 +16,7 @@ var ( // ErrSourceClosed is returned by Source methods invoked after Close. ErrSourceClosed = errors.New("tile57: source closed") - // ErrEmptyInput is returned when a call is handed no usable input — no cells, + // ErrEmptyInput is returned when a call is handed no usable input — no charts, // empty bytes, an empty style template, or empty asset inputs. ErrEmptyInput = errors.New("tile57: empty input") diff --git a/bindings/go/partition_test.go b/bindings/go/partition_test.go index 29af2ac..684fe7b 100644 --- a/bindings/go/partition_test.go +++ b/bindings/go/partition_test.go @@ -19,7 +19,7 @@ func TestBakePartitionDebug(t *testing.T) { t.Fatal(err) } if n == 0 { - t.Fatal("baked 0 cells") + t.Fatal("baked 0 charts") } fi, err := os.Stat(out) if err != nil || fi.Size() == 0 { diff --git a/bindings/go/s57meta.go b/bindings/go/s57meta.go index 9d472c2..ef90f92 100644 --- a/bindings/go/s57meta.go +++ b/bindings/go/s57meta.go @@ -2,7 +2,7 @@ package tile57 -// Raw S-57 access, handle-free: per-cell metadata, GeoJSON feature extraction, +// Raw S-57 access, handle-free: per-chart metadata, GeoJSON feature extraction, // and exchange-set catalogue decode — everything a host previously parsed from // S-57/ISO-8211 itself. With these, a host's S-57 knowledge shrinks to file // staging conventions (.000/.NNN, ENC_ROOT/, CATALOG.031 as opaque bytes). @@ -20,10 +20,10 @@ import ( "unsafe" ) -// CellInfo is one cell's identity + coverage, as recorded in its DSID/DSPM +// ChartRecord is one chart's identity + coverage, as recorded in its DSID/DSPM // after the applied update chain. -type CellInfo struct { - Name string `json:"name"` // cell stem, e.g. "US5MD1MC" +type ChartRecord struct { + Name string `json:"name"` // chart stem, e.g. "US5MD1MC" Scale int `json:"scale"` // DSPM CSCL (1:N) Edition string `json:"edition"` // DSID EDTN Update string `json:"update"` // DSID UPDN (last applied update) @@ -33,10 +33,10 @@ type CellInfo struct { HasBBox bool `json:"-"` } -// Cells returns the per-cell metadata of the S-57 data at path — one .000 file +// Charts returns the per-chart metadata of the S-57 data at path — one .000 file // (with its update chain applied) or a whole ENC_ROOT directory — for a host's // chart-database scan. -func Cells(path string) ([]CellInfo, error) { +func Charts(path string) ([]ChartRecord, error) { if path == "" { return nil, fmt.Errorf("tile57: empty path: %w", ErrEmptyInput) } @@ -45,7 +45,7 @@ func Cells(path string) ([]CellInfo, error) { var out *C.uint8_t var outLen C.size_t var cerr C.tile57_error - if st := C.tile57_enc_cells(cPath, &out, &outLen, &cerr); st != C.TILE57_OK { + if st := C.tile57_enc_charts(cPath, &out, &outLen, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } if out == nil { @@ -53,15 +53,15 @@ func Cells(path string) ([]CellInfo, error) { } raw := tileBytes(out, outLen) var wire []struct { - CellInfo + ChartRecord BBox []float64 `json:"bbox"` } if err := json.Unmarshal(raw, &wire); err != nil { - return nil, fmt.Errorf("tile57: cell metadata decode: %w", err) + return nil, fmt.Errorf("tile57: chart metadata decode: %w", err) } - infos := make([]CellInfo, len(wire)) + infos := make([]ChartRecord, len(wire)) for i, w := range wire { - infos[i] = w.CellInfo + infos[i] = w.ChartRecord if len(w.BBox) == 4 { copy(infos[i].BBox[:], w.BBox) infos[i].HasBBox = true @@ -74,7 +74,7 @@ func Cells(path string) ([]CellInfo, error) { type CatalogEntry struct { File string `json:"file"` // recorded path, '/'-normalised LongName string `json:"longName"` // LFIL — the human chart title ("" when absent) - Impl string `json:"impl"` // "BIN" (a cell), "ASC", "TXT" + Impl string `json:"impl"` // "BIN" (a chart), "ASC", "TXT" BBox [4]float64 `json:"bbox"` // [west, south, east, north] HasBBox bool `json:"-"` } @@ -122,10 +122,10 @@ type Feature struct { Geometry json.RawMessage // the GeoJSON geometry object (lon/lat; soundings carry depth as a 3rd coord) } -// Features returns the features of the S-57 data at path (one cell, updates +// Features returns the features of the S-57 data at path (one chart, updates // applied, or a whole ENC_ROOT) for the given object-class acronyms (e.g. // "DEPARE", "DRGARE"), parsed without portrayal. A whole-ENC_ROOT extraction -// walks every cell — the caller owns that cost. +// walks every chart — the caller owns that cost. func Features(path string, classes ...string) ([]Feature, error) { if path == "" { return nil, fmt.Errorf("tile57: empty path: %w", ErrEmptyInput) @@ -146,11 +146,11 @@ func Features(path string, classes ...string) ([]Feature, error) { return decodeFeatures(out, outLen) } -// FeaturesBytes is [Features] over in-memory base-cell bytes (a .000 read from a +// FeaturesBytes is [Features] over in-memory base .000 bytes (read from a // zip member, say). No update chain is applied. func FeaturesBytes(base []byte, classes ...string) ([]Feature, error) { if len(base) == 0 { - return nil, fmt.Errorf("tile57: empty cell bytes: %w", ErrEmptyInput) + return nil, fmt.Errorf("tile57: empty chart bytes: %w", ErrEmptyInput) } if len(classes) == 0 { return nil, nil diff --git a/bindings/go/s57meta_test.go b/bindings/go/s57meta_test.go index 0ac00bc..984f50c 100644 --- a/bindings/go/s57meta_test.go +++ b/bindings/go/s57meta_test.go @@ -11,14 +11,14 @@ import ( // TestCells reads the testdata cell's DSID identity + coverage handle-free — // the metadata a host previously parsed from ISO-8211 itself. func TestCells(t *testing.T) { - cells, err := Cells(testCell) + charts, err := Charts(testCell) if err != nil { - t.Fatalf("Cells: %v", err) + t.Fatalf("Charts: %v", err) } - if len(cells) != 1 { - t.Fatalf("cells = %d, want 1", len(cells)) + if len(charts) != 1 { + t.Fatalf("charts = %d, want 1", len(charts)) } - c := cells[0] + c := charts[0] if c.Name != "US5MD1MC" { t.Errorf("name = %q, want US5MD1MC", c.Name) } diff --git a/bindings/go/tile57.go b/bindings/go/tile57.go index e7fbb51..ff7f9f7 100644 --- a/bindings/go/tile57.go +++ b/bindings/go/tile57.go @@ -10,12 +10,12 @@ // below link it by a path relative to this package, so an importing module needs a // `replace` pointing at a local checkout (see this package's README). // -// The pipeline mirrors the C header: bake ENC cells to per-cell PMTiles -// ([BakeCell] / [BakeTree]), open each archive as a [Source] ([Open] / +// The pipeline mirrors the C header: bake ENC charts to per-chart PMTiles +// ([BakeChart] / [BakeTree]), open each archive as a [Source] ([Open] / // [OpenBytes]) for metadata, and compose the open charts into one seamless tile // pyramid with [OpenCompose] / [OpenComposeCharts] for serving. Raw S-57 reading -// (cell inventory, feature extraction, catalogue decode) is handle-free — see -// [Cells], [Features], [FeaturesBytes], [CatalogEntries]. +// (chart inventory, feature extraction, catalogue decode) is handle-free — see +// [Charts], [Features], [FeaturesBytes], [CatalogEntries]. // // libtile57 is NOT internally synchronized, so every call into a handle is // guarded by a mutex. @@ -35,7 +35,7 @@ import ( "unsafe" ) -// Version returns the libtile57 version string (e.g. "0.2.0"). +// Version returns the libtile57 version string (e.g. "0.3.0"). func Version() string { return C.GoString(C.tile57_version()) } // statusError turns a non-OK tile57_status (with the optional tile57_error the @@ -113,7 +113,7 @@ type Meta struct { // are serialized internally. type Source struct { mu sync.Mutex - ptr *C.tile57 + ptr *C.tile57_chart scamin []uint32 // cached SCAMIN manifest (resolved once on first use) scaminDone bool } @@ -127,23 +127,23 @@ func Open(path string) (*Source, error) { } cPath := C.CString(path) defer C.free(unsafe.Pointer(cPath)) - var ptr *C.tile57 + var ptr *C.tile57_chart var cerr C.tile57_error - if st := C.tile57_open(cPath, &ptr, &cerr); st != C.TILE57_OK { + if st := C.tile57_chart_open(cPath, &ptr, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } return &Source{ptr: ptr}, nil } // OpenBytes opens a baked PMTiles archive from in-memory bytes (e.g. straight from -// [BakeCell], before any file exists). Bytes are copied. +// [BakeChart], before any file exists). Bytes are copied. func OpenBytes(pmtiles []byte) (*Source, error) { if len(pmtiles) == 0 { return nil, fmt.Errorf("tile57: empty archive bytes: %w", ErrEmptyInput) } - var ptr *C.tile57 + var ptr *C.tile57_chart var cerr C.tile57_error - if st := C.tile57_open_bytes((*C.uint8_t)(unsafe.Pointer(&pmtiles[0])), C.size_t(len(pmtiles)), &ptr, &cerr); st != C.TILE57_OK { + if st := C.tile57_chart_open_bytes((*C.uint8_t)(unsafe.Pointer(&pmtiles[0])), C.size_t(len(pmtiles)), &ptr, &cerr); st != C.TILE57_OK { return nil, statusError(st, &cerr) } return &Source{ptr: ptr}, nil @@ -161,7 +161,7 @@ type ChartInfo struct { AnchorLat, AnchorLon, AnchorZoom float64 // TileType is the archive's stored encoding (FormatMVT or FormatMLT). TileType TileFormat - // NativeScale is the compilation scale (1:N) embedded by the per-cell bake; + // NativeScale is the compilation scale (1:N) embedded by the per-chart bake; // 0 = unknown (a composed/foreign archive — derive from the zoom band). NativeScale int32 } @@ -171,7 +171,7 @@ func (s *Source) Info() ChartInfo { s.mu.Lock() defer s.mu.Unlock() var ci C.tile57_info - C.tile57_get_info(s.ptr, &ci) + C.tile57_chart_get_info(s.ptr, &ci) return ChartInfo{ MinZoom: uint8(ci.min_zoom), MaxZoom: uint8(ci.max_zoom), Bands: uint32(ci.bands), @@ -195,7 +195,7 @@ func (s *Source) Meta() Meta { return m } var ci C.tile57_info - C.tile57_get_info(s.ptr, &ci) + C.tile57_chart_get_info(s.ptr, &ci) m.MinZoom, m.MaxZoom = uint8(ci.min_zoom), uint8(ci.max_zoom) if bool(ci.has_bounds) { m.W, m.S, m.E, m.N = float64(ci.west), float64(ci.south), float64(ci.east), float64(ci.north) @@ -224,7 +224,7 @@ func (s *Source) scaminLocked() []uint32 { var out *C.int32_t var n C.size_t var cerr C.tile57_error - if C.tile57_scamin(s.ptr, &out, &n, &cerr) == C.TILE57_OK && out != nil && n > 0 { + if C.tile57_chart_scamin(s.ptr, &out, &n, &cerr) == C.TILE57_OK && out != nil && n > 0 { vals := unsafe.Slice(out, int(n)) res := make([]uint32, n) for i, v := range vals { @@ -243,7 +243,7 @@ func (s *Source) Close() error { s.mu.Lock() defer s.mu.Unlock() if s.ptr != nil { - C.tile57_close(s.ptr) + C.tile57_chart_close(s.ptr) s.ptr = nil } return nil diff --git a/bindings/go/tile57_test.go b/bindings/go/tile57_test.go index d5f71c6..e0711c1 100644 --- a/bindings/go/tile57_test.go +++ b/bindings/go/tile57_test.go @@ -34,9 +34,9 @@ func bakeTestCell(t *testing.T) []byte { if _, err := os.Stat(testCell); err != nil { t.Skipf("no test cell: %v", err) } - pm, err := BakeCell(testCell) + pm, err := BakeChart(testCell) if err != nil { - t.Fatalf("BakeCell: %v", err) + t.Fatalf("BakeChart: %v", err) } return pm } From b8e84992ee83eb3bf49b3fc42dbe277ffd9bbb25 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 20:11:17 -0400 Subject: [PATCH 129/140] docs: track the v0.3.0 family rename across every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 placeholders and cell/cells commands. Co-Authored-By: Claude Fable 5 --- docs/docs/architecture.md | 12 ++-- docs/docs/c-api.md | 111 +++++++++++++++++------------------ docs/docs/getting-started.md | 16 ++--- docs/docs/installation.md | 2 +- docs/docs/limitations.md | 2 +- docs/docs/rendering.md | 12 ++-- docs/docs/zig-api.md | 41 +++++++------ 7 files changed, 97 insertions(+), 99 deletions(-) diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 313736a..f723865 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -130,13 +130,13 @@ The public surface composes the packages into high-level entry points: bytes), then take its outputs: view renders (`renderView` — PNG, PDF, or a callback canvas; `renderSurfaceView` — world-space GPU callbacks), the cursor pick (`queryPoint`), the metadata getters, and — for an archive — its stored - tiles verbatim through `pmtilesReader()`. In the C ABI: `tile57_tile` / - `tile57_png` / `tile57_pdf` / `tile57_canvas` / `tile57_surface` / - `tile57_query`. A streaming ENC_ROOT open (`openPath` / `openCells` / - `openCellsStreaming`) is the metadata + extraction view of raw source data + tiles verbatim through `pmtilesReader()`. In the C ABI: `tile57_chart_tile` / + `tile57_chart_png` / `tile57_chart_pdf` / `tile57_chart_canvas` / `tile57_chart_surface` / + `tile57_chart_query`. A streaming ENC_ROOT open (`openPath` / `openCharts` / + `openChartsStreaming`) is the metadata + extraction view of raw source data (the C `tile57_enc_*` readers); it serves no tiles or renders. - **Tile production** — bake each chart to its own PMTiles at its compilation scale - (`tile57_bake_cell_bytes`, which runs the banded bake engine `scene/bake_enc.zig` + (`tile57_bake_chart_bytes`, which runs the banded bake engine `scene/bake_enc.zig` on a single chart), then a runtime **compositor** stitches the overlapping charts through an ownership partition and offers the SAME outputs as a chart, composed: `tile57_compose_tile` for any `(z, x, y)` on demand, `tile57_compose_png` / @@ -159,7 +159,7 @@ tile57 is built to hold only its working set: scales are resolved by the precomputed ownership partition — each tile's ground belongs to exactly one chart per band, so composing never loads every overlapping chart. -- **Streaming open.** `openCellsStreaming` (and its on-disk driver `openPath`, +- **Streaming open.** `openChartsStreaming` (and its on-disk driver `openPath`, which backs the C `tile57_enc_*` readers) take per-chart metadata (bbox + scale) plus a reader; a chart's bytes are read only on demand and freed on eviction. A diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index 7a0fd07..7b602bc 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -20,8 +20,8 @@ same way: catalogue). - **Render** — a **`tile57`** chart handle opens ONE baked archive and answers for it with NO composition: metadata (info / SCAMIN / coverage), its stored - tiles verbatim (`tile57_tile` — the primitive for writing your own - compositor), the S-52 cursor pick, and view outputs (`tile57_png` / `_pdf` / + tiles verbatim (`tile57_chart_tile` — the primitive for writing your own + compositor), the S-52 cursor pick, and view outputs (`tile57_chart_png` / `_pdf` / `_canvas` / `_surface`). - **Compose** — a **`tile57_compose`** handle stitches MANY open charts through the ownership partition and offers the SAME output set, composed: @@ -50,7 +50,7 @@ typedef enum { TILE57_OK = 0, /* success */ TILE57_ERR_BADARG, /* a NULL or out-of-range argument */ TILE57_ERR_IO, /* a file/directory could not be opened, read, or written */ - TILE57_ERR_PARSE, /* malformed input (S-57 cell, PMTiles, partition, JSON) */ + TILE57_ERR_PARSE, /* malformed input (S-57 chart, PMTiles, partition, JSON) */ TILE57_ERR_NOMEM, /* an allocation failed */ TILE57_ERR_UNSUPPORTED, /* valid but unusable input */ TILE57_ERR_RENDER, /* tile generation or rendering failed */ @@ -67,9 +67,9 @@ typedef struct { ``` ```c -tile57 *chart = NULL; +tile57_chart *chart = NULL; tile57_error err; -if (tile57_open("US5MD1MC.pmtiles", &chart, &err) != TILE57_OK) { +if (tile57_chart_open("US5MD1MC.pmtiles", &chart, &err) != TILE57_OK) { fprintf(stderr, "open failed: %s\n", err.message); /* "path: reason" */ } ``` @@ -90,36 +90,35 @@ own PMTiles at its compilation scale; the archive embeds the chart's M_COVR coverage, compilation scale, and identity in its metadata. Then open a **compositor** over the archives and serve any `(z, x, y)` tile on demand — the compositor stitches the overlapping charts through an ownership partition, -handling cross-band zoom. (The bake and enc APIs keep S-57's own word for a -chart — a *cell*: `tile57_bake_cell_bytes`, `tile57_enc_cells`.) +handling cross-band zoom. ```c -/* Bake ONE cell (+ its .001.. updates, read from disk) to PMTiles bytes over its +/* Bake ONE chart (+ its .001.. updates, read from disk) to PMTiles bytes over its * native band zoom range. Returned in *out/*out_len (free with tile57_free); - * NULL/0 when the cell produced no tiles. */ -tile57_status tile57_bake_cell_bytes(const char *path, uint8_t **out, size_t *out_len, + * NULL/0 when the chart produced no tiles. */ +tile57_status tile57_bake_chart_bytes(const char *path, uint8_t **out, size_t *out_len, tile57_error *err); -/* Bake `n` cells IN PARALLEL across up to `workers` threads (a MEMORY bound — - * pass a small count). out_bytes[i]/out_lens[i] receive cell i's archive or - * NULL/0; *out_baked (NULL to ignore) counts the cells that produced bytes. */ -tile57_status tile57_bake_cells(const char *const *paths, size_t n, uint32_t workers, +/* Bake `n` charts IN PARALLEL across up to `workers` threads (a MEMORY bound — + * pass a small count). out_bytes[i]/out_lens[i] receive chart i's archive or + * NULL/0; *out_baked (NULL to ignore) counts the charts that produced bytes. */ +tile57_status tile57_bake_charts(const char *const *paths, size_t n, uint32_t workers, uint8_t **out_bytes, size_t *out_lens, size_t *out_baked, tile57_error *err); -/* Walk in_dir for *.000 cells and bake each IN PARALLEL to the SAME relative +/* Walk in_dir for *.000 charts and bake each IN PARALLEL to the SAME relative * path under out_dir with a .pmtiles extension (+ an .sha sidecar). - * INCREMENTAL: a cell whose archive is already at least as new as its whole + * INCREMENTAL: a chart whose archive is already at least as new as its whole * input (.000 + update chain) is skipped, so a re-run over an unchanged tree * bakes nothing — *out_baked counts THIS run, and 0 over a warm cache is - * success. progress (or NULL) fires per cell, possibly from worker threads. */ + * success. progress (or NULL) fires per chart, possibly from worker threads. */ typedef void (*tile57_bake_progress)(void *ctx, uint32_t done, uint32_t total); tile57_status tile57_bake_tree(const char *in_dir, const char *out_dir, uint32_t workers, tile57_bake_progress progress, void *progress_ctx, uint32_t *out_baked, tile57_error *err); /* Read a PMTiles archive's metadata JSON blob (decompressed); NULL/0 when the - * archive carries none. A per-cell bake embeds the cell's coverage + cscl + + * archive carries none. A per-chart bake embeds the chart's coverage + cscl + * date/name under a "coverage" key. */ tile57_status tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, uint8_t **out, size_t *out_len, @@ -128,7 +127,7 @@ tile57_status tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, Every baked feature carries the pick-report properties `class` (object-class acronym), `cell` (source chart stem), and `s57` (the full S-57 attribute set as a -JSON object) — what `tile57_query` and a host inspector read back. +JSON object) — what `tile57_chart_query` and a host inspector read back. The `tile57 bake -o out/` CLI produces this structure directly: `out/tiles/.pmtiles` per chart plus `out/partition.tpart`. @@ -139,10 +138,10 @@ The bake section also reads the source data directly — no handle, no bake — a host's import UI: ```c -/* Per-cell metadata of the S-57 data at `path` (one .000, updates applied, or a +/* Per-chart metadata of the S-57 data at `path` (one .000, updates applied, or a * whole ENC_ROOT) as a JSON array: [{"name","scale","edition","update", * "issueDate","agency","bbox"}, ...] — a host's chart-database scan. */ -tile57_status tile57_enc_cells(const char *path, uint8_t **out, size_t *out_len, +tile57_status tile57_enc_charts(const char *path, uint8_t **out, size_t *out_len, tile57_error *err); /* Features for comma-separated object-class acronyms (e.g. "DEPARE,DRGARE") as @@ -151,7 +150,7 @@ tile57_status tile57_enc_cells(const char *path, uint8_t **out, size_t *out_len, tile57_status tile57_enc_features(const char *path, const char *classes, uint8_t **out, size_t *out_len, tile57_error *err); -/* The same over in-memory base-cell bytes (a .000 from a zip member, say). */ +/* The same over in-memory base .000 bytes (from a zip member, say). */ tile57_status tile57_enc_features_bytes(const uint8_t *base, size_t len, const char *classes, uint8_t **out, size_t *out_len, tile57_error *err); @@ -165,22 +164,22 @@ tile57_status tile57_enc_catalog(const uint8_t *catalog_031, size_t len, The CLI mirrors these as `tile57 cells`, `tile57 features`, and `tile57 catalog`. -## Render: the `tile57` chart handle +## Render: the `tile57_chart` handle -A `tile57` is a chart: ONE baked PMTiles archive, opened for metadata and +A `tile57_chart` is ONE baked PMTiles archive, opened for metadata and output — with no composition (the compositor below offers the same outputs across many charts). Open it from a path (mmap'd — a whole chart library can be open without being resident) or from bytes (copied). ```c -const char *tile57_version(void); /* "0.2.0" */ +const char *tile57_version(void); /* "0.3.0" */ /* Opaque chart handle: one open baked archive. */ -typedef struct tile57 tile57; +typedef struct tile57_chart tile57_chart; -tile57_status tile57_open(const char *path, tile57 **out, tile57_error *err); -tile57_status tile57_open_bytes(const uint8_t *pmtiles, size_t len, - tile57 **out, tile57_error *err); +tile57_status tile57_chart_open(const char *path, tile57_chart **out, tile57_error *err); +tile57_status tile57_chart_open_bytes(const uint8_t *pmtiles, size_t len, + tile57_chart **out, tile57_error *err); /* Vector-tile encodings an archive can store (reported in tile57_info.tile_type; * the engine bakes MLT). */ @@ -201,11 +200,11 @@ typedef struct { uint8_t tile_type; /* tile57_tile_type */ int32_t native_scale; } tile57_info; -void tile57_get_info(tile57 *chart, tile57_info *out); +void tile57_chart_get_info(tile57_chart *chart, tile57_info *out); /* The distinct SCAMIN denominators present in the chart (ascending); NULL/0 when * none. Free with tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ -tile57_status tile57_scamin(tile57 *chart, int32_t **out, size_t *out_len, +tile57_status tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len, tile57_error *err); /* The chart's M_COVR data-coverage polygons, from the coverage the bake embedded: @@ -216,18 +215,18 @@ typedef struct { void *ctx; void (*ring)(void *ctx, const double *lonlat, size_t npts); } tile57_coverage_cb; -tile57_status tile57_coverage(tile57 *chart, const tile57_coverage_cb *cb, +tile57_status tile57_chart_coverage(tile57_chart *chart, const tile57_coverage_cb *cb, tile57_error *err); /* The chart's own stored tile at (z,x,y), decompressed (MLT or MVT per * tile57_info.tile_type), with NO composition — the per-archive primitive for * an embedder writing its own compositor. NULL/0 when the archive has no tile * there. */ -tile57_status tile57_tile(tile57 *chart, uint8_t z, uint32_t x, uint32_t y, +tile57_status tile57_chart_tile(tile57_chart *chart, uint8_t z, uint32_t x, uint32_t y, uint8_t **out, size_t *out_len, tile57_error *err); /* Release a chart and all cached tiles (not while a compositor still holds it). */ -void tile57_close(tile57 *chart); +void tile57_chart_close(tile57_chart *chart); ``` ### Query the features under a point (object query / pick) @@ -250,16 +249,16 @@ typedef struct { void *ctx; void (*feature)(void *ctx, const char *cls, size_t cls_len, const char *s57, size_t s57_len, - const char *cell, size_t cell_len); + const char *chart, size_t chart_len); } tile57_query_cb; /* Calls cb->feature once per displayed feature under (lon,lat) at view `zoom`. * Callback pointers are valid only during that call. */ -tile57_status tile57_query(tile57 *chart, double lon, double lat, double zoom, +tile57_status tile57_chart_query(tile57_chart *chart, double lon, double lat, double zoom, const tile57_query_cb *cb, tile57_error *err); ``` -The class and cell come through for any chart; the attribute JSON is filled in +The class and chart name come through for any hit; the attribute JSON is filled in from the `s57` pick property baked into the tiles (empty if a chart was baked without pick attributes). @@ -279,14 +278,14 @@ with the [style builders](#build-a-maplibre-style) below. ```c /* PNG raster in *out/*out_len (free with tile57_free). */ -tile57_status tile57_png(tile57 *chart, double lon, double lat, double zoom, +tile57_status tile57_chart_png(tile57_chart *chart, double lon, double lat, double zoom, uint32_t width, uint32_t height, const tile57_mariner *m, uint8_t **out, size_t *out_len, tile57_error *err); /* Its vector twin: the SAME scene as a deterministic single-page PDF * (1 px = 1 pt, 72 dpi; vector fills + glyph-outline text). */ -tile57_status tile57_pdf(tile57 *chart, double lon, double lat, double zoom, +tile57_status tile57_chart_pdf(tile57_chart *chart, double lon, double lat, double zoom, uint32_t width, uint32_t height, const tile57_mariner *m, uint8_t **out, size_t *out_len, tile57_error *err); @@ -303,7 +302,7 @@ of draw calls in world space. A GPU host tessellates that stream once, then pans and zooms by transforming the vertices each frame, so symbols and text stay a constant size on screen and no re-portrayal is needed while the view moves. -You fill in a `tile57_surface_cb` vtable and pass it to `tile57_surface` (or +You fill in a `tile57_surface_cb` vtable and pass it to `tile57_chart_surface` (or `tile57_compose_surface` for the composed set). Area and line geometry come in web-mercator world coordinates (the range 0 to 1, with y pointing down). Point symbols, soundings, and @@ -341,7 +340,7 @@ typedef struct { } tile57_surface_cb; /* Portray the view once and drive the callbacks. */ -tile57_status tile57_surface(tile57 *chart, double lon, double lat, double zoom, +tile57_status tile57_chart_surface(tile57_chart *chart, double lon, double lat, double zoom, uint32_t width, uint32_t height, const tile57_mariner *m, const tile57_surface_cb *surface, tile57_error *err); @@ -356,7 +355,7 @@ those two fields NULL, the same features arrive as vector outlines instead. tile57 also declutters overlapping text for you before it makes the calls (symbols and soundings always draw, per S-52), so you don't repeat that work. -There is a pixel-space twin, `tile57_canvas` with a `tile57_canvas_cb` vtable, +There is a pixel-space twin, `tile57_chart_canvas` with a `tile57_canvas_cb` vtable, that emits the SAME portrayal as resolved paint-order draw calls in canvas pixels — for a host that wants the engine's own paint pipeline without the PNG encode. Both callback forms have composed twins (`tile57_compose_canvas` / @@ -376,11 +375,11 @@ Open once, serve many, close. /* Opaque runtime-compositor handle. */ typedef struct tile57_compose tile57_compose; -/* Coverage/zoom summary filled by tile57_compose_meta_get. */ +/* Coverage/zoom summary filled by tile57_compose_get_meta. */ typedef struct { uint8_t min_zoom; uint8_t max_zoom; /* deepest zoom served (native + one overscale zoom) */ - uint32_t cells; /* coverage-carrying charts held */ + uint32_t charts; /* coverage-carrying charts held */ double west, south, east, north; /* union coverage bounds, degrees */ } tile57_compose_meta; @@ -390,22 +389,22 @@ typedef struct { * written by tile57_compose_save_partition (the `tile57 bake` CLI emits * partition.tpart) — to load and skip the build; a missing/stale one falls back * to building. Close with tile57_compose_close BEFORE closing the charts. */ -tile57_status tile57_compose_open(tile57 *const *charts, size_t n, +tile57_status tile57_compose_open(tile57_chart *const *charts, size_t n, const char *partition_path, tile57_compose **out, tile57_error *err); /* Compose tile (z,x,y) on demand into RAW (decompressed) MLT — what a live tile * server hands its HTTP layer (which gzips on the wire). NULL/0 out with OK = * no bytes; *out_owned (NULL to ignore) then distinguishes the two empties: - * owned=false: no cell owns this ground — true empty ocean, safe to cache; - * owned=true: a cell owns this ground but produced nothing — transient while - * its per-cell bake is running, suspect once bakes are done. */ + * owned=false: no chart owns this ground — true empty ocean, safe to cache; + * owned=true: a chart owns this ground but produced nothing — transient while + * its per-chart bake is running, suspect once bakes are done. */ tile57_status tile57_compose_tile(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, uint8_t **out, size_t *out_len, bool *out_owned, tile57_error *err); /* The composed view outputs and pick — the section-4 calls across the WHOLE - * composed set: every covering tile is composed on demand (seams stitched + * composed set: every covering tile is composed on demand (stitched * through the ownership partition) and replayed through the S-52 pixel path. * Same parameters, limits, and ownership as the single-chart forms. */ tile57_status tile57_compose_png(tile57_compose *c, double lon, double lat, double zoom, @@ -424,7 +423,7 @@ tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, do const tile57_query_cb *cb, tile57_error *err); /* Fill *out with the compositor's zoom range + union coverage bounds. */ -void tile57_compose_meta_get(tile57_compose *c, tile57_compose_meta *out); +void tile57_compose_get_meta(tile57_compose *c, tile57_compose_meta *out); /* Serialize the ownership partition to `path` (a sidecar a later * tile57_compose_open loads to skip the build). */ @@ -437,9 +436,9 @@ void tile57_compose_close(tile57_compose *c); ```c /* bake -> open -> compose -> serve */ -tile57 *charts[2]; -tile57_open("tiles/US5MD1MC.pmtiles", &charts[0], NULL); -tile57_open("tiles/US5MD1MD.pmtiles", &charts[1], NULL); +tile57_chart *charts[2]; +tile57_chart_open("tiles/US5MD1MC.pmtiles", &charts[0], NULL); +tile57_chart_open("tiles/US5MD1MD.pmtiles", &charts[1], NULL); tile57_compose *cmp = NULL; tile57_compose_open(charts, 2, "partition.tpart", &cmp, NULL); uint8_t *tile; size_t n; bool owned; @@ -522,7 +521,7 @@ void tile57_mariner_defaults(tile57_mariner *m); /* canonical defaults, date_v /* enabled_bands: NULL = show all; else only features whose band rank is in the * array. scamin: the distinct SCAMIN denominators present in the source (e.g. - * from tile57_scamin) — when non-NULL the `_scamin` layers split into per-value + * from tile57_chart_scamin) — when non-NULL the `_scamin` layers split into per-value * native-minzoom buckets; scamin_lat is the representative latitude. */ tile57_status tile57_style_build(const char *template_json, size_t template_len, const tile57_mariner *m, @@ -571,7 +570,7 @@ tile57_status tile57_style_template(tile57_scheme scheme, const char *source_til ```c /* Populate the process-global read-only registries (feature catalogue + * complex-linestyle table) on the calling thread. Call ONCE on your main thread - * before opening or baking cells from worker threads, so concurrent bake/render is + * before opening or baking charts from worker threads, so concurrent bake/render is * race-free. Idempotent. */ void tile57_warmup(void); @@ -588,4 +587,4 @@ part of the embedding API. ## Versioning -Pre-1.0 (`0.2.0`). No external consumers yet, so the ABI is not frozen. +Pre-1.0 (`0.3.0`). No external consumers yet, so the ABI is not frozen. diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 64df424..5f1f09b 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -85,9 +85,9 @@ Open a compositor over the bake output and serve tiles by `(z, x, y)`: #include "tile57.h" /* Open each baked archive as a chart, then a compositor over the charts. */ -tile57 *chart = NULL; +tile57_chart *chart = NULL; tile57_error err; -if (tile57_open("out/tiles/US5MD1MC.pmtiles", &chart, &err) != TILE57_OK) { +if (tile57_chart_open("out/tiles/US5MD1MC.pmtiles", &chart, &err) != TILE57_OK) { fprintf(stderr, "%s\n", err.message); } tile57_compose *src = NULL; @@ -100,13 +100,13 @@ if (tile57_compose_tile(src, z, x, y, &tile, &n, &owned, &err) == TILE57_OK) { else { /* not owned — open ocean; cache as blank */ } } tile57_compose_close(src); /* the compositor borrows its charts… */ -tile57_close(chart); /* …so close them after it */ +tile57_chart_close(chart); /* …so close them after it */ ``` Link against `libtile57.a`. The `partition.tpart` sidecar (NULL to skip) lets the compositor load the ownership partition instead of rebuilding it. The chart and -the compositor offer the same outputs — `tile57_png(chart, …)` renders one chart -alone; `tile57_compose_png(src, …)` renders the composed set; `tile57_tile` +the compositor offer the same outputs — `tile57_chart_png(chart, …)` renders one chart +alone; `tile57_compose_png(src, …)` renders the composed set; `tile57_chart_tile` hands back one chart's stored tiles verbatim for anyone writing their own compositor. Raw S-57 reading (chart inventory, feature extraction) is handle-free via `tile57_enc_*`. See the [C API](./c-api.md). @@ -124,7 +124,7 @@ defer chart.deinit(); const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null // Or compose the whole bake output and take any output from it. -var src = (try tile57.compose.openComposeSourceFiles(io, gpa, paths, "out/partition.tpart")).?; +var src = (try tile57.compose.ComposeSource.openFiles(io, gpa, paths, "out/partition.tpart")).?; defer src.deinit(); const result = try src.tile(gpa, 15, 9371, 12534); // one composed tile const png = try tile57.compose.renderView(src, -76.48, 38.974, 13.5, @@ -132,13 +132,13 @@ const png = try tile57.compose.renderView(src, -76.48, 38.974, 13.5, ``` Baking (`tile57.bake.tree`), raw S-57 reading (`Chart.openPath` + -`cellsJson`/`featuresJson`), and the style builders are all in the same +`chartsJson`/`featuresJson`), and the style builders are all in the same package. See the [Zig API](./zig-api.md). ## ENC_ROOT and updates Open an ENC_ROOT (many charts, each with its sequential `.001`, `.002` … updates) -with `Chart.openPath` (Zig) or scan it with `tile57_enc_cells` (C) — a single +with `Chart.openPath` (Zig) or scan it with `tile57_enc_charts` (C) — a single call that walks a whole on-disk catalogue, applying each chart's updates. Baking (`tile57 bake` / `tile57_bake_tree`) turns it into the per-chart archives the compositor serves. diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 11d68b5..02b8c6d 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -44,7 +44,7 @@ zig build test # runs the test suite | `tile57` (`zig-out/bin/tile57`) | the offline CLI: bake charts/ENC_ROOTs to PMTiles or a chart bundle, and emit portrayal assets. | | `libtile57.a` | the static library behind the [C ABI](./c-api.md) (`include/tile57.h`). | -The engine is also a Zig package named `tile57` (v0.2.0); a Zig consumer +The engine is also a Zig package named `tile57` (v0.3.0); a Zig consumer depends on it and uses `@import("tile57")` — see the [Zig API](./zig-api.md). :::note Consume it as a path dependency (for now) diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 95e4bc1..600b660 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -98,7 +98,7 @@ has its own short list of deliberate gaps — see ## ENC_ROOT loading -Opening an ENC_ROOT (`Chart.openPath` / `openCellsStreaming`) builds a cheap +Opening an ENC_ROOT (`Chart.openPath` / `openChartsStreaming`) builds a cheap spatial index (band + bbox per chart) and reads a chart's bytes only when a metadata or feature query needs them — the catalogue opens in seconds and memory stays bounded. It serves metadata and extraction only: tiles and views diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index 4d3c072..b2a95e9 100644 --- a/docs/docs/rendering.md +++ b/docs/docs/rendering.md @@ -124,8 +124,8 @@ for a view (same allocate-`*out` / free-with-`tile57_free` convention as the rest of the ABI): ```c -tile57 *c = NULL; -tile57_open("US5MD1MC.pmtiles", &c, NULL); /* a baked archive */ +tile57_chart *c = NULL; +tile57_chart_open("US5MD1MC.pmtiles", &c, NULL); /* a baked archive */ tile57_mariner m; tile57_mariner_defaults(&m); @@ -133,12 +133,12 @@ m.safety_contour = 5.0; m.scheme = TILE57_SCHEME_NIGHT; uint8_t *png; size_t len; -tile57_png(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &png, &len, NULL); +tile57_chart_png(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &png, &len, NULL); /* ... write/display png ... */ tile57_free(png); uint8_t *pdf; size_t plen; -tile57_pdf(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &pdf, &plen, NULL); +tile57_chart_pdf(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &pdf, &plen, NULL); ``` That renders ONE chart, no composition. A view across a whole chart library @@ -171,9 +171,9 @@ how MVT and MLT are done (`TileSurface` in `src/scene/scene.zig`), and how a GeoJSON debug dump or a GPU display list would be done. **From the C ABI:** both interfaces are exposed as callback tables. -`tile57_canvas` drives a `tile57_canvas_cb` — C function pointers receiving +`tile57_chart_canvas` drives a `tile57_canvas_cb` — C function pointers receiving resolved, flattened paths, patterns, and glyph outlines in pixel space, in -paint order (the Canvas seat). `tile57_surface` drives a `tile57_surface_cb` — +paint order (the Canvas seat). `tile57_chart_surface` drives a `tile57_surface_cb` — the world-space, semantically tagged stream (per-feature class + SCAMIN, world anchors, reference-pixel outlines) a GPU host tessellates once and transforms per frame (the Surface seat). Both have composed twins on the compositor diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index 3e1cfbd..a1bdbfa 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -6,7 +6,7 @@ sidebar_position: 5 # Zig API -The engine is a Zig package named **`tile57`** (v0.2.0, requires Zig 0.16). +The engine is a Zig package named **`tile57`** (v0.3.0, requires Zig 0.16). Add it as a dependency and `@import("tile57")` for the curated public surface (`src/tile57.zig`). The [C ABI](./c-api.md) is a thin shim over this same API, and both share the same shape: **bake, then compose** (or bake, then render) — @@ -23,8 +23,7 @@ Add it as a **path dependency** on a local clone (submodules initialised) — ## Bake Bake each chart to its own PMTiles at its compilation scale — the input the -compositor serves from (the bake APIs keep S-57's own word for a chart: a -*cell*). Free any returned bytes with `tile57.freeBytes`. +compositor serves from. Free any returned bytes with `tile57.freeBytes`. ```zig // Bake an ENC_ROOT: each chart -> /tiles/.pmtiles + /partition.tpart. @@ -34,8 +33,8 @@ const n = try tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null) | Surface | What it does | |---------|--------------| -| `tile57.bake.cellBytes(path, rules)` | bake one chart (+ updates) to PMTiles bytes. | -| `tile57.bake.cellsParallel(...)` / `bake.cellsToFiles(...)` | bake many charts in parallel, to memory / to files. | +| `tile57.bake.chartBytes(path, rules)` | bake one chart (+ updates) to PMTiles bytes. | +| `tile57.bake.chartsParallel(...)` / `bake.chartsToFiles(...)` | bake many charts in parallel, to memory / to files. | | `tile57.bake.tree(io, in, out, ...)` | walk an ENC_ROOT, bake each chart to a mirrored path (incremental). | | `tile57.bake.pmtilesMetadata(a, bytes)` | read an archive's metadata JSON (embedded coverage + scamin). | | `tile57.bake.Progress` | the optional progress-callback type. | @@ -67,8 +66,8 @@ What a chart can do depends on how it was opened: | `openPmtilesPath(io, path)` | baked archive, mmap'd | metadata (embedded coverage + scale), query, view renders (tile replay), raw tiles via `pmtilesReader()`. | | `openBytes(bytes, .pmtiles, …)` | baked archive, copied | the same, from memory. | | `openBytes(cell_bytes, .auto, rules_dir)` | ONE live S-57 chart, fully portrayed | metadata, query, view renders with the S-101 rules evaluated live. | -| `openPath(path, rules_dir, pick_attrs)` | streaming ENC_ROOT (or a single `.000`) | metadata + extraction ONLY: `cellsJson`, `featuresJson`, `scamin`, bounds. Charts are enumerated up front and parsed on demand, so a whole catalogue opens instantly. No view renders, no tiles. | -| `openCells(cells, …)` / `openCellsStreaming(metas, reader, …)` | the same, from in-memory charts / a host reader callback | as `openPath`. | +| `openPath(path, rules_dir, pick_attrs)` | streaming ENC_ROOT (or a single `.000`) | metadata + extraction ONLY: `chartsJson`, `featuresJson`, `scamin`, bounds. Charts are enumerated up front and parsed on demand, so a whole catalogue opens instantly. No view renders, no tiles. | +| `openCharts(charts, …)` / `openChartsStreaming(metas, reader, …)` | the same, from in-memory charts / a host reader callback | as `openPath`. | `Chart` methods: @@ -78,7 +77,7 @@ What a chart can do depends on how it was opened: | `renderSurfaceView(lon, lat, zoom, w, h, palette, settings, cb)` | drive world-space surface callbacks (the GPU vector twin). | | `renderAscii(lon, lat, zoom, cols, rows, palette, settings, ansi)` | the same view as a terminal text grid. | | `queryPoint(lon, lat, zoom, cb)` | the S-52 cursor pick — features under a point at the view zoom. | -| `cellsJson()` / `featuresJson(classes)` | per-chart metadata / GeoJSON feature extraction (the `cells` / `features` CLI). | +| `chartsJson()` / `featuresJson(classes)` | per-chart metadata / GeoJSON feature extraction (the `cells` / `features` CLI). | | `coverage()` | the M_COVR data-coverage rings (a live chart, or the copy a per-chart bake embeds in its archive metadata). | | `bounds() -> ?[4]f64` | geographic extent `[w, s, e, n]`, if known. | | `anchor()` | a good initial camera (lat, lon, zoom) on real data. | @@ -88,18 +87,18 @@ What a chart can do depends on how it was opened: | `scamin() -> ![]u32` | the distinct SCAMIN denominators present (the live SCAMIN manifest). | | `tileType()` | the tile encoding the chart's tiles use (MVT/MLT). | | `format() -> Format` | the resolved backend (after `.auto`). | -| `pmtilesReader()` / `cellCoverage()` | the archive reader (raw per-archive tiles — the primitive for writing your own compositor) + the decoded per-chart coverage; what the built-in compositor borrows. | +| `pmtilesReader()` / `decodedCoverage()` | the archive reader (raw per-archive tiles — the primitive for writing your own compositor) + the decoded per-chart coverage; what the built-in compositor borrows. | | `deinit()` | release the chart and its cached tiles. | -`tile57.Format` is `.auto` / `.pmtiles` / `.s57_cell`. `rules_dir` is the S-101 +`tile57.Format` is `.auto` / `.pmtiles` / `.s57`. `rules_dir` is the S-101 portrayal rules directory for live S-57 charts; `null` (or `""`) uses the rules embedded in the binary (or `TILE57_S101_RULES` if set), so no on-disk catalogue is required; a path overrides with an on-disk catalogue. -The streaming open uses the extern types `tile57.CellMeta` (bbox + `cscl`), -`tile57.CellBytes` (the chart's base + updates, ownership transferred to the -library), and `tile57.CellReadFn` (the reader callback). Multi-chart input for -`openCells` is `tile57.CellInput`. +The streaming open uses the extern types `tile57.ChartMeta` (bbox + `cscl`), +`tile57.ChartBytes` (the chart's base + updates, ownership transferred to the +library), and `tile57.ChartReadFn` (the reader callback). Multi-chart input for +`openCharts` is `tile57.ChartInput`. ## Compose @@ -110,7 +109,7 @@ composed. ```zig // Open the compositor over the archives + partition, then compose tiles. -var src = (try tile57.compose.openComposeSourceFiles(io, gpa, paths, "/out/partition.tpart")).?; +var src = (try tile57.compose.ComposeSource.openFiles(io, gpa, paths, "/out/partition.tpart")).?; defer src.deinit(); const result = try src.tile(gpa, 13, 2359, 3139); // result.tile: ?[]u8, result.owned: bool @@ -124,20 +123,20 @@ must outlive it: ```zig const archives = [_]tile57.compose.ChartArchive{ - .{ .reader = chart.pmtilesReader().?, .cov = chart.cellCoverage().? }, + .{ .reader = chart.pmtilesReader().?, .cov = chart.decodedCoverage().? }, }; -var src = (try tile57.compose.openComposeSourceCharts(gpa, &archives, null)).?; +var src = (try tile57.compose.ComposeSource.open(gpa, &archives, null)).?; ``` | Surface | What it does | |---------|--------------| -| `tile57.compose.openComposeSourceFiles(...)` | open a `ComposeSource` over on-disk archives + a partition. | -| `tile57.compose.openComposeSourceCharts(...)` | the same over borrowed `ChartArchive`s (already-open charts). | +| `ComposeSource.openFiles(io, gpa, paths, part)` | open a `ComposeSource` over on-disk archives + a partition. | +| `ComposeSource.open(gpa, archives, part)` | the same over borrowed `ChartArchive`s (already-open charts). | | `ComposeSource.tile(gpa, z, x, y)` | compose one tile on demand (raw MLT + the ownership flag). | | `tile57.compose.renderView(src, ...)` | the composed view render — PNG, PDF, or a callback canvas. | | `tile57.compose.renderSurfaceView(src, ...)` | the composed world-space surface stream. | | `tile57.compose.queryPoint(src, lon, lat, zoom, cb)` | the composed cursor pick, across chart boundaries. | -| `tile57.compose.composeTile(...)` | the stateless core `ComposeSource.tile` uses. | +| `tile57.compose.tile(...)` | the stateless core `ComposeSource.tile` uses. | | `tile57.partition` | the ownership partition and its `.tpart` sidecar (serialize / deserialize). | ## Style + portrayal assets @@ -180,4 +179,4 @@ The pure-Zig foundational parsers under `tile57.formats`: `tile57.coverage` is the per-chart M_COVR coverage sidecar (carried in an archive's PMTiles metadata). `tile57.version` is the package version string -(`"0.2.0"`), matching `build.zig.zon` and `tile57_version()`. +(`"0.3.0"`), matching `build.zig.zon` and `tile57_version()`. From aeac905c84df29828b28a39f5ce844a3a56f0eab Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 20:33:31 -0400 Subject: [PATCH 130/140] =?UTF-8?q?fix(capi):=20tile57=5Fversion=20reports?= =?UTF-8?q?=200.3.0=20=E2=80=94=20the=20hardcoded=20string=20missed=20the?= =?UTF-8?q?=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/capi.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/capi.zig b/src/capi.zig index 655c3ac..5c804e0 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -31,7 +31,7 @@ const Chart = chart.Chart; extern fn time(tloc: ?*c_long) callconv(.c) c_long; // Keep in sync with the TILE57_VERSION_* macros in tile57.h. -const version_string = "0.2.0"; +const version_string = "0.3.0"; fn spanOpt(s: ?[*:0]const u8) ?[]const u8 { return if (s) |p| std.mem.span(p) else null; @@ -170,7 +170,7 @@ export fn tile57_status_str(status: c_int) callconv(.c) [*:0]const u8 { }; } -/// Return the library version string ("0.2.0"). +/// Return the library version string ("0.3.0"). export fn tile57_version() callconv(.c) [*:0]const u8 { return version_string; } From 3567528f25a7ce808130613054eb8bafde3a7b17 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 20:55:26 -0400 Subject: [PATCH 131/140] perf(render): cache decoded tiles + palette + symbol store per chart handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/chart.zig | 148 ++++++++++++++++++++++++++++++++++++------ src/render/vector.zig | 15 +++-- 2 files changed, 138 insertions(+), 25 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index ff457e7..d345e62 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -19,6 +19,7 @@ const std = @import("std"); const pmtiles = @import("tiles").pmtiles; const mlt = @import("tiles").mlt; +const tiles_mvt = @import("tiles").mvt; const gzip = @import("tiles").gzip; const s57 = @import("s57"); const scene = @import("scene"); @@ -1051,6 +1052,22 @@ pub const Chart = struct { // released in deinit. Bytes-opened archives use `data` instead. data_map: ?[]align(std.heap.page_size_min) const u8 = null, + // ---- view-render caches (reader backend) -------------------------------- + // A view re-render replays mostly the SAME tiles (a pan reveals one strip, a + // zoom settle swaps one band), and profiling put ~35% of every re-render in + // gzip+MLT decode and another ~3% in re-building the palette and re-parsing + // the SVG symbol catalogue. All of it is immutable for an open handle, so it + // caches here: decoded tiles in a generation-evicted map (each entry owns its + // arena, so eviction frees exactly one tile), colors + per-palette symbol + // stores in one handle-lifetime arena. Same threading rule as the handle: + // NOT synchronized. + view_tiles: std.AutoHashMapUnmanaged(u64, *DecodedTile) = .empty, + view_gen: u64 = 0, + view_tiles_max: usize = 192, // > the ~96 tiles of one 2560px view: never evicts mid-render + view_arena: ?*std.heap.ArenaAllocator = null, + view_colors: ?*render.resolve.Colors = null, + view_stores: [3]?*sprite.CatalogStore = .{ null, null, null }, + /// Open a source from in-memory bytes. `fmt` selects the backend (`.auto` /// sniffs PMTiles then S-57); `rules_dir` is the S-101 rules dir for cells /// (null = TILE57_S101_RULES env, else the vendored default). Bytes are copied. @@ -1205,6 +1222,97 @@ pub const Chart = struct { } /// Release the source and all cached tiles. + const DecodedTile = struct { + arena: std.heap.ArenaAllocator, + layers: []tiles_mvt.DecodedLayer, + gen: u64, + }; + + fn viewTileKey(z: u8, x: u32, y: u32) u64 { + return (@as(u64, z) << 58) | (@as(u64, x) << 29) | @as(u64, y); + } + + /// The decoded layers of stored tile (z,x,y) through the handle's decoded-tile + /// cache. Null when the archive has no tile there (not cached — the miss is a + /// directory binary-search, no decompression). Entries stay valid until this + /// handle either evicts them (never within one render; see view_tiles_max) or + /// closes. + fn viewTileLayers(self: *Chart, rd: *pmtiles.Reader, z: u8, x: u32, y: u32) ?[]tiles_mvt.DecodedLayer { + const key = viewTileKey(z, x, y); + self.view_gen += 1; + if (self.view_tiles.getPtr(key)) |ep| { + ep.*.gen = self.view_gen; + return ep.*.layers; + } + const e = gpa.create(DecodedTile) catch return null; + e.* = .{ .arena = std.heap.ArenaAllocator.init(gpa), .layers = &.{}, .gen = self.view_gen }; + const ea = e.arena.allocator(); + const is_mlt = rd.header.tile_type == .mlt; + const ok = blk: { + const bytes = (rd.getTile(ea, z, x, y) catch break :blk false) orelse break :blk false; + e.layers = (if (is_mlt) mlt.decode(ea, bytes) else tiles_mvt.decode(ea, bytes)) catch break :blk false; + break :blk true; + }; + if (!ok) { + e.arena.deinit(); + gpa.destroy(e); + return null; + } + if (self.view_tiles.count() >= self.view_tiles_max) { + // Evict the least-recently-touched entry (linear scan; the map is small). + var oldest_key: u64 = 0; + var oldest_gen: u64 = std.math.maxInt(u64); + var it = self.view_tiles.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.*.gen < oldest_gen) { + oldest_gen = kv.value_ptr.*.gen; + oldest_key = kv.key_ptr.*; + } + } + if (self.view_tiles.fetchRemove(oldest_key)) |kv| { + kv.value.arena.deinit(); + gpa.destroy(kv.value); + } + } + self.view_tiles.put(gpa, key, e) catch { + e.arena.deinit(); + gpa.destroy(e); + return null; + }; + return e.layers; + } + + /// The handle-lifetime arena backing the cached palette + symbol stores. + fn viewArena(self: *Chart) !std.mem.Allocator { + if (self.view_arena == null) { + const va = try gpa.create(std.heap.ArenaAllocator); + va.* = std.heap.ArenaAllocator.init(gpa); + self.view_arena = va; + } + return self.view_arena.?.allocator(); + } + + /// The palette colour tables, parsed once per handle (all three palettes). + fn viewColorsRef(self: *Chart) !*render.resolve.Colors { + if (self.view_colors) |c| return c; + const va = try self.viewArena(); + const c = try va.create(render.resolve.Colors); + c.* = try render.resolve.Colors.init(va, embedded_assets.colorprofile[0].bytes); + self.view_colors = c; + return c; + } + + /// The palette's symbol store, built once per handle per palette (the SVG + /// catalogue parse dominated the old per-render setup). + fn viewStoreFor(self: *Chart, palette: render.resolve.PaletteId) !*sprite.CatalogStore { + const idx: usize = @intFromEnum(palette); + if (self.view_stores[idx]) |st| return st; + const va = try self.viewArena(); + const st = try viewSymbolStore(va, palette); + self.view_stores[idx] = st; + return st; + } + pub fn deinit(self: *Chart) void { switch (self.backend) { .reader => |*r| r.deinit(), @@ -1225,6 +1333,18 @@ pub const Chart = struct { ca.deinit(); gpa.destroy(ca); } + { + var vit = self.view_tiles.valueIterator(); + while (vit.next()) |v| { + v.*.arena.deinit(); + gpa.destroy(v.*); + } + self.view_tiles.deinit(gpa); + } + if (self.view_arena) |va| { + va.deinit(); + gpa.destroy(va); + } gpa.destroy(self); } @@ -1413,14 +1533,13 @@ pub const Chart = struct { defer arena.deinit(); const a = arena.allocator(); - var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); - const store = try viewSymbolStore(a, palette); - defer store.deinit(); + const colors = try self.viewColorsRef(); + const store = try self.viewStoreFor(palette); // Continuous scaling between integer zooms; the host applies physical // calibration / @2x via settings.size_scale. const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); - var ps = render.pixel.PixelSurface.initView(a, &colors, palette, settings, zoom, w, h, pt, @import("tiles").tile.EXTENT); + var ps = render.pixel.PixelSurface.initView(a, colors, palette, settings, zoom, w, h, pt, @import("tiles").tile.EXTENT); ps.store = store.asStore(); ps.output = output; ps.cb = cb_table; @@ -1434,13 +1553,8 @@ pub const Chart = struct { var vt = scene.ViewTiles.init(lon, lat, zoom, w, h, pt); const surf = ps.asSurface(); try surf.beginScene(vt.z); - const is_mlt = rd.header.tile_type == .mlt; while (vt.next()) |t| { - const bytes = (rd.getTile(a, t.z, t.x, t.y) catch continue) orelse continue; - const layers = if (is_mlt) - @import("tiles").mlt.decode(a, bytes) catch continue - else - @import("tiles").mvt.decode(a, bytes) catch continue; + const layers = self.viewTileLayers(rd, t.z, t.x, t.y) orelse continue; ps.setOrigin(t.origin_x, t.origin_y); scene.replayTile(a, surf, layers) catch return error.TileGen; } @@ -1470,12 +1584,11 @@ pub const Chart = struct { defer arena.deinit(); const a = arena.allocator(); - var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); - const store = try viewSymbolStore(a, palette); - defer store.deinit(); + const colors = try self.viewColorsRef(); + const store = try self.viewStoreFor(palette); const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); - var vs = render.vector.VectorSurface.init(a, &colors, palette, settings, cb); + var vs = render.vector.VectorSurface.init(a, colors, palette, settings, cb); vs.store = store.asStore(); vs.view_zoom = zoom; // scale at which labels/symbols declutter const surf = vs.asSurface(); @@ -1484,13 +1597,8 @@ pub const Chart = struct { try surf.beginScene(vt.z); switch (self.backend) { .reader => |*rd| { - const is_mlt = rd.header.tile_type == .mlt; while (vt.next()) |t| { - const bytes = (rd.getTile(a, t.z, t.x, t.y) catch continue) orelse continue; - const layers = if (is_mlt) - @import("tiles").mlt.decode(a, bytes) catch continue - else - @import("tiles").mvt.decode(a, bytes) catch continue; + const layers = self.viewTileLayers(rd, t.z, t.x, t.y) orelse continue; vs.setTile(t.z, t.x, t.y); scene.replayTile(a, surf, layers) catch continue; } diff --git a/src/render/vector.zig b/src/render/vector.zig index 7349e59..940cf3f 100644 --- a/src/render/vector.zig +++ b/src/render/vector.zig @@ -150,6 +150,10 @@ pub const VectorSurface = struct { tz: u8 = 0, tx: u32 = 0, ty: u32 = 0, + // 1/2^tz and 1/(2^tz·EXTENT), set by setTile — worldOf runs per VERTEX, and + // recomputing pow(2,tz) there was ~9% of a whole view render. + inv_n: f64 = 1.0, + inv_ne: f64 = 1.0, cur: rs.FeatureMeta = .{}, cur_visible: bool = true, @@ -194,6 +198,8 @@ pub const VectorSurface = struct { self.tz = z; self.tx = x; self.ty = y; + self.inv_n = 1.0 / std.math.exp2(@as(f64, @floatFromInt(z))); + self.inv_ne = self.inv_n / @as(f64, @floatFromInt(tile.EXTENT)); } fn sp(ctx: *anyopaque) *VectorSurface { @@ -222,13 +228,12 @@ pub const VectorSurface = struct { return sp(ctx).refDev(); } - /// Tile-space point -> web-mercator world [0,1] (y down). + /// Tile-space point -> web-mercator world [0,1] (y down). Per-vertex hot + /// path: the tile factors are precomputed in setTile. fn worldOf(self: *const VectorSurface, p: rs.TilePoint) CWorldPt { - const n = std.math.pow(f64, 2.0, @floatFromInt(self.tz)); - const e: f64 = @floatFromInt(tile.EXTENT); return .{ - .x = (@as(f64, @floatFromInt(self.tx)) + @as(f64, @floatFromInt(p.x)) / e) / n, - .y = (@as(f64, @floatFromInt(self.ty)) + @as(f64, @floatFromInt(p.y)) / e) / n, + .x = @as(f64, @floatFromInt(self.tx)) * self.inv_n + @as(f64, @floatFromInt(p.x)) * self.inv_ne, + .y = @as(f64, @floatFromInt(self.ty)) * self.inv_n + @as(f64, @floatFromInt(p.y)) * self.inv_ne, }; } From 1d19871468a95bd340e96e03ae4cfceb13677522 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 22:37:08 -0400 Subject: [PATCH 132/140] feat(cli): --meta renders the meta-bounds inspection view (png/pdf/explore) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tools/explore.zig | 6 +++++- tools/render.zig | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/explore.zig b/tools/explore.zig index a31406e..23d2150 100644 --- a/tools/explore.zig +++ b/tools/explore.zig @@ -784,7 +784,7 @@ fn viewportBbox(lon: f64, lat: f64, zoom: f64, w_px: f64, h_px: f64) [4]f64 { pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { if (args.len < 3) { - std.debug.print("usage: tile57 explore [--class ACR[,ACR..]] [--object FOID|RCID|INDEX] [--zoom N] [--view LON,LAT,ZOOM|URL] [--viewport WxH] [--json] [--tui] [--kitty] [--no-resolve] [--rules DIR]\n", .{}); + std.debug.print("usage: tile57 explore [--class ACR[,ACR..]] [--object FOID|RCID|INDEX] [--zoom N] [--view LON,LAT,ZOOM|URL] [--viewport WxH] [--json] [--tui] [--kitty] [--no-resolve] [--meta] [--rules DIR]\n", .{}); return; } const path = args[2]; @@ -792,6 +792,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var json = false; var tui = false; var kitty = false; + var meta_bounds = false; var rules_flag: ?[]const u8 = null; var view: ?View = null; var viewport_w: f64 = 1280; // the screen the viewport filter assumes (CSS px) @@ -823,6 +824,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { F.kitty = true; } else if (std.mem.eql(u8, arg, "--no-resolve")) { F.do_resolve = false; + } else if (std.mem.eql(u8, arg, "--meta")) { + meta_bounds = true; // resolve meta-object boundaries (M_NSYS/M_COVR/…) the inspection view shows } else if (std.mem.eql(u8, arg, "--rules")) { rules_flag = f.val("--rules") orelse return; } else return usageErr("unknown flag"); @@ -840,6 +843,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const palette: render.resolve.PaletteId = .day; var m = render.resolve.Settings{ .display_other = true }; m.scheme = .day; + m.show_meta_bounds = meta_bounds; // explore inspects one or more source cells. `dir` stays open for the whole run // (the TUI re-reads cells lazily to rebuild level 3 + the map render). diff --git a/tools/render.zig b/tools/render.zig index 428a755..402ad61 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -19,7 +19,7 @@ const resolveRulesDir = common.resolveRulesDir; // tile (labels + declutter over the full canvas, no seams). pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: render.pixel.Output) !void { if (args.len < 4) { - std.debug.print("usage: tile57 {s} -o [--size N] [--palette day|dusk|night] [--rules DIR] [--dq] [--scale F]\n" ++ + std.debug.print("usage: tile57 {s} -o [--size N] [--palette day|dusk|night] [--rules DIR] [--dq] [--meta] [--scale F]\n" ++ " tile57 {s} --view --size WxH -o [flags]\n", .{ @tagName(output), @tagName(output) }); return; } @@ -72,6 +72,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: rules = f.next() orelse return usageErr("--rules needs a dir"); } else if (std.mem.eql(u8, arg, "--dq")) { dq = true; // S-52 data-quality overlay (M_QUAL DQUAL* patterns) + } else if (std.mem.eql(u8, arg, "--meta")) { + m.show_meta_bounds = true; // meta-object coverage/scale/nav-system bounds inspection view } else if (std.mem.eql(u8, arg, "--scale")) { const v = f.next() orelse return usageErr("--scale needs a value"); size_scale = std.fmt.parseFloat(f64, v) catch return usageErr("bad --scale"); From 20693b4fe687052a22034f6ae2a5a296983337ed Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 22:37:18 -0400 Subject: [PATCH 133/140] fix(compose): a meta-bounds boundary outline survives compositing whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/compose/clip.zig | 48 +++++++++++++++++++++++++++++++++++++++++++- src/scene/scene.zig | 13 ++++++++---- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/compose/clip.zig b/src/compose/clip.zig index 0e23ce5..6e44056 100644 --- a/src/compose/clip.zig +++ b/src/compose/clip.zig @@ -47,6 +47,28 @@ pub fn isLightFigure(feat: mvt.DecodedFeature) bool { return false; } +/// A `masked`-tagged linestring is the meta-bounds inspection outline of ONE chart's +/// OWN extent (the §8.6.2-masked boundary complement — see scene.emitMaskedBoundary). +/// It must trace that chart's whole coverage boundary, so it is NOT clipped to the +/// owned face: clipping it there carves the outline apart wherever a finer chart owns +/// the ground on top of it, so a coarse chart's rectangle comes out in disjoint +/// fragments (the "missing edges" a meta-bounds inspection shows). Kept whole, like a +/// LIGHTS sector figure — it draws over neighbouring ground exactly as the single-chart +/// meta-bounds render would. The standard display filters the meta class out anyway. +pub fn isMaskedBoundary(feat: mvt.DecodedFeature) bool { + for (feat.properties) |p| { + if (std.mem.eql(u8, p.key, "masked")) { + return switch (p.value) { + .int => |v| v != 0, + .uint => |v| v != 0, + .boolean => |v| v, + else => false, + }; + } + } + return false; +} + /// Clip `feat` (tile-pixel space) to `face` (the cell's owned rings, tile-pixel space) and /// append the surviving feature(s) to `out`, or nothing if the feature is entirely outside /// the face. Geometry is freshly allocated in `a`; `properties` are borrowed from `feat`. @@ -69,7 +91,7 @@ pub fn clipFeatureToFace(a: std.mem.Allocator, out: *std.ArrayList(mvt.Feature), // anchored at the light, not ground. Clipping it to the owned face // amputates the figure at the seam, so keep it WHOLE — it draws over // neighbouring ground exactly as a single-chart render would. - if (isLightFigure(feat)) { + if (isLightFigure(feat) or isMaskedBoundary(feat)) { const parts = try a.alloc([]const mvt.Point, feat.parts.len); for (feat.parts, 0..) |part, i| parts[i] = try a.dupe(mvt.Point, part); try out.append(a, .{ .geom_type = .linestring, .parts = parts, .properties = feat.properties }); @@ -223,6 +245,30 @@ test "clipFeatureToFace: line clipped to the part inside the face" { try testing.expectEqual(@as(i32, 3000), @max(kept[0].x, kept[kept.len - 1].x)); } +test "clipFeatureToFace: a masked meta-bounds outline is kept whole, not clipped to the face" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // The same horizontal line as above, but tagged masked=1 (a chart's own coverage + // outline): it must survive whole — the finer chart's face must NOT carve it, or + // the coarse chart's boundary comes out in fragments. + const line = try a.alloc(mvt.Point, 2); + line[0] = .{ .x = 0, .y = 2000 }; + line[1] = .{ .x = 4096, .y = 2000 }; + var feat = try decoded(a, .linestring, &.{line}); + feat.properties = &.{.{ .key = "masked", .value = .{ .int = 1 } }}; + const face = try boxFace(a, 1000, 1000, 3000, 3000); + + var out = std.ArrayList(mvt.Feature).empty; + try clipFeatureToFace(a, &out, feat, face); + try testing.expectEqual(@as(usize, 1), out.items.len); + const kept = out.items[0].parts[0]; + try testing.expectEqual(@as(usize, 2), kept.len); // untouched: both original endpoints survive + try testing.expectEqual(@as(i32, 0), kept[0].x); + try testing.expectEqual(@as(i32, 4096), kept[1].x); +} + test "clipFeatureToFace: feature entirely outside the face is dropped" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 5e90846..5ff8796 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1735,7 +1735,7 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize // The masked complement (cell-limit stretches) rides along tagged, for the // meta-bounds view; the standard display filters the class out anyway. - if (masked_boundary) try emitMaskedBoundary(a, cell, f, fmeta, z, x, y, tb, box, opts, surf); + if (masked_boundary) try emitMaskedBoundary(a, cell, f, fmeta, z, x, y, tb, box, surf); if (geo_parts.len == 0) return; // Best-available: cut the covered stretches before tessellating/stroking. @@ -1768,14 +1768,19 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize /// masking alone leaves the toggle nothing to show. The standard display never /// renders it (the meta classes are filtered out unless meta-bounds is on), and /// the tag keeps the masked pieces letter-free in the styled view too. -fn emitMaskedBoundary(a: Allocator, cell: s57.Cell, f: s57.Feature, base: rs.FeatureMeta, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { +fn emitMaskedBoundary(a: Allocator, cell: s57.Cell, f: s57.Feature, base: rs.FeatureMeta, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, surf: rs.Surface) !void { const masked_parts = cell.maskedLineParts(a, f) catch return; if (masked_parts.len == 0) return; var fmeta = base; fmeta.masked = true; - const parts_cov = if (opts.cover_clip) |cc| clipGeoPartsOutsideCover(a, masked_parts, cc, z, x, y) else masked_parts; + // NOT cover-clipped: this is the meta-bounds inspection outline of one chart's + // OWN extent, so it must trace that chart's whole boundary even where a finer + // chart owns the ground on top of it. Clipping it to the best-available cover + // (as the drawn portrayal is) carves the outline apart wherever a finer chart + // overlaps — a coarse chart's rectangle comes out in disjoint fragments. The + // inspection view wants every contributing chart's complete extent. try surf.beginFeature(&fmeta); - for (parts_cov) |gp| { + for (masked_parts) |gp| { if (gp.len < 2) continue; if (!overlaps(geomBounds(gp), tb)) continue; const proj = try a.alloc(mvt.Point, gp.len); From 5a67c79c01c352bc64cd7474d02f0145e0919642 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 10 Jul 2026 22:49:23 -0400 Subject: [PATCH 134/140] =?UTF-8?q?feat(capi):=20tile57=5Fchart=5Ftile=5Fs?= =?UTF-8?q?urface=20=E2=80=94=20per-tile=20S-52=20portrayal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- include/tile57.h | 12 ++++++++++++ src/capi.zig | 20 ++++++++++++++++++++ src/chart.zig | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/include/tile57.h b/include/tile57.h index f5657b7..2d9ad71 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -532,6 +532,18 @@ tile57_status tile57_chart_surface(tile57_chart *chart, double lon, double lat, const tile57_mariner *m, const tile57_surface_cb *surface, tile57_error *err); +/* Portray ONE tile (z, x, y) through the SAME S-52 portrayal and the SAME + * tile57_surface_cb, but for a single tile instead of a whole view. Lets a host + * portray + tessellate each tile ONCE, cache the geometry keyed by (chart, z, x, y), + * and compose the view from cached tiles (re-portray only newly-visible tiles) — + * the MapLibre tile model, reusing tile57's portrayal. World coordinates and SCAMIN + * tags are identical to tile57_chart_surface, so the same callbacks/shaders apply. + * Decluttering is PER-TILE (labels resolve within the tile); a host wanting + * cross-tile label suppression keeps a separate view-level text pass. */ +tile57_status tile57_chart_tile_surface(tile57_chart *chart, uint8_t z, uint32_t x, uint32_t y, + const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); + /* Release a chart and all cached tiles. Must not be called while any borrower * (a compositor, a renderer thread) may still read from it. */ void tile57_chart_close(tile57_chart *chart); diff --git a/src/capi.zig b/src/capi.zig index 5c804e0..5dcae29 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -663,6 +663,26 @@ export fn tile57_chart_surface( return OK; } +/// Portray ONE tile (z, x, y) to a surface — the per-tile twin of +/// tile57_chart_surface. Same WORLD-SPACE tagged draw calls, for a single tile, so +/// a host can portray+tessellate each tile once, cache it, and compose the view +/// from cached tiles (the MapLibre model). Decluttering is per-tile. See tile57.h. +export fn tile57_chart_tile_surface( + handle: ?*Chart, + z: u8, + x: u32, + y: u32, + m: ?*const CMariner, + surface: ?*const CSurface, + err: ?*CError, +) callconv(.c) c_int { + const c = handle orelse return failWith(err, .badarg, "chart must not be null"); + const sfc = surface orelse return failWith(err, .badarg, "surface must not be null"); + const settings: mariner.Settings = if (m) |p| marinerFromC(p) else .{}; + c.renderSurfaceTile(z, x, y, paletteOf(&settings), &settings, sfc) catch |e| return fail(err, e); + return OK; +} + /// Release a chart and all cached tiles. Must not be called while any borrower /// (a compositor, a renderer) may still read from it. See tile57.h. export fn tile57_chart_close(handle: ?*Chart) callconv(.c) void { diff --git a/src/chart.zig b/src/chart.zig index d345e62..ae4de48 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -1623,6 +1623,52 @@ pub const Chart = struct { _ = try surf.endScene(a); } + /// Portray a SINGLE tile (z, x, y) to a CSurface — the per-tile twin of + /// renderSurfaceView. Emits the same WORLD-SPACE tagged draw calls, but for + /// exactly one tile instead of every tile under a view, so a host can portray + + /// tessellate each tile ONCE, cache the geometry, and compose tiles itself + /// (the MapLibre model). Decluttering is per-tile (labels resolve within the + /// tile), so a host that wants cross-tile label suppression must still do a + /// separate view-level text pass. `view_zoom` is the tile's own zoom. + pub fn renderSurfaceTile(self: *Chart, z: u8, x: u32, y: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, cb: *const render.vector.CSurface) !void { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + + const colors = try self.viewColorsRef(); + const store = try self.viewStoreFor(palette); + + var vs = render.vector.VectorSurface.init(a, colors, palette, settings, cb); + vs.store = store.asStore(); + vs.view_zoom = @floatFromInt(z); // declutter at the tile's native zoom + const surf = vs.asSurface(); + + try surf.beginScene(z); + switch (self.backend) { + .reader => |*rd| { + if (self.viewTileLayers(rd, z, x, y)) |layers| { + vs.setTile(z, x, y); + scene.replayTile(a, surf, layers) catch {}; + } + }, + .cell => |*cb2| { + const one = [_]scene.CellRef{.{ + .cell = &cb2.cell, + .portrayal = cb2.portrayal, + .portrayal_plain = cb2.portrayal_plain, + .portrayal_simplified = cb2.portrayal_simplified, + .geo = cb2.geo, + .geo_world = cb2.geo_world, + .feat_bbox = cb2.feat_bbox, + }}; + vs.setTile(z, x, y); + scene.appendTile(surf, a, &one, z, x, y, self.pick_attrs) catch {}; + }, + .cells => return error.Unsupported, + } + _ = try surf.endScene(a); + } + /// Cursor object-query: replay the finest tile covering (lon,lat) through a /// QuerySurface and report each feature the point falls in (class + S-57 /// attribute JSON + source cell) via `cb`. Used for the S-52 §10.8 pick. From 8f822d10ec4103893fe2bd3fbcdd6dff8f379882 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 11 Jul 2026 05:24:39 -0400 Subject: [PATCH 135/140] =?UTF-8?q?scene:=20show=20full=20aid=20names=20?= =?UTF-8?q?=E2=80=94=20drop=20the=20buoy/beacon=20designation=20shortening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/scene/scene.zig | 88 ++++++++++++--------------------------------- 1 file changed, 22 insertions(+), 66 deletions(-) diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 5ff8796..5447e85 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -791,31 +791,6 @@ const Meta = struct { // modifier, or 12 by default), halign/valign (the resolved TextAlign values the // style's TEXT_ANCHOR keys on), and the §14.5 text group. Halo + offset are // separate findings (still on the OpText halo / LocalOffset rows). -/// Case-insensitive last occurrence of `needle` in `haystack`. -fn lastIndexOfCI(haystack: []const u8, needle: []const u8) ?usize { - if (needle.len == 0 or needle.len > haystack.len) return null; - var i: usize = haystack.len - needle.len + 1; - while (i > 0) { - i -= 1; - if (std.ascii.eqlIgnoreCase(haystack[i .. i + needle.len], needle)) return i; - } - return null; -} - -/// Reduce a buoy/lighted-buoy name label to its chart designation. The S-101 buoy -/// rules tag the feature "by " (EncodeString 'by %s'); NOAA OBJNAM is the full -/// descriptive name ("Chesapeake Channel Lighted Buoy 78A") but a chart shows only the -/// trailing designation ("78A"), which also de-clutters a channel of buoys whose long -/// prefix repeats. The "by " prefix marks these name labels; all other text (depth -/// labels, light elevations, …) has no such prefix and passes through unchanged. The -/// designation is whatever follows the LAST buoy/beacon type-word; "Light"/"Lt" are -/// deliberately excluded so a named light keeps its name, and a name with no type-word -/// keeps its stripped form (e.g. a bare "22"). -/// -/// An extracted designation is QUOTED ("78A") like the paper chart: an aid's -/// designation in quotes cannot be misread as a sounding or a depth — the -/// chart convention exists for exactly that reason. Unshortened text passes -/// through borrowed; a quoted designation allocates from `a`. // SBDARE nature-of-surface labels arrive from the (pristine, vendored) S-101 // rule as INT1 abbreviations ("S", "M", "Cy" …, space-joined for multiple // surfaces). A screen has no chart-margin legend to decode them against, so @@ -853,25 +828,15 @@ fn expandSeabedText(a: Allocator, class: []const u8, text: []const u8) ![]const return out.toOwnedSlice(a); } -fn shortenName(a: Allocator, text: []const u8) ![]const u8 { - // "by " = buoy names, "bn " = beacon names (EncodeString prefixes in the - // buoy/beacon rules); both reduce to the designation. - const tagged = std.mem.startsWith(u8, text, "by ") or std.mem.startsWith(u8, text, "bn "); - if (!tagged) return text; - const name = std.mem.trim(u8, text[3..], " "); - const keywords = [_][]const u8{ "Daybeacon", "Daymark", "Buoy", "Beacon" }; - var best_end: ?usize = null; - for (keywords) |kw| { - if (lastIndexOfCI(name, kw)) |idx| { - const end = idx + kw.len; - if (best_end == null or end > best_end.?) best_end = end; - } - } - if (best_end) |end| { - const rest = std.mem.trim(u8, name[end..], " "); - if (rest.len > 0) return std.fmt.allocPrint(a, "\"{s}\"", .{rest}); - } - return name; +/// Strip the "by "/"bn " aid-name tag the S-101 buoy/beacon rules add (EncodeString +/// 'by %s' / 'bn %s') and show the FULL name. Previously this reduced the name to its +/// trailing chart designation ("Chesapeake Channel Lighted Buoy 78A" -> "78A"); that +/// shortening was removed so aid names read in full. Untagged text (depth labels, light +/// elevations, place names) passes through unchanged. Returns a borrowed slice. +fn stripNameTag(text: []const u8) []const u8 { + if (std.mem.startsWith(u8, text, "by ") or std.mem.startsWith(u8, text, "bn ")) + return std.mem.trim(u8, text[3..], " "); + return text; } /// Serialize a text label's props in the tile schema order. `text` arrives already @@ -1478,7 +1443,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, .group = t.group, }; try surf.beginFeature(&fmeta); - try surf.drawText(try expandSeabedText(a, fmeta.class, try shortenName(a, t.text)), &ts, pt); + try surf.drawText(try expandSeabedText(a, fmeta.class, stripNameTag(t.text)), &ts, pt); try surf.endFeature(); } } @@ -1606,7 +1571,7 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, .group = t.group, }; try surf.beginFeature(&fmeta); - try surf.drawText(try expandSeabedText(a, fmeta.class, try shortenName(a, t.text)), &ts, cpt); + try surf.drawText(try expandSeabedText(a, fmeta.class, stripNameTag(t.text)), &ts, cpt); try surf.endFeature(); } } @@ -2449,26 +2414,17 @@ test "appendTextProps: halo (CHWHT/1) gated on font_size >= 10, matching the ora } } -test "shortenName: buoy/light names reduce to the QUOTED chart designation" { - var arena_s = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_s.deinit(); - const al = arena_s.allocator(); - // Verbose NOAA OBJNAM tagged "by " by the S-101 buoy rules -> the - // designation IN QUOTES (paper-chart convention: not misread as a sounding). - try std.testing.expectEqualStrings("\"78A\"", try shortenName(al, "by Chesapeake Channel Lighted Buoy 78A")); - try std.testing.expectEqualStrings("\"CR\"", try shortenName(al, "by Chesapeake Channel Lighted Buoy CR")); - try std.testing.expectEqualStrings("\"3\"", try shortenName(al, "by Tangier Sound Daybeacon 3")); - try std.testing.expectEqualStrings("\"2CR\"", try shortenName(al, "by Lighted Whistle Buoy 2CR")); - // No type-word -> the stripped name (already short), unquoted. - try std.testing.expectEqualStrings("22", try shortenName(al, "by 22")); - // No "by " prefix -> passthrough (depth label, light elevation, place name). - try std.testing.expectEqualStrings(" 4.6m", try shortenName(al, " 4.6m")); - try std.testing.expectEqualStrings("Herring Bay", try shortenName(al, "Herring Bay")); - // "Light"/"Lt" are NOT split words -> a named light keeps its name (defensive; such - // labels don't carry the "by " buoy prefix anyway). - try std.testing.expectEqualStrings("Thomas Point Light", try shortenName(al, "by Thomas Point Light")); - // Beacon names ("bn " prefix) reduce the same way. - try std.testing.expectEqualStrings("\"2\"", try shortenName(al, "bn Turn Rock Daybeacon 2")); +test "stripNameTag: strips the by/bn aid-name tag, keeps the full name" { + // The S-101 buoy/beacon rules tag OBJNAM "by "/"bn "; the tag is + // stripped and the FULL name shown (the designation-shortening was removed). + try std.testing.expectEqualStrings("Chesapeake Channel Lighted Buoy 78A", stripNameTag("by Chesapeake Channel Lighted Buoy 78A")); + try std.testing.expectEqualStrings("Tangier Sound Daybeacon 3", stripNameTag("by Tangier Sound Daybeacon 3")); + try std.testing.expectEqualStrings("Turn Rock Daybeacon 2", stripNameTag("bn Turn Rock Daybeacon 2")); + try std.testing.expectEqualStrings("22", stripNameTag("by 22")); + // No "by "/"bn " prefix -> passthrough (depth label, light elevation, place name). + try std.testing.expectEqualStrings(" 4.6m", stripNameTag(" 4.6m")); + try std.testing.expectEqualStrings("Herring Bay", stripNameTag("Herring Bay")); + try std.testing.expectEqualStrings("Thomas Point Light", stripNameTag("by Thomas Point Light")); } test "listHasAny splits S-57 comma lists and matches any target" { From af6d965a0163f5c27ef0981578f9282c09dddc7d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 11 Jul 2026 08:15:59 -0400 Subject: [PATCH 136/140] build: add `zig build lib` step; port MLT decoder to 32-bit targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.zig | 9 +++++++++ src/tiles/mlt.zig | 48 ++++++++++++++++++++++++----------------------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/build.zig b/build.zig index 4ca0992..2e75a2d 100644 --- a/build.zig +++ b/build.zig @@ -436,6 +436,15 @@ pub fn build(b: *std.Build) void { b.installArtifact(lib); } + // `zig build lib` installs only libtile57.a for the resolved target. The + // default install also builds the host-only bake CLI (which force-links + // static musl and so can't cross-compile); an embedder cross-building the + // library for another platform — e.g. a Qt6 viewer for the reMarkable + // tablet (arm-linux-gnueabihf / aarch64-linux-gnu) — uses this step to get + // just the archive without the CLI. + const lib_only_step = b.step("lib", "Build only libtile57.a for the target"); + lib_only_step.dependOn(&b.addInstallArtifact(lib, .{}).step); + // The offline baker / inspector CLI. It runs the embedded-Lua S-101 portrayal // so baked tiles get full S-101 styling (not the classify() fallback), so — // unlike the unit tests — its engine module is bake_root.zig (root.zig + diff --git a/src/tiles/mlt.zig b/src/tiles/mlt.zig index b9ade3e..4323773 100644 --- a/src/tiles/mlt.zig +++ b/src/tiles/mlt.zig @@ -638,14 +638,16 @@ fn unzigzag32(v: u64) i32 { return @bitCast((u >> 1) ^ (0 -% (u & 1))); } -const StreamMeta = struct { phys: u8, sub: u8, num_values: u64, byte_length: u64 }; +// num_values / byte_length count and address in-memory data, so they are usize +// (u32 on 32-bit targets); the wire varints are range-checked into them here. +const StreamMeta = struct { phys: u8, sub: u8, num_values: usize, byte_length: usize }; fn readStreamMeta(r: *DecReader) StreamMeta { const b0 = r.byte(); _ = r.byte(); // llt/plt byte — the subset's streams are self-describing by (phys, sub) const nv = r.varint(); const bl = r.varint(); - return .{ .phys = b0 >> 4, .sub = b0 & 0x0F, .num_values = nv, .byte_length = bl }; + return .{ .phys = b0 >> 4, .sub = b0 & 0x0F, .num_values = @intCast(nv), .byte_length = @intCast(bl) }; } // ORC byte-RLE (as writePresentStream emits, plus repeat runs for safety): @@ -691,14 +693,14 @@ pub fn decode(a: std.mem.Allocator, data: []const u8) ![]mvt.DecodedLayer { var layers = std.ArrayList(mvt.DecodedLayer).empty; var top = DecReader{ .buf = data }; while (top.pos < data.len) { - const block_len = top.varint(); + const block_len: usize = @intCast(top.varint()); const block = top.bytes(block_len); var r = DecReader{ .buf = block }; if (r.varint() != 1) return error.BadTile; // tag - const name = try a.dupe(u8, r.bytes(r.varint())); + const name = try a.dupe(u8, r.bytes(@intCast(r.varint()))); const extent: u32 = @intCast(r.varint()); - const ncols = r.varint(); + const ncols: usize = @intCast(r.varint()); if (ncols == 0 or r.byte() != TYPECODE_GEOMETRY) return error.BadTile; var cols = std.ArrayList(DecCol).empty; for (1..ncols) |_| { @@ -713,16 +715,16 @@ pub fn decode(a: std.mem.Allocator, data: []const u8) ![]mvt.DecodedLayer { ST_FLOAT => .float, else => return error.BadTile, }; - const key = try a.dupe(u8, r.bytes(r.varint())); + const key = try a.dupe(u8, r.bytes(@intCast(r.varint()))); try cols.append(a, .{ .kind = kind, .nullable = (tc - 10) % 2 == 1, .key = key }); } // ---- geometry column ---------------------------------------------- - const n_gstreams = r.varint(); + const n_gstreams: usize = @intCast(r.varint()); var gtypes: []u32 = &.{}; - var geoms: []u64 = &.{}; - var parts_lens: []u64 = &.{}; - var rings_lens: []u64 = &.{}; + var geoms: []usize = &.{}; + var parts_lens: []usize = &.{}; + var rings_lens: []usize = &.{}; var verts: []i32 = &.{}; for (0..n_gstreams) |si| { const m = readStreamMeta(&r); @@ -731,14 +733,14 @@ pub fn decode(a: std.mem.Allocator, data: []const u8) ![]mvt.DecodedLayer { gtypes = try a.alloc(u32, m.num_values); for (gtypes) |*g| g.* = @intCast(r.varint()); } else if (m.phys == PHYS_LENGTH and m.sub == LEN_GEOMETRIES) { - geoms = try a.alloc(u64, m.num_values); - for (geoms) |*v| v.* = r.varint(); + geoms = try a.alloc(usize, m.num_values); + for (geoms) |*v| v.* = @intCast(r.varint()); } else if (m.phys == PHYS_LENGTH and m.sub == LEN_PARTS) { - parts_lens = try a.alloc(u64, m.num_values); - for (parts_lens) |*v| v.* = r.varint(); + parts_lens = try a.alloc(usize, m.num_values); + for (parts_lens) |*v| v.* = @intCast(r.varint()); } else if (m.phys == PHYS_LENGTH and m.sub == LEN_RINGS) { - rings_lens = try a.alloc(u64, m.num_values); - for (rings_lens) |*v| v.* = r.varint(); + rings_lens = try a.alloc(usize, m.num_values); + for (rings_lens) |*v| v.* = @intCast(r.varint()); } else { // VertexBuffer (componentwise delta + zigzag) verts = try a.alloc(i32, m.num_values); var px: i32 = 0; @@ -763,7 +765,7 @@ pub fn decode(a: std.mem.Allocator, data: []const u8) ![]mvt.DecodedLayer { for (gtypes, 0..) |g, fi| { switch (g) { G_POINT, G_MULTIPOINT => { - const n: u64 = if (g == G_POINT) 1 else blk: { + const n: usize = if (g == G_POINT) 1 else blk: { const v = geoms[gi]; gi += 1; break :blk v; @@ -778,7 +780,7 @@ pub fn decode(a: std.mem.Allocator, data: []const u8) ![]mvt.DecodedLayer { feats[fi] = .{ .geom_type = .point, .parts = parts, .properties = &.{} }; }, G_MULTILINESTRING, G_LINESTRING => { - const nlines: u64 = if (g == G_MULTILINESTRING) blk: { + const nlines: usize = if (g == G_MULTILINESTRING) blk: { const v = geoms[gi]; gi += 1; break :blk v; @@ -831,8 +833,8 @@ pub fn decode(a: std.mem.Allocator, data: []const u8) ![]mvt.DecodedLayer { } if (nstreams == 3) { // dictionary const lm = readStreamMeta(&r); - const dlens = try a.alloc(u64, lm.num_values); - for (dlens) |*v| v.* = r.varint(); + const dlens = try a.alloc(usize, lm.num_values); + for (dlens) |*v| v.* = @intCast(r.varint()); const dm = readStreamMeta(&r); _ = dm; const dict = try a.alloc([]const u8, dlens.len); @@ -840,15 +842,15 @@ pub fn decode(a: std.mem.Allocator, data: []const u8) ![]mvt.DecodedLayer { const om = readStreamMeta(&r); var fi: usize = 0; for (0..om.num_values) |_| { - const ix = r.varint(); + const ix: usize = @intCast(r.varint()); while (present != null and !present.?[fi]) fi += 1; try propbuf[fi].append(a, .{ .key = col.key, .value = .{ .string = dict[ix] } }); fi += 1; } } else { // plain: lengths + data const lm = readStreamMeta(&r); - const lens = try a.alloc(u64, lm.num_values); - for (lens) |*v| v.* = r.varint(); + const lens = try a.alloc(usize, lm.num_values); + for (lens) |*v| v.* = @intCast(r.varint()); _ = readStreamMeta(&r); var fi: usize = 0; for (lens) |sl| { From e0a68f3a9046b3d8037cd972a9a780b63eb1534a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 11 Jul 2026 11:03:36 -0400 Subject: [PATCH 137/140] =?UTF-8?q?tiles:=20cross-platform=20file=20mmap?= =?UTF-8?q?=20=E2=80=94=20fix=20Windows=20cross-compile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/chart.zig | 11 ++++---- src/compose/compose.zig | 15 ++++++----- src/tiles/filemap.zig | 57 +++++++++++++++++++++++++++++++++++++++++ src/tiles/tiles.zig | 3 +++ 4 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 src/tiles/filemap.zig diff --git a/src/chart.zig b/src/chart.zig index ae4de48..50702ff 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -18,6 +18,7 @@ const std = @import("std"); const pmtiles = @import("tiles").pmtiles; +const filemap = @import("tiles").filemap; const mlt = @import("tiles").mlt; const tiles_mvt = @import("tiles").mvt; const gzip = @import("tiles").gzip; @@ -476,8 +477,8 @@ pub fn openPmtilesPath(io: std.Io, path: []const u8) !*Chart { const st = f.stat(io) catch return error.IoFailed; const len: usize = @intCast(st.size); if (len == 0) return error.InvalidArchive; - const map = std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, f.handle, 0) catch return error.IoFailed; - errdefer std.posix.munmap(map); + const map = filemap.mapReadonly(f.handle, len) catch return error.IoFailed; + errdefer filemap.unmap(map); var reader = pmtiles.Reader.init(gpa, map) catch return error.InvalidArchive; const src = gpa.create(Chart) catch { reader.deinit(); @@ -1328,7 +1329,7 @@ pub const Chart = struct { while (it.next()) |v| gpa.free(v.*); self.cache.deinit(); if (self.data) |d| gpa.free(d); - if (self.data_map) |m| std.posix.munmap(m); + if (self.data_map) |m| filemap.unmap(m); if (self.coverage_arena) |ca| { ca.deinit(); gpa.destroy(ca); @@ -1590,7 +1591,7 @@ pub const Chart = struct { const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); var vs = render.vector.VectorSurface.init(a, colors, palette, settings, cb); vs.store = store.asStore(); - vs.view_zoom = zoom; // scale at which labels/symbols declutter + vs.view_zoom = zoom; // scale at which labels/symbols declutter const surf = vs.asSurface(); var vt = scene.ViewTiles.init(lon, lat, zoom, w, h, pt); @@ -1640,7 +1641,7 @@ pub const Chart = struct { var vs = render.vector.VectorSurface.init(a, colors, palette, settings, cb); vs.store = store.asStore(); - vs.view_zoom = @floatFromInt(z); // declutter at the tile's native zoom + vs.view_zoom = @floatFromInt(z); // declutter at the tile's native zoom const surf = vs.asSurface(); try surf.beginScene(z); diff --git a/src/compose/compose.zig b/src/compose/compose.zig index 91e1ccb..82be8d1 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -17,6 +17,7 @@ const mlt = @import("tiles").mlt; const gzip = @import("tiles").gzip; const tile = @import("tiles").tile; const band = @import("tiles").band; +const filemap = @import("tiles").filemap; const geometry = @import("geometry"); const coverage = @import("coverage"); const s57 = @import("s57"); @@ -319,7 +320,7 @@ pub const ComposeSource = struct { self.part.deinit(); if (self.owns_archives) { for (self.readers) |rp| rp.deinit(); - for (self.maps) |m| std.posix.munmap(m); + for (self.maps) |m| filemap.unmap(m); } self.arena.deinit(); gpa.destroy(self); @@ -353,7 +354,7 @@ fn openSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8 var shims = std.ArrayList(LoadedCov).empty; errdefer { for (readers.items) |rp| rp.deinit(); - for (maps.items) |m| std.posix.munmap(m); + for (maps.items) |m| filemap.unmap(m); } for (paths) |path| { var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch continue; @@ -366,27 +367,27 @@ fn openSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8 f.close(io); continue; } - const map = std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, f.handle, 0) catch { + const map = filemap.mapReadonly(f.handle, len) catch { f.close(io); continue; }; f.close(io); const rp = a.create(pmtiles.Reader) catch { - std.posix.munmap(map); + filemap.unmap(map); continue; }; rp.* = pmtiles.Reader.init(gpa, map) catch { - std.posix.munmap(map); + filemap.unmap(map); continue; }; const meta = readMetaJson(a, rp) orelse { rp.deinit(); - std.posix.munmap(map); + filemap.unmap(map); continue; }; const cc = (coverage.decodeFromMetadata(a, meta) catch null) orelse { rp.deinit(); - std.posix.munmap(map); + filemap.unmap(map); continue; }; try maps.append(a, map); diff --git a/src/tiles/filemap.zig b/src/tiles/filemap.zig new file mode 100644 index 0000000..577ac29 --- /dev/null +++ b/src/tiles/filemap.zig @@ -0,0 +1,57 @@ +//! filemap — a read-only memory map of a file, cross-platform. +//! +//! Zig std has no portable mmap: `std.posix.mmap` is POSIX-only (its PROT/MAP flag +//! types are `void` on Windows, so it won't even compile there), and `std.os.windows` +//! doesn't bind the file-mapping calls. So this maps via `std.posix.mmap` on POSIX and +//! `CreateFileMapping`/`MapViewOfFile` (declared below) on Windows — the SAME lazily +//! paged, page-cache-shared view on both, so a whole chart library can be open without +//! being resident. Callers pass a file handle (`std.posix.fd_t` is `HANDLE` on Windows) +//! and a length; the mapping outlives the file handle on both platforms. +const std = @import("std"); +const builtin = @import("builtin"); +const windows = std.os.windows; +const page = std.heap.page_size_min; + +const PAGE_READONLY: windows.DWORD = 0x02; +const FILE_MAP_READ: windows.DWORD = 0x0004; +extern "kernel32" fn CreateFileMappingW( + hFile: windows.HANDLE, + lpAttributes: ?*anyopaque, + flProtect: windows.DWORD, + dwMaximumSizeHigh: windows.DWORD, + dwMaximumSizeLow: windows.DWORD, + lpName: ?windows.LPCWSTR, +) callconv(.winapi) ?windows.HANDLE; +extern "kernel32" fn MapViewOfFile( + hFileMappingObject: windows.HANDLE, + dwDesiredAccess: windows.DWORD, + dwFileOffsetHigh: windows.DWORD, + dwFileOffsetLow: windows.DWORD, + dwNumberOfBytesToMap: usize, +) callconv(.winapi) ?windows.LPVOID; +extern "kernel32" fn UnmapViewOfFile(lpBaseAddress: windows.LPCVOID) callconv(.winapi) windows.BOOL; + +/// Map the first `len` bytes of `handle` read-only (`len` must be > 0). Release with +/// `unmap`. The file handle may be closed once this returns — the view keeps the +/// underlying data alive on both POSIX (mmap) and Windows (MapViewOfFile). +pub fn mapReadonly(handle: std.posix.fd_t, len: usize) error{IoFailed}![]align(page) const u8 { + if (builtin.os.tag == .windows) { + const h = CreateFileMappingW(handle, null, PAGE_READONLY, 0, 0, null) orelse return error.IoFailed; + defer windows.CloseHandle(h); // the mapped view keeps the section alive after this + const p = MapViewOfFile(h, FILE_MAP_READ, 0, 0, 0) orelse return error.IoFailed; + // MapViewOfFile is aligned to the 64 KB allocation granularity, so >= page align. + const base: [*]align(page) const u8 = @ptrCast(@alignCast(p)); + return base[0..len]; + } + return std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, handle, 0) catch + return error.IoFailed; +} + +/// Release a mapping returned by `mapReadonly`. +pub fn unmap(m: []align(page) const u8) void { + if (builtin.os.tag == .windows) { + _ = UnmapViewOfFile(@ptrCast(m.ptr)); + } else { + std.posix.munmap(m); + } +} diff --git a/src/tiles/tiles.zig b/src/tiles/tiles.zig index a70081e..069c9bc 100644 --- a/src/tiles/tiles.zig +++ b/src/tiles/tiles.zig @@ -7,6 +7,7 @@ //! tile — web-mercator tile math: projection, extent, clipping, //! simplification (the geometry side of tiling) //! band — compilation-scale -> zoom-range mapping (navigational bands) +//! filemap — cross-platform read-only mmap of a file (std has no portable one) //! //! Pure std; the leaf bundle everything tile-shaped builds on. @@ -16,6 +17,7 @@ pub const gzip = @import("gzip.zig"); pub const pmtiles = @import("pmtiles.zig"); pub const tile = @import("tile.zig"); pub const band = @import("band.zig"); +pub const filemap = @import("filemap.zig"); test { _ = mvt; @@ -24,4 +26,5 @@ test { _ = pmtiles; _ = tile; _ = band; + _ = filemap; } From 5153b5903a391db6a58c760a11281ae93cae1da7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 11 Jul 2026 11:07:01 -0400 Subject: [PATCH 138/140] style: zig fmt the tree + enforce `zig fmt --check` in CI 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) --- .github/workflows/ci.yml | 2 + docs/bun.lock | 2569 +++++++++++++++++++++++++++ src/bundle.zig | 1 - src/geometry/boolean_repro_test.zig | 14 +- src/render/vector.zig | 6 +- src/scene/linestyle.zig | 1 - src/scene/scene.zig | 5 - src/sprite/sprite.zig | 4 +- src/style/style.zig | 1 - 9 files changed, 2583 insertions(+), 20 deletions(-) create mode 100644 docs/bun.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0007b10..1081d88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: uses: mlugg/setup-zig@v2 with: version: 0.16.0 + - name: Format check + run: zig fmt --check src/ tools/ build.zig - name: Build (native) run: zig build -Doptimize=ReleaseFast - name: Test diff --git a/docs/bun.lock b/docs/bun.lock new file mode 100644 index 0000000..3ecea3b --- /dev/null +++ b/docs/bun.lock @@ -0,0 +1,2569 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "tile57-docs", + "dependencies": { + "@docusaurus/core": "^3.6.3", + "@docusaurus/preset-classic": "^3.6.3", + "@mdx-js/react": "^3.0.0", + "clsx": "^2.0.0", + "prism-react-renderer": "^2.3.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "^3.6.3", + "@docusaurus/types": "^3.6.3", + }, + }, + }, + "packages": { + "@algolia/abtesting": ["@algolia/abtesting@1.21.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-uXj0rgk30EpsKvOpuS+R+1XFDrnm56hED1Lz56e8uBkZdKCxw99LS2U8eXBqAHYU8kpkbsnV1GC8velBG070Hg=="], + + "@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.19.9", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.19.9", "@algolia/autocomplete-shared": "1.19.9" } }, "sha512-4U2JKLMWlDu0CotYyUkWakDxr8AIav3QtIUXXRpfavYN29aVWfzlwJp9T0rPKEf/dO2QCPAUc0Kq1Tj1GJxo2A=="], + + "@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.19.9", "", { "dependencies": { "@algolia/autocomplete-shared": "1.19.9" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-6mExC6X7762s2SV3eJy3QOkB8bdMmnUhQ2agvGVDuzwoGyr3PquGSY/0vPQXCfiAiCaXUz1rXn+lwghgSi0l0w=="], + + "@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.19.9", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-YosP9Uoek6y/Ur1r1qeogk4biMe/hzkyNcgMCciw0//3XpCM7VlYLSHnyt/vOnEOGhCCc0+3v+unEiH6zz+Z1A=="], + + "@algolia/client-abtesting": ["@algolia/client-abtesting@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-y7Epol8HcjlBxKXHhyhfFPFhm78B3P6x9cCbCyGTdxjsdVCptXCy5hpkZWxjGpnaLHvWsHS4QRF0TiBOLst2xg=="], + + "@algolia/client-analytics": ["@algolia/client-analytics@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-8Pxj2VVmpM2d+UZufnlTq7T1QIcYPVugLV5XC50PnHsV5uRM9CSoYkg2Y+CwqwRk2La0xK5QsfZ0obIU+9XftQ=="], + + "@algolia/client-common": ["@algolia/client-common@5.55.2", "", {}, "sha512-9L4IpIYUqA63a7sw1trnHQGUvwiAjKz67nsgDnal98JGAc7wyposRb0Iag+eiMuyzFFaSHLe2/rGyIo+PafRBA=="], + + "@algolia/client-insights": ["@algolia/client-insights@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-ZBm2ytY5EHFcj+kjNsXxMNO/TGlOHe2fBFXGKHJOM1bk1rAy4o2YI+d9oV/w/jrqx44pvJMJlc8X6vKnCuDgUQ=="], + + "@algolia/client-personalization": ["@algolia/client-personalization@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-3FGVW/jDk7sdYwqa2NKnF/qXWcttc4bvGrwNbvqz3VoWSRv42CNvRk+3Y9QJFIUf1vY50hAuVWUoFKdyc8vaXA=="], + + "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-JsG8LovDAYul5t8e533tZ3O1uZILxso5zsTtB7ONc5RJ8ACdTxAAC/jaOnsBNYb+x+STP7fzx/Iro55v5DNgoQ=="], + + "@algolia/client-search": ["@algolia/client-search@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-5wDnoIfC75zJ2MSHv5SSzTlRL2z7jQMbqQ5jrzottuq2p3oBObv8pD/JpXWu8pRaimaxNr3/Bs/KZIGVXxJ7hg=="], + + "@algolia/events": ["@algolia/events@4.0.1", "", {}, "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ=="], + + "@algolia/ingestion": ["@algolia/ingestion@1.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-da+SC6ikpza98W7C5ChsKEQDvZc8PQLQ0sxmQ5yMRsHpdD3iPKnclJA6ViB5Nr5T9qOX+IDswC6AyqY4V3rtug=="], + + "@algolia/monitoring": ["@algolia/monitoring@1.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-Y8kEcPqCiIEeaGv83l9RRA09mfYECqAJHNnOyEtZc9UirI6XBMUyFVss/sSeYUiV/Lf30hkbWcl00V1uXsf86Q=="], + + "@algolia/recommend": ["@algolia/recommend@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-5zmobuCQqFZkx+84Nt+suL7vo6jTh2CfAs2ndDSeTS2QHvnzP8YEEGWtWftjyACI0cK/FuH8urWwCHP+d2j8TA=="], + + "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2" } }, "sha512-qnGUUuWG66dRMnr33owLsrYIh9fHVxtU4R2rd3SpneAHuoAUcGbDOWNrj05glVU6M8yOqo9gQ22K8zpz0I8Xpg=="], + + "@algolia/requester-fetch": ["@algolia/requester-fetch@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2" } }, "sha512-lKZ5uhafMvR7dWCJEyuaeyZitid1I3ICx+k0vGf5x/ktdIQvc7bndCiOPpmIDqUmN26FE3jTehkAzSqee95G2Q=="], + + "@algolia/requester-node-http": ["@algolia/requester-node-http@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2" } }, "sha512-Zc90xvKWUvxcNicvvTO9Pr/hT2TAnkixOIzJm/KMj5Ptm2pKjk71ngTsdkbRtJQvhZ2Kr9N1YdIjLrNHB5P2xw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg=="], + + "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": ["@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w=="], + + "@babel/plugin-bugfix-safari-class-field-initializer-scope": ["@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ=="], + + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ["@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ=="], + + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": ["@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA=="], + + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ["@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.13.0" } }, "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ["@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw=="], + + "@babel/plugin-proposal-private-property-in-object": ["@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w=="], + + "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="], + + "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw=="], + + "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + + "@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="], + + "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ=="], + + "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA=="], + + "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w=="], + + "@babel/plugin-transform-block-scoped-functions": ["@babel/plugin-transform-block-scoped-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA=="], + + "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="], + + "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="], + + "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="], + + "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="], + + "@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/template": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA=="], + + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="], + + "@babel/plugin-transform-dotall-regex": ["@babel/plugin-transform-dotall-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ=="], + + "@babel/plugin-transform-duplicate-keys": ["@babel/plugin-transform-duplicate-keys@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ=="], + + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": ["@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA=="], + + "@babel/plugin-transform-dynamic-import": ["@babel/plugin-transform-dynamic-import@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg=="], + + "@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw=="], + + "@babel/plugin-transform-exponentiation-operator": ["@babel/plugin-transform-exponentiation-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ=="], + + "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="], + + "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="], + + "@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg=="], + + "@babel/plugin-transform-json-strings": ["@babel/plugin-transform-json-strings@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg=="], + + "@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw=="], + + "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="], + + "@babel/plugin-transform-member-expression-literals": ["@babel/plugin-transform-member-expression-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg=="], + + "@babel/plugin-transform-modules-amd": ["@babel/plugin-transform-modules-amd@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ=="], + + "@babel/plugin-transform-modules-umd": ["@babel/plugin-transform-modules-umd@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA=="], + + "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="], + + "@babel/plugin-transform-new-target": ["@babel/plugin-transform-new-target@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A=="], + + "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="], + + "@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw=="], + + "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="], + + "@babel/plugin-transform-object-super": ["@babel/plugin-transform-object-super@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA=="], + + "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="], + + "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="], + + "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="], + + "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug=="], + + "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA=="], + + "@babel/plugin-transform-property-literals": ["@babel/plugin-transform-property-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA=="], + + "@babel/plugin-transform-react-constant-elements": ["@babel/plugin-transform-react-constant-elements@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-J0wGhKan+rIiE2OhfhRptySLrJ6SjQYM6b6N1FMlhyhCcw1Mig8vQjWchyB+bgHGDvaWo6Diu6CLRMra2uMtmg=="], + + "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], + + "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], + + "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA=="], + + "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw=="], + + "@babel/plugin-transform-regexp-modifiers": ["@babel/plugin-transform-regexp-modifiers@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ=="], + + "@babel/plugin-transform-reserved-words": ["@babel/plugin-transform-reserved-words@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA=="], + + "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q=="], + + "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg=="], + + "@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ=="], + + "@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA=="], + + "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA=="], + + "@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], + + "@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA=="], + + "@babel/plugin-transform-unicode-property-regex": ["@babel/plugin-transform-unicode-property-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw=="], + + "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA=="], + + "@babel/plugin-transform-unicode-sets-regex": ["@babel/plugin-transform-unicode-sets-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg=="], + + "@babel/preset-env": ["@babel/preset-env@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-import-assertions": "^7.29.7", "@babel/plugin-syntax-import-attributes": "^7.29.7", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.29.7", "@babel/plugin-transform-async-generator-functions": "^7.29.7", "@babel/plugin-transform-async-to-generator": "^7.29.7", "@babel/plugin-transform-block-scoped-functions": "^7.29.7", "@babel/plugin-transform-block-scoping": "^7.29.7", "@babel/plugin-transform-class-properties": "^7.29.7", "@babel/plugin-transform-class-static-block": "^7.29.7", "@babel/plugin-transform-classes": "^7.29.7", "@babel/plugin-transform-computed-properties": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-dotall-regex": "^7.29.7", "@babel/plugin-transform-duplicate-keys": "^7.29.7", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-explicit-resource-management": "^7.29.7", "@babel/plugin-transform-exponentiation-operator": "^7.29.7", "@babel/plugin-transform-export-namespace-from": "^7.29.7", "@babel/plugin-transform-for-of": "^7.29.7", "@babel/plugin-transform-function-name": "^7.29.7", "@babel/plugin-transform-json-strings": "^7.29.7", "@babel/plugin-transform-literals": "^7.29.7", "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", "@babel/plugin-transform-member-expression-literals": "^7.29.7", "@babel/plugin-transform-modules-amd": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-modules-systemjs": "^7.29.7", "@babel/plugin-transform-modules-umd": "^7.29.7", "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-new-target": "^7.29.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", "@babel/plugin-transform-numeric-separator": "^7.29.7", "@babel/plugin-transform-object-rest-spread": "^7.29.7", "@babel/plugin-transform-object-super": "^7.29.7", "@babel/plugin-transform-optional-catch-binding": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/plugin-transform-private-methods": "^7.29.7", "@babel/plugin-transform-private-property-in-object": "^7.29.7", "@babel/plugin-transform-property-literals": "^7.29.7", "@babel/plugin-transform-regenerator": "^7.29.7", "@babel/plugin-transform-regexp-modifiers": "^7.29.7", "@babel/plugin-transform-reserved-words": "^7.29.7", "@babel/plugin-transform-shorthand-properties": "^7.29.7", "@babel/plugin-transform-spread": "^7.29.7", "@babel/plugin-transform-sticky-regex": "^7.29.7", "@babel/plugin-transform-template-literals": "^7.29.7", "@babel/plugin-transform-typeof-symbol": "^7.29.7", "@babel/plugin-transform-unicode-escapes": "^7.29.7", "@babel/plugin-transform-unicode-property-regex": "^7.29.7", "@babel/plugin-transform-unicode-regex": "^7.29.7", "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.15", "babel-plugin-polyfill-corejs3": "^0.14.0", "babel-plugin-polyfill-regenerator": "^0.6.6", "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA=="], + + "@babel/preset-modules": ["@babel/preset-modules@0.1.6-no-external-plugins", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", "esutils": "^2.0.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA=="], + + "@babel/preset-react": ["@babel/preset-react@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-transform-react-display-name": "^7.29.7", "@babel/plugin-transform-react-jsx": "^7.29.7", "@babel/plugin-transform-react-jsx-development": "^7.29.7", "@babel/plugin-transform-react-pure-annotations": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + + "@csstools/cascade-layer-name-parser": ["@csstools/cascade-layer-name-parser@2.0.5", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@csstools/media-query-list-parser": ["@csstools/media-query-list-parser@4.0.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ=="], + + "@csstools/postcss-alpha-function": ["@csstools/postcss-alpha-function@1.0.1", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-isfLLwksH3yHkFXfCI2Gcaqg7wGGHZZwunoJzEZk0yKYIokgre6hYVFibKL3SYAoR1kBXova8LB+JoO5vZzi9w=="], + + "@csstools/postcss-cascade-layers": ["@csstools/postcss-cascade-layers@5.0.2", "", { "dependencies": { "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg=="], + + "@csstools/postcss-color-function": ["@csstools/postcss-color-function@4.0.12", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-yx3cljQKRaSBc2hfh8rMZFZzChaFgwmO2JfFgFr1vMcF3C/uyy5I4RFIBOIWGq1D+XbKCG789CGkG6zzkLpagA=="], + + "@csstools/postcss-color-function-display-p3-linear": ["@csstools/postcss-color-function-display-p3-linear@1.0.1", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-E5qusdzhlmO1TztYzDIi8XPdPoYOjoTY6HBYBCYSj+Gn4gQRBlvjgPQXzfzuPQqt8EhkC/SzPKObg4Mbn8/xMg=="], + + "@csstools/postcss-color-mix-function": ["@csstools/postcss-color-mix-function@3.0.12", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-4STERZfCP5Jcs13P1U5pTvI9SkgLgfMUMhdXW8IlJWkzOOOqhZIjcNhWtNJZes2nkBDsIKJ0CJtFtuaZ00moag=="], + + "@csstools/postcss-color-mix-variadic-function-arguments": ["@csstools/postcss-color-mix-variadic-function-arguments@1.0.2", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-rM67Gp9lRAkTo+X31DUqMEq+iK+EFqsidfecmhrteErxJZb6tUoJBVQca1Vn1GpDql1s1rD1pKcuYzMsg7Z1KQ=="], + + "@csstools/postcss-content-alt-text": ["@csstools/postcss-content-alt-text@2.0.8", "", { "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-9SfEW9QCxEpTlNMnpSqFaHyzsiRpZ5J5+KqCu1u5/eEJAWsMhzT40qf0FIbeeglEvrGRMdDzAxMIz3wqoGSb+Q=="], + + "@csstools/postcss-contrast-color-function": ["@csstools/postcss-contrast-color-function@2.0.12", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-YbwWckjK3qwKjeYz/CijgcS7WDUCtKTd8ShLztm3/i5dhh4NaqzsbYnhm4bjrpFpnLZ31jVcbK8YL77z3GBPzA=="], + + "@csstools/postcss-exponential-functions": ["@csstools/postcss-exponential-functions@2.0.9", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-abg2W/PI3HXwS/CZshSa79kNWNZHdJPMBXeZNyPQFbbj8sKO3jXxOt/wF7juJVjyDTc6JrvaUZYFcSBZBhaxjw=="], + + "@csstools/postcss-font-format-keywords": ["@csstools/postcss-font-format-keywords@4.0.0", "", { "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-usBzw9aCRDvchpok6C+4TXC57btc4bJtmKQWOHQxOVKen1ZfVqBUuCZ/wuqdX5GHsD0NRSr9XTP+5ID1ZZQBXw=="], + + "@csstools/postcss-gamut-mapping": ["@csstools/postcss-gamut-mapping@2.0.11", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-fCpCUgZNE2piVJKC76zFsgVW1apF6dpYsqGyH8SIeCcM4pTEsRTWTLCaJIMKFEundsCKwY1rwfhtrio04RJ4Dw=="], + + "@csstools/postcss-gradients-interpolation-method": ["@csstools/postcss-gradients-interpolation-method@5.0.12", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-jugzjwkUY0wtNrZlFeyXzimUL3hN4xMvoPnIXxoZqxDvjZRiSh+itgHcVUWzJ2VwD/VAMEgCLvtaJHX+4Vj3Ow=="], + + "@csstools/postcss-hwb-function": ["@csstools/postcss-hwb-function@4.0.12", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-mL/+88Z53KrE4JdePYFJAQWFrcADEqsLprExCM04GDNgHIztwFzj0Mbhd/yxMBngq0NIlz58VVxjt5abNs1VhA=="], + + "@csstools/postcss-ic-unit": ["@csstools/postcss-ic-unit@4.0.4", "", { "dependencies": { "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-yQ4VmossuOAql65sCPppVO1yfb7hDscf4GseF0VCA/DTDaBc0Wtf8MTqVPfjGYlT5+2buokG0Gp7y0atYZpwjg=="], + + "@csstools/postcss-initial": ["@csstools/postcss-initial@2.0.1", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-L1wLVMSAZ4wovznquK0xmC7QSctzO4D0Is590bxpGqhqjboLXYA16dWZpfwImkdOgACdQ9PqXsuRroW6qPlEsg=="], + + "@csstools/postcss-is-pseudo-class": ["@csstools/postcss-is-pseudo-class@5.0.3", "", { "dependencies": { "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ=="], + + "@csstools/postcss-light-dark-function": ["@csstools/postcss-light-dark-function@2.0.11", "", { "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-fNJcKXJdPM3Lyrbmgw2OBbaioU7yuKZtiXClf4sGdQttitijYlZMD5K7HrC/eF83VRWRrYq6OZ0Lx92leV2LFA=="], + + "@csstools/postcss-logical-float-and-clear": ["@csstools/postcss-logical-float-and-clear@3.0.0", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-SEmaHMszwakI2rqKRJgE+8rpotFfne1ZS6bZqBoQIicFyV+xT1UF42eORPxJkVJVrH9C0ctUgwMSn3BLOIZldQ=="], + + "@csstools/postcss-logical-overflow": ["@csstools/postcss-logical-overflow@2.0.0", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-spzR1MInxPuXKEX2csMamshR4LRaSZ3UXVaRGjeQxl70ySxOhMpP2252RAFsg8QyyBXBzuVOOdx1+bVO5bPIzA=="], + + "@csstools/postcss-logical-overscroll-behavior": ["@csstools/postcss-logical-overscroll-behavior@2.0.0", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-e/webMjoGOSYfqLunyzByZj5KKe5oyVg/YSbie99VEaSDE2kimFm0q1f6t/6Jo+VVCQ/jbe2Xy+uX+C4xzWs4w=="], + + "@csstools/postcss-logical-resize": ["@csstools/postcss-logical-resize@3.0.0", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-DFbHQOFW/+I+MY4Ycd/QN6Dg4Hcbb50elIJCfnwkRTCX05G11SwViI5BbBlg9iHRl4ytB7pmY5ieAFk3ws7yyg=="], + + "@csstools/postcss-logical-viewport-units": ["@csstools/postcss-logical-viewport-units@3.0.4", "", { "dependencies": { "@csstools/css-tokenizer": "^3.0.4", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-q+eHV1haXA4w9xBwZLKjVKAWn3W2CMqmpNpZUk5kRprvSiBEGMgrNH3/sJZ8UA3JgyHaOt3jwT9uFa4wLX4EqQ=="], + + "@csstools/postcss-media-minmax": ["@csstools/postcss-media-minmax@2.0.9", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/media-query-list-parser": "^4.0.3" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-af9Qw3uS3JhYLnCbqtZ9crTvvkR+0Se+bBqSr7ykAnl9yKhk6895z9rf+2F4dClIDJWxgn0iZZ1PSdkhrbs2ig=="], + + "@csstools/postcss-media-queries-aspect-ratio-number-values": ["@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5", "", { "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/media-query-list-parser": "^4.0.3" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-zhAe31xaaXOY2Px8IYfoVTB3wglbJUVigGphFLj6exb7cjZRH9A6adyE22XfFK3P2PzwRk0VDeTJmaxpluyrDg=="], + + "@csstools/postcss-nested-calc": ["@csstools/postcss-nested-calc@4.0.0", "", { "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-jMYDdqrQQxE7k9+KjstC3NbsmC063n1FTPLCgCRS2/qHUbHM0mNy9pIn4QIiQGs9I/Bg98vMqw7mJXBxa0N88A=="], + + "@csstools/postcss-normalize-display-values": ["@csstools/postcss-normalize-display-values@4.0.1", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-TQUGBuRvxdc7TgNSTevYqrL8oItxiwPDixk20qCB5me/W8uF7BPbhRrAvFuhEoywQp/woRsUZ6SJ+sU5idZAIA=="], + + "@csstools/postcss-oklab-function": ["@csstools/postcss-oklab-function@4.0.12", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-HhlSmnE1NKBhXsTnNGjxvhryKtO7tJd1w42DKOGFD6jSHtYOrsJTQDKPMwvOfrzUAk8t7GcpIfRyM7ssqHpFjg=="], + + "@csstools/postcss-position-area-property": ["@csstools/postcss-position-area-property@1.0.0", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-fUP6KR8qV2NuUZV3Cw8itx0Ep90aRjAZxAEzC3vrl6yjFv+pFsQbR18UuQctEKmA72K9O27CoYiKEgXxkqjg8Q=="], + + "@csstools/postcss-progressive-custom-properties": ["@csstools/postcss-progressive-custom-properties@4.2.1", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-uPiiXf7IEKtUQXsxu6uWtOlRMXd2QWWy5fhxHDnPdXKCQckPP3E34ZgDoZ62r2iT+UOgWsSbM4NvHE5m3mAEdw=="], + + "@csstools/postcss-property-rule-prelude-list": ["@csstools/postcss-property-rule-prelude-list@1.0.0", "", { "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-IxuQjUXq19fobgmSSvUDO7fVwijDJaZMvWQugxfEUxmjBeDCVaDuMpsZ31MsTm5xbnhA+ElDi0+rQ7sQQGisFA=="], + + "@csstools/postcss-random-function": ["@csstools/postcss-random-function@2.0.1", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-q+FQaNiRBhnoSNo+GzqGOIBKoHQ43lYz0ICrV+UudfWnEF6ksS6DsBIJSISKQT2Bvu3g4k6r7t0zYrk5pDlo8w=="], + + "@csstools/postcss-relative-color-syntax": ["@csstools/postcss-relative-color-syntax@3.0.12", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-0RLIeONxu/mtxRtf3o41Lq2ghLimw0w9ByLWnnEVuy89exmEEq8bynveBxNW3nyHqLAFEeNtVEmC1QK9MZ8Huw=="], + + "@csstools/postcss-scope-pseudo-class": ["@csstools/postcss-scope-pseudo-class@4.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-IMi9FwtH6LMNuLea1bjVMQAsUhFxJnyLSgOp/cpv5hrzWmrUYU5fm0EguNDIIOHUqzXode8F/1qkC/tEo/qN8Q=="], + + "@csstools/postcss-sign-functions": ["@csstools/postcss-sign-functions@1.1.4", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-P97h1XqRPcfcJndFdG95Gv/6ZzxUBBISem0IDqPZ7WMvc/wlO+yU0c5D/OCpZ5TJoTt63Ok3knGk64N+o6L2Pg=="], + + "@csstools/postcss-stepped-value-functions": ["@csstools/postcss-stepped-value-functions@4.0.9", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-h9btycWrsex4dNLeQfyU3y3w40LMQooJWFMm/SK9lrKguHDcFl4VMkncKKoXi2z5rM9YGWbUQABI8BT2UydIcA=="], + + "@csstools/postcss-syntax-descriptor-syntax-production": ["@csstools/postcss-syntax-descriptor-syntax-production@1.0.1", "", { "dependencies": { "@csstools/css-tokenizer": "^3.0.4" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-GneqQWefjM//f4hJ/Kbox0C6f2T7+pi4/fqTqOFGTL3EjnvOReTqO1qUQ30CaUjkwjYq9qZ41hzarrAxCc4gow=="], + + "@csstools/postcss-system-ui-font-family": ["@csstools/postcss-system-ui-font-family@1.0.0", "", { "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-s3xdBvfWYfoPSBsikDXbuorcMG1nN1M6GdU0qBsGfcmNR0A/qhloQZpTxjA3Xsyrk1VJvwb2pOfiOT3at/DuIQ=="], + + "@csstools/postcss-text-decoration-shorthand": ["@csstools/postcss-text-decoration-shorthand@4.0.3", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-KSkGgZfx0kQjRIYnpsD7X2Om9BUXX/Kii77VBifQW9Ih929hK0KNjVngHDH0bFB9GmfWcR9vJYJJRvw/NQjkrA=="], + + "@csstools/postcss-trigonometric-functions": ["@csstools/postcss-trigonometric-functions@4.0.9", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-Hnh5zJUdpNrJqK9v1/E3BbrQhaDTj5YiX7P61TOvUhoDHnUmsNNxcDAgkQ32RrcWx9GVUvfUNPcUkn8R3vIX6A=="], + + "@csstools/postcss-unset-value": ["@csstools/postcss-unset-value@4.0.0", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA=="], + + "@csstools/selector-resolve-nested": ["@csstools/selector-resolve-nested@3.1.0", "", { "peerDependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g=="], + + "@csstools/selector-specificity": ["@csstools/selector-specificity@5.0.0", "", { "peerDependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw=="], + + "@csstools/utilities": ["@csstools/utilities@2.0.0", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ=="], + + "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], + + "@docsearch/core": ["@docsearch/core@4.6.3", "", { "peerDependencies": { "@types/react": ">= 16.8.0 < 20.0.0", "react": ">= 16.8.0 < 20.0.0", "react-dom": ">= 16.8.0 < 20.0.0" }, "optionalPeers": ["@types/react", "react", "react-dom"] }, "sha512-rUOujwIpxJRgD7+kicVsI3D5sqBvdiRTquzWBpTEXZs8ZXfGbfzpus5HqumaNYTppN2HvH8E2yNuRwYdHJeOlA=="], + + "@docsearch/css": ["@docsearch/css@4.6.3", "", {}, "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ=="], + + "@docsearch/react": ["@docsearch/react@4.6.3", "", { "dependencies": { "@algolia/autocomplete-core": "1.19.2", "@docsearch/core": "4.6.3", "@docsearch/css": "4.6.3" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 20.0.0", "react": ">= 16.8.0 < 20.0.0", "react-dom": ">= 16.8.0 < 20.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom", "search-insights"] }, "sha512-Bg2wdDsoQVlNCcEKuEJAU04tvHCqgx8rIu+uIoM4pRtcx3TBKJuXutJik3LTA8LRc9YEyHkrYUrmcC0D7BYf+g=="], + + "@docusaurus/babel": ["@docusaurus/babel@3.10.1", "", { "dependencies": { "@babel/core": "^7.25.9", "@babel/generator": "^7.25.9", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-transform-runtime": "^7.25.9", "@babel/preset-env": "^7.25.9", "@babel/preset-react": "^7.25.9", "@babel/preset-typescript": "^7.25.9", "@babel/runtime": "^7.25.9", "@babel/traverse": "^7.25.9", "@docusaurus/logger": "3.10.1", "@docusaurus/utils": "3.10.1", "babel-plugin-dynamic-import-node": "^2.3.3", "fs-extra": "^11.1.1", "tslib": "^2.6.0" } }, "sha512-DZzFO1K3v/GoEt1fx1DiYHF4en+PuhtQf1AkQJa5zu3CoeKSpr5cpQRUlz3jr0m44wyzmSXu9bVpfir+N4+8bg=="], + + "@docusaurus/bundler": ["@docusaurus/bundler@3.10.1", "", { "dependencies": { "@babel/core": "^7.25.9", "@docusaurus/babel": "3.10.1", "@docusaurus/cssnano-preset": "3.10.1", "@docusaurus/logger": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils": "3.10.1", "babel-loader": "^9.2.1", "clean-css": "^5.3.3", "copy-webpack-plugin": "^11.0.0", "css-loader": "^6.11.0", "css-minimizer-webpack-plugin": "^5.0.1", "cssnano": "^6.1.2", "file-loader": "^6.2.0", "html-minifier-terser": "^7.2.0", "mini-css-extract-plugin": "^2.9.2", "null-loader": "^4.0.1", "postcss": "^8.5.4", "postcss-loader": "^7.3.4", "postcss-preset-env": "^10.2.1", "terser-webpack-plugin": "^5.3.9", "tslib": "^2.6.0", "url-loader": "^4.1.1", "webpack": "^5.95.0", "webpackbar": "^7.0.0" }, "peerDependencies": { "@docusaurus/faster": "*" }, "optionalPeers": ["@docusaurus/faster"] }, "sha512-HIqQPvbqnnQRe4NsBd1774KRarjXqS6wHsWELtyuSs1gCfvixJO2jUGH/OEBtr1Gvzpw+ze5CjGMvSJ8UE1KUw=="], + + "@docusaurus/core": ["@docusaurus/core@3.10.1", "", { "dependencies": { "@docusaurus/babel": "3.10.1", "@docusaurus/bundler": "3.10.1", "@docusaurus/logger": "3.10.1", "@docusaurus/mdx-loader": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-common": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "boxen": "^6.2.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", "cli-table3": "^0.6.3", "combine-promises": "^1.1.0", "commander": "^5.1.0", "core-js": "^3.31.1", "detect-port": "^1.5.1", "escape-html": "^1.0.3", "eta": "^2.2.0", "eval": "^0.1.8", "execa": "^5.1.1", "fs-extra": "^11.1.1", "html-tags": "^3.3.1", "html-webpack-plugin": "^5.6.0", "leven": "^3.1.0", "lodash": "^4.17.21", "open": "^8.4.0", "p-map": "^4.0.0", "prompts": "^2.4.2", "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", "react-loadable": "npm:@docusaurus/react-loadable@6.0.0", "react-loadable-ssr-addon-v5-slorber": "^1.0.3", "react-router": "^5.3.4", "react-router-config": "^5.1.1", "react-router-dom": "^5.3.4", "semver": "^7.5.4", "serve-handler": "^6.1.7", "tinypool": "^1.0.2", "tslib": "^2.6.0", "update-notifier": "^6.0.2", "webpack": "^5.95.0", "webpack-bundle-analyzer": "^4.10.2", "webpack-dev-server": "^5.2.2", "webpack-merge": "^6.0.1" }, "peerDependencies": { "@docusaurus/faster": "*", "@mdx-js/react": "^3.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@docusaurus/faster"], "bin": { "docusaurus": "bin/docusaurus.mjs" } }, "sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w=="], + + "@docusaurus/cssnano-preset": ["@docusaurus/cssnano-preset@3.10.1", "", { "dependencies": { "cssnano-preset-advanced": "^6.1.2", "postcss": "^8.5.4", "postcss-sort-media-queries": "^5.2.0", "tslib": "^2.6.0" } }, "sha512-eNfHGcTKCSq6xmcavAkX3RRclHaE2xRCMParlDXLdXVP01/a2e/jKXMj/0ULnLFQSNwwuI62L0Ge8J+nZsR7UQ=="], + + "@docusaurus/logger": ["@docusaurus/logger@3.10.1", "", { "dependencies": { "chalk": "^4.1.2", "tslib": "^2.6.0" } }, "sha512-oPjNFnfJsRCkePVjkGrxWGq4MvJKRQT0r9jOP0eRBTZ7Wr9FAbzdP/Gjs0I2Ss6YRkPoEgygKG112OkE6skvJw=="], + + "@docusaurus/mdx-loader": ["@docusaurus/mdx-loader@3.10.1", "", { "dependencies": { "@docusaurus/logger": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", "estree-util-value-to-estree": "^3.0.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", "image-size": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-to-string": "^4.0.0", "rehype-raw": "^7.0.0", "remark-directive": "^3.0.0", "remark-emoji": "^4.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "stringify-object": "^3.3.0", "tslib": "^2.6.0", "unified": "^11.0.3", "unist-util-visit": "^5.0.0", "url-loader": "^4.1.1", "vfile": "^6.0.1", "webpack": "^5.88.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-GRmeb/wQ+iXRrFwcHBfgQhrJxGElgCsoTWZYDhccjsZVne1p8MK/EpQVIloXttz76TCe78kKD5AEG9n1xc1oxQ=="], + + "@docusaurus/module-type-aliases": ["@docusaurus/module-type-aliases@3.10.1", "", { "dependencies": { "@docusaurus/types": "3.10.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", "@types/react-router-dom": "*", "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", "react-loadable": "npm:@docusaurus/react-loadable@6.0.0" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-YoOZKUdGlp8xSYhuAkGdSo5Ydkbq4V4eK3sD8v0a2hloxCWdQbNBhkc+Ko9QyjpESc0BYcIGM5iHVAy5hdFV6w=="], + + "@docusaurus/plugin-content-blog": ["@docusaurus/plugin-content-blog@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/logger": "3.10.1", "@docusaurus/mdx-loader": "3.10.1", "@docusaurus/theme-common": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-common": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "cheerio": "1.0.0-rc.12", "combine-promises": "^1.1.0", "feed": "^4.2.2", "fs-extra": "^11.1.1", "lodash": "^4.17.21", "schema-dts": "^1.1.2", "srcset": "^4.0.0", "tslib": "^2.6.0", "unist-util-visit": "^5.0.0", "utility-types": "^3.10.0", "webpack": "^5.88.1" }, "peerDependencies": { "@docusaurus/plugin-content-docs": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-mmkgE6Q2+K74tnkou7tXlpDLvoCU/qkSa2GSQ3XUiHWvcebCoDQzS670RR3tO8PmaWlIyWWISYWzZLuMfxunRA=="], + + "@docusaurus/plugin-content-docs": ["@docusaurus/plugin-content-docs@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/logger": "3.10.1", "@docusaurus/mdx-loader": "3.10.1", "@docusaurus/module-type-aliases": "3.10.1", "@docusaurus/theme-common": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-common": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "schema-dts": "^1.1.2", "tslib": "^2.6.0", "utility-types": "^3.10.0", "webpack": "^5.88.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ=="], + + "@docusaurus/plugin-content-pages": ["@docusaurus/plugin-content-pages@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/mdx-loader": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "fs-extra": "^11.1.1", "tslib": "^2.6.0", "webpack": "^5.88.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-huJpaRPMl42nsFwuCXvV8bVDj2MazuwRJIUylI/RSlmZeJssVoZXeCjVf1y+1Drtpa9SKcdGn8yoJ76IRJijtw=="], + + "@docusaurus/plugin-css-cascade-layers": ["@docusaurus/plugin-css-cascade-layers@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "tslib": "^2.6.0" } }, "sha512-r//fn+MNHkE1wCof8T29VAQezt1enGCpsFxoziBbvLgBM4JfXN2P3rxrBaavHmvLvm7lYkpJeitcDthwnmWCTw=="], + + "@docusaurus/plugin-debug": ["@docusaurus/plugin-debug@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils": "3.10.1", "fs-extra": "^11.1.1", "react-json-view-lite": "^2.3.0", "tslib": "^2.6.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-9KqOpKNfAyqGZykRb9LhIT/vyRF6sm/ykhjj/39JvaJahDS+jZJE0Z1Wfz9q3DUNDTMNN0Q7u/kk4rKKU+IJuA=="], + + "@docusaurus/plugin-google-analytics": ["@docusaurus/plugin-google-analytics@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "tslib": "^2.6.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-8o0P1KtmgdYQHH+oInitPpRWI0Of5XednAX4+DMhQNSmGSRNrsEEHg1ebv35m9AgRClfAytCJ5jA9KvcASTyuA=="], + + "@docusaurus/plugin-google-gtag": ["@docusaurus/plugin-google-gtag@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "@types/gtag.js": "^0.0.20", "tslib": "^2.6.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-pu3xIUo5o/zCMLfUY9BO5KOwSH0zIsAGyFRPvXHayFSA5XIhCU/SFuB0g0ZNjFn9niZLCaNvoeAuOGFJZq0fdw=="], + + "@docusaurus/plugin-google-tag-manager": ["@docusaurus/plugin-google-tag-manager@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "tslib": "^2.6.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-f6fyGHiCm7kJHBtAisGQS5oNBnpnMTYQZxDXeVrnw/3zWU+LMA22pr6UHGYkBKDbN+qPC5QHG3NuOfzQLq3+Lw=="], + + "@docusaurus/plugin-sitemap": ["@docusaurus/plugin-sitemap@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/logger": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-common": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-C26MbmmqgdjkDq1htaZ3aD7LzEDKFWXfpyQpt0EOUThuq5nV77zDaedV20yHcVo9p+3ey9aZ4pbHA0D3QcZTzg=="], + + "@docusaurus/plugin-svgr": ["@docusaurus/plugin-svgr@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "@svgr/core": "8.1.0", "@svgr/webpack": "^8.1.0", "tslib": "^2.6.0", "webpack": "^5.88.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-6SFxsmjWFkVLDmBUvFK6i72QjUwqyQFe4Ovz+SUJophJjOyVG3ZZG5IQpBC/kX/Gfv1yWeU9nWauH6F6Q7QX/Q=="], + + "@docusaurus/preset-classic": ["@docusaurus/preset-classic@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/plugin-content-blog": "3.10.1", "@docusaurus/plugin-content-docs": "3.10.1", "@docusaurus/plugin-content-pages": "3.10.1", "@docusaurus/plugin-css-cascade-layers": "3.10.1", "@docusaurus/plugin-debug": "3.10.1", "@docusaurus/plugin-google-analytics": "3.10.1", "@docusaurus/plugin-google-gtag": "3.10.1", "@docusaurus/plugin-google-tag-manager": "3.10.1", "@docusaurus/plugin-sitemap": "3.10.1", "@docusaurus/plugin-svgr": "3.10.1", "@docusaurus/theme-classic": "3.10.1", "@docusaurus/theme-common": "3.10.1", "@docusaurus/theme-search-algolia": "3.10.1", "@docusaurus/types": "3.10.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-YO/FL8v1zmbxoTso6mjMz/RDjhaTJxb1UpFFTDdY5847LLDCeyYiYlrhyTbgN1RIN3xnkLKZ9Lj1x8hUzI4JOg=="], + + "@docusaurus/theme-classic": ["@docusaurus/theme-classic@3.10.1", "", { "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/logger": "3.10.1", "@docusaurus/mdx-loader": "3.10.1", "@docusaurus/module-type-aliases": "3.10.1", "@docusaurus/plugin-content-blog": "3.10.1", "@docusaurus/plugin-content-docs": "3.10.1", "@docusaurus/plugin-content-pages": "3.10.1", "@docusaurus/theme-common": "3.10.1", "@docusaurus/theme-translations": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-common": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", "infima": "0.2.0-alpha.45", "lodash": "^4.17.21", "nprogress": "^0.2.0", "postcss": "^8.5.4", "prism-react-renderer": "^2.3.0", "prismjs": "^1.29.0", "react-router-dom": "^5.3.4", "rtlcss": "^4.1.0", "tslib": "^2.6.0", "utility-types": "^3.10.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-VU1RK0qb2pab0si4r7HFK37cYco8VzqLj3u1PspVipSr/z/GPVKHO4/HXbnePqHoWDk8urjyGSeatH0NIMBM1A=="], + + "@docusaurus/theme-common": ["@docusaurus/theme-common@3.10.1", "", { "dependencies": { "@docusaurus/mdx-loader": "3.10.1", "@docusaurus/module-type-aliases": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-common": "3.10.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", "clsx": "^2.0.0", "parse-numeric-range": "^1.3.0", "prism-react-renderer": "^2.3.0", "tslib": "^2.6.0", "utility-types": "^3.10.0" }, "peerDependencies": { "@docusaurus/plugin-content-docs": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA=="], + + "@docusaurus/theme-search-algolia": ["@docusaurus/theme-search-algolia@3.10.1", "", { "dependencies": { "@algolia/autocomplete-core": "^1.19.2", "@docsearch/react": "^3.9.0 || ^4.3.2", "@docusaurus/core": "3.10.1", "@docusaurus/logger": "3.10.1", "@docusaurus/plugin-content-docs": "3.10.1", "@docusaurus/theme-common": "3.10.1", "@docusaurus/theme-translations": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-validation": "3.10.1", "algoliasearch": "^5.37.0", "algoliasearch-helper": "^3.26.0", "clsx": "^2.0.0", "eta": "^2.2.0", "fs-extra": "^11.1.1", "lodash": "^4.17.21", "tslib": "^2.6.0", "utility-types": "^3.10.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-OTaARARVZj2GvkJQjB+1jOIxntRaXea+G+fMsNqrZBAU1O1vJKDW22R7kECOHW27oJCLFN9HKaZeRrfAUyviug=="], + + "@docusaurus/theme-translations": ["@docusaurus/theme-translations@3.10.1", "", { "dependencies": { "fs-extra": "^11.1.1", "tslib": "^2.6.0" } }, "sha512-cLMyaKivjBVWKMJuWqyFVVgtqe8DPJNPkog0bn8W1MDVAKcPdxRFycBfC1We1RaNp7Rdk513bmtW78RR6OBxBw=="], + + "@docusaurus/types": ["@docusaurus/types@3.10.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "@types/history": "^4.7.11", "@types/mdast": "^4.0.2", "@types/react": "*", "commander": "^5.1.0", "joi": "^17.9.2", "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", "utility-types": "^3.10.0", "webpack": "^5.95.0", "webpack-merge": "^5.9.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-XYMK8k1szDCFMw2V+Xyen0g7Kee1sP3dtFnl7vkGkZOkeAJ/oPDQPL8iz4HBKOo/cwU8QeV6onVjMqtP+tFzsw=="], + + "@docusaurus/utils": ["@docusaurus/utils@3.10.1", "", { "dependencies": { "@docusaurus/logger": "3.10.1", "@docusaurus/types": "3.10.1", "@docusaurus/utils-common": "3.10.1", "escape-string-regexp": "^4.0.0", "execa": "^5.1.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", "github-slugger": "^1.5.0", "globby": "^11.1.0", "gray-matter": "^4.0.3", "jiti": "^1.20.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "micromatch": "^4.0.5", "p-queue": "^6.6.2", "prompts": "^2.4.2", "resolve-pathname": "^3.0.0", "tslib": "^2.6.0", "url-loader": "^4.1.1", "utility-types": "^3.10.0", "webpack": "^5.88.1" } }, "sha512-3ojeJry9xBYdJO6qoyyzqeJFSJBVx2mXhyDzSdjwL2+URFQMf+h25gG38iswGImicK0ELjTd1EL2xzk8hf3QPw=="], + + "@docusaurus/utils-common": ["@docusaurus/utils-common@3.10.1", "", { "dependencies": { "@docusaurus/types": "3.10.1", "tslib": "^2.6.0" } }, "sha512-5mFSgEADtnFxFH7RLw02QA5MpU5JVUCj0MPeIvi/aF4Fi45tQRIuTwXoXDqJ+1VfQJuYJGz3SI63wmGz4HvXzA=="], + + "@docusaurus/utils-validation": ["@docusaurus/utils-validation@3.10.1", "", { "dependencies": { "@docusaurus/logger": "3.10.1", "@docusaurus/utils": "3.10.1", "@docusaurus/utils-common": "3.10.1", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "tslib": "^2.6.0" } }, "sha512-cRv1X69jwaWv47waglllgZVWzeBFLhl53XT/XED/83BerVBTC5FTP8WTcVl8Z6sZOegDSwitu/wpCSPCDOT6lg=="], + + "@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="], + + "@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="], + + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@jsonjoy.com/base64": ["@jsonjoy.com/base64@1.1.2", "", { "peerDependencies": { "tslib": "2" } }, "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA=="], + + "@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw=="], + + "@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@1.0.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g=="], + + "@jsonjoy.com/fs-core": ["@jsonjoy.com/fs-core@4.64.0", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.64.0", "@jsonjoy.com/fs-node-utils": "4.64.0", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-zs2TAq7Six5jgMuoMNjpspAvOP3mhtgq/k1UyQodEzCtQi/N83y2/y+zcvnZSGp/Rxq96DBN+bValOBQAyn/ew=="], + + "@jsonjoy.com/fs-fsa": ["@jsonjoy.com/fs-fsa@4.64.0", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.64.0", "@jsonjoy.com/fs-node-builtins": "4.64.0", "@jsonjoy.com/fs-node-utils": "4.64.0", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-nMWOVbkLFyEgmXZih3wyvxA9XpgyyqyfrINMHvEFqhi7uqfRl7c9ERJt6yX7vgMPrB9Uo+OJO+Spa0cFzPD01w=="], + + "@jsonjoy.com/fs-node": ["@jsonjoy.com/fs-node@4.64.0", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.64.0", "@jsonjoy.com/fs-node-builtins": "4.64.0", "@jsonjoy.com/fs-node-utils": "4.64.0", "@jsonjoy.com/fs-print": "4.64.0", "@jsonjoy.com/fs-snapshot": "4.64.0", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-dO+NNkODbUli4uV42bcNrrLvq5rE7SNpdZ5TNd0dtbLsAaNK3MDiIC9lUi+brboGoIjW6vd2fB1qao60nrk5xA=="], + + "@jsonjoy.com/fs-node-builtins": ["@jsonjoy.com/fs-node-builtins@4.64.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-/o7WRFhUWaM/fOrslwLZGnzn4RmRILykn+lAL+mNObqqRNw+CQSiij6hpCeZ+C7buhdoVo7go/OYqzaSUfDYmA=="], + + "@jsonjoy.com/fs-node-to-fsa": ["@jsonjoy.com/fs-node-to-fsa@4.64.0", "", { "dependencies": { "@jsonjoy.com/fs-fsa": "4.64.0", "@jsonjoy.com/fs-node-builtins": "4.64.0", "@jsonjoy.com/fs-node-utils": "4.64.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-WDD9WVs0hb7UAEKTgZW2f66WDrbj7gIIWwpP3spbLyXa0rghtUaFTB8L4gdR3ZCWwiKIsj38/CNijpVmpnuPUw=="], + + "@jsonjoy.com/fs-node-utils": ["@jsonjoy.com/fs-node-utils@4.64.0", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.64.0", "glob-to-regex.js": "^1.0.1" }, "peerDependencies": { "tslib": "2" } }, "sha512-k5Indsx9hWW9xSF7Y6oSKKwtCUNhzZxadub3owhIlitc+iMRVlPPdX2duTKQWBL3qNWpXya8jykgaaWpheeS4w=="], + + "@jsonjoy.com/fs-print": ["@jsonjoy.com/fs-print@4.64.0", "", { "dependencies": { "@jsonjoy.com/fs-node-utils": "4.64.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-PHZFccchvkhWrwPWHjmVAhbC3vSHCtyZvlZfJJ3ho2bnzl450hXri6/8e6pbkWdH+SkmLXNml0sV8e5HDAfxKw=="], + + "@jsonjoy.com/fs-snapshot": ["@jsonjoy.com/fs-snapshot@4.64.0", "", { "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", "@jsonjoy.com/fs-node-utils": "4.64.0", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-oM7UDeL83q6NBzzsfKAsYKXKVXlykKFqqOLh4xZZKAzzROTlInkPbc6LTDGThEOnPiFiUzA7tYziHG9xavd76Q=="], + + "@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@1.21.0", "", { "dependencies": { "@jsonjoy.com/base64": "^1.1.2", "@jsonjoy.com/buffers": "^1.2.0", "@jsonjoy.com/codegen": "^1.0.0", "@jsonjoy.com/json-pointer": "^1.0.2", "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg=="], + + "@jsonjoy.com/json-pointer": ["@jsonjoy.com/json-pointer@1.0.2", "", { "dependencies": { "@jsonjoy.com/codegen": "^1.0.0", "@jsonjoy.com/util": "^1.9.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg=="], + + "@jsonjoy.com/util": ["@jsonjoy.com/util@1.9.0", "", { "dependencies": { "@jsonjoy.com/buffers": "^1.0.0", "@jsonjoy.com/codegen": "^1.0.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ=="], + + "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], + + "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + + "@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-rsa": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pfx": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], + + "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], + + "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="], + + "@pnpm/npm-conf": ["@pnpm/npm-conf@3.0.3", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="], + + "@sideway/formula": ["@sideway/formula@3.0.1", "", {}, "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="], + + "@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + + "@slorber/remark-comment": ["@slorber/remark-comment@1.0.0", "", { "dependencies": { "micromark-factory-space": "^1.0.0", "micromark-util-character": "^1.1.0", "micromark-util-symbol": "^1.0.1" } }, "sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA=="], + + "@svgr/babel-plugin-add-jsx-attribute": ["@svgr/babel-plugin-add-jsx-attribute@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g=="], + + "@svgr/babel-plugin-remove-jsx-attribute": ["@svgr/babel-plugin-remove-jsx-attribute@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA=="], + + "@svgr/babel-plugin-remove-jsx-empty-expression": ["@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA=="], + + "@svgr/babel-plugin-replace-jsx-attribute-value": ["@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ=="], + + "@svgr/babel-plugin-svg-dynamic-title": ["@svgr/babel-plugin-svg-dynamic-title@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og=="], + + "@svgr/babel-plugin-svg-em-dimensions": ["@svgr/babel-plugin-svg-em-dimensions@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g=="], + + "@svgr/babel-plugin-transform-react-native-svg": ["@svgr/babel-plugin-transform-react-native-svg@8.1.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q=="], + + "@svgr/babel-plugin-transform-svg-component": ["@svgr/babel-plugin-transform-svg-component@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw=="], + + "@svgr/babel-preset": ["@svgr/babel-preset@8.1.0", "", { "dependencies": { "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", "@svgr/babel-plugin-transform-svg-component": "8.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug=="], + + "@svgr/core": ["@svgr/core@8.1.0", "", { "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", "camelcase": "^6.2.0", "cosmiconfig": "^8.1.3", "snake-case": "^3.0.4" } }, "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA=="], + + "@svgr/hast-util-to-babel-ast": ["@svgr/hast-util-to-babel-ast@8.0.0", "", { "dependencies": { "@babel/types": "^7.21.3", "entities": "^4.4.0" } }, "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q=="], + + "@svgr/plugin-jsx": ["@svgr/plugin-jsx@8.1.0", "", { "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", "@svgr/hast-util-to-babel-ast": "8.0.0", "svg-parser": "^2.0.4" }, "peerDependencies": { "@svgr/core": "*" } }, "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA=="], + + "@svgr/plugin-svgo": ["@svgr/plugin-svgo@8.1.0", "", { "dependencies": { "cosmiconfig": "^8.1.3", "deepmerge": "^4.3.1", "svgo": "^3.0.2" }, "peerDependencies": { "@svgr/core": "*" } }, "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA=="], + + "@svgr/webpack": ["@svgr/webpack@8.1.0", "", { "dependencies": { "@babel/core": "^7.21.3", "@babel/plugin-transform-react-constant-elements": "^7.21.3", "@babel/preset-env": "^7.20.2", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.0", "@svgr/core": "8.1.0", "@svgr/plugin-jsx": "8.1.0", "@svgr/plugin-svgo": "8.1.0" } }, "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA=="], + + "@szmarczak/http-timer": ["@szmarczak/http-timer@5.0.1", "", { "dependencies": { "defer-to-connect": "^2.0.1" } }, "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/bonjour": ["@types/bonjour@3.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/connect-history-api-fallback": ["@types/connect-history-api-fallback@1.5.4", "", { "dependencies": { "@types/express-serve-static-core": "*", "@types/node": "*" } }, "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.9", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg=="], + + "@types/gtag.js": ["@types/gtag.js@0.0.20", "", {}, "sha512-wwAbk3SA2QeU67unN7zPxjEHmPmlXwZXZvQEpbEUQuMCRGgKyE1m6XDuTUA9b6pCGb/GqJmdfMOY5LuDjJSbbg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/history": ["@types/history@4.7.11", "", {}, "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="], + + "@types/html-minifier-terser": ["@types/html-minifier-terser@6.1.0", "", {}, "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg=="], + + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/http-proxy": ["@types/http-proxy@1.17.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw=="], + + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], + + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], + + "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-router": ["@types/react-router@5.1.20", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*" } }, "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q=="], + + "@types/react-router-config": ["@types/react-router-config@5.0.11", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router": "^5.1.0" } }, "sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw=="], + + "@types/react-router-dom": ["@types/react-router-dom@5.3.3", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router": "*" } }, "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw=="], + + "@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="], + + "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], + + "@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + + "@types/serve-index": ["@types/serve-index@1.9.4", "", { "dependencies": { "@types/express": "*" } }, "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug=="], + + "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], + + "@types/sockjs": ["@types/sockjs@0.3.36", "", { "dependencies": { "@types/node": "*" } }, "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="], + + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], + + "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], + + "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], + + "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], + + "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], + + "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], + + "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], + + "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], + + "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], + + "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], + + "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], + + "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], + + "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], + + "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], + + "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], + + "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + + "address": ["address@1.2.2", "", {}, "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA=="], + + "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + + "algoliasearch": ["algoliasearch@5.55.2", "", { "dependencies": { "@algolia/abtesting": "1.21.2", "@algolia/client-abtesting": "5.55.2", "@algolia/client-analytics": "5.55.2", "@algolia/client-common": "5.55.2", "@algolia/client-insights": "5.55.2", "@algolia/client-personalization": "5.55.2", "@algolia/client-query-suggestions": "5.55.2", "@algolia/client-search": "5.55.2", "@algolia/ingestion": "1.55.2", "@algolia/monitoring": "1.55.2", "@algolia/recommend": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw=="], + + "algoliasearch-helper": ["algoliasearch-helper@3.29.2", "", { "dependencies": { "@algolia/events": "^4.0.1" }, "peerDependencies": { "algoliasearch": ">= 3.1 < 6" } }, "sha512-SaV+rZM3drExb0punEYYjT+sNcH74YFwN8ocjya7IDOyQvKWeQpEaSMVG3+IGTVos+feuatj7ljQ4BXlXdUp3w=="], + + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + + "ansi-html-community": ["ansi-html-community@0.0.8", "", { "bin": { "ansi-html": "bin/ansi-html" } }, "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], + + "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + + "autoprefixer": ["autoprefixer@10.5.2", "", { "dependencies": { "browserslist": "^4.28.4", "caniuse-lite": "^1.0.30001799", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q=="], + + "babel-loader": ["babel-loader@9.2.1", "", { "dependencies": { "find-cache-dir": "^4.0.0", "schema-utils": "^4.0.0" }, "peerDependencies": { "@babel/core": "^7.12.0", "webpack": ">=5" } }, "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA=="], + + "babel-plugin-dynamic-import-node": ["babel-plugin-dynamic-import-node@2.3.3", "", { "dependencies": { "object.assign": "^4.1.0" } }, "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ=="], + + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], + + "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], + + "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], + + "batch": ["batch@0.6.1", "", {}, "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw=="], + + "big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "body-parser": ["body-parser@1.20.6", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g=="], + + "bonjour-service": ["bonjour-service@1.4.2", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-lMskhnsW70yWHr4PhPeh2rvaIkLSaDpp+nmtbXBZaNKTXwxL73QOkW6HhbzqTImXjevn9TreGT4GACGBCGP9nQ=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "boxen": ["boxen@6.2.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^6.2.0", "chalk": "^4.1.2", "cli-boxes": "^3.0.0", "string-width": "^5.0.1", "type-fest": "^2.5.0", "widest-line": "^4.0.1", "wrap-ansi": "^8.0.1" } }, "sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw=="], + + "brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "bytes": ["bytes@3.0.0", "", {}, "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw=="], + + "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], + + "cacheable-lookup": ["cacheable-lookup@7.0.0", "", {}, "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w=="], + + "cacheable-request": ["cacheable-request@10.2.14", "", { "dependencies": { "@types/http-cache-semantics": "^4.0.2", "get-stream": "^6.0.1", "http-cache-semantics": "^4.1.1", "keyv": "^4.5.3", "mimic-response": "^4.0.0", "normalize-url": "^8.0.0", "responselike": "^3.0.0" } }, "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ=="], + + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "caniuse-api": ["caniuse-api@3.0.0", "", { "dependencies": { "browserslist": "^4.0.0", "caniuse-lite": "^1.0.0", "lodash.memoize": "^4.1.2", "lodash.uniq": "^4.5.0" } }, "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001803", "", {}, "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "cheerio": ["cheerio@1.0.0-rc.12", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "htmlparser2": "^8.0.1", "parse5": "^7.0.0", "parse5-htmlparser2-tree-adapter": "^7.0.0" } }, "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + + "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], + + "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + + "clone-deep": ["clone-deep@4.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4", "kind-of": "^6.0.2", "shallow-clone": "^3.0.0" } }, "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "colord": ["colord@2.9.3", "", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "combine-promises": ["combine-promises@1.2.0", "", {}, "sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "common-path-prefix": ["common-path-prefix@3.0.0", "", {}, "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w=="], + + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], + + "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], + + "configstore": ["configstore@6.0.0", "", { "dependencies": { "dot-prop": "^6.0.1", "graceful-fs": "^4.2.6", "unique-string": "^3.0.0", "write-file-atomic": "^3.0.3", "xdg-basedir": "^5.0.1" } }, "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA=="], + + "connect-history-api-fallback": ["connect-history-api-fallback@2.0.0", "", {}, "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "content-disposition": ["content-disposition@0.5.2", "", {}, "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + + "copy-text-to-clipboard": ["copy-text-to-clipboard@3.2.2", "", {}, "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A=="], + + "copy-webpack-plugin": ["copy-webpack-plugin@11.0.0", "", { "dependencies": { "fast-glob": "^3.2.11", "glob-parent": "^6.0.1", "globby": "^13.1.1", "normalize-path": "^3.0.0", "schema-utils": "^4.0.0", "serialize-javascript": "^6.0.0" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ=="], + + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + + "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crypto-random-string": ["crypto-random-string@4.0.0", "", { "dependencies": { "type-fest": "^1.0.1" } }, "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA=="], + + "css-blank-pseudo": ["css-blank-pseudo@7.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-jf+twWGDf6LDoXDUode+nc7ZlrqfaNphrBIBrcmeP3D8yw1uPaix1gCC8LUQUGQ6CycuK2opkbFFWFuq/a94ag=="], + + "css-declaration-sorter": ["css-declaration-sorter@7.4.0", "", { "peerDependencies": { "postcss": "^8.0.9" } }, "sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw=="], + + "css-has-pseudo": ["css-has-pseudo@7.0.3", "", { "dependencies": { "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-oG+vKuGyqe/xvEMoxAQrhi7uY16deJR3i7wwhBerVrGQKSqUC5GiOVxTpM9F9B9hw0J+eKeOWLH7E9gZ1Dr5rA=="], + + "css-loader": ["css-loader@6.11.0", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.33", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.5.4" }, "peerDependencies": { "@rspack/core": "0.x || 1.x", "webpack": "^5.0.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g=="], + + "css-minimizer-webpack-plugin": ["css-minimizer-webpack-plugin@5.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "cssnano": "^6.0.1", "jest-worker": "^29.4.3", "postcss": "^8.4.24", "schema-utils": "^4.0.1", "serialize-javascript": "^6.0.1" }, "peerDependencies": { "webpack": "^5.0.0" } }, "sha512-3caImjKFQkS+ws1TGcFn0V1HyDJFq1Euy589JlD6/3rV2kj+w7r5G9WDMgSHvpvXHNZ2calVypZWuEDQd9wfLg=="], + + "css-prefers-color-scheme": ["css-prefers-color-scheme@10.0.0", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-VCtXZAWivRglTZditUfB4StnsWr6YVZ2PRtuxQLKTNRdtAf8tpzaVPE9zXIF3VaSc7O70iK/j1+NXxyQCqdPjQ=="], + + "css-select": ["css-select@4.3.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", "domhandler": "^4.3.1", "domutils": "^2.8.0", "nth-check": "^2.0.1" } }, "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ=="], + + "css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "cssdb": ["cssdb@8.9.0", "", {}, "sha512-J8jOU/hLjaXcO1LldOLraJSQpfLXRKof0I7mtbRyOy2AAXgqst0x9rlgi2qXeD6d0ou3ZLqcPAMqYVbpCbrxEw=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "cssnano": ["cssnano@6.1.2", "", { "dependencies": { "cssnano-preset-default": "^6.1.2", "lilconfig": "^3.1.1" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA=="], + + "cssnano-preset-advanced": ["cssnano-preset-advanced@6.1.2", "", { "dependencies": { "autoprefixer": "^10.4.19", "browserslist": "^4.23.0", "cssnano-preset-default": "^6.1.2", "postcss-discard-unused": "^6.0.5", "postcss-merge-idents": "^6.0.3", "postcss-reduce-idents": "^6.0.3", "postcss-zindex": "^6.0.2" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ=="], + + "cssnano-preset-default": ["cssnano-preset-default@6.1.2", "", { "dependencies": { "browserslist": "^4.23.0", "css-declaration-sorter": "^7.2.0", "cssnano-utils": "^4.0.2", "postcss-calc": "^9.0.1", "postcss-colormin": "^6.1.0", "postcss-convert-values": "^6.1.0", "postcss-discard-comments": "^6.0.2", "postcss-discard-duplicates": "^6.0.3", "postcss-discard-empty": "^6.0.3", "postcss-discard-overridden": "^6.0.2", "postcss-merge-longhand": "^6.0.5", "postcss-merge-rules": "^6.1.1", "postcss-minify-font-values": "^6.1.0", "postcss-minify-gradients": "^6.0.3", "postcss-minify-params": "^6.1.0", "postcss-minify-selectors": "^6.0.4", "postcss-normalize-charset": "^6.0.2", "postcss-normalize-display-values": "^6.0.2", "postcss-normalize-positions": "^6.0.2", "postcss-normalize-repeat-style": "^6.0.2", "postcss-normalize-string": "^6.0.2", "postcss-normalize-timing-functions": "^6.0.2", "postcss-normalize-unicode": "^6.1.0", "postcss-normalize-url": "^6.0.2", "postcss-normalize-whitespace": "^6.0.2", "postcss-ordered-values": "^6.0.2", "postcss-reduce-initial": "^6.1.0", "postcss-reduce-transforms": "^6.0.2", "postcss-svgo": "^6.0.3", "postcss-unique-selectors": "^6.0.4" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg=="], + + "cssnano-utils": ["cssnano-utils@4.0.2", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ=="], + + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + + "detect-port": ["detect-port@1.6.1", "", { "dependencies": { "address": "^1.0.1", "debug": "4" }, "bin": { "detect": "bin/detect-port.js", "detect-port": "bin/detect-port.js" } }, "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + + "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], + + "dom-converter": ["dom-converter@0.2.0", "", { "dependencies": { "utila": "~0.4" } }, "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], + + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], + + "emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="], + + "emoticon": ["emoticon@4.1.0", "", {}, "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], + + "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-goat": ["escape-goat@4.0.0", "", {}, "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], + + "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-util-scope": ["estree-util-scope@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ=="], + + "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], + + "estree-util-value-to-estree": ["estree-util-value-to-estree@3.5.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ=="], + + "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eta": ["eta@2.2.0", "", {}, "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eval": ["eval@0.1.8", "", { "dependencies": { "@types/node": "*", "require-like": ">= 0.1.1" } }, "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + + "faye-websocket": ["faye-websocket@0.11.4", "", { "dependencies": { "websocket-driver": ">=0.5.1" } }, "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g=="], + + "feed": ["feed@4.2.2", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ=="], + + "file-loader": ["file-loader@6.2.0", "", { "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], + + "find-cache-dir": ["find-cache-dir@4.0.0", "", { "dependencies": { "common-path-prefix": "^3.0.0", "pkg-dir": "^7.0.0" } }, "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg=="], + + "find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "^7.1.0", "path-exists": "^5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="], + + "flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="], + + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="], + + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + + "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "github-slugger": ["github-slugger@1.5.0", "", {}, "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "glob-to-regex.js": ["glob-to-regex.js@1.2.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ=="], + + "global-dirs": ["global-dirs@3.0.1", "", { "dependencies": { "ini": "2.0.0" } }, "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA=="], + + "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "got": ["got@12.6.1", "", { "dependencies": { "@sindresorhus/is": "^5.2.0", "@szmarczak/http-timer": "^5.0.1", "cacheable-lookup": "^7.0.0", "cacheable-request": "^10.2.8", "decompress-response": "^6.0.0", "form-data-encoder": "^2.1.2", "get-stream": "^6.0.1", "http2-wrapper": "^2.1.10", "lowercase-keys": "^3.0.0", "p-cancelable": "^3.0.0", "responselike": "^3.0.0" } }, "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + + "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], + + "handle-thing": ["handle-thing@2.0.1", "", {}, "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-yarn": ["has-yarn@3.0.0", "", {}, "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "history": ["history@4.10.1", "", { "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", "resolve-pathname": "^3.0.0", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0", "value-equal": "^1.0.1" } }, "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew=="], + + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + + "hpack.js": ["hpack.js@2.1.6", "", { "dependencies": { "inherits": "^2.0.1", "obuf": "^1.0.0", "readable-stream": "^2.0.1", "wbuf": "^1.1.0" } }, "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + + "html-minifier-terser": ["html-minifier-terser@7.2.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "~5.3.2", "commander": "^10.0.0", "entities": "^4.4.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.15.1" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA=="], + + "html-tags": ["html-tags@3.3.1", "", {}, "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "html-webpack-plugin": ["html-webpack-plugin@5.6.7", "", { "dependencies": { "@types/html-minifier-terser": "^6.0.0", "html-minifier-terser": "^6.0.2", "lodash": "^4.17.21", "pretty-error": "^4.0.0", "tapable": "^2.0.0" }, "peerDependencies": { "@rspack/core": "0.x || 1.x", "webpack": "^5.20.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-md+vXtdCAe60s1k6AU3dUyMJnDxUyQAwfwPKoLisvgUF1IXjtlLsk2se54+qfL9Mdm26bbwvjJybpNx48NKRLw=="], + + "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-deceiver": ["http-deceiver@1.2.7", "", {}, "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-parser-js": ["http-parser-js@0.5.10", "", {}, "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="], + + "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], + + "http-proxy-middleware": ["http-proxy-middleware@2.0.10", "", { "dependencies": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", "is-glob": "^4.0.1", "is-plain-obj": "^3.0.0", "micromatch": "^4.0.2" }, "peerDependencies": { "@types/express": "^4.17.13" }, "optionalPeers": ["@types/express"] }, "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ=="], + + "http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], + + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "hyperdyperid": ["hyperdyperid@1.2.0", "", {}, "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "infima": ["infima@0.2.0-alpha.45", "", {}, "sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + + "ipaddr.js": ["ipaddr.js@2.4.0", "", {}, "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-ci": ["is-ci@3.0.1", "", { "dependencies": { "ci-info": "^3.2.0" }, "bin": { "is-ci": "bin.js" } }, "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-installed-globally": ["is-installed-globally@0.4.0", "", { "dependencies": { "global-dirs": "^3.0.0", "is-path-inside": "^3.0.2" } }, "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ=="], + + "is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="], + + "is-npm": ["is-npm@6.1.0", "", {}, "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-obj": ["is-obj@1.0.1", "", {}, "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="], + + "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + + "is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "is-yarn-global": ["is-yarn-global@0.4.1", "", {}, "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ=="], + + "isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], + + "jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], + + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "joi": ["joi@17.13.4", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "latest-version": ["latest-version@7.0.0", "", { "dependencies": { "package-json": "^8.1.0" } }, "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg=="], + + "launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="], + + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="], + + "loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="], + + "locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + + "lodash.memoize": ["lodash.memoize@4.1.2", "", {}, "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="], + + "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], + + "lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-directive": ["mdast-util-directive@3.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], + + "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "memfs": ["memfs@4.64.0", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.64.0", "@jsonjoy.com/fs-fsa": "4.64.0", "@jsonjoy.com/fs-node": "4.64.0", "@jsonjoy.com/fs-node-builtins": "4.64.0", "@jsonjoy.com/fs-node-to-fsa": "4.64.0", "@jsonjoy.com/fs-node-utils": "4.64.0", "@jsonjoy.com/fs-print": "4.64.0", "@jsonjoy.com/fs-snapshot": "4.64.0", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" } }, "sha512-Kw72fgY7Wn+sD8KmtNWSafl1dz0UvAsE/PHs3YVfLiaZuA3HxNm9sRLqAu0ATiBGJvME1PxZXbBZPv5GycDeAw=="], + + "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-directive": ["micromark-extension-directive@3.0.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA=="], + + "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], + + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], + + "micromark-factory-space": ["micromark-factory-space@1.1.0", "", { "dependencies": { "micromark-util-character": "^1.0.0", "micromark-util-types": "^1.0.0" } }, "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@1.2.0", "", { "dependencies": { "micromark-util-symbol": "^1.0.0", "micromark-util-types": "^1.0.0" } }, "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@1.1.0", "", {}, "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@2.1.18", "", { "dependencies": { "mime-db": "~1.33.0" } }, "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="], + + "mini-css-extract-plugin": ["mini-css-extract-plugin@2.10.2", "", { "dependencies": { "schema-utils": "^4.0.0", "tapable": "^2.2.1" }, "peerDependencies": { "webpack": "^5.0.0" } }, "sha512-AOSS0IdEB95ayVkxn5oGzNQwqAi2J0Jb/kKm43t7H73s8+f5873g0yuj0PNvK4dO75mu5DHg4nlgp4k6Kga8eg=="], + + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minimizer-webpack-plugin": ["minimizer-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="], + + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + + "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + + "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], + + "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="], + + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "nprogress": ["nprogress@0.2.0", "", {}, "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "null-loader": ["null-loader@4.0.1", "", { "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "opener": ["opener@1.5.2", "", { "bin": { "opener": "bin/opener-bin.js" } }, "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="], + + "p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], + + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + + "p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], + + "p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], + + "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + + "p-retry": ["p-retry@6.2.1", "", { "dependencies": { "@types/retry": "0.12.2", "is-network-error": "^1.0.0", "retry": "^0.13.1" } }, "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ=="], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], + + "package-json": ["package-json@8.1.1", "", { "dependencies": { "got": "^12.1.0", "registry-auth-token": "^5.0.1", "registry-url": "^6.0.0", "semver": "^7.3.7" } }, "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA=="], + + "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse-numeric-range": ["parse-numeric-range@1.3.0", "", {}, "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], + + "path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + + "path-is-inside": ["path-is-inside@1.0.2", "", {}, "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-to-regexp": ["path-to-regexp@1.9.0", "", { "dependencies": { "isarray": "0.0.1" } }, "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "pkg-dir": ["pkg-dir@7.0.0", "", { "dependencies": { "find-up": "^6.3.0" } }, "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA=="], + + "pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="], + + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + + "postcss-attribute-case-insensitive": ["postcss-attribute-case-insensitive@7.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw=="], + + "postcss-calc": ["postcss-calc@9.0.1", "", { "dependencies": { "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2.2" } }, "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ=="], + + "postcss-clamp": ["postcss-clamp@4.1.0", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.6" } }, "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow=="], + + "postcss-color-functional-notation": ["postcss-color-functional-notation@7.0.12", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-TLCW9fN5kvO/u38/uesdpbx3e8AkTYhMvDZYa9JpmImWuTE99bDQ7GU7hdOADIZsiI9/zuxfAJxny/khknp1Zw=="], + + "postcss-color-hex-alpha": ["postcss-color-hex-alpha@10.0.0", "", { "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-1kervM2cnlgPs2a8Vt/Qbe5cQ++N7rkYo/2rz2BkqJZIHQwaVuJgQH38REHrAi4uM0b1fqxMkWYmese94iMp3w=="], + + "postcss-color-rebeccapurple": ["postcss-color-rebeccapurple@10.0.0", "", { "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-JFta737jSP+hdAIEhk1Vs0q0YF5P8fFcj+09pweS8ktuGuZ8pPlykHsk6mPxZ8awDl4TrcxUqJo9l1IhVr/OjQ=="], + + "postcss-colormin": ["postcss-colormin@6.1.0", "", { "dependencies": { "browserslist": "^4.23.0", "caniuse-api": "^3.0.0", "colord": "^2.9.3", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw=="], + + "postcss-convert-values": ["postcss-convert-values@6.1.0", "", { "dependencies": { "browserslist": "^4.23.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w=="], + + "postcss-custom-media": ["postcss-custom-media@11.0.6", "", { "dependencies": { "@csstools/cascade-layer-name-parser": "^2.0.5", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/media-query-list-parser": "^4.0.3" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-C4lD4b7mUIw+RZhtY7qUbf4eADmb7Ey8BFA2px9jUbwg7pjTZDl4KY4bvlUV+/vXQvzQRfiGEVJyAbtOsCMInw=="], + + "postcss-custom-properties": ["postcss-custom-properties@14.0.6", "", { "dependencies": { "@csstools/cascade-layer-name-parser": "^2.0.5", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ=="], + + "postcss-custom-selectors": ["postcss-custom-selectors@8.0.5", "", { "dependencies": { "@csstools/cascade-layer-name-parser": "^2.0.5", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-9PGmckHQswiB2usSO6XMSswO2yFWVoCAuih1yl9FVcwkscLjRKjwsjM3t+NIWpSU2Jx3eOiK2+t4vVTQaoCHHg=="], + + "postcss-dir-pseudo-class": ["postcss-dir-pseudo-class@9.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-tRBEK0MHYvcMUrAuYMEOa0zg9APqirBcgzi6P21OhxtJyJADo/SWBwY1CAwEohQ/6HDaa9jCjLRG7K3PVQYHEA=="], + + "postcss-discard-comments": ["postcss-discard-comments@6.0.2", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw=="], + + "postcss-discard-duplicates": ["postcss-discard-duplicates@6.0.3", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw=="], + + "postcss-discard-empty": ["postcss-discard-empty@6.0.3", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ=="], + + "postcss-discard-overridden": ["postcss-discard-overridden@6.0.2", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ=="], + + "postcss-discard-unused": ["postcss-discard-unused@6.0.5", "", { "dependencies": { "postcss-selector-parser": "^6.0.16" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-wHalBlRHkaNnNwfC8z+ppX57VhvS+HWgjW508esjdaEYr3Mx7Gnn2xA4R/CKf5+Z9S5qsqC+Uzh4ueENWwCVUA=="], + + "postcss-double-position-gradients": ["postcss-double-position-gradients@6.0.4", "", { "dependencies": { "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-m6IKmxo7FxSP5nF2l63QbCC3r+bWpFUWmZXZf096WxG0m7Vl1Q1+ruFOhpdDRmKrRS+S3Jtk+TVk/7z0+BVK6g=="], + + "postcss-focus-visible": ["postcss-focus-visible@10.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-U58wyjS/I1GZgjRok33aE8juW9qQgQUNwTSdxQGuShHzwuYdcklnvK/+qOWX1Q9kr7ysbraQ6ht6r+udansalA=="], + + "postcss-focus-within": ["postcss-focus-within@9.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-fzNUyS1yOYa7mOjpci/bR+u+ESvdar6hk8XNK/TRR0fiGTp2QT5N+ducP0n3rfH/m9I7H/EQU6lsa2BrgxkEjw=="], + + "postcss-font-variant": ["postcss-font-variant@5.0.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA=="], + + "postcss-gap-properties": ["postcss-gap-properties@6.0.0", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-Om0WPjEwiM9Ru+VhfEDPZJAKWUd0mV1HmNXqp2C29z80aQ2uP9UVhLc7e3aYMIor/S5cVhoPgYQ7RtfeZpYTRw=="], + + "postcss-image-set-function": ["postcss-image-set-function@7.0.0", "", { "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-QL7W7QNlZuzOwBTeXEmbVckNt1FSmhQtbMRvGGqqU4Nf4xk6KUEQhAoWuMzwbSv5jxiRiSZ5Tv7eiDB9U87znA=="], + + "postcss-lab-function": ["postcss-lab-function@7.0.12", "", { "dependencies": { "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/utilities": "^2.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-tUcyRk1ZTPec3OuKFsqtRzW2Go5lehW29XA21lZ65XmzQkz43VY2tyWEC202F7W3mILOjw0voOiuxRGTsN+J9w=="], + + "postcss-loader": ["postcss-loader@7.3.4", "", { "dependencies": { "cosmiconfig": "^8.3.5", "jiti": "^1.20.0", "semver": "^7.5.4" }, "peerDependencies": { "postcss": "^7.0.0 || ^8.0.1", "webpack": "^5.0.0" } }, "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A=="], + + "postcss-logical": ["postcss-logical@8.1.0", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-pL1hXFQ2fEXNKiNiAgtfA005T9FBxky5zkX6s4GZM2D8RkVgRqz3f4g1JUoq925zXv495qk8UNldDwh8uGEDoA=="], + + "postcss-merge-idents": ["postcss-merge-idents@6.0.3", "", { "dependencies": { "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-1oIoAsODUs6IHQZkLQGO15uGEbK3EAl5wi9SS8hs45VgsxQfMnxvt+L+zIr7ifZFIH14cfAeVe2uCTa+SPRa3g=="], + + "postcss-merge-longhand": ["postcss-merge-longhand@6.0.5", "", { "dependencies": { "postcss-value-parser": "^4.2.0", "stylehacks": "^6.1.1" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w=="], + + "postcss-merge-rules": ["postcss-merge-rules@6.1.1", "", { "dependencies": { "browserslist": "^4.23.0", "caniuse-api": "^3.0.0", "cssnano-utils": "^4.0.2", "postcss-selector-parser": "^6.0.16" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ=="], + + "postcss-minify-font-values": ["postcss-minify-font-values@6.1.0", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg=="], + + "postcss-minify-gradients": ["postcss-minify-gradients@6.0.3", "", { "dependencies": { "colord": "^2.9.3", "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q=="], + + "postcss-minify-params": ["postcss-minify-params@6.1.0", "", { "dependencies": { "browserslist": "^4.23.0", "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA=="], + + "postcss-minify-selectors": ["postcss-minify-selectors@6.0.4", "", { "dependencies": { "postcss-selector-parser": "^6.0.16" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ=="], + + "postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="], + + "postcss-modules-local-by-default": ["postcss-modules-local-by-default@4.2.0", "", { "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw=="], + + "postcss-modules-scope": ["postcss-modules-scope@3.2.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA=="], + + "postcss-modules-values": ["postcss-modules-values@4.0.0", "", { "dependencies": { "icss-utils": "^5.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ=="], + + "postcss-nesting": ["postcss-nesting@13.0.2", "", { "dependencies": { "@csstools/selector-resolve-nested": "^3.1.0", "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ=="], + + "postcss-normalize-charset": ["postcss-normalize-charset@6.0.2", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ=="], + + "postcss-normalize-display-values": ["postcss-normalize-display-values@6.0.2", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg=="], + + "postcss-normalize-positions": ["postcss-normalize-positions@6.0.2", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q=="], + + "postcss-normalize-repeat-style": ["postcss-normalize-repeat-style@6.0.2", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ=="], + + "postcss-normalize-string": ["postcss-normalize-string@6.0.2", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ=="], + + "postcss-normalize-timing-functions": ["postcss-normalize-timing-functions@6.0.2", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA=="], + + "postcss-normalize-unicode": ["postcss-normalize-unicode@6.1.0", "", { "dependencies": { "browserslist": "^4.23.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg=="], + + "postcss-normalize-url": ["postcss-normalize-url@6.0.2", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ=="], + + "postcss-normalize-whitespace": ["postcss-normalize-whitespace@6.0.2", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q=="], + + "postcss-opacity-percentage": ["postcss-opacity-percentage@3.0.0", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ=="], + + "postcss-ordered-values": ["postcss-ordered-values@6.0.2", "", { "dependencies": { "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q=="], + + "postcss-overflow-shorthand": ["postcss-overflow-shorthand@6.0.0", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-BdDl/AbVkDjoTofzDQnwDdm/Ym6oS9KgmO7Gr+LHYjNWJ6ExORe4+3pcLQsLA9gIROMkiGVjjwZNoL/mpXHd5Q=="], + + "postcss-page-break": ["postcss-page-break@3.0.4", "", { "peerDependencies": { "postcss": "^8" } }, "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ=="], + + "postcss-place": ["postcss-place@10.0.0", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-5EBrMzat2pPAxQNWYavwAfoKfYcTADJ8AXGVPcUZ2UkNloUTWzJQExgrzrDkh3EKzmAx1evfTAzF9I8NGcc+qw=="], + + "postcss-preset-env": ["postcss-preset-env@10.6.1", "", { "dependencies": { "@csstools/postcss-alpha-function": "^1.0.1", "@csstools/postcss-cascade-layers": "^5.0.2", "@csstools/postcss-color-function": "^4.0.12", "@csstools/postcss-color-function-display-p3-linear": "^1.0.1", "@csstools/postcss-color-mix-function": "^3.0.12", "@csstools/postcss-color-mix-variadic-function-arguments": "^1.0.2", "@csstools/postcss-content-alt-text": "^2.0.8", "@csstools/postcss-contrast-color-function": "^2.0.12", "@csstools/postcss-exponential-functions": "^2.0.9", "@csstools/postcss-font-format-keywords": "^4.0.0", "@csstools/postcss-gamut-mapping": "^2.0.11", "@csstools/postcss-gradients-interpolation-method": "^5.0.12", "@csstools/postcss-hwb-function": "^4.0.12", "@csstools/postcss-ic-unit": "^4.0.4", "@csstools/postcss-initial": "^2.0.1", "@csstools/postcss-is-pseudo-class": "^5.0.3", "@csstools/postcss-light-dark-function": "^2.0.11", "@csstools/postcss-logical-float-and-clear": "^3.0.0", "@csstools/postcss-logical-overflow": "^2.0.0", "@csstools/postcss-logical-overscroll-behavior": "^2.0.0", "@csstools/postcss-logical-resize": "^3.0.0", "@csstools/postcss-logical-viewport-units": "^3.0.4", "@csstools/postcss-media-minmax": "^2.0.9", "@csstools/postcss-media-queries-aspect-ratio-number-values": "^3.0.5", "@csstools/postcss-nested-calc": "^4.0.0", "@csstools/postcss-normalize-display-values": "^4.0.1", "@csstools/postcss-oklab-function": "^4.0.12", "@csstools/postcss-position-area-property": "^1.0.0", "@csstools/postcss-progressive-custom-properties": "^4.2.1", "@csstools/postcss-property-rule-prelude-list": "^1.0.0", "@csstools/postcss-random-function": "^2.0.1", "@csstools/postcss-relative-color-syntax": "^3.0.12", "@csstools/postcss-scope-pseudo-class": "^4.0.1", "@csstools/postcss-sign-functions": "^1.1.4", "@csstools/postcss-stepped-value-functions": "^4.0.9", "@csstools/postcss-syntax-descriptor-syntax-production": "^1.0.1", "@csstools/postcss-system-ui-font-family": "^1.0.0", "@csstools/postcss-text-decoration-shorthand": "^4.0.3", "@csstools/postcss-trigonometric-functions": "^4.0.9", "@csstools/postcss-unset-value": "^4.0.0", "autoprefixer": "^10.4.23", "browserslist": "^4.28.1", "css-blank-pseudo": "^7.0.1", "css-has-pseudo": "^7.0.3", "css-prefers-color-scheme": "^10.0.0", "cssdb": "^8.6.0", "postcss-attribute-case-insensitive": "^7.0.1", "postcss-clamp": "^4.1.0", "postcss-color-functional-notation": "^7.0.12", "postcss-color-hex-alpha": "^10.0.0", "postcss-color-rebeccapurple": "^10.0.0", "postcss-custom-media": "^11.0.6", "postcss-custom-properties": "^14.0.6", "postcss-custom-selectors": "^8.0.5", "postcss-dir-pseudo-class": "^9.0.1", "postcss-double-position-gradients": "^6.0.4", "postcss-focus-visible": "^10.0.1", "postcss-focus-within": "^9.0.1", "postcss-font-variant": "^5.0.0", "postcss-gap-properties": "^6.0.0", "postcss-image-set-function": "^7.0.0", "postcss-lab-function": "^7.0.12", "postcss-logical": "^8.1.0", "postcss-nesting": "^13.0.2", "postcss-opacity-percentage": "^3.0.0", "postcss-overflow-shorthand": "^6.0.0", "postcss-page-break": "^3.0.4", "postcss-place": "^10.0.0", "postcss-pseudo-class-any-link": "^10.0.1", "postcss-replace-overflow-wrap": "^4.0.0", "postcss-selector-not": "^8.0.1" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-yrk74d9EvY+W7+lO9Aj1QmjWY9q5NsKjK2V9drkOPZB/X6KZ0B3igKsHUYakb7oYVhnioWypQX3xGuePf89f3g=="], + + "postcss-pseudo-class-any-link": ["postcss-pseudo-class-any-link@10.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-3el9rXlBOqTFaMFkWDOkHUTQekFIYnaQY55Rsp8As8QQkpiSgIYEcF/6Ond93oHiDsGb4kad8zjt+NPlOC1H0Q=="], + + "postcss-reduce-idents": ["postcss-reduce-idents@6.0.3", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-G3yCqZDpsNPoQgbDUy3T0E6hqOQ5xigUtBQyrmq3tn2GxlyiL0yyl7H+T8ulQR6kOcHJ9t7/9H4/R2tv8tJbMA=="], + + "postcss-reduce-initial": ["postcss-reduce-initial@6.1.0", "", { "dependencies": { "browserslist": "^4.23.0", "caniuse-api": "^3.0.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw=="], + + "postcss-reduce-transforms": ["postcss-reduce-transforms@6.0.2", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA=="], + + "postcss-replace-overflow-wrap": ["postcss-replace-overflow-wrap@4.0.0", "", { "peerDependencies": { "postcss": "^8.0.3" } }, "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw=="], + + "postcss-selector-not": ["postcss-selector-not@8.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-kmVy/5PYVb2UOhy0+LqUYAhKj7DUGDpSWa5LZqlkWJaaAV+dxxsOG3+St0yNLu6vsKD7Dmqx+nWQt0iil89+WA=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], + + "postcss-sort-media-queries": ["postcss-sort-media-queries@5.2.0", "", { "dependencies": { "sort-css-media-queries": "2.2.0" }, "peerDependencies": { "postcss": "^8.4.23" } }, "sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA=="], + + "postcss-svgo": ["postcss-svgo@6.0.3", "", { "dependencies": { "postcss-value-parser": "^4.2.0", "svgo": "^3.2.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g=="], + + "postcss-unique-selectors": ["postcss-unique-selectors@6.0.4", "", { "dependencies": { "postcss-selector-parser": "^6.0.16" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "postcss-zindex": ["postcss-zindex@6.0.2", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg=="], + + "pretty-error": ["pretty-error@4.0.0", "", { "dependencies": { "lodash": "^4.17.20", "renderkid": "^3.0.0" } }, "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw=="], + + "pretty-time": ["pretty-time@1.1.0", "", {}, "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA=="], + + "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + + "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + + "range-parser": ["range-parser@1.2.0", "", {}, "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A=="], + + "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], + + "react-helmet-async": ["@slorber/react-helmet-async@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "invariant": "^2.2.4", "prop-types": "^15.7.2", "react-fast-compare": "^3.2.0", "shallowequal": "^1.1.0" }, "peerDependencies": { "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-json-view-lite": ["react-json-view-lite@2.5.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g=="], + + "react-loadable": ["@docusaurus/react-loadable@6.0.0", "", { "dependencies": { "@types/react": "*" }, "peerDependencies": { "react": "*" } }, "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ=="], + + "react-loadable-ssr-addon-v5-slorber": ["react-loadable-ssr-addon-v5-slorber@1.0.3", "", { "dependencies": { "@babel/runtime": "^7.10.3" }, "peerDependencies": { "react-loadable": "*", "webpack": ">=4.41.1 || 5.x" } }, "sha512-GXfh9VLwB5ERaCsU6RULh7tkemeX15aNh6wuMEBtfdyMa7fFG8TXrhXlx1SoEK2Ty/l6XIkzzYIQmyaWW3JgdQ=="], + + "react-router": ["react-router@5.3.4", "", { "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", "hoist-non-react-statics": "^3.1.0", "loose-envify": "^1.3.1", "path-to-regexp": "^1.7.0", "prop-types": "^15.6.2", "react-is": "^16.6.0", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" }, "peerDependencies": { "react": ">=15" } }, "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA=="], + + "react-router-config": ["react-router-config@5.1.1", "", { "dependencies": { "@babel/runtime": "^7.1.2" }, "peerDependencies": { "react": ">=15", "react-router": ">=5" } }, "sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg=="], + + "react-router-dom": ["react-router-dom@5.3.4", "", { "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", "loose-envify": "^1.3.1", "prop-types": "^15.6.2", "react-router": "5.3.4", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" }, "peerDependencies": { "react": ">=15" } }, "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], + + "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], + + "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], + + "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], + + "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], + + "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], + + "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], + + "registry-url": ["registry-url@6.0.1", "", { "dependencies": { "rc": "1.2.8" } }, "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q=="], + + "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], + + "regjsparser": ["regjsparser@0.13.2", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + + "relateurl": ["relateurl@0.2.7", "", {}, "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog=="], + + "remark-directive": ["remark-directive@3.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", "micromark-extension-directive": "^3.0.0", "unified": "^11.0.0" } }, "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A=="], + + "remark-emoji": ["remark-emoji@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.2", "emoticon": "^4.0.1", "mdast-util-find-and-replace": "^3.0.1", "node-emoji": "^2.1.0", "unified": "^11.0.4" } }, "sha512-fHdvsTR1dHkWKev9eNyhTo4EFwbUvJ8ka9SgeWkMPYFX4WoI7ViVBms3PjlQYgw5TLvNQso3GUB/b/8t3yo+dg=="], + + "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "renderkid": ["renderkid@3.0.0", "", { "dependencies": { "css-select": "^4.1.3", "dom-converter": "^0.2.0", "htmlparser2": "^6.1.0", "lodash": "^4.17.21", "strip-ansi": "^6.0.1" } }, "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "require-like": ["require-like@0.1.2", "", {}, "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pathname": ["resolve-pathname@3.0.0", "", {}, "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="], + + "responselike": ["responselike@3.0.0", "", { "dependencies": { "lowercase-keys": "^3.0.0" } }, "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg=="], + + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rtlcss": ["rtlcss@4.3.0", "", { "dependencies": { "escalade": "^3.1.1", "picocolors": "^1.0.0", "postcss": "^8.4.21", "strip-json-comments": "^3.1.1" }, "bin": { "rtlcss": "bin/rtlcss.js" } }, "sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "schema-dts": ["schema-dts@1.1.5", "", {}, "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg=="], + + "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + + "search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="], + + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + + "select-hose": ["select-hose@2.0.0", "", {}, "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg=="], + + "selfsigned": ["selfsigned@5.5.0", "", { "dependencies": { "@peculiar/x509": "^1.14.2", "pkijs": "^3.3.3" } }, "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "semver-diff": ["semver-diff@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA=="], + + "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + + "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + + "serve-handler": ["serve-handler@6.1.7", "", { "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", "mime-types": "2.1.18", "minimatch": "3.1.5", "path-is-inside": "1.0.2", "path-to-regexp": "3.3.0", "range-parser": "1.2.0" } }, "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg=="], + + "serve-index": ["serve-index@1.9.2", "", { "dependencies": { "accepts": "~1.3.8", "batch": "0.6.1", "debug": "2.6.9", "escape-html": "~1.0.3", "http-errors": "~1.8.0", "mime-types": "~2.1.35", "parseurl": "~1.3.3" } }, "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ=="], + + "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shallow-clone": ["shallow-clone@3.0.1", "", { "dependencies": { "kind-of": "^6.0.2" } }, "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA=="], + + "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "sirv": ["sirv@2.0.4", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "sitemap": ["sitemap@7.1.3", "", { "dependencies": { "@types/node": "^17.0.5", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.2.4" }, "bin": { "sitemap": "dist/cli.js" } }, "sha512-tAjEd+wt/YwnEbfNB2ht51ybBJxbEWwe5ki/Z//Wh0rpBFTCUSj46GnxUKEWzhfuJTsee8x3lybHxFgUMig2hw=="], + + "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], + + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "snake-case": ["snake-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg=="], + + "sockjs": ["sockjs@0.3.24", "", { "dependencies": { "faye-websocket": "^0.11.3", "uuid": "^8.3.2", "websocket-driver": "^0.7.4" } }, "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ=="], + + "sort-css-media-queries": ["sort-css-media-queries@2.2.0", "", {}, "sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "spdy": ["spdy@4.0.2", "", { "dependencies": { "debug": "^4.1.0", "handle-thing": "^2.0.0", "http-deceiver": "^1.2.7", "select-hose": "^2.0.0", "spdy-transport": "^3.0.0" } }, "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA=="], + + "spdy-transport": ["spdy-transport@3.0.0", "", { "dependencies": { "debug": "^4.1.0", "detect-node": "^2.0.4", "hpack.js": "^2.1.6", "obuf": "^1.1.2", "readable-stream": "^3.0.6", "wbuf": "^1.7.3" } }, "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw=="], + + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "srcset": ["srcset@4.0.0", "", {}, "sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "stringify-object": ["stringify-object@3.3.0", "", { "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", "is-regexp": "^1.0.0" } }, "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "stylehacks": ["stylehacks@6.1.1", "", { "dependencies": { "browserslist": "^4.23.0", "postcss-selector-parser": "^6.0.16" }, "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "svg-parser": ["svg-parser@2.0.4", "", {}, "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ=="], + + "svgo": ["svgo@3.3.3", "", { "dependencies": { "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0", "sax": "^1.5.0" }, "bin": "./bin/svgo" }, "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "terser": ["terser@5.49.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA=="], + + "terser-webpack-plugin": ["terser-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ=="], + + "thingies": ["thingies@2.6.0", "", { "peerDependencies": { "tslib": "^2" } }, "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg=="], + + "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tree-dump": ["tree-dump@1.1.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + + "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "typedarray-to-buffer": ["typedarray-to-buffer@3.1.5", "", { "dependencies": { "is-typedarray": "^1.0.0" } }, "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], + + "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], + + "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], + + "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], + + "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unique-string": ["unique-string@3.0.0", "", { "dependencies": { "crypto-random-string": "^4.0.0" } }, "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "update-notifier": ["update-notifier@6.0.2", "", { "dependencies": { "boxen": "^7.0.0", "chalk": "^5.0.1", "configstore": "^6.0.0", "has-yarn": "^3.0.0", "import-lazy": "^4.0.0", "is-ci": "^3.0.1", "is-installed-globally": "^0.4.0", "is-npm": "^6.0.0", "is-yarn-global": "^0.4.0", "latest-version": "^7.0.0", "pupa": "^3.1.0", "semver": "^7.3.7", "semver-diff": "^4.0.0", "xdg-basedir": "^5.1.0" } }, "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "url-loader": ["url-loader@4.1.1", "", { "dependencies": { "loader-utils": "^2.0.0", "mime-types": "^2.1.27", "schema-utils": "^3.0.0" }, "peerDependencies": { "file-loader": "*", "webpack": "^4.0.0 || ^5.0.0" }, "optionalPeers": ["file-loader"] }, "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "utila": ["utila@0.4.0", "", {}, "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA=="], + + "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], + + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], + + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "value-equal": ["value-equal@1.0.1", "", {}, "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="], + + "wbuf": ["wbuf@1.7.3", "", { "dependencies": { "minimalistic-assert": "^1.0.0" } }, "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "webpack": ["webpack@5.108.4", "", { "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.22.2", "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "graceful-fs": "^4.2.11", "loader-runner": "^4.3.2", "mime-db": "^1.54.0", "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "watchpack": "^2.5.2", "webpack-sources": "^3.5.0" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w=="], + + "webpack-bundle-analyzer": ["webpack-bundle-analyzer@4.10.2", "", { "dependencies": { "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", "acorn-walk": "^8.0.0", "commander": "^7.2.0", "debounce": "^1.2.1", "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", "ws": "^7.3.1" }, "bin": { "webpack-bundle-analyzer": "lib/bin/analyzer.js" } }, "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw=="], + + "webpack-dev-middleware": ["webpack-dev-middleware@7.4.5", "", { "dependencies": { "colorette": "^2.0.10", "memfs": "^4.43.1", "mime-types": "^3.0.1", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "schema-utils": "^4.0.0" }, "peerDependencies": { "webpack": "^5.0.0" }, "optionalPeers": ["webpack"] }, "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA=="], + + "webpack-dev-server": ["webpack-dev-server@5.2.6", "", { "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", "@types/express": "^4.17.25", "@types/express-serve-static-core": "^4.17.21", "@types/serve-index": "^1.9.4", "@types/serve-static": "^1.15.5", "@types/sockjs": "^0.3.36", "@types/ws": "^8.5.10", "ansi-html-community": "^0.0.8", "bonjour-service": "^1.2.1", "chokidar": "^3.6.0", "colorette": "^2.0.10", "compression": "^1.8.1", "connect-history-api-fallback": "^2.0.0", "express": "^4.22.1", "graceful-fs": "^4.2.6", "http-proxy-middleware": "^2.0.9", "ipaddr.js": "^2.1.0", "launch-editor": "^2.14.1", "open": "^10.0.3", "p-retry": "^6.2.0", "schema-utils": "^4.2.0", "selfsigned": "^5.5.0", "serve-index": "^1.9.1", "sockjs": "^0.3.24", "spdy": "^4.0.2", "webpack-dev-middleware": "^7.4.2", "ws": "^8.18.0" }, "peerDependencies": { "webpack": "^5.0.0" }, "optionalPeers": ["webpack"], "bin": { "webpack-dev-server": "bin/webpack-dev-server.js" } }, "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw=="], + + "webpack-merge": ["webpack-merge@5.10.0", "", { "dependencies": { "clone-deep": "^4.0.1", "flat": "^5.0.2", "wildcard": "^2.0.0" } }, "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA=="], + + "webpack-sources": ["webpack-sources@3.5.1", "", {}, "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw=="], + + "webpackbar": ["webpackbar@7.0.0", "", { "dependencies": { "ansis": "^3.2.0", "consola": "^3.2.3", "pretty-time": "^1.1.0", "std-env": "^3.7.0" }, "peerDependencies": { "@rspack/core": "*", "webpack": "3 || 4 || 5" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-aS9soqSO2iCHgqHoCrj4LbfGQUboDCYJPSFOAchEK+9psIjNrfSWW4Y0YEz67MKURNvMmfo0ycOg9d/+OOf9/Q=="], + + "websocket-driver": ["websocket-driver@0.7.5", "", { "dependencies": { "http-parser-js": ">=0.5.1", "safe-buffer": ">=5.1.0", "websocket-extensions": ">=0.1.1" } }, "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA=="], + + "websocket-extensions": ["websocket-extensions@0.1.4", "", {}, "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "widest-line": ["widest-line@4.0.1", "", { "dependencies": { "string-width": "^5.0.1" } }, "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig=="], + + "wildcard": ["wildcard@2.0.1", "", {}, "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "write-file-atomic": ["write-file-atomic@3.0.3", "", { "dependencies": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", "signal-exit": "^3.0.2", "typedarray-to-buffer": "^3.1.5" } }, "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q=="], + + "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + + "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/preset-env/babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.2", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g=="], + + "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@docsearch/react/@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.19.2", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", "@algolia/autocomplete-shared": "1.19.2" } }, "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw=="], + + "@docusaurus/core/webpack-merge": ["webpack-merge@6.0.1", "", { "dependencies": { "clone-deep": "^4.0.1", "flat": "^5.0.2", "wildcard": "^2.0.1" } }, "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@17.67.0", "", { "dependencies": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0", "@jsonjoy.com/json-pointer": "17.67.0", "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/util": ["@jsonjoy.com/util@17.67.0", "", { "dependencies": { "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew=="], + + "@jsonjoy.com/json-pack/@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@1.2.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA=="], + + "@jsonjoy.com/util/@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@1.2.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA=="], + + "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], + + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "body-parser/bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "cheerio-select/css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "cli-table3/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "compression/bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "copy-webpack-plugin/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "copy-webpack-plugin/globby": ["globby@13.2.2", "", { "dependencies": { "dir-glob": "^3.0.1", "fast-glob": "^3.3.0", "ignore": "^5.2.4", "merge2": "^1.4.1", "slash": "^4.0.0" } }, "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w=="], + + "crypto-random-string/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], + + "css-minimizer-webpack-plugin/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + + "css-select/domhandler": ["domhandler@4.3.1", "", { "dependencies": { "domelementtype": "^2.2.0" } }, "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ=="], + + "css-select/domutils": ["domutils@2.8.0", "", { "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", "domhandler": "^4.2.0" } }, "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A=="], + + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + + "decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], + + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], + + "express/range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "file-loader/schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], + + "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="], + + "gray-matter/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], + + "hpack.js/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + + "html-webpack-plugin/html-minifier-terser": ["html-minifier-terser@6.1.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "^5.2.2", "commander": "^8.3.0", "he": "^1.2.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.10.0" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw=="], + + "http-proxy-middleware/is-plain-obj": ["is-plain-obj@3.0.0", "", {}, "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA=="], + + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "mdast-util-from-markdown/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "mdast-util-frontmatter/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "mdast-util-gfm-autolink-literal/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-core-commonmark/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-core-commonmark/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-core-commonmark/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-directive/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-extension-directive/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-extension-directive/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-frontmatter/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-extension-frontmatter/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-gfm-autolink-literal/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-extension-gfm-autolink-literal/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-gfm-footnote/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-extension-gfm-footnote/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-extension-gfm-footnote/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-gfm-strikethrough/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-gfm-table/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-extension-gfm-table/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-extension-gfm-table/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-gfm-task-list-item/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-extension-gfm-task-list-item/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-extension-gfm-task-list-item/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-mdx-expression/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-extension-mdx-expression/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-extension-mdx-expression/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-mdx-jsx/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-extension-mdx-jsx/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-extension-mdx-jsx/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-extension-mdxjs-esm/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-extension-mdxjs-esm/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-factory-destination/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-factory-destination/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-factory-label/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-factory-label/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-factory-mdx-expression/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-mdx-expression/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-factory-mdx-expression/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-factory-space/micromark-util-types": ["micromark-util-types@1.1.0", "", {}, "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg=="], + + "micromark-factory-title/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-factory-title/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-factory-whitespace/micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-whitespace/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-factory-whitespace/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-character/micromark-util-types": ["micromark-util-types@1.1.0", "", {}, "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg=="], + + "micromark-util-chunked/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-classify-character/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-classify-character/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-decode-numeric-character-reference/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-decode-string/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-decode-string/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-events-to-acorn/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-normalize-identifier/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-sanitize-uri/micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-sanitize-uri/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-subtokenize/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "mime-types/mime-db": ["mime-db@1.33.0", "", {}, "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="], + + "null-loader/schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "postcss-calc/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-discard-unused/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-merge-rules/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-minify-selectors/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-unique-selectors/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "raw-body/bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "renderkid/htmlparser2": ["htmlparser2@6.1.0", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.0.0", "domutils": "^2.5.2", "entities": "^2.0.0" } }, "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A=="], + + "renderkid/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "send/range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "serve-handler/path-to-regexp": ["path-to-regexp@3.3.0", "", {}, "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw=="], + + "serve-index/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "serve-index/http-errors": ["http-errors@1.8.1", "", { "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": ">= 1.5.0 < 2", "toidentifier": "1.0.1" } }, "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g=="], + + "serve-index/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "stylehacks/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "svgo/css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "update-notifier/boxen": ["boxen@7.1.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^7.0.1", "chalk": "^5.2.0", "cli-boxes": "^3.0.0", "string-width": "^5.1.2", "type-fest": "^2.13.0", "widest-line": "^4.0.1", "wrap-ansi": "^8.1.0" } }, "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog=="], + + "update-notifier/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "url-loader/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "url-loader/schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], + + "webpack-bundle-analyzer/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "webpack-dev-middleware/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "webpack-dev-middleware/range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "webpack-dev-server/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + + "webpack-dev-server/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "@docsearch/react/@algolia/autocomplete-core/@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.19.2", "", { "dependencies": { "@algolia/autocomplete-shared": "1.19.2" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg=="], + + "@docsearch/react/@algolia/autocomplete-core/@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.19.2", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/base64": ["@jsonjoy.com/base64@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/json-pointer": ["@jsonjoy.com/json-pointer@17.67.0", "", { "dependencies": { "@jsonjoy.com/util": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA=="], + + "@jsonjoy.com/fs-snapshot/@jsonjoy.com/util/@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q=="], + + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "cli-table3/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cli-table3/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "copy-webpack-plugin/globby/slash": ["slash@4.0.0", "", {}, "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew=="], + + "css-minimizer-webpack-plugin/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "css-select/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], + + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + + "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "file-loader/schema-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "file-loader/schema-utils/ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "hpack.js/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "hpack.js/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "hpack.js/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "html-webpack-plugin/html-minifier-terser/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "mdast-util-gfm-autolink-literal/micromark-util-character/micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "null-loader/schema-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "null-loader/schema-utils/ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + + "renderkid/htmlparser2/domhandler": ["domhandler@4.3.1", "", { "dependencies": { "domelementtype": "^2.2.0" } }, "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ=="], + + "renderkid/htmlparser2/domutils": ["domutils@2.8.0", "", { "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", "domhandler": "^4.2.0" } }, "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A=="], + + "renderkid/htmlparser2/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + + "renderkid/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "serve-index/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "serve-index/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="], + + "serve-index/http-errors/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], + + "serve-index/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "update-notifier/boxen/camelcase": ["camelcase@7.0.1", "", {}, "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw=="], + + "url-loader/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "url-loader/schema-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "url-loader/schema-utils/ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + + "webpack-dev-server/open/define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "cli-table3/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + + "file-loader/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "null-loader/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "renderkid/htmlparser2/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], + + "url-loader/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + } +} diff --git a/src/bundle.zig b/src/bundle.zig index fa886cc..e68180d 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -268,7 +268,6 @@ fn readParseCell(io: std.Io, dir: std.Io.Dir, bpath: []const u8) ?engine.s57.Cel return engine.s57.parseCellWithUpdates(gpa, base, ups.items) catch null; } - /// THE eager cell-coverage loader: walk an ENC_ROOT, parse each cell once (base + /// updates via readParseCell), and capture its M_COVR coverage + bbox + cscl + name /// + date. One entry per stem (a boundary cell shared by two districts loads once). diff --git a/src/geometry/boolean_repro_test.zig b/src/geometry/boolean_repro_test.zig index fedb840..5dfc8e3 100644 --- a/src/geometry/boolean_repro_test.zig +++ b/src/geometry/boolean_repro_test.zig @@ -24,7 +24,7 @@ const S17 = [_]Pt{ .{ .x = 2815, .y = -64 }, .{ .x = 2836, .y = -52 }, .{ .x = 2 const S18 = [_]Pt{ .{ .x = 2541, .y = 2675 }, .{ .x = 2553, .y = 2671 }, .{ .x = 2558, .y = 2657 }, .{ .x = 2548, .y = 2623 }, .{ .x = 2490, .y = 2584 }, .{ .x = 2476, .y = 2564 }, .{ .x = 2461, .y = 2562 }, .{ .x = 2510, .y = 2623 }, .{ .x = 2526, .y = 2669 }, .{ .x = 2541, .y = 2675 } }; const F0 = [_]Pt{ .{ .x = 4160, .y = -64 }, .{ .x = 4160, .y = 4160 }, .{ .x = 1924, .y = 4160 }, .{ .x = 1924, .y = 4006 }, .{ .x = 1924, .y = 3715 }, .{ .x = 1924, .y = 3035 }, .{ .x = 1924, .y = 2490 }, .{ .x = 1924, .y = 2190 }, .{ .x = 1924, .y = 1215 }, .{ .x = 1924, .y = 1191 }, .{ .x = 1924, .y = 1083 }, .{ .x = 1924, .y = 855 }, .{ .x = 1924, .y = -64 } }; const SUBJECT = [_][]const Pt{ &S0, &S1, &S2, &S3, &S4, &S5, &S6, &S7, &S8, &S9, &S10, &S11, &S12, &S13, &S14, &S15, &S16, &S17, &S18 }; -const FACE = [_][]const Pt{ &F0 }; +const FACE = [_][]const Pt{&F0}; test "compose clip repro: wedge points stay inside subject-and-face intersection" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -51,8 +51,8 @@ test "compose clip repro: wedge points stay inside subject-and-face intersection const LI_S0 = [_]Pt{ .{ .x = 4030, .y = -64 }, .{ .x = 4030, .y = -42 }, .{ .x = 4022, .y = -44 }, .{ .x = 4009, .y = -64 }, .{ .x = 3621, .y = -64 }, .{ .x = 3615, .y = -59 }, .{ .x = 3565, .y = -64 }, .{ .x = 3572, .y = -53 }, .{ .x = 3594, .y = -46 }, .{ .x = 3594, .y = -39 }, .{ .x = 3561, .y = -18 }, .{ .x = 3514, .y = -17 }, .{ .x = 3494, .y = 2 }, .{ .x = 3486, .y = 2 }, .{ .x = 3476, .y = -17 }, .{ .x = 3469, .y = -17 }, .{ .x = 3463, .y = 10 }, .{ .x = 3407, .y = 86 }, .{ .x = 3371, .y = 160 }, .{ .x = 3337, .y = 197 }, .{ .x = 3306, .y = 246 }, .{ .x = 3271, .y = 254 }, .{ .x = 3254, .y = 269 }, .{ .x = 3237, .y = 315 }, .{ .x = 3217, .y = 337 }, .{ .x = 3205, .y = 343 }, .{ .x = 3148, .y = 348 }, .{ .x = 3123, .y = 371 }, .{ .x = 3115, .y = 371 }, .{ .x = 3054, .y = 343 }, .{ .x = 3030, .y = 358 }, .{ .x = 3016, .y = 359 }, .{ .x = 3012, .y = 319 }, .{ .x = 3004, .y = 318 }, .{ .x = 2985, .y = 375 }, .{ .x = 2975, .y = 377 }, .{ .x = 2967, .y = 336 }, .{ .x = 2956, .y = 343 }, .{ .x = 2959, .y = 380 }, .{ .x = 2951, .y = 368 }, .{ .x = 2909, .y = 366 }, .{ .x = 2907, .y = 372 }, .{ .x = 2920, .y = 384 }, .{ .x = 2965, .y = 397 }, .{ .x = 2965, .y = 405 }, .{ .x = 2941, .y = 430 }, .{ .x = 2906, .y = 431 }, .{ .x = 2858, .y = 471 }, .{ .x = 2746, .y = 469 }, .{ .x = 2721, .y = 479 }, .{ .x = 2761, .y = 484 }, .{ .x = 2811, .y = 475 }, .{ .x = 2859, .y = 485 }, .{ .x = 2923, .y = 457 }, .{ .x = 2937, .y = 459 }, .{ .x = 2943, .y = 482 }, .{ .x = 2910, .y = 514 }, .{ .x = 2948, .y = 529 }, .{ .x = 2957, .y = 558 }, .{ .x = 2965, .y = 564 }, .{ .x = 2986, .y = 541 }, .{ .x = 2994, .y = 503 }, .{ .x = 3029, .y = 491 }, .{ .x = 3047, .y = 495 }, .{ .x = 3057, .y = 479 }, .{ .x = 3069, .y = 489 }, .{ .x = 3076, .y = 524 }, .{ .x = 3098, .y = 546 }, .{ .x = 3106, .y = 566 }, .{ .x = 3119, .y = 568 }, .{ .x = 3112, .y = 531 }, .{ .x = 3118, .y = 522 }, .{ .x = 3129, .y = 527 }, .{ .x = 3137, .y = 543 }, .{ .x = 3160, .y = 553 }, .{ .x = 3179, .y = 575 }, .{ .x = 3187, .y = 550 }, .{ .x = 3219, .y = 548 }, .{ .x = 3239, .y = 539 }, .{ .x = 3263, .y = 514 }, .{ .x = 3265, .y = 489 }, .{ .x = 3256, .y = 470 }, .{ .x = 3261, .y = 468 }, .{ .x = 3304, .y = 493 }, .{ .x = 3346, .y = 500 }, .{ .x = 3545, .y = 621 }, .{ .x = 3598, .y = 640 }, .{ .x = 3623, .y = 637 }, .{ .x = 3606, .y = 651 }, .{ .x = 3609, .y = 723 }, .{ .x = 3622, .y = 781 }, .{ .x = 3643, .y = 806 }, .{ .x = 3679, .y = 794 }, .{ .x = 3682, .y = 810 }, .{ .x = 3637, .y = 907 }, .{ .x = 3608, .y = 1003 }, .{ .x = 3599, .y = 1004 }, .{ .x = 3571, .y = 978 }, .{ .x = 3508, .y = 966 }, .{ .x = 3491, .y = 942 }, .{ .x = 3487, .y = 917 }, .{ .x = 3480, .y = 910 }, .{ .x = 3487, .y = 966 }, .{ .x = 3504, .y = 1008 }, .{ .x = 3429, .y = 988 }, .{ .x = 3427, .y = 967 }, .{ .x = 3454, .y = 941 }, .{ .x = 3462, .y = 922 }, .{ .x = 3452, .y = 904 }, .{ .x = 3391, .y = 849 }, .{ .x = 3387, .y = 852 }, .{ .x = 3394, .y = 913 }, .{ .x = 3381, .y = 940 }, .{ .x = 3354, .y = 969 }, .{ .x = 3345, .y = 992 }, .{ .x = 3336, .y = 1062 }, .{ .x = 3268, .y = 1062 }, .{ .x = 3242, .y = 1050 }, .{ .x = 3217, .y = 1016 }, .{ .x = 3207, .y = 1011 }, .{ .x = 3204, .y = 1017 }, .{ .x = 3215, .y = 1032 }, .{ .x = 3216, .y = 1047 }, .{ .x = 3211, .y = 1075 }, .{ .x = 3220, .y = 1087 }, .{ .x = 3206, .y = 1127 }, .{ .x = 3196, .y = 1131 }, .{ .x = 3164, .y = 1119 }, .{ .x = 3128, .y = 1131 }, .{ .x = 3155, .y = 1141 }, .{ .x = 3162, .y = 1150 }, .{ .x = 3167, .y = 1181 }, .{ .x = 3160, .y = 1207 }, .{ .x = 3134, .y = 1214 }, .{ .x = 3080, .y = 1199 }, .{ .x = 3084, .y = 1208 }, .{ .x = 3130, .y = 1228 }, .{ .x = 3139, .y = 1240 }, .{ .x = 3151, .y = 1242 }, .{ .x = 3187, .y = 1212 }, .{ .x = 3193, .y = 1212 }, .{ .x = 3194, .y = 1230 }, .{ .x = 3213, .y = 1225 }, .{ .x = 3231, .y = 1232 }, .{ .x = 3234, .y = 1218 }, .{ .x = 3218, .y = 1208 }, .{ .x = 3220, .y = 1200 }, .{ .x = 3287, .y = 1210 }, .{ .x = 3274, .y = 1181 }, .{ .x = 3313, .y = 1198 }, .{ .x = 3307, .y = 1182 }, .{ .x = 3313, .y = 1173 }, .{ .x = 3386, .y = 1178 }, .{ .x = 3418, .y = 1169 }, .{ .x = 3440, .y = 1149 }, .{ .x = 3470, .y = 1148 }, .{ .x = 3494, .y = 1132 }, .{ .x = 3519, .y = 1129 }, .{ .x = 3536, .y = 1109 }, .{ .x = 3574, .y = 1101 }, .{ .x = 3586, .y = 1084 }, .{ .x = 3635, .y = 1075 }, .{ .x = 3642, .y = 1042 }, .{ .x = 3689, .y = 1059 }, .{ .x = 3717, .y = 1059 }, .{ .x = 3755, .y = 1046 }, .{ .x = 3776, .y = 1059 }, .{ .x = 3775, .y = 1075 }, .{ .x = 3768, .y = 1079 }, .{ .x = 3740, .y = 1080 }, .{ .x = 3701, .y = 1105 }, .{ .x = 3609, .y = 1131 }, .{ .x = 3359, .y = 1232 }, .{ .x = 2947, .y = 1386 }, .{ .x = 2831, .y = 1386 }, .{ .x = 2831, .y = 1371 }, .{ .x = 2834, .y = 1367 }, .{ .x = 2853, .y = 1377 }, .{ .x = 2877, .y = 1372 }, .{ .x = 2911, .y = 1353 }, .{ .x = 2951, .y = 1343 }, .{ .x = 2997, .y = 1313 }, .{ .x = 2987, .y = 1295 }, .{ .x = 2971, .y = 1288 }, .{ .x = 2955, .y = 1292 }, .{ .x = 2937, .y = 1282 }, .{ .x = 2932, .y = 1272 }, .{ .x = 2936, .y = 1228 }, .{ .x = 2950, .y = 1193 }, .{ .x = 2948, .y = 1142 }, .{ .x = 2943, .y = 1146 }, .{ .x = 2940, .y = 1185 }, .{ .x = 2920, .y = 1201 }, .{ .x = 2904, .y = 1244 }, .{ .x = 2904, .y = 1281 }, .{ .x = 2886, .y = 1283 }, .{ .x = 2870, .y = 1277 }, .{ .x = 2844, .y = 1232 }, .{ .x = 2845, .y = 1264 }, .{ .x = 2852, .y = 1282 }, .{ .x = 2895, .y = 1310 }, .{ .x = 2899, .y = 1333 }, .{ .x = 2870, .y = 1360 }, .{ .x = 2851, .y = 1360 }, .{ .x = 2842, .y = 1343 }, .{ .x = 2814, .y = 1352 }, .{ .x = 2796, .y = 1386 }, .{ .x = 2736, .y = 1381 }, .{ .x = 2711, .y = 1359 }, .{ .x = 2695, .y = 1353 }, .{ .x = 2691, .y = 1333 }, .{ .x = 2672, .y = 1310 }, .{ .x = 2659, .y = 1314 }, .{ .x = 2653, .y = 1338 }, .{ .x = 2628, .y = 1343 }, .{ .x = 2618, .y = 1353 }, .{ .x = 2592, .y = 1353 }, .{ .x = 2569, .y = 1342 }, .{ .x = 2542, .y = 1346 }, .{ .x = 2527, .y = 1256 }, .{ .x = 2522, .y = 1260 }, .{ .x = 2520, .y = 1337 }, .{ .x = 2512, .y = 1379 }, .{ .x = 2438, .y = 1386 }, .{ .x = 2408, .y = 1378 }, .{ .x = 2372, .y = 1359 }, .{ .x = 2326, .y = 1292 }, .{ .x = 2333, .y = 1245 }, .{ .x = 2373, .y = 1220 }, .{ .x = 2347, .y = 1223 }, .{ .x = 2300, .y = 1261 }, .{ .x = 2299, .y = 1203 }, .{ .x = 2291, .y = 1211 }, .{ .x = 2279, .y = 1249 }, .{ .x = 2250, .y = 1224 }, .{ .x = 2246, .y = 1227 }, .{ .x = 2257, .y = 1251 }, .{ .x = 2289, .y = 1280 }, .{ .x = 2279, .y = 1312 }, .{ .x = 2281, .y = 1366 }, .{ .x = 2273, .y = 1373 }, .{ .x = 2229, .y = 1374 }, .{ .x = 2203, .y = 1352 }, .{ .x = 2168, .y = 1353 }, .{ .x = 2138, .y = 1325 }, .{ .x = 2134, .y = 1332 }, .{ .x = 2147, .y = 1359 }, .{ .x = 2151, .y = 1386 }, .{ .x = 1712, .y = 1386 }, .{ .x = 1709, .y = 1358 }, .{ .x = 1701, .y = 1362 }, .{ .x = 1700, .y = 1386 }, .{ .x = -64, .y = 1386 }, .{ .x = -64, .y = 128 }, .{ .x = -52, .y = 110 }, .{ .x = -53, .y = 101 }, .{ .x = -64, .y = 102 }, .{ .x = -64, .y = 90 }, .{ .x = -39, .y = 79 }, .{ .x = 3, .y = 45 }, .{ .x = 34, .y = 45 }, .{ .x = 41, .y = 65 }, .{ .x = 34, .y = 93 }, .{ .x = 26, .y = 107 }, .{ .x = -8, .y = 133 }, .{ .x = -20, .y = 156 }, .{ .x = -7, .y = 197 }, .{ .x = 27, .y = 200 }, .{ .x = 43, .y = 232 }, .{ .x = 53, .y = 176 }, .{ .x = 44, .y = 110 }, .{ .x = 65, .y = 68 }, .{ .x = 88, .y = 82 }, .{ .x = 98, .y = 97 }, .{ .x = 105, .y = 129 }, .{ .x = 131, .y = 138 }, .{ .x = 142, .y = 152 }, .{ .x = 134, .y = 174 }, .{ .x = 134, .y = 205 }, .{ .x = 113, .y = 212 }, .{ .x = 74, .y = 202 }, .{ .x = 68, .y = 228 }, .{ .x = 100, .y = 236 }, .{ .x = 118, .y = 266 }, .{ .x = 126, .y = 268 }, .{ .x = 135, .y = 267 }, .{ .x = 141, .y = 232 }, .{ .x = 176, .y = 222 }, .{ .x = 176, .y = 213 }, .{ .x = 168, .y = 208 }, .{ .x = 162, .y = 169 }, .{ .x = 170, .y = 160 }, .{ .x = 184, .y = 171 }, .{ .x = 218, .y = 179 }, .{ .x = 242, .y = 204 }, .{ .x = 260, .y = 206 }, .{ .x = 302, .y = 250 }, .{ .x = 324, .y = 246 }, .{ .x = 332, .y = 234 }, .{ .x = 340, .y = 204 }, .{ .x = 313, .y = 177 }, .{ .x = 272, .y = 97 }, .{ .x = 272, .y = 70 }, .{ .x = 251, .y = 62 }, .{ .x = 241, .y = 77 }, .{ .x = 225, .y = 66 }, .{ .x = 205, .y = 78 }, .{ .x = 198, .y = 63 }, .{ .x = 198, .y = 58 }, .{ .x = 209, .y = 61 }, .{ .x = 231, .y = 47 }, .{ .x = 248, .y = 44 }, .{ .x = 285, .y = 53 }, .{ .x = 369, .y = 99 }, .{ .x = 435, .y = 115 }, .{ .x = 463, .y = 128 }, .{ .x = 491, .y = 160 }, .{ .x = 506, .y = 168 }, .{ .x = 509, .y = 184 }, .{ .x = 537, .y = 210 }, .{ .x = 550, .y = 197 }, .{ .x = 573, .y = 191 }, .{ .x = 591, .y = 156 }, .{ .x = 526, .y = 160 }, .{ .x = 515, .y = 143 }, .{ .x = 587, .y = 140 }, .{ .x = 594, .y = 136 }, .{ .x = 595, .y = 124 }, .{ .x = 488, .y = 123 }, .{ .x = 479, .y = 120 }, .{ .x = 480, .y = 108 }, .{ .x = 726, .y = 104 }, .{ .x = 794, .y = 109 }, .{ .x = 886, .y = 98 }, .{ .x = 1001, .y = 103 }, .{ .x = 1107, .y = 126 }, .{ .x = 1330, .y = 143 }, .{ .x = 1448, .y = 130 }, .{ .x = 1496, .y = 117 }, .{ .x = 1515, .y = 117 }, .{ .x = 1519, .y = 135 }, .{ .x = 1531, .y = 137 }, .{ .x = 1547, .y = 130 }, .{ .x = 1530, .y = 125 }, .{ .x = 1531, .y = 113 }, .{ .x = 1538, .y = 109 }, .{ .x = 1604, .y = 92 }, .{ .x = 1721, .y = 77 }, .{ .x = 1849, .y = 90 }, .{ .x = 1885, .y = 103 }, .{ .x = 1944, .y = 100 }, .{ .x = 2019, .y = 113 }, .{ .x = 2107, .y = 104 }, .{ .x = 2332, .y = 51 }, .{ .x = 2486, .y = -4 }, .{ .x = 2566, .y = -7 }, .{ .x = 2658, .y = 5 }, .{ .x = 2732, .y = -6 }, .{ .x = 2836, .y = -11 }, .{ .x = 2881, .y = -24 }, .{ .x = 2958, .y = -64 } }; const LI_F0 = [_]Pt{ .{ .x = -64, .y = 1386 }, .{ .x = -64, .y = -64 }, .{ .x = 4160, .y = -64 }, .{ .x = 4160, .y = 1386 }, .{ .x = 4038, .y = 1386 }, .{ .x = 4032, .y = 1386 }, .{ .x = 3457, .y = 1386 }, .{ .x = 2947, .y = 1386 }, .{ .x = 2831, .y = 1386 }, .{ .x = 2788, .y = 1386 }, .{ .x = 2755, .y = 1386 }, .{ .x = 2151, .y = 1386 }, .{ .x = 2108, .y = 1386 }, .{ .x = 2086, .y = 1386 }, .{ .x = 1991, .y = 1386 }, .{ .x = 1959, .y = 1386 }, .{ .x = 1712, .y = 1386 }, .{ .x = 1700, .y = 1386 }, .{ .x = 1677, .y = 1386 }, .{ .x = 1597, .y = 1386 } }; -const LI_SUBJECT = [_][]const Pt{ &LI_S0 }; -const LI_FACE = [_][]const Pt{ &LI_F0 }; +const LI_SUBJECT = [_][]const Pt{&LI_S0}; +const LI_FACE = [_][]const Pt{&LI_F0}; test "compose clip repro: Long Island land ring vs coverage-edge face" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -72,8 +72,8 @@ test "compose clip repro: Long Island land ring vs coverage-edge face" { const SM_S0 = [_]Pt{ .{ .x = 2075, .y = 1078 }, .{ .x = 2075, .y = 1205 }, .{ .x = 1966, .y = 1205 }, .{ .x = 1966, .y = 1066 }, .{ .x = 1981, .y = 1076 }, .{ .x = 2002, .y = 1067 }, .{ .x = 2017, .y = 1081 }, .{ .x = 2039, .y = 1066 }, .{ .x = 2058, .y = 1066 }, .{ .x = 2075, .y = 1078 } }; const SM_F0 = [_]Pt{ .{ .x = 1966, .y = 1205 }, .{ .x = 1966, .y = 1066 }, .{ .x = 1967, .y = 1066 }, .{ .x = 1968, .y = 1066 }, .{ .x = 1969, .y = 1066 }, .{ .x = 1970, .y = 1066 }, .{ .x = 1970, .y = 1066 }, .{ .x = 1970, .y = 1066 }, .{ .x = 1971, .y = 1066 }, .{ .x = 1974, .y = 1066 }, .{ .x = 1994, .y = 1066 }, .{ .x = 1994, .y = 1066 }, .{ .x = 1994, .y = 1066 }, .{ .x = 1996, .y = 1066 }, .{ .x = 2001, .y = 1066 }, .{ .x = 2002, .y = 1066 }, .{ .x = 2005, .y = 1066 }, .{ .x = 2015, .y = 1066 }, .{ .x = 2015, .y = 1076 }, .{ .x = 2015, .y = 1079 }, .{ .x = 2015, .y = 1205 } }; -const SM_SUBJECT = [_][]const Pt{ &SM_S0 }; -const SM_FACE = [_][]const Pt{ &SM_F0 }; +const SM_SUBJECT = [_][]const Pt{&SM_S0}; +const SM_FACE = [_][]const Pt{&SM_F0}; test "compose clip repro: micro-subdivided seam edge with duplicate vertices" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -202,8 +202,8 @@ test "compose clip repro: dense sounding-hole DEPARE vs jagged face" { const OB_S0 = [_]Pt{ .{ .x = 2135, .y = -64 }, .{ .x = 2130, .y = 2 }, .{ .x = 2078, .y = 230 }, .{ .x = 2092, .y = 269 }, .{ .x = 2116, .y = 300 }, .{ .x = 2229, .y = 359 }, .{ .x = 2262, .y = 401 }, .{ .x = 2289, .y = 517 }, .{ .x = 2322, .y = 712 }, .{ .x = 2339, .y = 754 }, .{ .x = 2351, .y = 808 }, .{ .x = 2346, .y = 836 }, .{ .x = 2261, .y = 961 }, .{ .x = 2214, .y = 1121 }, .{ .x = 2202, .y = 1259 }, .{ .x = 2185, .y = 1331 }, .{ .x = 2127, .y = 1389 }, .{ .x = 2114, .y = 1419 }, .{ .x = 2109, .y = 1487 }, .{ .x = 2134, .y = 1582 }, .{ .x = 2155, .y = 1633 }, .{ .x = 2166, .y = 1642 }, .{ .x = 2190, .y = 1610 }, .{ .x = 2242, .y = 1564 }, .{ .x = 2343, .y = 1423 }, .{ .x = 2377, .y = 1357 }, .{ .x = 2403, .y = 1263 }, .{ .x = 2514, .y = 1064 }, .{ .x = 2525, .y = 1026 }, .{ .x = 2595, .y = 897 }, .{ .x = 2651, .y = 817 }, .{ .x = 2725, .y = 732 }, .{ .x = 2754, .y = 690 }, .{ .x = 2777, .y = 642 }, .{ .x = 2868, .y = 540 }, .{ .x = 2889, .y = 463 }, .{ .x = 3087, .y = 157 }, .{ .x = 3141, .y = 91 }, .{ .x = 3309, .y = -64 }, .{ .x = 3393, .y = -64 }, .{ .x = 3165, .y = 263 }, .{ .x = 3052, .y = 443 }, .{ .x = 2889, .y = 633 }, .{ .x = 2835, .y = 706 }, .{ .x = 2841, .y = 724 }, .{ .x = 2829, .y = 748 }, .{ .x = 2661, .y = 970 }, .{ .x = 2657, .y = 994 }, .{ .x = 2638, .y = 1021 }, .{ .x = 2621, .y = 1070 }, .{ .x = 2620, .y = 1094 }, .{ .x = 2629, .y = 1107 }, .{ .x = 2599, .y = 1143 }, .{ .x = 2561, .y = 1214 }, .{ .x = 2540, .y = 1287 }, .{ .x = 2470, .y = 1427 }, .{ .x = 2297, .y = 1578 }, .{ .x = 2176, .y = 1745 }, .{ .x = 2176, .y = 1764 }, .{ .x = 2210, .y = 1805 }, .{ .x = 2264, .y = 1936 }, .{ .x = 2282, .y = 1959 }, .{ .x = 2316, .y = 1968 }, .{ .x = 2346, .y = 1965 }, .{ .x = 2367, .y = 1954 }, .{ .x = 2399, .y = 1908 }, .{ .x = 2436, .y = 1877 }, .{ .x = 2490, .y = 1863 }, .{ .x = 2521, .y = 1867 }, .{ .x = 2560, .y = 1887 }, .{ .x = 2613, .y = 1942 }, .{ .x = 2646, .y = 1986 }, .{ .x = 2651, .y = 2012 }, .{ .x = 2631, .y = 2050 }, .{ .x = 2569, .y = 2107 }, .{ .x = 2541, .y = 2147 }, .{ .x = 2537, .y = 2188 }, .{ .x = 2555, .y = 2222 }, .{ .x = 2684, .y = 2299 }, .{ .x = 2727, .y = 2313 }, .{ .x = 2886, .y = 2333 }, .{ .x = 3015, .y = 2329 }, .{ .x = 3096, .y = 2316 }, .{ .x = 3202, .y = 2277 }, .{ .x = 3251, .y = 2247 }, .{ .x = 3276, .y = 2222 }, .{ .x = 3308, .y = 2162 }, .{ .x = 3321, .y = 2079 }, .{ .x = 3323, .y = 2006 }, .{ .x = 3289, .y = 1812 }, .{ .x = 3287, .y = 1749 }, .{ .x = 3291, .y = 1706 }, .{ .x = 3332, .y = 1530 }, .{ .x = 3361, .y = 1466 }, .{ .x = 3499, .y = 1215 }, .{ .x = 3578, .y = 1167 }, .{ .x = 3659, .y = 1149 }, .{ .x = 3781, .y = 1138 }, .{ .x = 3848, .y = 1152 }, .{ .x = 3913, .y = 1184 }, .{ .x = 3980, .y = 1241 }, .{ .x = 4124, .y = 1500 }, .{ .x = 4160, .y = 1526 }, .{ .x = 4160, .y = 2516 }, .{ .x = 3293, .y = 3252 }, .{ .x = 1897, .y = 1610 }, .{ .x = 1894, .y = 1573 }, .{ .x = 1808, .y = 1334 }, .{ .x = 1794, .y = 1225 }, .{ .x = 1769, .y = 1174 }, .{ .x = 1777, .y = 1092 }, .{ .x = 1768, .y = 1022 }, .{ .x = 1700, .y = 819 }, .{ .x = 1628, .y = 572 }, .{ .x = 1523, .y = -64 }, .{ .x = 1066, .y = -64 }, .{ .x = 1076, .y = -6 }, .{ .x = 1070, .y = 20 }, .{ .x = 940, .y = 230 }, .{ .x = 871, .y = 390 }, .{ .x = 490, .y = -64 } }; const OB_F0 = [_]Pt{ .{ .x = 4160, .y = -64 }, .{ .x = 4160, .y = 2516 }, .{ .x = 3427, .y = 3139 }, .{ .x = 3293, .y = 3252 }, .{ .x = 3221, .y = 3167 }, .{ .x = 3206, .y = 3150 }, .{ .x = 3203, .y = 3145 }, .{ .x = 2458, .y = 2270 }, .{ .x = 2369, .y = 2166 }, .{ .x = 2191, .y = 1956 }, .{ .x = 2125, .y = 1879 }, .{ .x = 2109, .y = 1859 }, .{ .x = 2102, .y = 1851 }, .{ .x = 2099, .y = 1848 }, .{ .x = 2098, .y = 1846 }, .{ .x = 2095, .y = 1843 }, .{ .x = 2094, .y = 1842 }, .{ .x = 2092, .y = 1839 }, .{ .x = 2088, .y = 1835 }, .{ .x = 2062, .y = 1804 }, .{ .x = 2059, .y = 1801 }, .{ .x = 2058, .y = 1799 }, .{ .x = 2057, .y = 1798 }, .{ .x = 2057, .y = 1798 }, .{ .x = 2056, .y = 1797 }, .{ .x = 2050, .y = 1790 }, .{ .x = 2045, .y = 1784 }, .{ .x = 2044, .y = 1783 }, .{ .x = 2043, .y = 1782 }, .{ .x = 2043, .y = 1782 }, .{ .x = 2042, .y = 1781 }, .{ .x = 2018, .y = 1753 }, .{ .x = 1897, .y = 1610 }, .{ .x = 1851, .y = 1556 }, .{ .x = 1851, .y = 1556 }, .{ .x = 1829, .y = 1530 }, .{ .x = 1812, .y = 1510 }, .{ .x = 1669, .y = 1340 }, .{ .x = 871, .y = 390 }, .{ .x = 871, .y = 390 }, .{ .x = 490, .y = -64 } }; -const OB_SUBJECT = [_][]const Pt{ &OB_S0 }; -const OB_FACE = [_][]const Pt{ &OB_F0 }; +const OB_SUBJECT = [_][]const Pt{&OB_S0}; +const OB_FACE = [_][]const Pt{&OB_F0}; test "compose clip repro: Pamlico DEPARE blob (retry-resistant)" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); diff --git a/src/render/vector.zig b/src/render/vector.zig index 940cf3f..a56446b 100644 --- a/src/render/vector.zig +++ b/src/render/vector.zig @@ -352,8 +352,7 @@ pub const VectorSurface = struct { // scaled by size_scale (scene.walkComplexRun), so the brick must scale to // match — otherwise bricks would be native-sized at display-scaled spacing. const dev: f64 = self.refDev(); - if (self.cb.draw_sprite) |ds| self.emitSprite(ds, eff, s, at, rot_deg, scale, dev, null) - else try self.emitSymbol(s, at, rot_deg, scale); + if (self.cb.draw_sprite) |ds| self.emitSprite(ds, eff, s, at, rot_deg, scale, dev, null) else try self.emitSymbol(s, at, rot_deg, scale); } /// Emit a symbol as an atlas sprite: pass its un-rotated pivot-relative @@ -423,8 +422,7 @@ pub const VectorSurface = struct { while (it.next()) |glyph| { if (glyph.len == 0) continue; const s = store.get(glyph) orelse continue; - if (self.cb.draw_sprite) |ds| self.emitSprite(ds, glyph, s, at, 0, sndfrm.SYMBOL_SCALE, self.refDev(), null) - else try self.emitSymbol(s, at, 0, sndfrm.SYMBOL_SCALE); + if (self.cb.draw_sprite) |ds| self.emitSprite(ds, glyph, s, at, 0, sndfrm.SYMBOL_SCALE, self.refDev(), null) else try self.emitSymbol(s, at, 0, sndfrm.SYMBOL_SCALE); } } diff --git a/src/scene/linestyle.zig b/src/scene/linestyle.zig index c5b9879..f684c36 100644 --- a/src/scene/linestyle.zig +++ b/src/scene/linestyle.zig @@ -55,7 +55,6 @@ pub fn lookup(id: []const u8) ?Info { return g_linestyles.get(id); } - /// Populate the complex-linestyle table from S-101 LineStyles XML sources /// (id = file stem). IDEMPOTENT — a populated table is left untouched, so /// every scene entry point (bake, lib renderView, CLI render) can call it diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 5447e85..fc33913 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -64,7 +64,6 @@ const SYMBOL_SCALE: f64 = @import("render").sndfrm.SYMBOL_SCALE; // rounds to whole feet — contour valdco values are whole metres). const M_TO_FT: f64 = @import("render").sndfrm.M_TO_FT; - // S-57 attribute code for SCAMIN (the minimum display scale 1:N, S-57 Appendix A // attr 133 / S-52 §8.4). Features carrying it are routed to a dedicated *_scamin // MVT layer so the style can drop them below their 1:N scale; the value travels on @@ -331,7 +330,6 @@ fn coveredByFiner(cover_clip: []const []const mvt.Point, lon: f64, lat: f64, z: // Clip a line + simplify each kept run (drop runs that collapse below 2 vertices). - fn geomBounds(g: []const s57.LonLat) [4]f64 { var b = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; for (g) |p| { @@ -2435,7 +2433,6 @@ test "listHasAny splits S-57 comma lists and matches any target" { try std.testing.expect(!listHasAny("", &.{18})); } - fn findProp(props: []const mvt.Prop, key: []const u8) ?mvt.Value { for (props) |pr| if (std.mem.eql(u8, pr.key, key)) return pr.value; return null; @@ -2450,8 +2447,6 @@ test "featureScamin reads s57 attr 133" { try std.testing.expectEqual(@as(?i64, null), featureScamin(without)); } - - test "processFeatureInstr routes SCAMIN point to the bucket + carries draw_prio/scamin" { const gpa = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(gpa); diff --git a/src/sprite/sprite.zig b/src/sprite/sprite.zig index 6d17f5b..77155b5 100644 --- a/src/sprite/sprite.zig +++ b/src/sprite/sprite.zig @@ -18,7 +18,9 @@ extern fn tg_svg_free(p: ?*anyopaque) void; /// SDF glyph atlas for GPU text (stb_truetype), a sibling baked asset. pub const glyph = @import("glyph.zig"); -test { _ = glyph; } +test { + _ = glyph; +} /// device px per 0.01-mm symbol unit (matches the Go oracle's raster.go pxPerUnit). pub const px_per_unit: f64 = 0.08; diff --git a/src/style/style.zig b/src/style/style.zig index 13599c3..e9787ca 100644 --- a/src/style/style.zig +++ b/src/style/style.zig @@ -406,7 +406,6 @@ pub fn linestylesJson(alloc: std.mem.Allocator, srcs: []const LineStyleSrc) ![]u return out.toOwnedSlice(alloc); } - // ---- tests --------------------------------------------------------------- test "colorTablesJson: sorted tokens, lowercase hex, all three palettes" { From 220ae5247704bac49a64279e04622273ce9b122b Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 11 Jul 2026 11:08:05 -0400 Subject: [PATCH 139/140] ci: add vulnerability scanning (Trivy fs scan + Dependabot) - .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) --- .github/dependabot.yml | 9 +++++++++ .github/workflows/security.yml | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/security.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0aff5f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + # Zig package deps (build.zig.zon) aren't supported by Dependabot; the submodules + # carry the vendored catalogues. So the actionable surface here is the CI actions — + # keep them patched. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..f4bc769 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,36 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "23 6 * * 1" # weekly, Monday morning + +permissions: + contents: read + security-events: write # upload SARIF to the Security tab + +jobs: + trivy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + # Filesystem scan: known-vulnerable dependencies + committed secrets. Reports to + # the Security tab rather than failing the build, so a new advisory doesn't block + # merges — triage it there. + - name: Trivy filesystem scan + uses: aquasecurity/trivy-action@0.28.0 + with: + scan-type: fs + scanners: vuln,secret + severity: CRITICAL,HIGH + ignore-unfixed: true + format: sarif + output: trivy-results.sarif + - name: Upload results to code scanning + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results.sarif From 4b6d73c5ae23955156a9799d62f3ef6abea4ce69 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 11 Jul 2026 11:13:53 -0400 Subject: [PATCH 140/140] ci: drop Trivy security scan Keep Dependabot (native dependency + Actions scanning); drop the Trivy workflow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/security.yml | 36 ---------------------------------- 1 file changed, 36 deletions(-) delete mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml deleted file mode 100644 index f4bc769..0000000 --- a/.github/workflows/security.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Security - -on: - push: - branches: [main] - pull_request: - schedule: - - cron: "23 6 * * 1" # weekly, Monday morning - -permissions: - contents: read - security-events: write # upload SARIF to the Security tab - -jobs: - trivy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - # Filesystem scan: known-vulnerable dependencies + committed secrets. Reports to - # the Security tab rather than failing the build, so a new advisory doesn't block - # merges — triage it there. - - name: Trivy filesystem scan - uses: aquasecurity/trivy-action@0.28.0 - with: - scan-type: fs - scanners: vuln,secret - severity: CRITICAL,HIGH - ignore-unfixed: true - format: sarif - output: trivy-results.sarif - - name: Upload results to code scanning - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: trivy-results.sarif