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/ 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/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/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/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. diff --git a/tools/bake.zig b/tools/bake.zig index 1b8de4a4..a1bb5c8b 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,20 @@ 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. 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; 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 +127,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 +192,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 }, ); }