From 387bfb7e9f1568c0e2426fc06b5bd1f55f53d4d2 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 13 Jul 2026 04:02:32 +0000 Subject: [PATCH 1/2] fix(render): shade depth areas from the mariner's contours, not the baked token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S-101 rules are run ONCE per cell with the fixed bake context (portray.Context defaults: shallow 2 m, safety/deep 30 m), because a baked tile must serve every mariner. So a DEPARE's ColorFill token is the BAKE's shading, not this mariner's — which is why scene.fillArea also hands every depth area its DRVAL1/DRVAL2, and why the MapLibre style re-runs SEABED01 client-side (mariner.seabedTokenExpr). The resolving surfaces never did. All three dropped the range on the floor (`_ = depth`), painted the baked token, and so: * DEPDW essentially never appeared — the baked deep contour is 30 m, so any coastal cell whose depths run under that showed no deep water at all; * DEPMD never appeared — the bake's safety and deep contours are BOTH 30 m, which collapses the medium-deep band to nothing; * the mariner's shallow/safety/deep contours and the two/four-shade toggle changed nothing on screen. Add resolve.seabedToken (SEABED01, mirroring mariner.seabedTokenExpr band for band) and swap the fill colour live in the pixel, vector and ascii surfaces when a fill carries a depth range. The baked fill transparency is preserved: the swap is of the colour, not the opacity. Non-depth areas keep their baked token. The other two live-mariner depth swaps in these surfaces (SNDFRM04 soundings vs safety_depth, DANGER01/02 vs safety_contour) were already wired; this was the gap. --- src/render/ascii.zig | 11 ++++--- src/render/pixel.zig | 67 ++++++++++++++++++++++++++++++++++++++++-- src/render/resolve.zig | 60 +++++++++++++++++++++++++++++++++++++ src/render/vector.zig | 7 +++-- 4 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/render/ascii.zig b/src/render/ascii.zig index 3dd90bc..1c1d9c1 100644 --- a/src/render/ascii.zig +++ b/src/render/ascii.zig @@ -251,17 +251,20 @@ pub const AsciiSurface = struct { } fn fillArea(ctx: *anyopaque, token: rs.ColorToken, rings: []const []const rs.TilePoint, depth: ?rs.DepthRange) anyerror!void { - _ = depth; // the rule already picked the depth-shade token const self = sp(ctx); if (!self.cur_visible) return; + // A depth area re-shades LIVE against the mariner's contours (SEABED01) — the + // baked token carries the bake context's contours, not this mariner's. The + // depth shade is most of what this backend draws, so it must swap here too. + const tok = if (depth) |d| resolve.seabedToken(d, self.settings) else token; // In ANSI mode land reads as a solid background wash, not a wall of // '#'/'@' — the color alone carries it, and marks/labels on land stay // legible. Plain text keeps the glyphs (no color to carry the fill). - const ch = if (self.ansi and (std.mem.eql(u8, token, "LANDA") or std.mem.eql(u8, token, "CHBRN"))) + const ch = if (self.ansi and (std.mem.eql(u8, tok, "LANDA") or std.mem.eql(u8, tok, "CHBRN"))) ' ' else - fillChar(token); - try self.push(.area, .{ .fill = .{ .rings = try self.toGrid(rings), .ch = ch, .color = self.resolveColor(token) } }); + fillChar(tok); + try self.push(.area, .{ .fill = .{ .rings = try self.toGrid(rings), .ch = ch, .color = self.resolveColor(tok) } }); } fn fillPattern(ctx: *anyopaque, name: rs.SymbolName, rings: []const []const rs.TilePoint) anyerror!void { diff --git a/src/render/pixel.zig b/src/render/pixel.zig index cc07704..a3ada49 100644 --- a/src/render/pixel.zig +++ b/src/render/pixel.zig @@ -209,12 +209,14 @@ pub const PixelSurface = struct { } fn fillArea(ctx: *anyopaque, token: rs.ColorToken, rings: []const []const rs.TilePoint, depth: ?rs.DepthRange) anyerror!void { - _ = depth; // the rule resolved the depth token against the real context const self = sp(ctx); if (!self.cur_visible) return; // ColorFill "NAME[,transparency]": apply the S-101 fill transparency (alpha). const ft = rs.fillToken(token); - var col = self.resolveColor(ft.name); + // A depth area re-shades LIVE against the mariner's contours (SEABED01) — the + // baked token carries the bake context's contours, not this mariner's. + const name = if (depth) |d| resolve.seabedToken(d, self.settings) else ft.name; + var col = self.resolveColor(name); col.a = ft.alpha; try self.push(.area, .{ .fill = .{ .rings = try self.toCanvas(rings), .color = col } }); } @@ -613,6 +615,67 @@ const test_profile = \\ ; +// Every depth shade, so a swapped band is distinguishable from the baked token. +const depth_profile = + \\ + \\ 111 + \\ 222 + \\ 333 + \\ 444 + \\ 555 + \\ +; + +// The bug this pins: the S-101 rules bake a depth area's fill token against the +// FIXED bake context (safety/deep 30 m), so the surface was painting that shade no +// matter what the mariner's contours were — deep water never reached DEPDW, and +// moving a contour did nothing. A depth area must re-shade from the DRVAL range the +// scene hands fillArea. (VectorSurface does the same swap for GPU hosts.) +test "PixelSurface: a depth area shades from the MARINER's contours, not the baked token" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var colors = try resolve.Colors.init(a, depth_profile); + const ring = [_]rs.TilePoint{ .{ .x = 0, .y = 0 }, .{ .x = 64, .y = 0 }, .{ .x = 64, .y = 64 } }; + const rings = [_][]const rs.TilePoint{&ring}; + + // One 12–20 m area, portrayed by the rules as DEPMS (the bake context's 2/30/30 + // bands put every depth under 30 m in the middle shade). The mariner decides. + const fillOnce = struct { + fn go(al: Allocator, cols: *const resolve.Colors, m: *const resolve.Settings, rr: []const []const rs.TilePoint, depth: ?rs.DepthRange) !cv.Color { + var ps = PixelSurface.init(al, cols, .day, m, 14.0, 64, 64); + const surf = ps.asSurface(); + try surf.beginScene(14); + const meta = rs.FeatureMeta{ .class = "DEPARE", .cat = 0 }; + try surf.beginFeature(&meta); + try surf.fillArea("DEPMS", rr, depth); + try surf.endFeature(); + try std.testing.expectEqual(@as(usize, 1), ps.ops.items.len); + return ps.ops.items[0].kind.fill.color; + } + }.go; + + const dr = rs.DepthRange{ .d1 = 12, .d2 = 20 }; + + // Default contours (shallow 2 / safety 10 / deep 30): 12 m is past the safety + // contour but not the deep one -> medium-deep, NOT the baked DEPMS. + const std_m = resolve.Settings{ .shallow_contour = 2, .safety_contour = 10, .deep_contour = 30 }; + try std.testing.expectEqual(@as(u8, 4), (try fillOnce(a, &colors, &std_m, &rings, dr)).r); // DEPMD + + // The mariner pulls the deep contour in to 10 m: the SAME area is now deep + // water. This is the shade that could never appear before. + const deep_m = resolve.Settings{ .shallow_contour = 2, .safety_contour = 5, .deep_contour = 10 }; + try std.testing.expectEqual(@as(u8, 5), (try fillOnce(a, &colors, &deep_m, &rings, dr)).r); // DEPDW + + // Two-shade water: the safety contour is the only split, so 12 m reads deep. + const two_m = resolve.Settings{ .safety_contour = 10, .deep_contour = 30, .four_shade_water = false }; + try std.testing.expectEqual(@as(u8, 5), (try fillOnce(a, &colors, &two_m, &rings, dr)).r); // DEPDW + + // A non-depth area (no DRVAL range) still takes its baked token, untouched. + try std.testing.expectEqual(@as(u8, 3), (try fillOnce(a, &colors, &std_m, &rings, null)).r); // DEPMS +} + test "PixelSurface: resolves tokens, gates SCAMIN, sorts by draw_prio" { const gpa = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(gpa); diff --git a/src/render/resolve.zig b/src/render/resolve.zig index 2e308ce..6f74341 100644 --- a/src/render/resolve.zig +++ b/src/render/resolve.zig @@ -94,6 +94,38 @@ fn collectItems(a: Allocator, block: []const u8, map: *std.StringHashMapUnmanage } } +// ---- depth shading (SEABED01) ------------------------------------------------ + +/// S-52 SEABED01: a depth area's DRVAL1/DRVAL2 against the mariner's contours -> +/// a depth colour token. Mirrors mariner.seabedTokenExpr (the `case` the MapLibre +/// client evaluates on the tile's drval1/drval2 props) — keep the two in step. +/// +/// The rules bake the fill token against the FIXED bake context (portray.Context's +/// defaults: safety/deep 30 m, shallow 2 m), because a baked tile must serve every +/// mariner. So the baked token is NOT the mariner's shading, and a resolving surface +/// has to redo this the way the style does: deepest band first, `>=` on DRVAL1 and +/// `>` on DRVAL2 per spec, so an area whose range straddles a contour takes the +/// SHALLOWER shade. +pub fn seabedToken(d: rs.DepthRange, m: *const Settings) []const u8 { + const d1: f64 = d.d1; + const d2: f64 = d.d2; + const band = struct { + fn at(dd1: f64, dd2: f64, x: f64) bool { + return dd1 >= x and dd2 > x; + } + }.at; + if (!m.four_shade_water) { + if (band(d1, d2, m.safety_contour)) return "DEPDW"; + if (band(d1, d2, 0)) return "DEPVS"; + return "DEPIT"; + } + if (band(d1, d2, m.deep_contour)) return "DEPDW"; + if (band(d1, d2, m.safety_contour)) return "DEPMD"; + if (band(d1, d2, m.shallow_contour)) return "DEPMS"; + if (band(d1, d2, 0)) return "DEPVS"; + return "DEPIT"; +} + // ---- display gates ---------------------------------------------------------- /// S-52 §10.3.4 display-category gate — mirrors mariner.categoryFilter: @@ -251,6 +283,34 @@ test "soundings ride their own switch, not the OTHER category" { try std.testing.expect(categoryVisible(2, "SOUNDG", null, &legacy_other)); } +test "seabedToken mirrors mariner.seabedTokenExpr" { + const four = Settings{ .shallow_contour = 2, .safety_contour = 10, .deep_contour = 30 }; + const dr = struct { + fn r(d1: f32, d2: f32) rs.DepthRange { + return .{ .d1 = d1, .d2 = d2 }; + } + }.r; + // The four bands + the drying/intertidal fallthrough. + try std.testing.expectEqualStrings("DEPDW", seabedToken(dr(30, 100), &four)); + try std.testing.expectEqualStrings("DEPMD", seabedToken(dr(10, 30), &four)); + try std.testing.expectEqualStrings("DEPMS", seabedToken(dr(2, 10), &four)); + try std.testing.expectEqualStrings("DEPVS", seabedToken(dr(0, 2), &four)); + try std.testing.expectEqualStrings("DEPIT", seabedToken(dr(-5, 0), &four)); + // An area straddling a contour takes the SHALLOWER shade (DRVAL2 > x fails at + // the bound): 20..40 spans the 30 m deep contour, so DEPMD, not DEPDW. + try std.testing.expectEqualStrings("DEPMD", seabedToken(dr(20, 40), &four)); + // Two-shade water: safety contour is the only split, and deep_contour is ignored. + const two = Settings{ .safety_contour = 10, .deep_contour = 30, .four_shade_water = false }; + try std.testing.expectEqualStrings("DEPDW", seabedToken(dr(10, 30), &two)); + try std.testing.expectEqualStrings("DEPVS", seabedToken(dr(0, 10), &two)); + try std.testing.expectEqualStrings("DEPIT", seabedToken(dr(-2, 0), &two)); + // The mariner's contours actually move the bands: a 12 m area is medium-deep at a + // 10 m safety contour, but DEEP once the mariner pulls the deep contour in to 10. + const shoal = Settings{ .shallow_contour = 2, .safety_contour = 5, .deep_contour = 10 }; + try std.testing.expectEqualStrings("DEPDW", seabedToken(dr(12, 20), &shoal)); + try std.testing.expectEqualStrings("DEPMD", seabedToken(dr(12, 20), &four)); +} + test "categoryVisible mirrors mariner.categoryFilter" { const def = Settings{}; // base+standard on, other off, no overlays try std.testing.expect(categoryVisible(0, "DEPARE", null, &def)); diff --git a/src/render/vector.zig b/src/render/vector.zig index 5af0374..09a1a0d 100644 --- a/src/render/vector.zig +++ b/src/render/vector.zig @@ -376,14 +376,17 @@ pub const VectorSurface = struct { } fn fillArea(ctx: *anyopaque, token: rs.ColorToken, rings: []const []const rs.TilePoint, depth: ?rs.DepthRange) anyerror!void { - _ = depth; const self = sp(ctx); if (!self.cur_visible) return; const feat = self.cur_feature(); var wr = try self.worldRings(rings); // ColorFill "NAME[,transparency]": apply the S-101 fill transparency (alpha). const ft = rs.fillToken(token); - var col = self.resolveColor(ft.name); + // A depth area re-shades LIVE against the mariner's contours (SEABED01) — the + // baked token carries the bake context's contours, not this mariner's. Keep the + // baked transparency: the swap is of the colour, not of the fill's opacity. + const name = if (depth) |d| resolve.seabedToken(d, self.settings) else ft.name; + var col = self.resolveColor(name); col.a = ft.alpha; self.cb.fill_area(self.cb.ctx, &feat, &wr, ccolor(col), 0); } From e54b46439a301fc2ab56e3228e59c626d94f21c0 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 13 Jul 2026 08:59:15 +0000 Subject: [PATCH 2/2] perf(chart): parse the colour profile once per process, not once per render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S-52 colour tables are a pure function of the embedded colour profile — the same bytes for every chart, and read-only once parsed — but every render path re-parsed the whole XML into three hash maps: the per-handle cache only helped the tile path, and a host that opens and purges chart handles as its quilt walks cells (a large chart set makes that constant) paid the parse on every open. The one-shot render paths (PNG/PDF/ascii/compose, and the per-feature explore thumbnails) paid it on every call. Share one lazily-parsed copy behind a CAS one-shot (Zig 0.16 puts mutexes behind an Io the engine deliberately doesn't take; contention is possible only on the very first render, and steady state is one acquire load). Measured on the explore path, which renders one thumbnail per feature — 239 DEPARE features in US5MD1MC: 239 profile parses before, 1 after. --- src/chart.zig | 66 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index f0e2d5e..f0dd4da 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -41,6 +41,35 @@ const compose_mod = @import("compose"); // the runtime compositor (compose-backe // lists); page_allocator would mmap each one. Matches the bake CLI + C ABI. const gpa = std.heap.smp_allocator; +// The S-52 colour tables, parsed once per process from the embedded profile (see +// Chart.viewColorsRef). Immutable after init — every chart shares these, so the +// parse cannot be charged to a chart open. gpa is thread-safe and the tables live +// for the process, so they are deliberately never freed. +// One-shot: 0 = unparsed, 1 = a thread is parsing, 2 = ready. Zig 0.16 puts mutexes +// behind an Io (which the engine deliberately does not take), so the guard is a CAS +// plus a spin — and it can only ever contend on the very first tile of the first +// chart. After that this is one acquire load. +var colors_state: std.atomic.Value(u8) = .init(0); +var shared_colors: render.resolve.Colors = undefined; +var shared_colors_err: ?anyerror = null; + +fn sharedColors() !*render.resolve.Colors { + while (colors_state.load(.acquire) != 2) { + if (colors_state.cmpxchgStrong(@as(u8, 0), @as(u8, 1), .acquire, .monotonic) == null) { + if (render.resolve.Colors.init(gpa, embedded_assets.colorprofile[0].bytes)) |c| { + shared_colors = c; + } else |e| { + shared_colors_err = e; + } + colors_state.store(2, .release); + break; + } + std.atomic.spinLoopHint(); + } + if (shared_colors_err) |e| return e; + return &shared_colors; +} + // Env access lives in C (Zig 0.16 puts env behind Io); returns the S-101 rules // dir from TILE57_S101_RULES or null. Provided by the portrayal C shim. extern fn tg_env_rules() callconv(.c) ?[*:0]const u8; @@ -913,12 +942,12 @@ pub fn renderComposeView(src: *compose_mod.ComposeSource, lon: f64, lat: f64, zo defer arena.deinit(); const a = arena.allocator(); - var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); + const colors = try sharedColors(); 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); + 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; @@ -943,12 +972,12 @@ pub fn renderComposeSurfaceView(src: *compose_mod.ComposeSource, lon: f64, lat: defer arena.deinit(); const a = arena.allocator(); - var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); + const colors = try sharedColors(); 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); + 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_rotation = rotation_rad; // contour-label uprightness + screen-frame declutter @@ -1114,7 +1143,6 @@ pub const Chart = struct { 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` @@ -1341,14 +1369,14 @@ pub const Chart = struct { 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 colour tables (all three palettes). Parsed once per PROCESS, not + /// once per handle: they are a pure function of the embedded colour profile — the + /// same bytes for every chart — and read-only once parsed. A host that opens and + /// purges chart handles as it walks a quilt (a large chart set makes that constant) + /// was re-parsing the whole profile on every open, which showed up as a visible + /// slice of frame time under the tile path. + fn viewColorsRef(_: *Chart) !*render.resolve.Colors { + return sharedColors(); } /// The palette's symbol store, built once per handle per palette (the SVG @@ -1786,9 +1814,9 @@ pub const Chart = struct { // and no complex-linestyle table: the ASCII surface lowers symbol // NAMES to glyphs itself, and complex linestyles degrading to the // generic dashed stroke is exactly the fidelity a text grid carries. - var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); + const colors = try sharedColors(); const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); - var as = render.ascii.AsciiSurface.initView(a, &colors, palette, settings, zoom, cols, rows, pt, @import("tiles").tile.EXTENT); + var as = render.ascii.AsciiSurface.initView(a, colors, palette, settings, zoom, cols, rows, pt, @import("tiles").tile.EXTENT); as.ansi = ansi; switch (self.backend) { @@ -2024,7 +2052,7 @@ pub fn renderFeature( defer arena.deinit(); const a = arena.allocator(); - var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); + const colors = try sharedColors(); const css_name = switch (palette) { .day => "daySvgStyle", .dusk => "duskSvgStyle", @@ -2052,7 +2080,7 @@ pub fn renderFeature( s.ignore_scamin = true; const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); - var ps = render.pixel.PixelSurface.initView(a, &colors, palette, &s, zoom, w, h, pt, tile.EXTENT); + var ps = render.pixel.PixelSurface.initView(a, colors, palette, &s, zoom, w, h, pt, tile.EXTENT); ps.store = store.asStore(); ps.output = output; ps.bg_token = bg; @@ -2101,7 +2129,7 @@ pub fn renderCellView( defer arena.deinit(); const a = arena.allocator(); - var colors = try render.resolve.Colors.init(a, embedded_assets.colorprofile[0].bytes); + const colors = try sharedColors(); const css_name = switch (palette) { .day => "daySvgStyle", .dusk => "duskSvgStyle", @@ -2125,7 +2153,7 @@ pub fn renderCellView( 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); + var ps = render.pixel.PixelSurface.initView(a, colors, palette, settings, zoom, w, h, pt, tile.EXTENT); ps.store = store.asStore(); ps.output = output;