diff --git a/build.zig b/build.zig index 81798bce..3813445c 100644 --- a/build.zig +++ b/build.zig @@ -585,10 +585,10 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/lib_root.zig"), .target = target, .optimize = optimize, - // iOS: std.debug's stack-trace machinery references - // _dyld_get_image_header_containing_address, which iOS' libdyld doesn't - // export — strip so the panic path never pulls it in. - .strip = target.result.os.tag == .ios, + // iOS and visionOS: std.debug's stack-trace machinery references + // _dyld_get_image_header_containing_address, which those libdylds do + // not export. Strip so the panic path never pulls it in. + .strip = target.result.os.tag == .ios or target.result.os.tag == .visionos, .pic = true, // links into a PIE C++ host .link_libc = true, // Lua needs the C runtime }); @@ -703,27 +703,25 @@ pub fn build(b: *std.Build) void { else target; - const bake = b.addExecutable(.{ - .name = "tile57", - .root_module = b.createModule(.{ - .root_source_file = b.path("tools/main.zig"), - .target = bake_target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ - .{ .name = "engine", .module = engine_full }, - .{ .name = "style", .module = style_mod }, - .{ .name = "sprite", .module = sprite_mod }, - .{ .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 - .{ .name = "raster", .module = raster_mod }, // `raster info` - }, - }), + const bake_mod = b.createModule(.{ + .root_source_file = b.path("tools/main.zig"), + .target = bake_target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "engine", .module = engine_full }, + .{ .name = "style", .module = style_mod }, + .{ .name = "sprite", .module = sprite_mod }, + .{ .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 + .{ .name = "raster", .module = raster_mod }, // `raster info` + }, }); + const bake = b.addExecutable(.{ .name = "tile57", .root_module = bake_mod }); b.installArtifact(bake); const run_bake = b.addRunArtifact(bake); @@ -808,6 +806,10 @@ pub fn build(b: *std.Build) void { // module import does NOT pull another module's `test {}` blocks in. const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = mod })).step); + // The CLI's own tests. They had never run: nothing but the executable + // referenced tools/*.zig, and an executable does not collect tests — so a + // test written beside the code it checks was silently dead. + test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = bake_mod })).step); // The sprite module (SDF glyph atlas lives here): needs the C glue // (stb_truetype/nanosvg) + libc + render. const sprite_test = addPkgTest(b, test_step, "src/sprite/sprite.zig", target, optimize, &.{ diff --git a/include/tile57.h b/include/tile57.h index 67b02243..9c9b915b 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -244,6 +244,31 @@ tile57_status tile57_bake_tree(const char *in_dir, const char *out_dir, uint32_t tile57_bake_progress progress, void *progress_ctx, uint32_t *out_baked, tile57_error *err); +/* tile57_bake_tree for an exchange set STILL IN ITS .zip: find the cells, name + * every output, bake. Nothing is unpacked — each cell is inflated as its turn + * comes, so importing an 800 MB archive never costs the disk a second copy of + * the source. + * + * Outputs mirror each entry's path BELOW the directory the archive wraps + * everything in. NOAA's All_ENCs.zip puts every cell under `ENC_ROOT/`, and + * that name belongs to the archive rather than to the library being built: an + * `out_dir` of `.../ENC_ROOT` writes `.../ENC_ROOT/US5MD12M/US5MD12M.pmtiles`, + * NOT a second ENC_ROOT inside the first. The prefix is computed from what the + * cells actually share, so an archive holding two districts keeps them apart — + * two districts carrying the same boundary cell keep their own copies, and + * each cell's referenced text lands beside the right chart. A cell sharing its + * directory with no other gets a directory named for itself. + * + * Same contract as tile57_bake_tree otherwise: `workers` is a MEMORY bound, + * `progress` (NULL to skip) fires per chart and may CANCEL by returning false, + * *out_baked (NULL to ignore) counts what was written. An archive holding no + * .000 is TILE57_OK with *out_baked 0 — nothing to do is not a failure. A host + * that wants each finished chart NAMED owns the list itself and calls + * tile57_bake_zip_charts. */ +tile57_status tile57_bake_zip(const char *zip_path, const char *out_dir, uint32_t workers, + tile57_bake_progress progress, void *progress_ctx, + uint32_t *out_baked, tile57_error *err); + /* Names the chart that just finished, by its INDEX into the caller's in_paths. * Charts bake concurrently, so the count in tile57_bake_progress cannot say * which chart a step belongs to. Called from worker threads, out of order, so @@ -1442,6 +1467,29 @@ 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); +/* How wide YOU draw a tile: 256 (the default, the native convention) or 512 (the + * MapLibre style spec's world tile). + * + * A vector tile from tile57_compose_tile has its complex (symbolised) linestyles + * WALKED into plain geometry — each dash "on" run a solid line, each embedded + * symbol a point rotated to the local tangent at its own offset in the period, + * phased from the run's arc origin. S-101 lays those figures out in + * 256-px-per-tile space, so this is what the rhythm is restated in. It moves + * spacing only: never stroke width, never symbol size. The wrong value is + * invisible in the geometry and plain on the chart, as the right rhythm at the + * wrong spacing. + * + * A composed tile is drawn from a style, and a style cannot say where in a + * period a symbol sits, which is why the walk is not optional: ACHARE51 places + * four symbols at 5, 13.1, 21.2 and 29.3 mm of its 32.3 mm period, and a style + * can only ask for "one per period". The archives are untouched — they keep the + * stored run, which is display-independent, and the engine's own replay walks it + * at whatever scale it draws at. + * + * 0 is ignored. A raster compositor ignores this entirely; pictures carry no + * linestyles. */ +void tile57_compose_set_px_per_tile(tile57_compose *c, uint32_t px_per_tile); + /* 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 @@ -1582,6 +1630,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/bake_root.zig b/src/bake_root.zig index f6eae77b..1510b535 100644 --- a/src/bake_root.zig +++ b/src/bake_root.zig @@ -33,3 +33,12 @@ pub const zipsrc = root.zipsrc; // charts read straight out of a .zip pub const geometry = @import("geometry"); // integer geometry: boolean, plane, partition pub const portray = @import("portray"); + +// A test binary rooted on a consumer of this module links lua_shim.c through +// `portray` but analyses only what its tests reach. Reference the accessor +// files here so their tgp_* / tgc_* / tg_embedded_lua exports are emitted into +// every binary that carries the shim, as lib_root.zig does for the archive. +comptime { + _ = portray; + _ = catalogue; +} diff --git a/src/bundle.zig b/src/bundle.zig index f7186968..648fd0e9 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 { @@ -527,3 +540,10 @@ fn emitFacesZoom(sw: *engine.pmtiles.StreamWriter, sa: std.mem.Allocator, faces: try sw.add(z, kv.key_ptr.x, kv.key_ptr.y, enc); } } + +test { + // A test binary rooted here links lua_shim.c through engine's portray + // module but analyses only what its tests reach. Referencing engine scans + // bake_root.zig, whose comptime block emits the shim's C-ABI accessors. + _ = engine; +} diff --git a/src/capi.zig b/src/capi.zig index d3e615e9..8c5b6156 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -10,6 +10,8 @@ const std = @import("std"); const chart = @import("chart.zig"); const auxfiles = @import("engine").auxfiles; // via the named module: engine owns the file +const scene = @import("engine").scene; // tile surface + the complex-linestyle walk +const mlt_dec = @import("tiles").mlt; // decoding a verbatim composed tile const s57 = @import("s57"); const bundle = @import("bundle"); // portrayal-asset emitters + the partition debug bake const compose = @import("compose"); // the runtime tile compositor (tile57_compose_*) @@ -20,7 +22,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 @@ -386,6 +390,29 @@ export fn tile57_bake_tree( return OK; } +/// Bake a whole exchange set out of its .zip in one call — the archive twin of +/// tile57_bake_tree, naming every output itself. See tile57.h. +export fn tile57_bake_zip( + zip_path: ?[*: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 { + if (out_baked) |p| p.* = 0; + const zp = spanOpt(zip_path) orelse return failWith(err, .badarg, "zip_path must not be null"); + const out_d = spanOpt(out_dir) orelse return failWith(err, .badarg, "out_dir must not be null"); + // No label callback, for the same reason tile57_bake_tree has none: this + // ABI reports progress as a count, and naming a chart would put a string + // across the seam. A caller that wants names owns the list and calls + // tile57_bake_zip_charts instead. + const baked = chart.bakeZip(sharedIo(), zp, out_d, null, workers, progress, progress_ctx, null) catch |e| return failCtx(err, e, zp); + if (out_baked) |p| p.* = @intCast(baked); + return OK; +} + /// Bake the cells at `in_paths` to the archives at `out_paths`, in parallel. /// The CALLER owns the list, so `label` names each finished chart by its index /// into it and no string crosses the ABI. See tile57.h. @@ -1706,16 +1733,63 @@ 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"); + // Pictures carry no linestyles, so a raster compositor takes the plain path. + if (src.kind == .vector) return composeWalked(src, z, x, y, o, n, out_owned, err); 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| return exportOut(err, o, n, t); return OK; } -/// 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. +/// tile57_compose_tile's body for a vector compositor: compose to FEATURES, +/// step every complex linestyle into plain geometry, re-encode. +/// +/// Always, rather than on request. A composed tile is drawn from a STYLE, and a +/// style cannot say where in a linestyle period a symbol sits — so the +/// un-walked form draws every symbol of a style at one phase and loses the S-52 +/// rhythm. The only thing that can re-walk a stored run is the engine's own +/// replay, which reads tileContent directly and never comes through here. +/// +/// The compositor answers `.layers` where a tile seam-composes and `.bytes` +/// where one cell owns the whole tile and its stored blob passes through +/// verbatim. Both must be walked, or every tile away from a cell boundary +/// silently goes missing. +fn composeWalked( + src: *compose.ComposeSource, + z: u8, + x: u32, + y: u32, + o: *?[*]u8, + n: *usize, + out_owned: ?*bool, + err: ?*CError, +) c_int { + // The decoded features live only long enough to be walked: an arena for + // the compose and the walk's scratch, the encoded tile alone in gpa. + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const ta = arena.allocator(); + const px: f64 = @floatFromInt(src.draw_px_per_tile); + + const res = src.tileContent(ta, z, x, y) catch |e| return fail(err, e); + const layers = switch (res.content) { + .layers => |l| l, + .bytes => |b| mlt_dec.decode(ta, b) catch |e| return fail(err, e), + .none => return OK, + }; + const bytes = scene.walkTile(ta, gpa, layers, .mlt, z, px) catch |e| return fail(err, e); + if (out_owned) |p| p.* = true; + return exportOut(err, o, n, bytes); +} + +/// How wide the caller draws a tile: 256 for the native convention, 512 for the +/// MapLibre style spec's world tile. Sets what composed linestyle rhythms are +/// restated in. See tile57.h. +export fn tile57_compose_set_px_per_tile(handle: ?*compose.ComposeSource, px_per_tile: u32) void { + const src = handle orelse return; + if (px_per_tile != 0) src.draw_px_per_tile = px_per_tile; +} + export fn tile57_compose_png( handle: ?*compose.ComposeSource, lon: f64, @@ -1999,6 +2073,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/chart.zig b/src/chart.zig index 07c747e5..c5b6f611 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -228,6 +228,7 @@ const CellBackend = struct { portrayal: ?[]const ?[]const u8 = null, // per-feature default S-101 instruction stream portrayal_plain: ?[]const ?[]const u8 = null, // PlainBoundaries variant (areas) portrayal_simplified: ?[]const ?[]const u8 = null, // SimplifiedSymbols variant (points) + portrayal_lights: ?[]const ?[]const u8 = null, // FullLightLines variant (sectored lights) portray_arena: ?*std.heap.ArenaAllocator = null, coverage: []const []const []const s57.LonLat = &.{}, // M_COVR (in portray_arena) cscl: i32 = 0, // compilation scale (DSPM CSCL, 1:N) @@ -264,6 +265,7 @@ fn cellRef(cb: *CellBackend) scene.CellRef { .portrayal = cb.portrayal, .portrayal_plain = cb.portrayal_plain, .portrayal_simplified = cb.portrayal_simplified, + .portrayal_lights = cb.portrayal_lights, .geo = cb.geo, .geo_world = cb.geo_world, .feat_bbox = cb.feat_bbox, @@ -286,6 +288,7 @@ const LazyCell = struct { portrayal: ?[]const ?[]const u8 = null, portrayal_plain: ?[]const ?[]const u8 = null, portrayal_simplified: ?[]const ?[]const u8 = null, + portrayal_lights: ?[]const ?[]const u8 = null, arena: ?*std.heap.ArenaAllocator = null, tick: u64 = 0, // LRU: last tile that used this cell // M_COVR(CATCOV=1) coverage polygons, assembled once from `cell` for best-band @@ -519,6 +522,7 @@ fn lazyEnsureLoaded(ls: *LazySource, lc: *LazyCell) void { lc.portrayal = cp.base; lc.portrayal_plain = cp.plain; lc.portrayal_simplified = cp.simplified; + lc.portrayal_lights = cp.lights; lc.arena = p; } else |_| { p.deinit(); @@ -547,6 +551,7 @@ fn lazyUnload(lc: *LazyCell) void { lc.scamins = &.{}; // ditto (cell.arena) lc.portrayal_plain = null; lc.portrayal_simplified = null; + lc.portrayal_lights = null; if (lc.arena) |p| { p.deinit(); gpa.destroy(p); @@ -687,6 +692,7 @@ fn buildCellBackend(base: []const u8, updates: []const []const u8, dir: []const cb.portrayal = cp.base; cb.portrayal_plain = cp.plain; cb.portrayal_simplified = cp.simplified; + cb.portrayal_lights = cp.lights; } else |_| {} // Assemble geometry + its projection + per-feature bboxes ONCE (the baker's // per-cell caches) so live per-view rendering reuses them across the view's tiles @@ -1194,11 +1200,177 @@ pub fn bakeTree(io: std.Io, in_dir: []const u8, out_dir: []const u8, rules_dir: return bakeChartsToFiles(io, in_paths.items, out_paths.items, rules_dir, workers, progress, progress_ctx, label, true); } +/// Bake a whole exchange set STILL IN ITS ARCHIVE: find the cells, name every +/// output, bake. The zip twin of `bakeTree`, and for the same reason — working +/// out where each chart goes is this engine's job, not a thing every host +/// reinvents. It had been reinvented three times before this existed (the CLI, +/// the macOS shell, an Android path), and two of the three carried the +/// archive's own wrapper directory into the output. +/// +/// THE NAMING RULE. Mirror each entry's path BELOW the directory the archive +/// wraps everything in. NOAA's All_ENCs.zip puts every cell under `ENC_ROOT/`; +/// that name belongs to the archive, not to the library being built, so an +/// `out_dir` of `.../ENC_ROOT` must not produce `.../ENC_ROOT/ENC_ROOT/`. The +/// prefix is COMPUTED — the longest whole-component prefix the cells share — +/// rather than assumed to be one level, so an archive holding two districts +/// keeps them apart. That is what the mirroring is for: two districts carrying +/// the same boundary cell keep their own copies instead of overwriting each +/// other, and each cell's referenced text lands beside the right chart. +/// +/// A cell sharing its directory with no other (a single-cell archive) gets a +/// directory named for itself — the layout the aux manifest needs, and what a +/// bake from a loose `.000` already writes. +pub fn bakeZip(io: std.Io, zip_path: []const u8, out_dir: []const u8, rules_dir: ?[]const u8, workers: usize, progress: BakeProgress, progress_ctx: ?*anyopaque, label: BakeLabel) !usize { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + + var arc = try zipsrc.Archive.open(a, io, zip_path); + defer arc.deinit(); + + var names = std.ArrayList([]const u8).empty; + for (arc.entries) |e| { + if (std.mem.endsWith(u8, e.name, ".000")) names.append(a, e.name) catch continue; + } + if (names.items.len == 0) return 0; + + // Say the count the moment it is known, before the pass below decides what + // is already done. That pass stats one file per chart, which on a phone or + // tablet is slow enough to look like a hang — and until this fired the host + // had no denominator to draw anything but a spinner with. + if (progress) |p| { + if (!p(progress_ctx, 0, @intCast(names.items.len))) return 0; + } + + const root = archiveRootPrefix(names.items); + + // The cells this run will actually bake, and where each goes. `kept` is a + // subset of `names` once the incremental skip below has had its say, and + // the two lists must stay index-aligned: `label` names a chart by its index + // into what was HANDED to the bake, not into the archive. + var kept = std.ArrayList([]const u8).empty; + var out_paths = std.ArrayList([]const u8).empty; + for (names.items) |n| { + const base = std.fs.path.basename(n); + const stem = base[0 .. std.mem.lastIndexOfScalar(u8, base, '.') orelse base.len]; + const dir = zipDirOf(n); + var rel = dir[@min(root.len, dir.len)..]; + while (rel.len != 0 and rel[0] == '/') rel = rel[1..]; + const chart_dir = if (rel.len == 0) + std.fs.path.join(a, &.{ out_dir, stem }) catch continue + else + std.fs.path.join(a, &.{ out_dir, rel }) catch continue; + const name = std.fmt.allocPrint(a, "{s}.pmtiles", .{stem}) catch continue; + const out_path = std.fs.path.join(a, &.{ chart_dir, name }) catch continue; + // INCREMENTAL, as bakeTree is: an archive already newer than the zip it + // came from is done. A national exchange set is hours of work on a + // tablet and WILL be interrupted — the app is backgrounded, the battery + // goes, the mariner stops it to sail — and without this every resume + // starts from the first cell again and never finishes. + if (fileModNs(io, out_path)) |out_ns| { + if (fileModNs(io, zip_path)) |zip_ns| { + if (out_ns >= zip_ns) continue; + } + } + std.Io.Dir.cwd().createDirPath(io, chart_dir) catch {}; + kept.append(a, n) catch continue; + out_paths.append(a, out_path) catch continue; + } + if (out_paths.items.len != kept.items.len) return error.OutOfMemory; + if (kept.items.len == 0) return 0; // everything already prepared + + // Correct the count to the work actually left. A resumed import skips what + // it already baked, so the number from the listing was the archive's size, + // not this run's. + if (progress) |p| { + if (!p(progress_ctx, 0, @intCast(kept.items.len))) return 0; + } + + return bakeZipChartsToFiles(io, &arc, kept.items, out_paths.items, rules_dir, workers, progress, progress_ctx, label, true); +} + +/// Zip entry names always use '/', whatever the platform, so these split on +/// that rather than on the host separator. +fn zipDirOf(path: []const u8) []const u8 { + const at = std.mem.lastIndexOfScalar(u8, path, '/') orelse return ""; + return path[0..at]; +} + +/// The directory every one of `paths` sits under, as whole path components. +pub fn archiveRootPrefix(paths: []const []const u8) []const u8 { + if (paths.len == 0) return ""; + var pre: []const u8 = zipDirOf(paths[0]); + for (paths[1..]) |p| { + pre = commonComponents(pre, zipDirOf(p)); + if (pre.len == 0) break; + } + return pre; +} + +/// The longest common prefix of `a` and `b` ending on a component boundary, so +/// `ENC_ROOT/US5` and `ENC_ROOT/US4` share `ENC_ROOT`, not `ENC_ROOT/US`. +fn commonComponents(a: []const u8, b: []const u8) []const u8 { + var i: usize = 0; + var boundary: usize = 0; + while (i < a.len and i < b.len and a[i] == b[i]) : (i += 1) { + if (a[i] == '/') boundary = i; + } + if (i == a.len and (i == b.len or b[i] == '/')) return a; + if (i == b.len and a[i] == '/') return b; + return a[0..boundary]; +} + +test "an archive's own root directory is not part of the output path" { + // NOAA's All_ENCs.zip: every cell under one ENC_ROOT/. Carrying that + // through made `-o ~/Charts/ENC_ROOT` write ~/Charts/ENC_ROOT/ENC_ROOT/ — a + // second library beside the real one, which a host that opens the parent + // then composes together with it, one vintage silently winning per tile. + const noaa = [_][]const u8{ "ENC_ROOT/US5MD12M/US5MD12M.000", "ENC_ROOT/US4MD11M/US4MD11M.000" }; + try std.testing.expectEqualStrings("ENC_ROOT", archiveRootPrefix(&noaa)); + + // What the mirroring is FOR survives it: two districts carrying the same + // boundary cell keep their own copies. + const districts = [_][]const u8{ "ENC_ROOT/D1/US5MD12M/US5MD12M.000", "ENC_ROOT/D2/US5MD12M/US5MD12M.000" }; + try std.testing.expectEqualStrings("ENC_ROOT", archiveRootPrefix(&districts)); + + // A near-miss must not split mid-component. + const near = [_][]const u8{ "ENC_ROOT/US5/a.000", "ENC_ROOT/US4/b.000" }; + try std.testing.expectEqualStrings("ENC_ROOT", archiveRootPrefix(&near)); + + // A flat archive shares nothing; one cell under one directory shares all of + // it, and both fall back to a directory named for the chart. + const flat = [_][]const u8{ "a.000", "b.000" }; + try std.testing.expectEqualStrings("", archiveRootPrefix(&flat)); + const one = [_][]const u8{"ENC_ROOT/US5MD12M/US5MD12M.000"}; + try std.testing.expectEqualStrings("ENC_ROOT/US5MD12M", archiveRootPrefix(&one)); +} + +test "the GPU atlas declares the scale its cells were baked at" { + // A sprite quad is sized cell px x scale/ppm, so the ppm buildGpuAtlases + // declares must be the one spriteMlnOpts rasterized the cells at. + for ([_]f64{ 1.0, 2.0, 3.0 }) |ratio| { + try std.testing.expectApproxEqRel( + sprite.mlnPpm(ratio), + sprite.px_per_unit * 100.0 * ratio * sprite.mln_drawn_scale, + 1e-12, + ); + } + // The drawn scale is the engine's symbol scale, so a cell measured through + // that ppm is the size drawSymbol tessellates. + try std.testing.expectApproxEqRel( + sprite.mln_drawn_scale * sprite.px_per_unit, + render.sndfrm.SYMBOL_SCALE, + 1e-12, + ); +} + /// 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; + // statFile, not open+fstat+close: this runs once per chart before any bake + // starts, and on Android's FUSE-backed storage those three syscalls are + // three round trips each. Over a 7,217-cell exchange set that was minutes + // of the import spent deciding what was already done. + const st = std.Io.Dir.cwd().statFile(io, path, .{}) catch return null; return st.mtime.nanoseconds; } @@ -1292,8 +1464,11 @@ fn buildGpuAtlases(a: std.mem.Allocator, ratio: f64) !struct { sprites: render.g // Layout only: the scene consumer reads cells + dims, never the pixels — // the full bake here (composite + zlib) was ~2/3 of the render path's // cycles in a field profile whenever the shared atlases (re)built. + // The ppm must be the one the cells were rasterized at: render/gpu.zig + // sizes a sprite quad as cell px x scale/ppm, so a mismatch draws every + // symbol, sounding and linestyle brick at the ratio between the two. var atlas = try sprite.spriteMlnOpts(a, sym_srcs, fill_srcs, css_data, &[_][]const u8{}, ratio, false); - var sprites = render.gpu.SpriteAtlas{ .width = atlas.width, .height = atlas.height, .ppm = @floatCast(sprite.px_per_unit * 100.0 * ratio) }; + var sprites = render.gpu.SpriteAtlas{ .width = atlas.width, .height = atlas.height, .ppm = @floatCast(sprite.mlnPpm(ratio)) }; var cit = atlas.cells.iterator(); while (cit.next()) |e| { const r = e.value_ptr.*; @@ -2664,6 +2839,7 @@ pub const Chart = struct { .portrayal = cb.portrayal, .portrayal_plain = cb.portrayal_plain, .portrayal_simplified = cb.portrayal_simplified, + .portrayal_lights = cb.portrayal_lights, }}; return scene.generateView(&ps, a, gpa, &one, lon, lat, zoom, self.pick_attrs) catch error.TileGen; }, @@ -2925,6 +3101,7 @@ pub const Chart = struct { .portrayal = cb2.portrayal, .portrayal_plain = cb2.portrayal_plain, .portrayal_simplified = cb2.portrayal_simplified, + .portrayal_lights = cb2.portrayal_lights, .geo = cb2.geo, .geo_world = cb2.geo_world, .feat_bbox = cb2.feat_bbox, @@ -3086,6 +3263,7 @@ pub const Chart = struct { .portrayal = cb2.portrayal, .portrayal_plain = cb2.portrayal_plain, .portrayal_simplified = cb2.portrayal_simplified, + .portrayal_lights = cb2.portrayal_lights, }}; scene.appendTile(surf, a, &one, z, qt.tx, qt.ty, self.pick_attrs) catch continue; }, @@ -3138,6 +3316,7 @@ pub const Chart = struct { .portrayal = cb.portrayal, .portrayal_plain = cb.portrayal_plain, .portrayal_simplified = cb.portrayal_simplified, + .portrayal_lights = cb.portrayal_lights, }}; return scene.generateView(&as, a, gpa, &one, lon, lat, zoom, self.pick_attrs) catch error.TileGen; }, @@ -3712,6 +3891,7 @@ const BakeWork = struct { var portrayal: ?[]const ?[]const u8 = null; var portrayal_plain: ?[]const ?[]const u8 = null; var portrayal_simplified: ?[]const ?[]const u8 = null; + var portrayal_lights: ?[]const ?[]const u8 = null; var geo: ?scene.GeoParts = null; var geo_world: ?scene.GeoWorld = null; var feat_bbox: ?[]const ?[4]f64 = null; @@ -3722,6 +3902,7 @@ const BakeWork = struct { portrayal = cp.base; portrayal_plain = cp.plain; portrayal_simplified = cp.simplified; + portrayal_lights = cp.lights; } else |_| {} // Build the geometry cache for EVERY cell, unconditionally. // `build_geo` (cacheGeoForBand) gated it to the finer bands, but coarse cells are @@ -3760,7 +3941,7 @@ const BakeWork = struct { // Sector-figure reach (exact, from the portrayal streams): buildTileMap // addresses the neighbouring tiles the cell's light legs/arcs cross. const lr = scene.collectLightReach(&cell, portrayal); - c.outs[i] = .{ .cell = cell, .portrayal = portrayal, .portrayal_plain = portrayal_plain, .portrayal_simplified = portrayal_simplified, .geo = geo, .geo_world = geo_world, .feat_bbox = feat_bbox, .bounds = b, .cscl = cscl, .coverage = coverage, .scamins = scamins, .light_bbox = lr.bbox, .light_range_m = lr.range_m }; + c.outs[i] = .{ .cell = cell, .portrayal = portrayal, .portrayal_plain = portrayal_plain, .portrayal_simplified = portrayal_simplified, .portrayal_lights = portrayal_lights, .geo = geo, .geo_world = geo_world, .feat_bbox = feat_bbox, .bounds = b, .cscl = cscl, .coverage = coverage, .scamins = scamins, .light_bbox = lr.bbox, .light_range_m = lr.range_m }; c.arenas[i] = pa; } }; diff --git a/src/compose/compose.zig b/src/compose/compose.zig index ecd4f2c9..1da74760 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -291,6 +291,10 @@ fn composeLayers(ra: std.mem.Allocator, part: *const geometry.partition.Partitio const compose = clip; var buckets: [N_COMPOSE_LAYERS]std.ArrayList(mvt.Feature) = undefined; for (&buckets) |*b| b.* = std.ArrayList(mvt.Feature).empty; + // tile57/3 splits linestyle-decorated lines into per-style + // `lines-ls-