Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 47 additions & 19 deletions src/chart.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -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;

Expand Down
11 changes: 7 additions & 4 deletions src/render/ascii.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
67 changes: 65 additions & 2 deletions src/render/pixel.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
}
Expand Down Expand Up @@ -613,6 +615,67 @@ const test_profile =
\\</palette>
;

// Every depth shade, so a swapped band is distinguishable from the baked token.
const depth_profile =
\\<palette name="Day">
\\ <item token="DEPIT"><srgb><red>1</red><green>1</green><blue>1</blue></srgb></item>
\\ <item token="DEPVS"><srgb><red>2</red><green>2</green><blue>2</blue></srgb></item>
\\ <item token="DEPMS"><srgb><red>3</red><green>3</green><blue>3</blue></srgb></item>
\\ <item token="DEPMD"><srgb><red>4</red><green>4</green><blue>4</blue></srgb></item>
\\ <item token="DEPDW"><srgb><red>5</red><green>5</green><blue>5</blue></srgb></item>
\\</palette>
;

// 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);
Expand Down
60 changes: 60 additions & 0 deletions src/render/resolve.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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));
Expand Down
7 changes: 5 additions & 2 deletions src/render/vector.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading