From cb22c2054fff1622678608a3e97f9282a03a188f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 13 Jul 2026 14:19:12 -0400 Subject: [PATCH 1/2] perf(bake): cache the label point for the '?' (QUESMRK1) fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unmapped S-57 area features (no S-101 class mapping) take the QUESMRK1 '?' fallback in the per-tile emit, which anchors the marker at the area's pole-of-inaccessibility (areaRepresentativePoint). Those features carry a null/errored portrayal stream, so buildLabelCache skipped them and the polylabel search ran LIVE for every tile the polygon spanned — quadratic in the vertex count and repeated across hundreds of tiles. Inland-ENC cells are full of such areas (objl 17000+ IENC classes with no mapping, over huge river/fairway polygons), which made them minutes to bake. Extend buildLabelCache to also precompute the representative point for the QUESMRK1 fallback (and the stream-independent INFORM01 marker), mirroring the emit-side guards exactly so the cached set stays a strict superset of what the per-tile path consults. Output is byte-identical (verified: same sha256 on a German IENC cell before/after); the search now runs once per cell instead of once per tile. 1R8BR858 31.4s->1.9s, 1W7D2250 110s->2.9s. --- src/scene/scene.zig | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/scene/scene.zig b/src/scene/scene.zig index f082c7b..233d3c2 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1143,20 +1143,28 @@ pub fn buildLabelCache(a: Allocator, cell: *const s57.Cell, geo: ?GeoParts, stre for (cell.features, 0..) |f, i| { // Polylabel (areaRepresentativePoint) is AREA-only — lines anchor at their mid-vertex. if (f.prim != 3) continue; - if (i >= ss.len) break; - const stream = ss[i] orelse continue; // portrayed at all (an unportrayed area never reaches labelPoint) - // Only an area that PLACES something at the representative point consults it: a centred - // label (TextInstruction), a centred symbol (PointInstruction), or the native INFORM01 - // additional-info marker (hasAdditionalInfo). A plain fill/boundary area — the vast - // majority (DEPARE, LNDARE, SBDARE, DEPCNT, …) — anchors nothing there, and the pole-of- - // inaccessibility search is expensive, so skip it. QUESMRK1/SWPARE fallbacks carry a null - // stream (skipped above) and stay on the live path. The token test is a strict SUPERSET of - // what the per-tile path consults (an empty TextInstruction is dropped downstream), so a - // cached point is only ever spurious, never missing — labelPoint stays byte-identical and - // no per-tile recompute creeps back in. - if (!hasAdditionalInfo(f) and - std.mem.indexOf(u8, stream, "TextInstruction:") == null and - std.mem.indexOf(u8, stream, "PointInstruction:") == null) continue; + const stream: ?[]const u8 = if (i < ss.len) ss[i] else null; + // Cache exactly the areas whose per-tile emit anchors something at the representative + // point. A plain fill/boundary area — the vast majority (DEPARE, LNDARE, SBDARE, DEPCNT, + // …) — anchors nothing there, and the pole-of-inaccessibility search is expensive, so skip + // it. The set below is a strict SUPERSET of what the per-tile path consults, so a cached + // point is only ever spurious, never missing — labelPoint (which recomputes the SAME + // search from the SAME geometry on a miss) stays byte-identical either way: + // • hasAdditionalInfo -> the §10.6.1.1 INFORM01 marker (stream-independent), + // • an unmapped S-57 area -> the QUESMRK1 "?" fallback. It carries a NULL/errored + // portrayal stream yet still runs featureAnchor for EVERY tile the polygon spans — + // the per-tile pole-of-inaccessibility search that dominated Inland-ENC bakes (objl + // 17000+ area classes with no S-101 mapping, huge river/fairway polygons). Mirror the + // emit-side QUESMRK1 guard exactly so this leaves the live path only when that fires. + // • a portrayed area placing a centred label (TextInstruction) or symbol + // (PointInstruction). + const unmapped_qmark = !cell.native and f.objl != s57.OBJL_TOPMAR and adapter.resolveClass(f) == null; + const places_symbol = if (stream) |s| + std.mem.indexOf(u8, s, "TextInstruction:") != null or + std.mem.indexOf(u8, s, "PointInstruction:") != null + else + false; + if (!hasAdditionalInfo(f) and !unmapped_qmark and !places_symbol) continue; const parts = featureParts(tmp.allocator(), cell.*, geo, i, f) catch continue; out[i] = s57.areaRepresentativePoint(tmp.allocator(), parts); _ = tmp.reset(.retain_capacity); From 40a4b5d974972c743396da91b07c8c2c770ae8ac Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 13 Jul 2026 15:23:45 -0400 Subject: [PATCH 2/2] perf(bake): cache the drawn boundary's world coords per cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An area/line feature whose stroked boundary differs from its full geometry (needsDrawableBoundary: MASK/USAG edge flags, or a coast-coincident edge on a non-coast-definer area) restrokes only the drawableLineParts SUBSET. That subset was reconstructed AND reprojected with the transcendental web-mercator projection (tan/log/cos) on EVERY tile the feature spans — while the fill geometry has ridden the tile-invariant geo_world world-coord cache for a while. On Inland-ENC river cells (fairways/depth areas with long shared coast boundaries, DEPCNT contours, masked meta boundaries) this dominated the bake: 1R7EMS02 ran ~1.3 billion transcendental projections, ~41k per tile. Cache the drawn boundary once per cell (DrawnBoundary: the drawableLineParts geometry + each vertex's lonLatToWorld), so the per-tile stroke reprojects with a linear worldToTile. The cache condition mirrors the emit-side guard exactly — needsDrawableBoundary, ANY prim (a masked LINE takes the same path and was the bulk of the cost), not areas only. Byte-identical (project == worldToTile ∘ lonLatToWorld; verified same sha256 on 6 German IENC cells). 1R7EMS02 127s->48s. --- src/chart.zig | 6 ++++++ src/s57/s57.zig | 23 ++++++++++++++++++++++ src/scene/scene.zig | 47 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index f0dd4da..98db632 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -2450,6 +2450,12 @@ const BakeWork = struct { // per cell (only for Text/centred-symbol features) instead of re-running the search // 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; + // Per-feature drawn-boundary cache (masked/coast-clipped area boundaries): + // assemble the drawableLineParts subset + precompute its world coords ONCE, so + // the per-tile stroke reprojects with a linear map instead of the transcendental + // projection on every tile the area spans — the last per-tile projection hotspot + // on Inland-ENC river cells (long shared coast boundaries). + cell.drawn_boundary = scene.buildDrawnBoundary(p.allocator(), &cell) 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). diff --git a/src/s57/s57.zig b/src/s57/s57.zig index 7893172..076d5df 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -35,6 +35,18 @@ pub const LonLat = struct { return .{ .lon_e7 = degToE7(lon_deg), .lat_e7 = degToE7(lat_deg) }; } }; +/// One area feature's DRAWN boundary: the parts left after §8.6.2 masking drops the +/// masked / data-limit / coast-coincident edges (`Cell.drawableLineParts`), together +/// with each vertex's precomputed normalised web-mercator world coordinate parallel to +/// `parts` (`world[i].len == parts[i].len`). The baker fills `world` once per cell +/// (scene.buildDrawnBoundary via `tile.lonLatToWorld`) so the per-tile stroke path +/// reprojects with a linear `worldToTile` instead of the transcendental projection. The +/// coords are opaque f64 pairs to the Cell — only the renderer interprets them; the pair +/// is exactly what `tile.project` would recompute inline, so the cache stays byte-identical. +pub const DrawnBoundary = struct { + parts: [][]LonLat, + world: [][][2]f64, +}; pub const Sounding = struct { lon_e7: i32, lat_e7: i32, @@ -618,6 +630,17 @@ pub const Cell = struct { /// single-tile path falls back to an on-demand search). Allocated in the baker's /// geometry arena, not the cell arena. label_cache: ?[]const ?LonLat = null, + /// Per-feature DRAWN-boundary cache (area features whose stroked boundary differs + /// from the full fill ring — `needsDrawableBoundary`), built once per cell by the + /// baker (scene.buildDrawnBoundary). A null slot means "stroke the full ring" (no + /// masked/coast edges to drop). See `DrawnBoundary`: it carries the drawn parts plus + /// each vertex's precomputed web-mercator world coord, so the per-tile emit reprojects + /// the boundary with a cheap linear map instead of the transcendental projection on + /// every tile the area spans — the dominant cost on Inland-ENC cells whose fairway / + /// depth areas share long coast boundaries. Allocated in the baker's geometry arena, + /// not the cell arena; null = not built (the live single-tile path reconstructs + + /// projects on demand, byte-identical). + drawn_boundary: ?[]const ?DrawnBoundary = null, /// True when this cell was assembled from a NATIVE S-101 dataset (s101.native) /// rather than parsed from an S-57 source. It carries the S-57 geometry model /// but its features draw their portrayal from S-101-native `adapter.Adapted` diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 233d3c2..7d523c5 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1172,6 +1172,37 @@ pub fn buildLabelCache(a: Allocator, cell: *const s57.Cell, geo: ?GeoParts, stre return out; } +/// Per-feature DRAWN-boundary cache (cell.drawn_boundary), indexed by feature index. +/// An area feature whose stroked boundary differs from its full fill ring +/// (needsDrawableBoundary — MASK/USAG edge flags, or a coast-coincident edge on a +/// non-coast-definer area) restrokes only the drawableLineParts SUBSET. That subset is +/// tile-invariant, so assemble it ONCE here and precompute each vertex's web-mercator +/// world coordinate — the per-tile emit then reprojects with a linear worldToTile instead +/// of running the transcendental projection on the boundary for every tile the area spans +/// (the dominant cost on Inland-ENC river cells: fairway/depth areas with long shared coast +/// boundaries). A null slot leaves the feature on the live path (reconstruct + project), +/// byte-identical since `world` holds exactly `tile.lonLatToWorld` and `tile.project` is +/// `worldToTile ∘ lonLatToWorld`. Covers exactly the features the per-tile stroke path +/// routes through drawableLineParts — ANY prim whose `needsDrawableBoundary` holds, not +/// areas only: a LINE (prim 2) with MASK/USAG edge flags takes the same drawn-subset path +/// and was the bulk of the uncached per-tile projection on Inland-ENC cells. +pub fn buildDrawnBoundary(a: Allocator, cell: *const s57.Cell) ![]?s57.DrawnBoundary { + const out = try a.alloc(?s57.DrawnBoundary, cell.features.len); + @memset(out, null); + for (cell.features, 0..) |f, i| { + if (!cell.needsDrawableBoundary(f)) continue; + const parts = cell.drawableLineParts(a, f) catch continue; + const world = a.alloc([][2]f64, parts.len) catch continue; + for (parts, 0..) |part, pi| { + const wp = try a.alloc([2]f64, part.len); + for (part, 0..) |pt, j| wp[j] = tile.lonLatToWorld(pt.lon(), pt.lat()); + world[pi] = wp; + } + out[i] = .{ .parts = parts, .world = world }; + } + return out; +} + /// One assembled line/area part: its tile-independent web-mercator coords plus its /// static lon/lat bbox [w,s,e,n] and the matching normalised world bbox /// [min_wx,min_wy,max_wx,max_wy]. Both are computed ONCE here so the baker's @@ -1604,13 +1635,23 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, var stroke_proj: []const []const mvt.Point = projected.items; if (!opts.suppress_lines and p.lines.len > 0 and cell.needsDrawableBoundary(f)) { var stroke_storage = std.ArrayList([]mvt.Point).empty; - const dparts = cell.drawableLineParts(a, f) catch &[_][]s57.LonLat{}; + // The baker's per-cell drawn-boundary cache: the drawableLineParts subset plus each + // vertex's precomputed world coord. Reproject the cached world with a linear + // worldToTile instead of the transcendental projection on every tile — the drawn + // boundary is tile-invariant. Live path (no cache / single tile) reconstructs and + // projects directly; byte-identical, since project == worldToTile ∘ lonLatToWorld. + const cached: ?s57.DrawnBoundary = if (cell.drawn_boundary) |db| (if (fi < db.len) db[fi] else null) else null; + const dparts: []const []s57.LonLat = if (cached) |c| c.parts else (cell.drawableLineParts(a, f) catch &[_][]s57.LonLat{}); stroke_geo = dparts; - for (dparts) |dp| { + for (dparts, 0..) |dp, pi| { if (dp.len < 2) continue; if (!overlaps(geomBounds(dp), tb)) continue; const proj = try a.alloc(mvt.Point, dp.len); - for (dp, 0..) |p2, i| proj[i] = tile.project(p2.lon(), p2.lat(), z, x, y, tile.EXTENT); + if (cached) |c| { + for (c.world[pi], 0..) |ww, i| proj[i] = tile.worldToTile(ww, z, x, y, tile.EXTENT); + } else { + for (dp, 0..) |p2, i| proj[i] = tile.project(p2.lon(), p2.lat(), z, x, y, tile.EXTENT); + } try stroke_storage.append(a, proj); } stroke_proj = stroke_storage.items;