From 252abe201641286904a3be12f33fc618f358139e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 30 Jul 2026 18:55:22 -0400 Subject: [PATCH 1/4] aux: carry the text and pictures a cell points at A feature can name a text file or a picture instead of carrying it: TXTDSC and NTXTDS name text, PICREP names a picture, and S-101 puts the same in a fileReference. The bake dropped them, so a pick report could show the name of a caution note and nothing else. The bake now writes each chart into its own directory, the shape an exchange set uses, with the files that chart references beside it and an index.json: out/US5GU3TC/US5GU3TC.pmtiles out/US5GU3TC/US299TCA.TXT out/US5GU3TC/index.json That drops the tiles/ level, which held nothing once a chart owns a directory. The partition stores cell names, not paths, so it is unaffected. --no-aux skips the files. tile57_aux_open, tile57_aux_get and tile57_aux_close read them. A client goes through the ABI rather than the disk, so the layout stays ours to change. The lookup ignores case and any directory part, because S-57 upper-cases the reference and exchange sets differ across filesystems. A picture is stored as it arrives. The Go predecessor transcoded TIFF to PNG for the browser; every native client decodes TIFF, and the manifest states the type. --- README.md | 9 ++- include/tile57.h | 31 ++++++++ src/auxfiles.zig | 198 ++++++++++++++++++++++++++++++++++++++++++++++ src/bake_root.zig | 1 + src/capi.zig | 53 +++++++++++++ src/root.zig | 2 + tools/bake.zig | 74 +++++++++++++++-- 7 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 src/auxfiles.zig diff --git a/README.md b/README.md index 4797e3a6..185dd2f9 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,9 @@ Tiles are made one way — bake each cell to its own PMTiles, then compose on demand — the structure `tile57 bake ENC_ROOT -o out/` writes: ```c -// out/ holds tiles/.pmtiles (one per cell) + partition.tpart -const char *paths[] = { "out/tiles/US5MD1MC.pmtiles" }; +// out/ holds /.pmtiles (one directory per cell, with the files it +// references) + partition.tpart +const char *paths[] = { "out/US5MD1MC/US5MD1MC.pmtiles" }; tile57_compose_source *src = tile57_compose_open(paths, 1, "out/partition.tpart"); uint8_t *tile; size_t n; @@ -129,8 +130,8 @@ Any `.000` — native S-101 or S-57 — works everywhere; the format is auto-det ```sh zig build # builds zig-out/bin/tile57 -tile57 bake CELL.000 -o out/ # one cell -> out/tiles/.pmtiles + partition.tpart -tile57 bake ENC_ROOT -o out/ # whole catalogue -> per-cell tiles/ + partition.tpart +tile57 bake CELL.000 -o out/ # one cell -> out//.pmtiles + partition.tpart +tile57 bake ENC_ROOT -o out/ # whole catalogue -> one directory per cell + partition.tpart tile57 assets -o assets/ # colortables + linestyles + sprite + patterns tile57 png ENC_ROOT --view -76.48,38.974,15 --size 1600x1200 -o chart.png tile57 pdf ENC_ROOT --view -76.48,38.974,15 --size 1600x1200 -o chart.pdf diff --git a/include/tile57.h b/include/tile57.h index fc2cbd16..612c66d4 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -1187,6 +1187,37 @@ tile57_status tile57_compose_labels(tile57_compose *c, double lon, double lat, d const tile57_mariner *m, const tile57_surface_cb *surface, tile57_error *err); +/* ---- auxiliary files ------------------------------------------------------- + * + * A feature can point at a text file or a picture instead of carrying it: + * TXTDSC and NTXTDS name a text file, PICREP names a picture, and S-101 puts the + * same thing in a `fileReference`. `tile57 bake` writes those files beside the + * chart they belong to, in the shape an exchange set uses: + * + * out/US5GU3TC/US5GU3TC.pmtiles + * out/US5GU3TC/US299TCA.TXT + * out/US5GU3TC/index.json + * + * Read them through these calls rather than off the disk, so the layout can + * change without breaking a client. */ + +typedef struct tile57_aux tile57_aux; + +/* Open the auxiliary files of a chart directory. *out is NULL with TILE57_OK + * when the chart references nothing. Close with tile57_aux_close. */ +tile57_status tile57_aux_open(const char *dir, tile57_aux **out, tile57_error *err); + +/* The bytes and the MIME type of one referenced file, by the name the feature + * carries. The match ignores case and any directory part. *bytes is NULL and + * *len is 0 when the chart has no such file. The bytes belong to the handle and + * stay valid until tile57_aux_close; *mime is a static string. */ +tile57_status tile57_aux_get(tile57_aux *a, const char *name, + const uint8_t **bytes, size_t *len, + const char **mime, tile57_error *err); + +/* Release the handle and every file read through it. */ +void tile57_aux_close(tile57_aux *a); + /* The composed cursor pick (S-52 §10.8, across chart boundaries): tile57_chart_query across * the whole composed set. */ tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, double zoom, diff --git a/src/auxfiles.zig b/src/auxfiles.zig new file mode 100644 index 00000000..12a8c3bd --- /dev/null +++ b/src/auxfiles.zig @@ -0,0 +1,198 @@ +//! Auxiliary files: the external resources an ENC feature points at by name +//! instead of carrying inline. TXTDSC and NTXTDS name a text file; PICREP names +//! a picture. They ship in the exchange set beside the .000 cells, and a baked +//! archive carries only the NAME, so a pick report cannot read them. +//! +//! The bake keeps the ENC_ROOT shape: one directory per chart, holding the +//! archive and the files that chart references. +//! +//! tiles/US5MD1MC/US5MD1MC.pmtiles +//! tiles/US5MD1MC/US348MDE.TXT +//! tiles/US5MD1MC/index.json +//! +//! A chart directory is therefore self-contained: copy it and the report still +//! reads its caution note. The files are loose, so they work offline from any +//! static host, an SD card or a file:// URL, with no archive to unpack. +//! +//! A reference is keyed by the UPPER-CASED BASENAME. S-57 stores the value +//! upper-cased, and exchange sets differ in case across platforms, so a +//! case-sensitive lookup misses on the wrong filesystem. + +const std = @import("std"); + +pub const index_name = "index.json"; +pub const version = 1; + +/// The lookup key for a reference: the bare basename, upper-cased. +pub fn key(alloc: std.mem.Allocator, name: []const u8) ![]u8 { + const base = std.fs.path.basename(name); + const out = try alloc.alloc(u8, base.len); + for (base, 0..) |c, i| out[i] = std.ascii.toUpper(c); + return out; +} + +/// The MIME type a client needs to render the stored file. +pub fn mime(name: []const u8) []const u8 { + const ext = std.fs.path.extension(name); + var buf: [8]u8 = undefined; + if (ext.len == 0 or ext.len > buf.len) return "application/octet-stream"; + const lower = std.ascii.lowerString(buf[0..ext.len], ext); + if (std.mem.eql(u8, lower, ".txt")) return "text/plain"; + if (std.mem.eql(u8, lower, ".png")) return "image/png"; + if (std.mem.eql(u8, lower, ".jpg") or std.mem.eql(u8, lower, ".jpeg")) return "image/jpeg"; + if (std.mem.eql(u8, lower, ".tif") or std.mem.eql(u8, lower, ".tiff")) return "image/tiff"; + return "application/octet-stream"; +} + +/// True for a file that is aux CONTENT. The catalogue and the readmes are +/// exchange-set plumbing, not feature data. +pub fn isContent(name: []const u8) bool { + const base = std.fs.path.basename(name); + var upper: [64]u8 = undefined; + if (base.len <= upper.len) { + const u = std.ascii.upperString(upper[0..base.len], base); + if (std.mem.startsWith(u8, u, "README")) return false; + if (std.mem.startsWith(u8, u, "CATALOG")) return false; + } + const ext = std.fs.path.extension(base); + var buf: [8]u8 = undefined; + if (ext.len == 0 or ext.len > buf.len) return false; + const lower = std.ascii.lowerString(buf[0..ext.len], ext); + inline for (.{ ".txt", ".tif", ".tiff", ".jpg", ".jpeg", ".png" }) |e| { + if (std.mem.eql(u8, lower, e)) return true; + } + return false; +} + +/// One file to write. +pub const File = struct { + owner: []const u8 = "", // the chart that references it (its ENC_ROOT directory) + name: []const u8, // the name a feature references it by + bytes: []const u8, +}; + +/// Write `files` beside the chart in `dir`, with the manifest. Returns the +/// number written; an empty list writes nothing. +/// +/// A picture is stored as it arrives. The Go predecessor transcoded TIFF to PNG +/// for the browser, which cannot decode TIFF; every native client can, so the +/// engine keeps the original bytes and states the type in the manifest. +pub fn writeDir(io: std.Io, alloc: std.mem.Allocator, dir: []const u8, files: []const File) !usize { + if (files.len == 0) return 0; + try std.Io.Dir.cwd().createDirPath(io, dir); + + var manifest = std.ArrayList(u8).empty; + defer manifest.deinit(alloc); + try manifest.print(alloc, "{{\n \"version\": {d},\n \"files\": {{\n", .{version}); + + var written: usize = 0; + for (files) |f| { + const k = try key(alloc, f.name); + defer alloc.free(k); + const stored = std.fs.path.basename(f.name); + const path = try std.fs.path.join(alloc, &.{ dir, stored }); + defer alloc.free(path); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = f.bytes }); + if (written > 0) try manifest.appendSlice(alloc, ",\n"); + try manifest.print(alloc, " \"{s}\": {{ \"stored\": \"{s}\", \"type\": \"{s}\" }}", .{ k, stored, mime(stored) }); + written += 1; + } + try manifest.appendSlice(alloc, "\n }\n}\n"); + + const index_path = try std.fs.path.join(alloc, &.{ dir, index_name }); + defer alloc.free(index_path); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = index_path, .data = manifest.items }); + return written; +} + +/// A read handle over an aux directory: the manifest in memory, the files read +/// on demand and cached. +pub const Reader = struct { + alloc: std.mem.Allocator, + dir: []u8, + entries: std.StringHashMapUnmanaged(Entry) = .empty, + cache: std.StringHashMapUnmanaged([]u8) = .empty, + + pub const Entry = struct { stored: []u8, mime: []u8 }; + + /// Open a chart directory and read its manifest. Returns null when there is + /// none, which is what a chart with no referenced files leaves behind. + pub fn open(io: std.Io, alloc: std.mem.Allocator, dir: []const u8) !?Reader { + const index_path = try std.fs.path.join(alloc, &.{ dir, index_name }); + defer alloc.free(index_path); + const bytes = std.Io.Dir.cwd().readFileAlloc(io, index_path, alloc, .unlimited) catch return null; + defer alloc.free(bytes); + + var self = Reader{ .alloc = alloc, .dir = try alloc.dupe(u8, dir) }; + errdefer self.deinit(); + + const parsed = std.json.parseFromSlice(std.json.Value, alloc, bytes, .{}) catch return null; + defer parsed.deinit(); + const files = parsed.value.object.get("files") orelse return self; + var it = files.object.iterator(); + while (it.next()) |kv| { + const stored = kv.value_ptr.object.get("stored") orelse continue; + const typ = kv.value_ptr.object.get("type") orelse continue; + try self.entries.put(alloc, try alloc.dupe(u8, kv.key_ptr.*), .{ + .stored = try alloc.dupe(u8, stored.string), + .mime = try alloc.dupe(u8, typ.string), + }); + } + return self; + } + + /// The bytes and the type for a reference, or null when the directory has no + /// such file. The bytes stay valid until deinit. + pub fn get(self: *Reader, io: std.Io, name: []const u8) !?struct { bytes: []const u8, mime: []const u8 } { + const k = try key(self.alloc, name); + defer self.alloc.free(k); + const entry = self.entries.get(k) orelse return null; + if (self.cache.get(k)) |bytes| return .{ .bytes = bytes, .mime = entry.mime }; + + const path = try std.fs.path.join(self.alloc, &.{ self.dir, entry.stored }); + defer self.alloc.free(path); + const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, self.alloc, .unlimited) catch return null; + try self.cache.put(self.alloc, try self.alloc.dupe(u8, k), bytes); + return .{ .bytes = bytes, .mime = entry.mime }; + } + + pub fn deinit(self: *Reader) void { + var it = self.entries.iterator(); + while (it.next()) |kv| { + self.alloc.free(kv.key_ptr.*); + self.alloc.free(kv.value_ptr.stored); + self.alloc.free(kv.value_ptr.mime); + } + self.entries.deinit(self.alloc); + var ci = self.cache.iterator(); + while (ci.next()) |kv| { + self.alloc.free(kv.key_ptr.*); + self.alloc.free(kv.value_ptr.*); + } + self.cache.deinit(self.alloc); + self.alloc.free(self.dir); + } +}; + +test "key upper-cases the basename" { + const a = std.testing.allocator; + const k = try key(a, "ENC_ROOT/US5MD1MC/us348mde.txt"); + defer a.free(k); + try std.testing.expectEqualStrings("US348MDE.TXT", k); +} + +test "isContent takes text and pictures, not the catalogue" { + try std.testing.expect(isContent("US348MDE.TXT")); + try std.testing.expect(isContent("pic.TIF")); + try std.testing.expect(isContent("a/b/photo.jpeg")); + try std.testing.expect(!isContent("CATALOG.031")); + try std.testing.expect(!isContent("README.TXT")); + try std.testing.expect(!isContent("US5MD1MC.000")); +} + +test "mime states what a client must decode" { + try std.testing.expectEqualStrings("text/plain", mime("A.TXT")); + try std.testing.expectEqualStrings("image/tiff", mime("A.tif")); + try std.testing.expectEqualStrings("image/jpeg", mime("A.JPG")); + try std.testing.expectEqualStrings("application/octet-stream", mime("A.bin")); +} diff --git a/src/bake_root.zig b/src/bake_root.zig index fe297654..01b19f7f 100644 --- a/src/bake_root.zig +++ b/src/bake_root.zig @@ -28,6 +28,7 @@ pub const s101_instructions = root.s101_instructions; pub const s101_adapter = root.s101_adapter; pub const catalogue = root.catalogue; pub const bake_enc = root.bake_enc; +pub const auxfiles = root.auxfiles; pub const geometry = @import("geometry"); // integer geometry: boolean, plane, partition pub const portray = @import("portray"); diff --git a/src/capi.zig b/src/capi.zig index 10c61500..71fed25a 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -9,6 +9,7 @@ const std = @import("std"); const chart = @import("chart.zig"); +const auxfiles = @import("engine").auxfiles; // via the named module: engine owns the file 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_*) @@ -875,6 +876,58 @@ export fn tile57_chart_close(handle: ?*Chart) callconv(.c) void { if (handle) |s| s.deinit(); } +// ---- auxiliary files (the text and pictures a cell points at) -------------- + +/// Open the auxiliary files of a chart directory. The handle owns the manifest +/// and every file it has read. See tile57.h. +export fn tile57_aux_open(dir: ?[*:0]const u8, out: ?*?*auxfiles.Reader, err: ?*CError) callconv(.c) c_int { + const o = out orelse return failWith(err, .badarg, "out must not be null"); + o.* = null; + const d = spanOpt(dir) orelse return failWith(err, .badarg, "dir must not be null"); + const opened = auxfiles.Reader.open(sharedIo(), gpa, d) catch |e| return failCtx(err, e, d); + const r = opened orelse return OK; // no manifest: the chart references nothing + const handle = gpa.create(auxfiles.Reader) catch return failWith(err, .nomem, "out of memory"); + handle.* = r; + o.* = handle; + return OK; +} + +/// The bytes and the MIME type for a referenced file, by the name the feature +/// carries (TXTDSC, PICREP, or an S-101 fileReference). The bytes stay valid +/// until tile57_aux_close. NULL/0 when the chart has no such file. See tile57.h. +export fn tile57_aux_get(handle: ?*auxfiles.Reader, name: ?[*:0]const u8, bytes: ?*?[*]const u8, len: ?*usize, mime: ?*?[*:0]const u8, err: ?*CError) callconv(.c) c_int { + const h = handle orelse return failWith(err, .badarg, "aux must not be null"); + const b = bytes orelse return failWith(err, .badarg, "bytes must not be null"); + const n = len orelse return failWith(err, .badarg, "len must not be null"); + b.* = null; + n.* = 0; + if (mime) |m| m.* = null; + const nm = spanOpt(name) orelse return failWith(err, .badarg, "name must not be null"); + const found = (h.get(sharedIo(), nm) catch |e| return fail(err, e)) orelse return OK; + b.* = found.bytes.ptr; + n.* = found.bytes.len; + if (mime) |m| m.* = mimeZ(found.mime); + return OK; +} + +/// A static NUL-terminated string for a MIME type the manifest holds, so the +/// caller gets a C string without owning it. +fn mimeZ(m: []const u8) [*:0]const u8 { + if (std.mem.eql(u8, m, "text/plain")) return "text/plain"; + if (std.mem.eql(u8, m, "image/png")) return "image/png"; + if (std.mem.eql(u8, m, "image/jpeg")) return "image/jpeg"; + if (std.mem.eql(u8, m, "image/tiff")) return "image/tiff"; + return "application/octet-stream"; +} + +/// Release the auxiliary files of a chart, and every file read through it. +export fn tile57_aux_close(handle: ?*auxfiles.Reader) callconv(.c) void { + if (handle) |h| { + h.deinit(); + gpa.destroy(h); + } +} + // =========================================================================== // 5. Compose — the runtime compositor over open charts (see tile57.h) // =========================================================================== diff --git a/src/root.zig b/src/root.zig index 26e2820f..ad049904 100644 --- a/src/root.zig +++ b/src/root.zig @@ -23,6 +23,7 @@ pub const catalogue = s101.catalogue; pub const bake_enc = @import("scene").bake_enc; // banded multi-cell ENC_ROOT -> PMTiles pub const style = @import("style"); // colortables, line styles, and style.json generation pub const mariner = @import("style").mariner; // mariner-driven MapLibre style patching +pub const auxfiles = @import("auxfiles.zig"); // the text and pictures a cell points at // capi (the C ABI) lives in lib_root.zig so the test/bake exes stay pure Zig. test { @@ -39,5 +40,6 @@ test { _ = bake_enc; _ = style; _ = mariner; + _ = auxfiles; _ = @import("mvt_parity_test.zig"); } diff --git a/tools/bake.zig b/tools/bake.zig index 1b8de4a4..0d8b030f 100644 --- a/tools/bake.zig +++ b/tools/bake.zig @@ -1,6 +1,6 @@ //! `bake -o [--rules DIR] [-j N]` — produce a //! LIVE-composite structure on disk. Bake each chart to its OWN native-scale PMTiles under -//! `/tiles/` (with its M_COVR coverage embedded in the metadata), then open a resident +//! `//` (with its M_COVR coverage embedded in the metadata), then open a resident //! compositor over them and write the ownership partition to `/partition.tpart`. There is //! NO merged archive: a runtime compositor (`ComposeSource` / the `compose-tile` command / the C //! ABI `tile57_compose_*`) serves any tile ON DEMAND from this structure, so the per-chart bakes stay @@ -11,6 +11,7 @@ const std = @import("std"); const chart = @import("chart"); // per-chart bake (bakeChartBytes) + freeBytes const compose = @import("compose"); // openComposeSourceFiles + serializePartition (the resident compositor) const common = @import("common.zig"); +const auxfiles = @import("engine").auxfiles; const Flags = common.Flags; const usageErr = common.usageErr; const resolveRulesDir = common.resolveRulesDir; @@ -32,6 +33,10 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // rather than one thread per core. Tile generation within a cell is serial, so N // workers stay N threads. var workers: usize = defaultWorkers(); + // The text and pictures the cells point at travel with the chart by default: + // a pick report that cannot read its caution note is worth less than the + // few kilobytes. + var want_aux = true; var f = Flags{ .args = args }; while (f.next()) |arg| { @@ -43,6 +48,8 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const v = f.val(arg) orelse return; workers = std.fmt.parseInt(usize, v, 10) catch return usageErr("-j/--workers expects a positive integer"); if (workers == 0) return usageErr("-j/--workers must be >= 1"); + } else if (std.mem.eql(u8, arg, "--no-aux")) { + want_aux = false; } else if (std.mem.startsWith(u8, arg, "-")) { return usageErr("unknown flag"); } else if (base == null) { @@ -57,16 +64,34 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { // The per-chart archive paths that back the compositor. var archive_paths = std.ArrayList([]const u8).empty; + var aux_written: usize = 0; { // Bake each chart (dedup by stem — a boundary chart shared by two districts bakes once) - // to its own /tiles/.pmtiles. - const tiles_dir = try std.fs.path.join(a, &.{ out_dir, "tiles" }); - try std.Io.Dir.cwd().createDirPath(io, tiles_dir); + // to its own //.pmtiles, the shape an exchange set uses. + try std.Io.Dir.cwd().createDirPath(io, out_dir); var cell_paths = std.ArrayList([]const u8).empty; + var aux_files = std.ArrayList(auxfiles.File).empty; if (std.mem.endsWith(u8, base_path, ".000")) { try cell_paths.append(a, base_path); + // The cell's own directory holds the files it references. + if (want_aux) { + const stem = std.fs.path.stem(std.fs.path.basename(base_path)); + if (std.fs.path.dirname(base_path)) |cell_dir| { + var dir = std.Io.Dir.cwd().openDir(io, cell_dir, .{ .iterate = true }) catch null; + if (dir) |*d| { + defer d.close(io); + var it = d.iterate(); + while (it.next(io) catch null) |entry| { + if (entry.kind != .file or !auxfiles.isContent(entry.name)) continue; + const p = std.fs.path.join(a, &.{ cell_dir, entry.name }) catch continue; + const bytes = std.Io.Dir.cwd().readFileAlloc(io, p, a, .unlimited) catch continue; + aux_files.append(a, .{ .owner = stem, .name = entry.name, .bytes = bytes }) catch {}; + } + } + } + } } else { var dir = std.Io.Dir.cwd().openDir(io, base_path, .{ .iterate = true }) catch return usageErr("cannot open ENC_ROOT"); defer dir.close(io); @@ -74,7 +99,17 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var walker = dir.walk(a) catch return usageErr("cannot walk ENC_ROOT"); defer walker.deinit(); while (walker.next(io) catch null) |entry| { - if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".000")) continue; + if (entry.kind != .file) continue; + if (want_aux and auxfiles.isContent(entry.path)) { + const p = std.fs.path.join(a, &.{ base_path, entry.path }) catch continue; + const bytes = std.Io.Dir.cwd().readFileAlloc(io, p, a, .unlimited) catch continue; + // The exchange set puts a cell's files in the cell's own + // directory, so that directory names the owner. + const owner = std.fs.path.basename(std.fs.path.dirname(entry.path) orelse ""); + aux_files.append(a, .{ .owner = owner, .name = entry.path, .bytes = bytes }) catch {}; + continue; + } + if (!std.mem.endsWith(u8, entry.path, ".000")) continue; const stem = std.fs.path.stem(std.fs.path.basename(entry.path)); if (seen.contains(stem)) continue; seen.put(a.dupe(u8, stem) catch continue, {}) catch {}; @@ -89,11 +124,30 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var out_paths = std.ArrayList([]const u8).empty; for (cell_paths.items) |cp| { const stem = std.fs.path.stem(std.fs.path.basename(cp)); + // One directory per chart, as the exchange set does it: the archive + // and the files that chart references travel together. + const chart_dir = std.fs.path.join(a, &.{ out_dir, stem }) catch continue; + std.Io.Dir.cwd().createDirPath(io, chart_dir) catch continue; const name = std.fmt.allocPrint(a, "{s}.pmtiles", .{stem}) catch continue; - out_paths.append(a, std.fs.path.join(a, &.{ tiles_dir, name }) catch continue) catch {}; + out_paths.append(a, std.fs.path.join(a, &.{ chart_dir, name }) catch continue) catch {}; } if (out_paths.items.len != cell_paths.items.len) return usageErr("out of memory naming archives"); + // The referenced text and pictures, beside the chart that names them. + for (cell_paths.items) |cp| { + const stem = std.fs.path.stem(std.fs.path.basename(cp)); + var mine = std.ArrayList(auxfiles.File).empty; + for (aux_files.items) |af| { + if (std.mem.eql(u8, af.owner, stem)) mine.append(a, af) catch {}; + } + if (mine.items.len == 0) continue; + const chart_dir = std.fs.path.join(a, &.{ out_dir, stem }) catch continue; + aux_written += auxfiles.writeDir(io, a, chart_dir, mine.items) catch |err| blk: { + std.debug.print("warning: aux files not written for {s} ({s})\n", .{ stem, @errorName(err) }); + break :blk 0; + }; + } + const n_workers = @min(workers, cell_paths.items.len); if (cell_paths.items.len > 1) { std.debug.print("baking {d} cell(s) across {d} worker(s)…\n", .{ cell_paths.items.len, n_workers }); @@ -135,8 +189,12 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const part_path = try std.fs.path.join(a, &.{ out_dir, "partition.tpart" }); try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = part_path, .data = part_bytes }); + if (aux_written > 0) { + std.debug.print(" {d} auxiliary file(s) beside their charts\n", .{aux_written}); + } + std.debug.print( - "live structure -> {s}/\n {d} per-chart archive(s){s} + partition.tpart (serve z {d}..{d})\n", - .{ out_dir, src.readers.len, " under tiles/", src.minz, src.loop_max }, + "live structure -> {s}/\n {d} per-chart directory(s) + partition.tpart (serve z {d}..{d})\n", + .{ out_dir, src.readers.len, src.minz, src.loop_max }, ); } From 860f760f9800239f10a384373cc0566e797ca65d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 30 Jul 2026 19:18:38 -0400 Subject: [PATCH 2/4] aux: copy the name, not the walker's buffer The directory walker reuses one buffer for the entry path, so every collected file borrowed the same slice. The four files of a cell all ended up under the last name the walker had seen, each overwriting the last, and the manifest listed that one name four times. --- tools/bake.zig | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/bake.zig b/tools/bake.zig index 0d8b030f..a1bb5c8b 100644 --- a/tools/bake.zig +++ b/tools/bake.zig @@ -104,9 +104,12 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const p = std.fs.path.join(a, &.{ base_path, entry.path }) catch continue; const bytes = std.Io.Dir.cwd().readFileAlloc(io, p, a, .unlimited) catch continue; // The exchange set puts a cell's files in the cell's own - // directory, so that directory names the owner. - const owner = std.fs.path.basename(std.fs.path.dirname(entry.path) orelse ""); - aux_files.append(a, .{ .owner = owner, .name = entry.path, .bytes = bytes }) catch {}; + // directory, so that directory names the owner. Both names + // must be COPIED: the walker reuses one buffer for the path, + // so a borrowed slice becomes the next entry's name. + const owner = a.dupe(u8, std.fs.path.basename(std.fs.path.dirname(entry.path) orelse "")) catch continue; + const name = a.dupe(u8, entry.path) catch continue; + aux_files.append(a, .{ .owner = owner, .name = name, .bytes = bytes }) catch {}; continue; } if (!std.mem.endsWith(u8, entry.path, ".000")) continue; From 8fb65e94eca834cf2fbff3dc26e576483277e0c6 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 30 Jul 2026 19:31:58 -0400 Subject: [PATCH 3/4] ignore the scratchpad Local work in progress does not belong in a commit. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d7478388..d6eeb1d8 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ __pycache__/ # Fetched zig packages, materialized per-project (the S-101 catalogue lands # here when the submodule is not initialized). zig-pkg/ + +# Local work in progress. +scratchpad/ From 15ecc9a6b3025ff15d2caffed20c4216b9d032f8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 30 Jul 2026 20:09:45 -0400 Subject: [PATCH 4/4] pick: a note area answers a pick anywhere inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pick replays the drawing, so an area that draws nothing but its INFORM01 marker — M_NPUB, and any area that carries INFORM or TXTDSC — answered only under that marker. A click one glyph away reported the water under it. The bake now stores the rings of such an area on a `pick_areas` layer. Only the query surface reads them; a renderer skips the layer. --- src/render/query.zig | 34 +++++++++++++++++++++++++++++++ src/render/render.zig | 1 + src/render/surface.zig | 18 +++++++++++++++++ src/scene/replay.zig | 8 +++++++- src/scene/scene.zig | 46 ++++++++++++++++++++++++++++++++++++++++++ src/tiles/mvt.zig | 6 +++++- 6 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/render/query.zig b/src/render/query.zig index 2a8c2489..38413387 100644 --- a/src/render/query.zig +++ b/src/render/query.zig @@ -36,6 +36,7 @@ pub const QuerySurface = struct { .drawText = drawText, .endFeature = endFeature, .endScene = endScene, + .pick_area = pickArea, }; pub fn asSurface(self: *QuerySurface) rs.Surface { @@ -121,6 +122,12 @@ pub const QuerySurface = struct { const self = sp(ctx); if (self.pointInRings(rings)) self.hit = true; } + /// An area the chart does not fill: a note area answers a pick anywhere + /// inside it, not only under its INFORM01 marker. + fn pickArea(ctx: *anyopaque, rings: []const []const rs.TilePoint) anyerror!void { + const self = sp(ctx); + if (self.pointInRings(rings)) self.hit = true; + } fn strokeLine(ctx: *anyopaque, _: rs.ColorToken, _: f64, _: rs.Dash, lines: []const []const rs.TilePoint, _: ?f64) anyerror!void { const self = sp(ctx); if (self.nearLines(lines)) self.hit = true; @@ -138,3 +145,30 @@ pub const QuerySurface = struct { if (self.nearPoint(at)) self.hit = true; } }; + +test "a note area answers a pick inside it, and only inside it" { + const Seen = struct { + var n: usize = 0; + fn feature(_: ?*anyopaque, _: [*]const u8, _: usize, _: [*]const u8, _: usize, _: [*]const u8, _: usize) callconv(.c) void { + n += 1; + } + }; + const cb = QueryCb{ .ctx = null, .feature = Seen.feature }; + // A square from (100,100) to (900,900) — the note area, which draws no fill. + const ring = [_]rs.TilePoint{ + .{ .x = 100, .y = 100 }, .{ .x = 900, .y = 100 }, + .{ .x = 900, .y = 900 }, .{ .x = 100, .y = 900 }, + }; + const rings = [_][]const rs.TilePoint{&ring}; + const meta = rs.FeatureMeta{ .class = "M_NPUB" }; + + for ([_][2]f64{ .{ 500, 500 }, .{ 2000, 500 } }, [_]usize{ 1, 0 }) |at, want| { + Seen.n = 0; + var qs = QuerySurface{ .qx = at[0], .qy = at[1], .radius = 96, .view_zoom = 12, .cb = &cb }; + const surf = qs.asSurface(); + try surf.beginFeature(&meta); + try surf.pickArea(&rings); + try surf.endFeature(); + try std.testing.expectEqual(want, Seen.n); + } +} diff --git a/src/render/render.zig b/src/render/render.zig index 76cc61a9..ba46a9f0 100644 --- a/src/render/render.zig +++ b/src/render/render.zig @@ -60,6 +60,7 @@ test { _ = labelcache; _ = noop; _ = inspect; + _ = query; _ = resolve; _ = canvas; _ = raster; diff --git a/src/render/surface.zig b/src/render/surface.zig index 5db58f09..3ab58e82 100644 --- a/src/render/surface.zig +++ b/src/render/surface.zig @@ -175,12 +175,30 @@ pub const Surface = struct { /// coincide). replayTile calls this per tile before emitting; the /// slice is valid only for the call. Null on the bake encoder. set_contour_ladder: ?*const fn (*anyopaque, ladder: []const f64) void = null, + /// Cursor-pick geometry for an area the chart does not fill. The pick + /// (§10.8) replays the DRAWING, so an area whose only mark is the + /// INFORM01 marker — M_NPUB, and any note area — answers a pick under + /// that marker alone, and a click inside the area reports the water + /// under it instead. The bake encoder stores these rings on a + /// query-only layer and the query surface tests them, so the area + /// answers anywhere inside it. Null on render surfaces: nothing draws. + pick_area: ?*const fn (*anyopaque, rings: []const []const TilePoint) anyerror!void = null, }; pub fn setContourLadder(self: Surface, ladder: []const f64) void { if (self.vtable.set_contour_ladder) |f| f(self.ptr, ladder); } + /// True when the surface takes pick geometry — the bake encoder and the + /// query surface. The emitter skips the work for every other surface. + pub fn wantsPickArea(self: Surface) bool { + return self.vtable.pick_area != null; + } + + pub fn pickArea(self: Surface, rings: []const []const TilePoint) !void { + if (self.vtable.pick_area) |f| try f(self.ptr, rings); + } + /// The S-52 effective safety contour against a tile's ladder: the least /// available value >= the mariner's, else the mariner's own. Shared by every /// render surface. diff --git a/src/scene/replay.zig b/src/scene/replay.zig index 7bf2e9bd..28b716b1 100644 --- a/src/scene/replay.zig +++ b/src/scene/replay.zig @@ -116,11 +116,17 @@ pub fn replayTile(a: Allocator, surf: rs.Surface, layers: []const mvt.DecodedLay const is_points = std.mem.startsWith(u8, layer.name, "point_symbols"); const is_soundings = std.mem.eql(u8, layer.name, "soundings"); const is_text = std.mem.startsWith(u8, layer.name, "text"); + // Query-only rings (a note area the chart does not fill). A render + // surface has nothing to do with them. + const is_pick = std.mem.eql(u8, layer.name, "pick_areas"); + if (is_pick and !surf.wantsPickArea()) continue; for (layer.features) |f| { const meta = metaFromProps(f.properties); try surf.beginFeature(&meta); defer surf.endFeature() catch {}; - if (is_patterns) { + if (is_pick) { + try surf.pickArea(f.parts); + } else if (is_patterns) { try surf.fillPattern(propStr(f.properties, "pattern_name"), f.parts); } else if (is_areas) { const d1 = propF64(f.properties, "drval1"); diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 1e8ab4cb..57bd1bad 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -485,6 +485,10 @@ pub const TileSurface = struct { // property in the style). SOUNDG multipoints and wreck/obstruction/rock depth // glyphs (coalesced from the portrayal via drawSounding) both land here. soundings: std.ArrayList(mvt.Feature) = .empty, + // The `pick_areas` layer: rings the cursor pick tests and no renderer draws + // (see Surface.pick_area). One list — a note area gates on its own `scamin` + // property like a sounding does. + pick_areas: std.ArrayList(mvt.Feature) = .empty, /// Current feature meta, set by beginFeature; the draw methods read it. cur: Meta = .{ .display_priority = 0 }, @@ -502,6 +506,7 @@ pub const TileSurface = struct { // Bake path: store complex runs un-tessellated (no size_scale set — bake is // native; replay re-walks the period display-scaled). .store_complex_run = storeComplexRun, + .pick_area = pickArea, }; pub fn init(a: Allocator, format: TileFormat) TileSurface { @@ -572,6 +577,13 @@ pub const TileSurface = struct { try s.areasL().append(s.a, .{ .geom_type = .polygon, .parts = rings, .properties = props.items }); } + fn pickArea(ctx: *anyopaque, rings: []const []const rs.TilePoint) anyerror!void { + const s = sp(ctx); + var props = std.ArrayList(mvt.Prop).empty; + try appendMeta(s.a, &props, s.cur); + try s.pick_areas.append(s.a, .{ .geom_type = .polygon, .parts = rings, .properties = props.items }); + } + fn fillPattern(ctx: *anyopaque, name: rs.SymbolName, rings: []const []const rs.TilePoint) anyerror!void { const s = sp(ctx); var props = std.ArrayList(mvt.Prop).empty; @@ -700,6 +712,7 @@ pub const TileSurface = struct { if (s.points.items.len > 0) try layers.append(s.a, .{ .name = "point_symbols", .features = s.points.items }); if (s.soundings.items.len > 0) try layers.append(s.a, .{ .name = "soundings", .features = s.soundings.items }); if (s.texts.items.len > 0) try layers.append(s.a, .{ .name = "text", .features = s.texts.items }); + if (s.pick_areas.items.len > 0) try layers.append(s.a, .{ .name = "pick_areas", .features = s.pick_areas.items }); if (layers.items.len == 0) return out.alloc(u8, 0); return switch (s.format) { .mvt => mvt.encode(out, .{ .layers = layers.items }), @@ -2289,6 +2302,37 @@ fn emitCentredSymbol(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, ge try surf.endFeature(); } +/// The rings a cursor pick needs from an area the chart does not fill. The pick +/// replays the drawing, so a note area — M_NPUB, and any area carrying INFORM or +/// TXTDSC — answers only under its INFORM01 marker, and a click one glyph away +/// reports the water under it. Emit the clipped rings for EVERY tile the area +/// spans (the marker rides one tile only), so the note answers across its own +/// ground. Only the bake encoder and the query surface take the call. +fn emitPickArea(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, z: u8, x: u32, y: u32, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { + const parts = featureParts(a, cell, geo, fi, f) catch return; + var rings = std.ArrayList([]const mvt.Point).empty; + for (parts) |gp| { + if (gp.len < 3) continue; + const proj = try a.alloc(mvt.Point, gp.len); + for (gp, 0..) |p, i| proj[i] = tile.project(p.lon(), p.lat(), z, x, y, tile.EXTENT); + const ring = try clipSimplifyPoly(a, proj, box, opts.detail); + if (ring.len >= 3) try rings.append(a, ring); + } + if (rings.items.len == 0) return; + const fmeta = rs.FeatureMeta{ + .display_priority = 8, + .display_category = 2, + .scamin = effScamin(f, opts), + .class = pickClass(cell, f, fi), + .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), + .cell_name = if (opts.pick_attrs) cell.name else "", + .band = opts.band, + }; + try surf.beginFeature(&fmeta); + try surf.pickArea(try mvt.orientAreaRings(a, rings.items)); + try surf.endFeature(); +} + /// Append one cell's features for tile (z,x,y), driving the Surface interface: /// the S-101 portrayal path through processFeatureInstr, native S-52 fallbacks /// (SWPARE / NEWOBJ / M_NSYS / INFORM01 / QUESMRK1 / SOUNDG) through their emit* @@ -2399,6 +2443,8 @@ fn appendCellFeatures( }; if (!fopts.suppress_points and hasAdditionalInfo(f)) { try emitCentredSymbol(a, cell.*, f, fi, geo, "INFORM01", 8, 2, z, x, y, tb, fopts, surf); + if (f.prim == 3 and surf.wantsPickArea()) + try emitPickArea(a, cell.*, f, fi, geo, z, x, y, box, fopts, surf); } // S-52 §10.1.10.2 overscale area at a chart scale boundary: // a cell's M_COVR (CATCOV=1) coverage polygon rides diff --git a/src/tiles/mvt.zig b/src/tiles/mvt.zig index 707d8601..0bcb53d8 100644 --- a/src/tiles/mvt.zig +++ b/src/tiles/mvt.zig @@ -577,8 +577,12 @@ test "zigzag" { /// The MVT source-layer set of the tile57/2 schema, in emit order. The scene /// emitter fills these layers and the compositor stitches them by name. +/// +/// `pick_areas` carries no style: it holds the rings of an area that the chart +/// does not fill (a note area), so the cursor pick can find it anywhere inside +/// it. A renderer never reads that layer. pub const VECTOR_LAYERS = [_][]const u8{ - "areas", "area_patterns", "lines", "point_symbols", "soundings", "text", + "areas", "area_patterns", "lines", "point_symbols", "soundings", "text", "pick_areas", }; /// Shoelace signed area (x2) of a ring in tile space; only its sign is used.