From 06454ff7cfccf746f64af975e90bbbdb1ef6049f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 12 Aug 2026 10:05:54 -0400 Subject: [PATCH 01/11] sprite: bake at the drawn scale, render sounding runs at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every symbol in a baked library draws at the engine's SYMBOL_SCALE, but the MapLibre sheet rasterized at the 0.08 catalogue scale — 8x the area anything displayed. Bake at the drawn scale (mln_drawn_scale) and rebase the style's icon-size so everything samples 1:1: the day sheet falls 105 MB -> 19 MB RGBA and every entry fits under MapLibre's 1024 px image cap. Sounding depths are comma-joined glyph runs no prebaked sheet can enumerate. tile57_render_symbol_run renders one run to RGBA on demand (exportAlloc convention, so tile57_free stays the one deallocator); the host answers MapLibre's missing-image events with it. renderSymbolRun applies the same drawn scale so runtime digits match the sheet. glyphpbf wrote fields 5 and 6 unsigned; they are zigzag in the spec, so any negative glyph bearing corrupted the whole PBF walk downstream (the 'Channel' -> 'anne' text). A TestReader regression walks the encoded ranges. --- include/tile57.h | 11 +++++ src/bundle.zig | 13 +++++ src/capi.zig | 44 ++++++++++++++++- src/sprite/glyphpbf.zig | 105 +++++++++++++++++++++++++++++++++++++--- src/sprite/sprite.zig | 67 ++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 9 deletions(-) diff --git a/include/tile57.h b/include/tile57.h index 67b02243..be09d845 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -1582,6 +1582,17 @@ tile57_status tile57_bake_assets(const char *catalog_dir, tile57_assets *out, tile57_status tile57_bake_sprite_mln(const char *catalog_dir, double pixel_ratio, tile57_scheme scheme, tile57_assets *out, tile57_error *err); +/* Render ONE comma-joined symbol run (a sounding digit stack such as + * "SOUNDG11,SOUNDG53") to a pivot-centred RGBA image at pixel_ratio, coloured + * for `scheme`. The runtime path behind a map client's missing-image event: a + * chart library carries more distinct runs than any prebaked sheet can + * enumerate, so the host renders exactly the ones the display asks for. + * TILE57_OK with *out_rgba NULL when the run names no known glyph (absent, + * not an error). Free *out_rgba with tile57_free. */ +tile57_status tile57_render_symbol_run(const char *catalog_dir, const char *run, + double pixel_ratio, int scheme, + uint8_t **out_rgba, uint32_t *out_w, + uint32_t *out_h, 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 diff --git a/src/bundle.zig b/src/bundle.zig index f7186968..7264d7c0 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -234,6 +234,19 @@ pub fn spriteMlnBytes(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8, return sprite.spriteMln(a, symbols, fills, css, soundings, ratio); } +/// Render one comma-joined symbol run (sounding digit stack) to a +/// pivot-centred RGBA image — the runtime answer to MapLibre's missing-image +/// event, for the runs no prebaked sheet can enumerate. Null when the run +/// names no known glyph. Caller owns the pixels (allocated in `a`). +pub fn symbolRunImage(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8, css_name: []const u8, run: []const u8, ratio: f64) !?sprite.RunImage { + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const ar = arena.allocator(); + const symbols = try readSymbols(io, ar, catalog_dir); + const css = try readCss(io, ar, catalog_dir, css_name); + return sprite.renderSymbolRun(a, symbols, css, run, ratio); +} + // Emit sprite-mln.{json,png} + identical @2x copies (MapLibre requests @2x on // HiDPI; a missing @2x sheet fails the whole sprite load). pub fn emitSpriteMln(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8, css_name: []const u8, out_dir: []const u8, base: []const u8, soundings: []const []const u8) !sprite.Atlas { diff --git a/src/capi.zig b/src/capi.zig index d3e615e9..31117e9c 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -20,7 +20,9 @@ const raster = @import("raster"); // raster charts (tile57_raster_chart_*) const zipsrc = @import("zipsrc"); // charts read straight out of a .zip // 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). +// catalogue. The full catalogue (symbols, linestyles, css) is embedded too, via +// the `catalog` module bundle.zig imports — tile57_style_template reads the +// analysed linestyles from it and tile57_bake_sprite_mln the symbol SVGs. const colorprofile_registry = @import("colorprofile_registry"); // c_allocator, not smp_allocator: smp never returns freed slabs to the OS, so @@ -1999,6 +2001,46 @@ export fn tile57_bake_sprite_mln(catalog_dir: ?[*:0]const u8, pixel_ratio: f64, return OK; } +/// Render one comma-joined symbol run (a sounding digit stack like +/// "SOUNDG11,SOUNDG53") to a pivot-centred RGBA image at `pixel_ratio`, in +/// the palette of `scheme`. The runtime path behind MapLibre's missing-image +/// event: a chart library carries more distinct runs than a prebaked sheet +/// can enumerate, so the host renders exactly the ones the map asks for. +/// TILE57_OK with *out_rgba NULL when the run names no known glyph (absent, +/// not an error). Free *out_rgba with tile57_free. +export fn tile57_render_symbol_run( + catalog_dir: ?[*:0]const u8, + run: ?[*:0]const u8, + pixel_ratio: f64, + scheme: c_int, + out_rgba: ?*?[*]u8, + out_w: ?*u32, + out_h: ?*u32, + err: ?*CError, +) callconv(.c) c_int { + const o = out_rgba orelse return failWith(err, .badarg, "out_rgba must not be null"); + const ow = out_w orelse return failWith(err, .badarg, "out_w must not be null"); + const oh = out_h orelse return failWith(err, .badarg, "out_h must not be null"); + o.* = null; + ow.* = 0; + oh.* = 0; + const r = run orelse return failWith(err, .badarg, "run must not be null"); + const cd = spanOpt(catalog_dir) orelse ""; + const ratio = if (pixel_ratio > 0) pixel_ratio else 1; + const img = bundle.symbolRunImage(sharedIo(), gpa, cd, svgCssFor(scheme), std.mem.span(r), ratio) catch |e| return fail(err, e); + if (img) |i| { + // Through the export-header convention (exportAlloc/tile57_free) like + // every other buffer this ABI hands out; the engine copy is freed here. + defer gpa.free(i.rgba); + const p = exportAlloc(i.rgba.len) orelse return failWith(err, .nomem, "out of memory"); + @memcpy(p[0..i.rgba.len], i.rgba); + o.* = p; + ow.* = i.w; + oh.* = i.h; + } + return OK; +} + const glyph_sdf = @import("sprite").glyph; // Glyph metrics as compact JSON: {"em_px","pad","glyphs":{cp:[u0,v0,u1,v1,ox,oy,w,h,adv]}}. diff --git a/src/sprite/glyphpbf.zig b/src/sprite/glyphpbf.zig index 4a6a2192..f223f4e7 100644 --- a/src/sprite/glyphpbf.zig +++ b/src/sprite/glyphpbf.zig @@ -4,9 +4,11 @@ //! SDF path as the GPU atlas (`glyph.zig`), only with the SDF encoding a GL //! client expects (fontnik's 24 px em, 3 px buffer, edge 191, 255/8 per px). //! -//! Metric fields are plain varints (matching the reference stacks a GL client -//! already consumes — a zigzag `top` would place glyphs below the baseline); -//! `left`/`top` clamp at 0, as every Latin glyph in the reference does. +//! `left`/`top` are sint32 on the wire (zigzag), exactly as glyphs.proto says: +//! MapLibre Native's parser (text/glyph_pbf.cpp) reads them with get_sint32(). +//! Encoding them as plain varints halves every even value and NEGATES every odd +//! one on decode, which scatters letters vertically by parity — tall glyphs land +//! below the baseline and words render with characters apparently missing. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -30,6 +32,10 @@ fn putVarintField(buf: *std.ArrayList(u8), a: Allocator, field: u32, v: u64) !vo try putVarint(buf, a, (@as(u64, field) << 3) | 0); // wire type 0 try putVarint(buf, a, v); } +fn putSintField(buf: *std.ArrayList(u8), a: Allocator, field: u32, v: i32) !void { + const zz = (@as(u64, @bitCast(@as(i64, v))) << 1) ^ @as(u64, @bitCast(@as(i64, v) >> 63)); + try putVarintField(buf, a, field, zz); +} fn putBytesField(buf: *std.ArrayList(u8), a: Allocator, field: u32, bytes: []const u8) !void { try putVarint(buf, a, (@as(u64, field) << 3) | 2); // wire type 2 try putVarint(buf, a, bytes.len); @@ -59,14 +65,14 @@ fn appendGlyph(a: Allocator, glyphs: *std.ArrayList(u8), font: []const u8, cp: u try putBytesField(&g, a, 2, sdf.?[0..@intCast(w * h)]); try putVarintField(&g, a, 3, gw); try putVarintField(&g, a, 4, gh); - try putVarintField(&g, a, 5, @intCast(@max(left, 0))); - try putVarintField(&g, a, 6, @intCast(@max(top, 0))); + try putSintField(&g, a, 5, left); + try putSintField(&g, a, 6, top); } else { // Blank glyph (space): advance only, no bitmap. try putVarintField(&g, a, 3, 0); try putVarintField(&g, a, 4, 0); - try putVarintField(&g, a, 5, 0); - try putVarintField(&g, a, 6, 0); + try putSintField(&g, a, 5, 0); + try putSintField(&g, a, 6, 0); } try putVarintField(&g, a, 7, @intFromFloat(@round(adv))); // advance try putBytesField(glyphs, a, 3, g.items); // fontstack.glyphs (field 3) @@ -93,6 +99,91 @@ pub fn encodeRange(a: Allocator, font: []const u8, name: []const u8, start: u21) // ---- tests ------------------------------------------------------------------- +const TestReader = struct { + buf: []const u8, + i: usize = 0, + + fn varint(self: *TestReader) u64 { + var shift: u6 = 0; + var v: u64 = 0; + while (true) { + const b = self.buf[self.i]; + self.i += 1; + v |= @as(u64, b & 0x7f) << shift; + if (b < 0x80) return v; + shift += 7; + } + } + + fn skipOrSlice(self: *TestReader, wire: u3) ?[]const u8 { + switch (wire) { + 0 => _ = self.varint(), + 2 => { + const len = self.varint(); + const s = self.buf[self.i .. self.i + len]; + self.i += len; + return s; + }, + else => unreachable, + } + return null; + } +}; + +fn zigzagDecode(v: u64) i64 { + return @as(i64, @intCast(v >> 1)) ^ -@as(i64, @intCast(v & 1)); +} + +test "glyphpbf: left/top are sint32 on the wire, as glyph_pbf.cpp decodes them" { + // MapLibre Native reads fields 5/6 with get_sint32() (zigzag). Encoded as + // plain varints they decode halved (even) or negated (odd) — glyphs land + // below the baseline and letters vanish from words. Lock the wire format. + const a = std.testing.allocator; + const font = @import("render").font.notosans; + const pbf = try encodeRange(a, font, "Noto Sans Regular", 0); + defer a.free(pbf); + + var found_cap = false; + var outer = TestReader{ .buf = pbf }; + while (outer.i < outer.buf.len) { + const key = outer.varint(); + const stack_msg = outer.skipOrSlice(@intCast(key & 7)) orelse continue; + if (key >> 3 != 1) continue; + var st = TestReader{ .buf = stack_msg }; + while (st.i < st.buf.len) { + const skey = st.varint(); + const gmsg = st.skipOrSlice(@intCast(skey & 7)) orelse continue; + if (skey >> 3 != 3) continue; + var g = TestReader{ .buf = gmsg }; + var id: u64 = 0; + var left: i64 = 0; + var top: i64 = 0; + var adv: u64 = 0; + while (g.i < g.buf.len) { + const gkey = g.varint(); + switch (gkey >> 3) { + 1 => id = g.varint(), + 5 => left = zigzagDecode(g.varint()), + 6 => top = zigzagDecode(g.varint()), + 7 => adv = g.varint(), + else => _ = g.skipOrSlice(@intCast(gkey & 7)), + } + } + if (id == 'A') { + found_cap = true; + // 'A' at the 24px fontnik em: cap top sits well above the + // baseline. Decoded THROUGH ZIGZAG it must come out in a + // plausible band; a plain-varint encoding would fail this for + // any odd top (negative) and halve the rest. + try std.testing.expect(top >= 10 and top <= 25); + try std.testing.expect(left >= -5 and left <= 10); + try std.testing.expect(adv >= 8 and adv <= 22); + } + } + } + try std.testing.expect(found_cap); +} + test "glyphpbf: range encodes a well-formed fontstack with 'A'" { const a = std.testing.allocator; const font = @import("render").font.notosans; diff --git a/src/sprite/sprite.zig b/src/sprite/sprite.zig index 42a55905..b8adc829 100644 --- a/src/sprite/sprite.zig +++ b/src/sprite/sprite.zig @@ -500,6 +500,17 @@ const MlnCell = struct { name: []const u8, w: u32, h: u32, ratio: f64, rgba: []c /// The GPU-scene cell-map (chart.buildGpuAtlases) and a host's uploaded texture /// (tile57_bake_sprite_mln) MUST pass the SAME ratio, or the normalized UVs the /// scene emits will not index the texture the host uploaded. +/// Every MapLibre-drawn symbol renders at the engine SYMBOL_SCALE (0.0283…), +/// measured across a full ENC library (134k features, 4.5k distinct names, +/// not one above it). The sheet used to rasterize at the 0.08 catalogue scale +/// — 8x the area anything ever drew at — and the oversize was both the +/// resident-memory floor (the map clones every image per style) and the +/// frame-time floor (its atlas churn ran renderUpdate at 60+ ms during a +/// zoom). Baked at drawn scale, icon-size 1.0 samples the artwork 1:1. +/// maplibre.zig's icon-size divisor must equal SYMBOL_SCALE for that to hold; +/// a test there asserts the pairing. +pub const mln_drawn_scale: f64 = 0.02834627777338028 / 0.08; + pub fn spriteMln(a: std.mem.Allocator, symbols: []const SvgSrc, fills: []const AreaFillSrc, css_data: []const u8, soundings: []const []const u8, ratio: f64) !Atlas { return spriteMlnOpts(a, symbols, fills, css_data, soundings, ratio, true); } @@ -512,7 +523,10 @@ pub fn spriteMlnOpts(a: std.mem.Allocator, symbols: []const SvgSrc, fills: []con defer arena_state.deinit(); const ar = arena_state.allocator(); var css = try loadCss(ar, css_data); - const ppm = px_per_mm * ratio; // device px per mm at this display ratio + // Drawn scale folded in: cells carry exactly the pixels the display uses + // (see mln_drawn_scale). The GPU-scene atlas takes a different path and + // keeps its own scale model. + const ppm = px_per_mm * ratio * mln_drawn_scale; // device px per mm as DRAWN const cell_cap: f64 = @as(f64, @floatFromInt(max_cell_side)) * ratio; const atlas_w: u32 = @intFromFloat(@round(@as(f64, @floatFromInt(sprite_atlas_width)) * ratio)); @@ -574,6 +588,57 @@ pub fn spriteMlnOpts(a: std.mem.Allocator, symbols: []const SvgSrc, fills: []con return packMlnOpts(a, ar, cells.items, atlas_w, want_pixels); } +/// Render ONE comma-joined glyph run (a sounding digit stack, a contour label +/// composite) to a pivot-centred RGBA image at `ratio`, from the symbol SVG +/// set + palette css. The runtime path behind MapLibre's missing-image event: +/// a library carries far more distinct runs than any prebaked sheet can, so +/// the host renders each one the map actually asks for. Caller owns rgba. +pub const RunImage = struct { w: u32, h: u32, rgba: []u8 }; +pub fn renderSymbolRun(a: std.mem.Allocator, symbols: []const SvgSrc, css_data: []const u8, run: []const u8, ratio: f64) !?RunImage { + var arena_state = std.heap.ArenaAllocator.init(a); + defer arena_state.deinit(); + const ar = arena_state.allocator(); + var css = try loadCss(ar, css_data); + const ppm = px_per_mm * ratio; + + var rendered = std.StringHashMap(RenderedSym).init(ar); + var it = std.mem.tokenizeScalar(u8, run, ','); + while (it.next()) |name| { + if (rendered.contains(name)) continue; + for (symbols) |s| { + if (!std.mem.eql(u8, s.id, name)) continue; + const sym = renderSymAt(ar, s.svg, &css, ppm * mln_drawn_scale) orelse break; + try rendered.put(s.id, sym); + break; + } + } + const t = compositeSounding(ar, &rendered, run) orelse return null; + return .{ .w = t.w, .h = t.h, .rgba = try a.dupe(u8, t.rgba) }; +} + +test "renderSymbolRun composites a two-glyph run pivot-centred" { + const a = std.testing.allocator; + // Two tiny synthetic glyphs with distinct pivots; the composite must be + // non-empty and sized to hold both about the shared pivot. + const svg1 = + \\ + ; + const svg2 = + \\ + ; + const syms = [_]SvgSrc{ .{ .id = "GA", .svg = svg1 }, .{ .id = "GB", .svg = svg2 } }; + const css = ".sl{fill:#112233}"; + const img = try renderSymbolRun(a, &syms, css, "GA,GB", 1.0); + if (img) |i| { + defer a.free(i.rgba); + try std.testing.expect(i.w > 0 and i.h > 0); + try std.testing.expect(i.rgba.len == @as(usize, i.w) * i.h * 4); + } + // A run of unknown glyphs renders nothing rather than failing. + const none = try renderSymbolRun(a, &syms, css, "NOPE1,NOPE2", 1.0); + try std.testing.expect(none == null); +} + // Composite a comma-joined glyph list (e.g. "SOUNDSC3,SOUNDS12,SOUNDS54") into // one pivot-centred image — each glyph self-positions by its pivot. Port of // build_sprite.py compositeSounding. null if no glyph is known. From 4c32e645ad1cc2756b8aabe24f279c3ebc0f8d88 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 12 Aug 2026 10:06:15 -0400 Subject: [PATCH 02/11] =?UTF-8?q?tiles:=20tile57/3=20=E2=80=94=20per-style?= =?UTF-8?q?=20line=20source-layers,=20baked=20style=20precomputes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MapLibre evaluates every style layer's filter against every feature of that layer's source-layer at each tile layout. With all decorated lines in one 'lines' source-layer, 85 of 100 layers scanned every line feature of every tile — about a thousand expression nodes per feature, the largest single cost in a zoom profile, and why content trailed the camera on a library crossing. The bake now routes each complex-linestyle feature into its own 'lines-ls-