From 42b1786b2146bbd0167d50805480c67d56b96b2a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 14:12:44 -0400 Subject: [PATCH 01/12] feat(s101): native S-101 (S-100 Part 10a) dataset reader + detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/s101/dataset.zig: an ISO 8211-based reader for native S-101 .000 files. It reuses the shared iso8211 container and decodes the S-100 General Feature Model — DSSI coordinate factors + record counts, the in-band code tables (FTCS/ATCS/ITCS/ARCS mapping numeric codes to S-101 class/attribute names), and the point/multipoint/curve/composite-curve/surface spatial records plus feature records (FRID/FOID/ATTR/SPAS/FASC/MASK). ATTR decodes the complex-attribute tree via the PAIX parent-position links. detect() discriminates S-101 from S-57 on the DDR field schema (the S-101-only FTCS code-table field). Add a 'tile57 s101' subcommand to inspect a dataset. Validated on all 8 SHOM France test cells: parsed record counts match the DSSI counts exactly and every feature code resolves to a class name; a real S-57 cell is correctly not detected. --- src/s101/dataset.zig | 540 +++++++++++++++++++++++++++++++++++++++++++ src/s101/s101.zig | 3 + tools/main.zig | 5 + tools/s101dump.zig | 93 ++++++++ 4 files changed, 641 insertions(+) create mode 100644 src/s101/dataset.zig create mode 100644 tools/s101dump.zig diff --git a/src/s101/dataset.zig b/src/s101/dataset.zig new file mode 100644 index 0000000..12dbf42 --- /dev/null +++ b/src/s101/dataset.zig @@ -0,0 +1,540 @@ +//! Native S-101 (S-100 Part 10a) dataset reader. An S-101 ENC rides on the SAME +//! ISO/IEC 8211 container as S-57 (so `iso8211.zig` is reused verbatim), but the +//! record model is the S-100 General Feature Model — a wholly different schema. +//! This module decodes that schema into typed records; `native.zig` then assembles +//! them into an `s57.Cell` geometry shell plus native `adapter.Adapted` portrayal +//! records, so a native S-101 cell renders through the existing pipeline WITHOUT +//! the S-57 -> S-101 adapter. +//! +//! The decisive property (verified against the SHOM France test cells): an S-101 +//! dataset carries its OWN in-band code tables (`FTCS`/`ATCS`/`ITCS`/…) that map the +//! numeric feature/attribute codes used on the wire to the S-101 camelCase class +//! and attribute NAMES — the exact vocabulary the portrayal rules consume. So we +//! read the target names straight from the dataset; no external catalogue lookup is +//! needed to resolve them. +//! +//! Record model (S-100 Part 10a), keyed by the leading field tag of each ISO 8211 +//! data record: +//! DSID/DSSI dataset id + structure (coord multiplication factors, record counts) +//! ATCS/…/ARCS code tables (numeric code <-> S-101 name) +//! CSID/CRSH coordinate reference system (WGS84 / EPSG:4326 for ENC) +//! PRID + C2IT point (one lat/lon tuple) +//! MRID + C3IL multipoint (soundings: lat/lon/depth tuples) +//! CRID + PTAS+C2IL curve (begin/end node refs + interior vertices) +//! CCID + CUCO composite curve (ordered constituent curves w/ orientation) +//! SRID + RIAS surface (bounding curves/composite-curves w/ ORNT+USAG) +//! FRID + FOID+ATTR+SPAS+FASC+MASK feature (type code, id, attributes, spatial +//! association, feature associations, masking) +//! IRID + ATTR+INAS information type (attribute-only meta record) +//! +//! Record-name codes (RCNM/RRNM), used to dispatch spatial associations: +//! 100 feature · 110 point · 115 multipoint · 120 curve · 125 composite curve · +//! 130 surface · 150 information. +//! +//! Coordinates: signed 32-bit integers, latitude first (YCOO, XCOO[, ZCOO]); +//! degrees = value / CMFx (CMFX/CMFY from DSSI, typically 1e7); depth = ZCOO / CMFZ. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const s57 = @import("s57"); +const iso = s57.iso8211; // the shared ISO 8211 container reader + +const UT: u8 = 0x1f; // subfield (unit) terminator + +// --- Record-name codes (RCNM / RRNM) -------------------------------------- +pub const RCNM_POINT: u8 = 110; +pub const RCNM_MULTIPOINT: u8 = 115; +pub const RCNM_CURVE: u8 = 120; +pub const RCNM_COMPOSITE: u8 = 125; +pub const RCNM_SURFACE: u8 = 130; +pub const RCNM_FEATURE: u8 = 100; +pub const RCNM_INFO: u8 = 150; + +// --- Little-endian binary subfield decoders ------------------------------- +// ISO 8211 binary subfields are little-endian (Intel). `b11`=u8, `b12`=u16, +// `b14`=u32, `b24`=i32, `b48`=f64. Out-of-range offsets yield 0 (a truncated +// field is treated as absent rather than crashing the whole cell). +fn u8at(b: []const u8, o: usize) u8 { + return if (o < b.len) b[o] else 0; +} +fn u16le(b: []const u8, o: usize) u16 { + if (o + 2 > b.len) return 0; + return @as(u16, b[o]) | (@as(u16, b[o + 1]) << 8); +} +fn u32le(b: []const u8, o: usize) u32 { + if (o + 4 > b.len) return 0; + return @as(u32, b[o]) | (@as(u32, b[o + 1]) << 8) | (@as(u32, b[o + 2]) << 16) | (@as(u32, b[o + 3]) << 24); +} +fn i32le(b: []const u8, o: usize) i32 { + return @bitCast(u32le(b, o)); +} +fn f64le(b: []const u8, o: usize) f64 { + if (o + 8 > b.len) return 0; + var v: u64 = 0; + inline for (0..8) |i| v |= @as(u64, b[o + i]) << (8 * i); + return @bitCast(v); +} + +// --- Typed records -------------------------------------------------------- + +/// DSSI structure parameters. The coordinate multiplication factors scale the +/// integer coordinates to degrees; the counts pre-size the assembly. +pub const Params = struct { + cmfx: f64 = 1e7, // coordinate multiplication factor, longitude + cmfy: f64 = 1e7, // coordinate multiplication factor, latitude + cmfz: f64 = 10, // sounding (Z) multiplication factor + n_info: u32 = 0, + n_point: u32 = 0, + n_multipoint: u32 = 0, + n_curve: u32 = 0, + n_composite: u32 = 0, + n_surface: u32 = 0, + n_feature: u32 = 0, +}; + +/// A bidirectional numeric-code <-> S-101-name table (FTCS/ATCS/ITCS/…). Names are +/// borrowed slices into the dataset's ISO 8211 arena (kept alive by `Dataset`). +pub const CodeTable = struct { + by_code: std.AutoHashMapUnmanaged(u16, []const u8) = .{}, + + pub fn name(self: CodeTable, code: u16) ?[]const u8 { + return self.by_code.get(code); + } +}; + +/// One decoded S-100 attribute occurrence (the ATTR field's repeating group). +/// `paix` is the 1-based position, within this same ATTR field, of the parent +/// (complex) attribute this one belongs to — 0 means it is a direct attribute of +/// the feature/information root. `atix` is the sibling-instance index. Complex +/// (container) attributes carry an empty `val` and are referenced as a `paix` by +/// their sub-attributes; simple attributes carry a value. +pub const Attr = struct { + natc: u16, // numeric attribute code (resolve via the ATCS table -> name) + atix: u16, + paix: u16, + atin: u8, + val: []const u8, +}; + +/// One SPAS entry: the feature's association to a spatial record. `rrnm` names the +/// spatial record type (point/multipoint/curve/composite/surface); `rrid` its RCID. +pub const SpatialAssoc = struct { + rrnm: u8, + rrid: u32, + ornt: u8, // 1=forward, 2=reverse, 255=null + usag: u8 = 0, // 1=exterior, 2=interior, 3=truncated by data limit (from RIAS; SPAS carries none) + mask: u8 = 0, // from the MASK field, joined by rrid +}; + +/// One FASC entry: a feature-to-feature association. `rrid` is the associated +/// FEATURE record's RCID (not an FOID); `narc` the association-role code (ARCS). +pub const FeatureAssoc = struct { + rrid: u32, + nfac: u16, // feature-association-class code (FACS) + narc: u16, // association-role code (ARCS) +}; + +pub const Foid = struct { agen: u16 = 0, fidn: u32 = 0, fids: u16 = 0 }; + +pub const FeatureRec = struct { + rcid: u32, + nftc: u16, // feature type code -> FTCS name (the S-101 class) + foid: Foid = .{}, + attrs: []const Attr = &.{}, + spas: []SpatialAssoc = &.{}, + fasc: []const FeatureAssoc = &.{}, +}; + +pub const PointRec = struct { rcid: u32, lon: f64, lat: f64 }; +pub const MultiRec = struct { rcid: u32, soundings: []const s57.Sounding }; +pub const CurveRec = struct { + rcid: u32, + begin_rcid: u32 = 0, // referenced point record RCID (PTAS TOPI=1) + end_rcid: u32 = 0, // PTAS TOPI=2 + interior: []const s57.LonLat = &.{}, // C2IL vertices between the begin and end nodes +}; +pub const CompositeMember = struct { rrid: u32, ornt: u8 }; +pub const CompositeRec = struct { rcid: u32, members: []CompositeMember }; +pub const RingRef = struct { rrnm: u8, rrid: u32, ornt: u8, usag: u8 }; +pub const SurfaceRec = struct { rcid: u32, rings: []RingRef }; + +pub const InfoRec = struct { rcid: u32, nitc: u16, attrs: []const Attr }; + +pub const Dataset = struct { + arena: std.heap.ArenaAllocator, + iso_file: iso.File, + params: Params = .{}, + feature_codes: CodeTable = .{}, // FTCS + attr_codes: CodeTable = .{}, // ATCS + info_codes: CodeTable = .{}, // ITCS + assoc_codes: CodeTable = .{}, // ARCS (roles) + + features: []FeatureRec = &.{}, + points: []PointRec = &.{}, + multis: []MultiRec = &.{}, + curves: []CurveRec = &.{}, + composites: []CompositeRec = &.{}, + surfaces: []SurfaceRec = &.{}, + infos: []InfoRec = &.{}, + + pub fn deinit(self: *Dataset) void { + self.iso_file.deinit(); + self.arena.deinit(); + } + + pub fn featureName(self: Dataset, f: FeatureRec) ?[]const u8 { + return self.feature_codes.name(f.nftc); + } + pub fn attrName(self: Dataset, a: Attr) ?[]const u8 { + return self.attr_codes.name(a.natc); + } +}; + +/// Is `bytes` a native S-101 dataset (rather than an S-57 cell)? Both share the +/// ISO 8211 container, so we discriminate on the DDR field schema: an S-101 DDR +/// defines the in-band code-table fields (`FTCS`, the feature-type code table), +/// which no S-57 DDR ever carries. Cheap — parses only the DDR record. +pub fn detect(bytes: []const u8) bool { + // The DDR is the first record; its field-control entries are keyed by the real + // tags. `iso.parse` reads the whole file, but on a non-S-101 file we simply + // return false and the caller falls through to the S-57 reader. To keep + // detection cheap we scan the DDR's field-control tags via a throwaway parse of + // just enough — but `iso` exposes no DDR-only parse, so match on the tag bytes + // in the DDR directory area instead. + return ddrHasTag(bytes, "FTCS"); +} + +/// True when the ISO 8211 DDR (first record) declares a data-descriptive field with +/// tag `tag`. Walks only the first record's directory, so it is O(#DDR fields) and +/// never touches the data records. +fn ddrHasTag(bytes: []const u8, tag: []const u8) bool { + if (bytes.len < 24) return false; + const field_area_start = asciiInt(bytes[12..17]) orelse return false; + if (field_area_start < 24 or field_area_start > bytes.len) return false; + const size_len = digit(bytes[20]) orelse return false; + const size_pos = digit(bytes[21]) orelse return false; + const size_tag = digit(bytes[23]) orelse return false; + const entry = size_tag + size_len + size_pos; + var p: usize = 24; + while (p + entry <= field_area_start) : (p += entry) { + if (bytes[p] == 0x1e) break; // directory terminator + if (size_tag == tag.len and std.mem.eql(u8, bytes[p .. p + size_tag], tag)) return true; + } + return false; +} + +fn asciiInt(b: []const u8) ?usize { + var v: usize = 0; + var any = false; + for (b) |c| { + if (c == ' ') continue; + if (c < '0' or c > '9') return null; + v = v * 10 + (c - '0'); + any = true; + } + return if (any) v else 0; +} +fn digit(c: u8) ?u8 { + return if (c >= '0' and c <= '9') c - '0' else null; +} + +/// Parse a native S-101 dataset from in-memory bytes (borrowed; the returned +/// `Dataset` owns an ISO 8211 file that references `bytes`, so keep `bytes` alive +/// until `deinit`). Caller must `deinit` the result. +pub fn parse(gpa: Allocator, bytes: []const u8) !Dataset { + var iso_file = try iso.parse(gpa, bytes); + errdefer iso_file.deinit(); + + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const a = arena.allocator(); + + // Parse into locals (the arena is MOVED into the returned Dataset only at the + // end, so its final state travels with the result — never snapshot it mid-parse). + var params: Params = .{}; + var feature_codes: CodeTable = .{}; + var attr_codes: CodeTable = .{}; + var info_codes: CodeTable = .{}; + var assoc_codes: CodeTable = .{}; + + var features = std.ArrayList(FeatureRec).empty; + var points = std.ArrayList(PointRec).empty; + var multis = std.ArrayList(MultiRec).empty; + var curves = std.ArrayList(CurveRec).empty; + var composites = std.ArrayList(CompositeRec).empty; + var surfaces = std.ArrayList(SurfaceRec).empty; + var infos = std.ArrayList(InfoRec).empty; + + for (iso_file.records) |rec| { + if (rec.fields.len == 0) continue; + const lead = rec.fields[0].tag; + if (std.mem.eql(u8, lead, "DSID")) { + try parseDatasetRecord(a, rec, ¶ms, &feature_codes, &attr_codes, &info_codes, &assoc_codes); + } else if (std.mem.eql(u8, lead, "PRID")) { + if (rec.field("C2IT")) |c| { + const p = rec.field("PRID").?; + try points.append(a, .{ + .rcid = u32le(p, 1), + .lat = @as(f64, @floatFromInt(i32le(c, 0))) / params.cmfy, + .lon = @as(f64, @floatFromInt(i32le(c, 4))) / params.cmfx, + }); + } + } else if (std.mem.eql(u8, lead, "MRID")) { + const m = rec.field("MRID").?; + const snds = if (rec.field("C3IL")) |c| try parseSoundings(a, c, params) else &[_]s57.Sounding{}; + try multis.append(a, .{ .rcid = u32le(m, 1), .soundings = snds }); + } else if (std.mem.eql(u8, lead, "CRID")) { + try curves.append(a, try parseCurve(a, rec, params)); + } else if (std.mem.eql(u8, lead, "CCID")) { + try composites.append(a, try parseComposite(a, rec)); + } else if (std.mem.eql(u8, lead, "SRID")) { + try surfaces.append(a, try parseSurface(a, rec)); + } else if (std.mem.eql(u8, lead, "FRID")) { + try features.append(a, try parseFeature(a, rec)); + } else if (std.mem.eql(u8, lead, "IRID")) { + const ir = rec.field("IRID").?; + const attrs = if (rec.field("ATTR")) |at| try parseAttrs(a, at) else &[_]Attr{}; + try infos.append(a, .{ .rcid = u32le(ir, 1), .nitc = u16le(ir, 5), .attrs = attrs }); + } + // CSID/CRSH/CSAX/VDAT (CRS) are WGS84/EPSG:4326 for ENC; the whole engine + // already assumes geographic lon/lat, so nothing to carry. + } + + return .{ + .arena = arena, + .iso_file = iso_file, + .params = params, + .feature_codes = feature_codes, + .attr_codes = attr_codes, + .info_codes = info_codes, + .assoc_codes = assoc_codes, + .features = features.items, + .points = points.items, + .multis = multis.items, + .curves = curves.items, + .composites = composites.items, + .surfaces = surfaces.items, + .infos = infos.items, + }; +} + +fn parseDatasetRecord( + a: Allocator, + rec: iso.Record, + params: *Params, + feature_codes: *CodeTable, + attr_codes: *CodeTable, + info_codes: *CodeTable, + assoc_codes: *CodeTable, +) !void { + if (rec.field("DSSI")) |s| { + // (3b48,10b14): DCOX,DCOY,DCOZ (origin, unused) then CMFX,CMFY,CMFZ then the + // seven record counts NOIR,NOPN,NOMN,NOCN,NOXN,NOSN,NOFR. + params.cmfx = @floatFromInt(u32le(s, 24)); + params.cmfy = @floatFromInt(u32le(s, 28)); + params.cmfz = @floatFromInt(u32le(s, 32)); + if (params.cmfx <= 0) params.cmfx = 1e7; + if (params.cmfy <= 0) params.cmfy = 1e7; + if (params.cmfz <= 0) params.cmfz = 10; + params.n_info = u32le(s, 36); + params.n_point = u32le(s, 40); + params.n_multipoint = u32le(s, 44); + params.n_curve = u32le(s, 48); + params.n_composite = u32le(s, 52); + params.n_surface = u32le(s, 56); + params.n_feature = u32le(s, 60); + } + if (rec.field("FTCS")) |t| feature_codes.* = try parseCodeTable(a, t); + if (rec.field("ATCS")) |t| attr_codes.* = try parseCodeTable(a, t); + if (rec.field("ITCS")) |t| info_codes.* = try parseCodeTable(a, t); + if (rec.field("ARCS")) |t| assoc_codes.* = try parseCodeTable(a, t); +} + +/// A code table field: repeating `(A, b12)` = a UT-terminated name followed by a +/// little-endian u16 code. Builds the code -> name direction (the only one we read). +fn parseCodeTable(a: Allocator, data: []const u8) !CodeTable { + var t: CodeTable = .{}; + var i: usize = 0; + while (i < data.len) { + const ut = std.mem.indexOfScalarPos(u8, data, i, UT) orelse break; + const nm = data[i..ut]; + const code = u16le(data, ut + 1); + try t.by_code.put(a, code, nm); + i = ut + 3; + } + return t; +} + +/// ATTR field: repeating `(3b12,b11,A)` = NATC,ATIX,PAIX (u16), ATIN (u8), then a +/// UT-terminated ASCII value. Preserves order (PAIX references the 1-based position). +fn parseAttrs(a: Allocator, data: []const u8) ![]const Attr { + var out = std.ArrayList(Attr).empty; + var i: usize = 0; + while (i + 7 <= data.len) { + const natc = u16le(data, i); + const atix = u16le(data, i + 2); + const paix = u16le(data, i + 4); + const atin = u8at(data, i + 6); + i += 7; + const ut = std.mem.indexOfScalarPos(u8, data, i, UT) orelse data.len; + const val = data[i..ut]; + i = ut + 1; + try out.append(a, .{ .natc = natc, .atix = atix, .paix = paix, .atin = atin, .val = val }); + } + return out.items; +} + +/// C3IL soundings: a 1-byte leading subfield then repeating `3b24` (YCOO,XCOO,ZCOO). +fn parseSoundings(a: Allocator, data: []const u8, params: Params) ![]const s57.Sounding { + if (data.len < 1) return &.{}; + const body = data[1..]; // skip the leading b11 subfield + const n = body.len / 12; + var out = try a.alloc(s57.Sounding, n); + for (0..n) |k| { + const o = k * 12; + out[k] = s57.Sounding.init( + @as(f64, @floatFromInt(i32le(body, o + 4))) / params.cmfx, // XCOO -> lon + @as(f64, @floatFromInt(i32le(body, o))) / params.cmfy, // YCOO -> lat + @as(f64, @floatFromInt(i32le(body, o + 8))) / params.cmfz, // ZCOO -> depth + ); + } + return out; +} + +fn parseCurve(a: Allocator, rec: iso.Record, params: Params) !CurveRec { + const c = rec.field("CRID").?; + var cr: CurveRec = .{ .rcid = u32le(c, 1) }; + // PTAS: repeating `(b11,b14,b11)` = RRNM,RRID,TOPI. TOPI 1=begin node, 2=end. + if (rec.field("PTAS")) |p| { + var o: usize = 0; + while (o + 6 <= p.len) : (o += 6) { + const rrid = u32le(p, o + 1); + switch (u8at(p, o + 5)) { + 1 => cr.begin_rcid = rrid, + 2 => cr.end_rcid = rrid, + else => {}, + } + } + } + // C2IL: repeating `2b24` (YCOO,XCOO) — the interior vertices. + if (rec.field("C2IL")) |cl| { + const n = cl.len / 8; + var pts = try a.alloc(s57.LonLat, n); + for (0..n) |k| { + const o = k * 8; + pts[k] = s57.LonLat.init( + @as(f64, @floatFromInt(i32le(cl, o + 4))) / params.cmfx, + @as(f64, @floatFromInt(i32le(cl, o))) / params.cmfy, + ); + } + cr.interior = pts; + } + return cr; +} + +fn parseComposite(a: Allocator, rec: iso.Record) !CompositeRec { + const c = rec.field("CCID").?; + var members = std.ArrayList(CompositeMember).empty; + // CUCO: repeating `(b11,b14,b11)` = RRNM(curve),RRID,ORNT. + if (rec.field("CUCO")) |u| { + var o: usize = 0; + while (o + 6 <= u.len) : (o += 6) { + try members.append(a, .{ .rrid = u32le(u, o + 1), .ornt = u8at(u, o + 5) }); + } + } + return .{ .rcid = u32le(c, 1), .members = members.items }; +} + +fn parseSurface(a: Allocator, rec: iso.Record) !SurfaceRec { + const s = rec.field("SRID").?; + var rings = std.ArrayList(RingRef).empty; + // RIAS: repeating `(b11,b14,3b11)` = RRNM,RRID,ORNT,USAG,RAUI. + if (rec.field("RIAS")) |r| { + var o: usize = 0; + while (o + 8 <= r.len) : (o += 8) { + try rings.append(a, .{ + .rrnm = u8at(r, o), + .rrid = u32le(r, o + 1), + .ornt = u8at(r, o + 5), + .usag = u8at(r, o + 6), + }); + } + } + return .{ .rcid = u32le(s, 1), .rings = rings.items }; +} + +fn parseFeature(a: Allocator, rec: iso.Record) !FeatureRec { + const f = rec.field("FRID").?; + var fr: FeatureRec = .{ .rcid = u32le(f, 1), .nftc = u16le(f, 5) }; + if (rec.field("FOID")) |o| { + fr.foid = .{ .agen = u16le(o, 0), .fidn = u32le(o, 2), .fids = u16le(o, 6) }; + } + if (rec.field("ATTR")) |at| fr.attrs = try parseAttrs(a, at); + + // SPAS: repeating `(b11,b14,b11,2b14,b11)` = RRNM,RRID,ORNT,SMIN,SMAX,SAUI. A + // feature may carry several (a surface plus a masked line, etc.), and the writer + // may split them across multiple SPAS fields, so collect every SPAS field. + var spas = std.ArrayList(SpatialAssoc).empty; + for (rec.fields) |fld| { + if (!std.mem.eql(u8, fld.tag, "SPAS")) continue; + var o: usize = 0; + while (o + 15 <= fld.data.len) : (o += 15) { + try spas.append(a, .{ .rrnm = u8at(fld.data, o), .rrid = u32le(fld.data, o + 1), .ornt = u8at(fld.data, o + 5) }); + } + } + fr.spas = spas.items; + + // MASK: repeating `(b11,b14,2b11)` = RRNM,RRID,MIND,MUIN. Join by rrid onto the + // matching spatial association so the masking survives into the geometry shell. + for (rec.fields) |fld| { + if (!std.mem.eql(u8, fld.tag, "MASK")) continue; + var o: usize = 0; + while (o + 7 <= fld.data.len) : (o += 7) { + const rrid = u32le(fld.data, o + 1); + const mind = u8at(fld.data, o + 5); // 1=mask, 2=show, 255=null + for (fr.spas) |*sp| if (sp.rrid == rrid) { + sp.mask = mind; + }; + } + } + + // FASC: repeating `(b11,b14,2b12,b11)` group head = RRNM,RRID,NFAC,NARC,FAUI + // (a nested ATTR block may follow per S-100, ignored here — role + target suffice). + var fasc = std.ArrayList(FeatureAssoc).empty; + for (rec.fields) |fld| { + if (!std.mem.eql(u8, fld.tag, "FASC")) continue; + // Only the fixed head is decoded; if the field packs multiple heads they are + // 11 bytes each up to the first ATTR sub-structure. Decode the leading head. + if (fld.data.len >= 11) { + try fasc.append(a, .{ .rrid = u32le(fld.data, 1), .nfac = u16le(fld.data, 5), .narc = u16le(fld.data, 7) }); + } + } + fr.fasc = fasc.items; + return fr; +} + +// ------------------------------------------------------------------------- +test "detect distinguishes an S-101 DDR from an S-57 DDR" { + // Minimal DDR leaders + directories (no data records needed for detect()). + // Tag size 4, len size 3, pos size 4; field_area_start points past the directory. + // S-101: a directory that declares FTCS. S-57: one that declares VRID. + const a = std.testing.allocator; + inline for (.{ .{ "FTCS", true }, .{ "VRID", false } }) |case| { + var buf = std.ArrayList(u8).empty; + defer buf.deinit(a); + // one directory entry (tag, len=000, pos=0000) + FT + const dir = case[0] ++ "0000000" ++ "\x1e"; + const field_area_start = 24 + dir.len; + var leader: [24]u8 = undefined; + @memset(&leader, ' '); + _ = std.fmt.bufPrint(leader[0..5], "{d:0>5}", .{field_area_start}) catch unreachable; + leader[6] = 'L'; + _ = std.fmt.bufPrint(leader[12..17], "{d:0>5}", .{field_area_start}) catch unreachable; + leader[20] = '3'; // size_of_field_length + leader[21] = '4'; // size_of_field_position + leader[23] = '4'; // size_of_field_tag + try buf.appendSlice(a, &leader); + try buf.appendSlice(a, dir); + try std.testing.expectEqual(case[1], detect(buf.items)); + } +} diff --git a/src/s101/s101.zig b/src/s101/s101.zig index 8d5d2d0..5c25530 100644 --- a/src/s101/s101.zig +++ b/src/s101/s101.zig @@ -6,13 +6,16 @@ //! * catalogue — the distilled S-101 feature/attribute catalogue //! * adapter — S-57 cell features -> S-101 feature/attribute records //! * instructions — the portrayal instruction stream (points, lines, text) +//! * dataset — native S-101 (S-100 Part 10a) dataset reader (.000 files) pub const catalogue = @import("catalogue.zig"); pub const adapter = @import("adapter.zig"); pub const instructions = @import("instructions.zig"); +pub const dataset = @import("dataset.zig"); test { _ = catalogue; _ = adapter; _ = instructions; + _ = dataset; } diff --git a/tools/main.zig b/tools/main.zig index a392f7b..98a5695 100644 --- a/tools/main.zig +++ b/tools/main.zig @@ -42,6 +42,7 @@ const audit_holes = @import("audit_holes.zig"); const audit_pairs = @import("audit_pairs.zig"); const objlcount = @import("objlcount.zig"); const cell = @import("cell.zig"); +const s101dump = @import("s101dump.zig"); const partdbg_png = @import("partdbg_png.zig"); pub fn main(init: std.process.Init) !void { @@ -137,6 +138,10 @@ pub fn main(init: std.process.Init) !void { return cell.run(io, arena, args); } + if (std.mem.eql(u8, sub, "s101")) { + return s101dump.run(io, arena, args); + } + if (std.mem.eql(u8, sub, "version") or std.mem.eql(u8, sub, "--version")) { std.debug.print("{s}\n", .{common.VERSION}); return; diff --git a/tools/s101dump.zig b/tools/s101dump.zig new file mode 100644 index 0000000..2aa7d03 --- /dev/null +++ b/tools/s101dump.zig @@ -0,0 +1,93 @@ +//! `tile57 s101 ` — inspect a native S-101 (S-100 Part 10a) dataset: +//! detection, DSSI parameters, code-table sizes, record counts, a feature-class +//! histogram, and a couple of sample features with their attributes. A ground-truth +//! check for the native reader against real datasets. + +const std = @import("std"); +const engine = @import("engine"); +const s101 = engine.s101; + +pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { + if (args.len < 3) { + std.debug.print("usage: tile57 s101 [--features N]\n", .{}); + return; + } + const path = args[2]; + var want_features: usize = 3; + if (args.len >= 5 and std.mem.eql(u8, args[3], "--features")) { + want_features = std.fmt.parseInt(usize, args[4], 10) catch 3; + } + + const data = try std.Io.Dir.cwd().readFileAlloc(io, path, a, .unlimited); + std.debug.print("{s}\n detected S-101: {}\n", .{ path, s101.dataset.detect(data) }); + if (!s101.dataset.detect(data)) { + std.debug.print(" (not a native S-101 dataset)\n", .{}); + return; + } + + var ds = try s101.dataset.parse(a, data); + defer ds.deinit(); + const p = ds.params; + std.debug.print( + " params: cmfx={d} cmfy={d} cmfz={d}\n DSSI counts: point={d} multi={d} curve={d} composite={d} surface={d} feature={d} info={d}\n", + .{ p.cmfx, p.cmfy, p.cmfz, p.n_point, p.n_multipoint, p.n_curve, p.n_composite, p.n_surface, p.n_feature, p.n_info }, + ); + std.debug.print( + " parsed: points={d} multis={d} curves={d} composites={d} surfaces={d} features={d} infos={d}\n", + .{ ds.points.len, ds.multis.len, ds.curves.len, ds.composites.len, ds.surfaces.len, ds.features.len, ds.infos.len }, + ); + std.debug.print(" code tables: feature={d} attr={d} info={d} assoc={d}\n", .{ + ds.feature_codes.by_code.count(), + ds.attr_codes.by_code.count(), + ds.info_codes.by_code.count(), + ds.assoc_codes.by_code.count(), + }); + + // Feature-class histogram (by S-101 class name). + var hist = std.StringHashMap(usize).init(a); + var unresolved: usize = 0; + for (ds.features) |f| { + const nm = ds.featureName(f) orelse { + unresolved += 1; + continue; + }; + const gop = try hist.getOrPut(nm); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += 1; + } + std.debug.print(" feature classes: {d} distinct ({d} unresolved codes)\n", .{ hist.count(), unresolved }); + // Print the histogram sorted by count desc. + const Entry = struct { name: []const u8, n: usize }; + var entries = std.ArrayList(Entry).empty; + var it = hist.iterator(); + while (it.next()) |e| try entries.append(a, .{ .name = e.key_ptr.*, .n = e.value_ptr.* }); + std.mem.sort(Entry, entries.items, {}, struct { + fn lt(_: void, x: Entry, y: Entry) bool { + return x.n > y.n; + } + }.lt); + for (entries.items) |e| std.debug.print(" {d:>5} {s}\n", .{ e.n, e.name }); + + // Sample features with attributes. + var shown: usize = 0; + for (ds.features) |f| { + if (shown >= want_features) break; + if (f.attrs.len < 3) continue; + const nm = ds.featureName(f) orelse continue; + var prim: []const u8 = "?"; + if (f.spas.len > 0) prim = switch (f.spas[0].rrnm) { + s101.dataset.RCNM_POINT => "Point", + s101.dataset.RCNM_MULTIPOINT => "Multipoint", + s101.dataset.RCNM_CURVE, s101.dataset.RCNM_COMPOSITE => "Curve", + s101.dataset.RCNM_SURFACE => "Surface", + else => "?", + }; + std.debug.print("\n FEATURE rcid={d} class={s} primitive={s} attrs={d} spas={d} fasc={d}\n", .{ f.rcid, nm, prim, f.attrs.len, f.spas.len, f.fasc.len }); + for (f.attrs) |at| { + const an = ds.attrName(at) orelse "?"; + const v = if (at.val.len > 60) at.val[0..60] else at.val; + std.debug.print(" natc={d:>3}({s}) atix={d} paix={d} val={s}\n", .{ at.natc, an, at.atix, at.paix, v }); + } + shown += 1; + } +} From a4319534b5a9859aae3c4fe36439e50ade9a6e17 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 14:28:07 -0400 Subject: [PATCH 02/12] feat(s101): assemble native S-101 into s57.Cell shell + adapter.Adapted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/s101/native.zig. parseDataset(gpa, bytes) maps a native S-101 dataset onto the two things the renderer consumes, bypassing the S-57->S-101 adapter: * a geometry-carrying s57.Cell shell — S-100 points -> VC nodes, curves -> VE edges (begin/end from PTAS + interior C2IL), multipoints -> VI sounding vectors; features resolve their SPAS (and, for surfaces, RIAS rings and composite-curve members) into FSPT-style edge/node refs with ORNT/USAG/MASK. Every existing geometry, masking, and boolean accessor then works unchanged. * []adapter.Adapted built directly — class name from the FTCS table, the CNode attribute tree reconstructed from ATTR's PAIX parent-position links (native S-101 already carries the complex attributes the adapter has to synthesize from flat S-57 attributes). A small class->objl / name->attr-code surrogate covers only the classes scene.zig special-cases (soundings, depth contours, dangers, coastline masking, lights). Validated on all 8 SHOM France cells: features==adapted, vectors==edges+ soundingVecs, nodes==points; area/line/point/sounding geometry all resolve through the s57.Cell accessors with bounds on the French coast. --- src/s101/native.zig | 380 ++++++++++++++++++++++++++++++++++++++++++++ src/s101/s101.zig | 3 + tools/s101dump.zig | 41 +++++ 3 files changed, 424 insertions(+) create mode 100644 src/s101/native.zig diff --git a/src/s101/native.zig b/src/s101/native.zig new file mode 100644 index 0000000..9e3ef6f --- /dev/null +++ b/src/s101/native.zig @@ -0,0 +1,380 @@ +//! Native S-101 assembly: turn a parsed `dataset.Dataset` into the two things the +//! rendering pipeline consumes, WITHOUT the S-57 -> S-101 adapter: +//! +//! 1. an `s57.Cell` GEOMETRY SHELL — the S-100 spatial records (point / multipoint +//! / curve / composite-curve / surface) mapped onto the S-57 vector model +//! (nodes / edges / sounding vectors + per-feature FSPT-style refs), so every +//! existing geometry, masking, and boolean accessor works unchanged; and +//! 2. `[]adapter.Adapted` PORTRAYAL RECORDS built directly from the S-101 feature +//! records — class name from the in-band FTCS table, the attribute `CNode` tree +//! reconstructed from ATTR's PAIX parent links. The S-101 data already speaks +//! the portrayal vocabulary, so there is nothing to translate. +//! +//! The S-57 `objl`/attribute surrogates on the shell features exist ONLY for the +//! handful of classes `scene.zig` special-cases (soundings, depth contours, dangers, +//! coastline masking, lights); general portrayal flows through the native `Adapted`. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const s57 = @import("s57"); +const dataset = @import("dataset.zig"); +const adapter = @import("adapter.zig"); + +const CNode = adapter.CNode; +const NameVal = adapter.NameVal; +const ChildEntry = adapter.ChildEntry; + +// Internal vector-record name codes for the shell (S-57 conventions the geometry +// accessors key on): points -> connected node (VC), multipoints -> isolated node +// (VI, which holds SG3D), curves -> edge (VE). +const VC = s57.RCNM_VC; // 120 +const VI = s57.RCNM_VI; // 110 +const VE = s57.RCNM_VE; // 130 + +pub const Loaded = struct { + cell: s57.Cell, + /// Native portrayal records, indexed 1:1 with `cell.features`. Lives in the + /// cell's arena — valid for the cell's lifetime. Pass to `portray` in place of + /// `adapter.adaptCell`. + adapted: []const adapter.Adapted, +}; + +/// Detect + parse + assemble a native S-101 dataset. On a non-S-101 file returns +/// `error.NotS101` so the caller can fall through to the S-57 reader. +pub fn parseDataset(gpa: Allocator, bytes: []const u8) !Loaded { + if (!dataset.detect(bytes)) return error.NotS101; + var ds = try dataset.parse(gpa, bytes); + defer ds.deinit(); // the shell dupes everything it keeps into its own arena + return assemble(gpa, &ds); +} + +fn combineOrnt(a: u8, b: u8) u8 { + return if ((a == 2) != (b == 2)) 2 else 1; +} + +/// S-101 class name -> the S-57 object class `scene.zig` keys its special-cases on +/// (soundings, depth areas/contours, dangers, coastline-masking definers, lights), +/// or 0 when the class needs no surrogate (general portrayal is class-name driven). +fn surrogateObjl(class: []const u8) u16 { + const eql = std.mem.eql; + if (eql(u8, class, "Sounding")) return 129; // SOUNDG (scene emits the multipoint) + if (eql(u8, class, "DepthArea")) return 42; // DEPARE + if (eql(u8, class, "DredgedArea")) return 46; // DRGARE + if (eql(u8, class, "DepthContour")) return 43; // DEPCNT (VALDCO label) + if (eql(u8, class, "Obstruction")) return 86; // OBSTRN (danger depth) + if (eql(u8, class, "UnderwaterAwashRock")) return 153; // UWTROC + if (eql(u8, class, "Wreck")) return 159; // WRECKS + if (eql(u8, class, "DataCoverage")) return 302; // M_COVR (quilting) + if (eql(u8, class, "NavigationalSystemOfMarks")) return 306; // M_NSYS + if (eql(u8, class, "Coastline")) return 30; // COALNE (coast-coincident masking) + if (eql(u8, class, "LandArea")) return 71; // LNDARE + if (eql(u8, class, "ShorelineConstruction")) return 122; // SLCONS + if (std.mem.startsWith(u8, class, "Light") and !eql(u8, class, "Lighthouse")) return 75; // LIGHTS (range rings) + return 0; +} + +/// S-101 simple-attribute name -> the S-57 attribute code `scene.zig`'s special-case +/// branches (and the danger-depth derivation) read directly, or null. General +/// portrayal reads attributes by S-101 NAME through the `CNode`; this is only the +/// bridge for the code-keyed scene branches. +fn surrogateAttr(name: []const u8) ?u16 { + const eql = std.mem.eql; + if (eql(u8, name, "valueOfSounding")) return s57.ATTR_VALSOU; // 179 (danger depth) + if (eql(u8, name, "valueOfDepthContour")) return s57.ATTR_VALDCO; // 174 (contour label) + if (eql(u8, name, "depthRangeMinimumValue")) return s57.ATTR_DRVAL1; // 87 + if (eql(u8, name, "depthRangeMaximumValue")) return s57.ATTR_DRVAL2; // 88 + return null; +} + +fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const a = arena.allocator(); + + // --- Geometry: spatial records -> S-57 vector model ------------------- + // nodes/edges/sounding_vecs use the gpa (Cell.deinit frees them); the record + // slices live in the arena. Mirrors s57.parseCellWithUpdates' final Cell. + var nodes = std.AutoHashMap(u64, s57.LonLat).init(gpa); + var edges = std.AutoHashMap(u32, usize).init(gpa); + var sounding_vecs = std.AutoHashMap(u64, usize).init(gpa); + errdefer { + nodes.deinit(); + edges.deinit(); + sounding_vecs.deinit(); + } + + // Point records -> connected nodes, keyed by (VC, rcid). Serve both curve + // endpoints and standalone point-feature geometry. + for (ds.points) |p| { + try nodes.put((@as(u64, VC) << 32) | p.rcid, s57.LonLat.init(p.lon, p.lat)); + } + + var vectors = std.ArrayList(s57.VectorRecord).empty; + // Curves -> edges (begin/end nodes + interior vertices). + for (ds.curves) |c| { + const idx = vectors.items.len; + try vectors.append(a, .{ + .rcnm = VE, + .rcid = c.rcid, + .points = try a.dupe(s57.LonLat, c.interior), + .soundings = &.{}, + .begin_node = c.begin_rcid, + .end_node = c.end_rcid, + }); + try edges.put(c.rcid, idx); + } + // Multipoints -> sounding vectors (keyed by (VI, rcid); soundingsFor reads them). + for (ds.multis) |m| { + const idx = vectors.items.len; + try vectors.append(a, .{ + .rcnm = VI, + .rcid = m.rcid, + .points = &.{}, + .soundings = try a.dupe(s57.Sounding, m.soundings), + }); + try sounding_vecs.put((@as(u64, VI) << 32) | m.rcid, idx); + } + + // Index composites + surfaces for SPAS resolution. + var comp_index = std.AutoHashMap(u32, dataset.CompositeRec).init(gpa); + defer comp_index.deinit(); + for (ds.composites) |c| try comp_index.put(c.rcid, c); + var surf_index = std.AutoHashMap(u32, dataset.SurfaceRec).init(gpa); + defer surf_index.deinit(); + for (ds.surfaces) |s| try surf_index.put(s.rcid, s); + + // --- Features -> shell features + native Adapted ---------------------- + var features = std.ArrayList(s57.Feature).empty; + var adapted = std.ArrayList(adapter.Adapted).empty; + + for (ds.features) |fr| { + const class = ds.featureName(fr) orelse continue; // unresolved code: skip + const prim: u8 = if (fr.spas.len == 0) 255 else switch (fr.spas[0].rrnm) { + dataset.RCNM_POINT, dataset.RCNM_MULTIPOINT => 1, + dataset.RCNM_CURVE, dataset.RCNM_COMPOSITE => 2, + dataset.RCNM_SURFACE => 3, + else => 255, + }; + + // Resolve SPAS -> S-57 spatial refs (VC node / VI sounding / VE edges). + var refs = std.ArrayList(s57.SpatialRef).empty; + for (fr.spas) |sp| { + switch (sp.rrnm) { + dataset.RCNM_POINT => try refs.append(a, .{ .name = .{ .rcnm = VC, .rcid = sp.rrid }, .ornt = 1 }), + dataset.RCNM_MULTIPOINT => try refs.append(a, .{ .name = .{ .rcnm = VI, .rcid = sp.rrid }, .ornt = 1 }), + dataset.RCNM_CURVE => try refs.append(a, .{ .name = .{ .rcnm = VE, .rcid = sp.rrid }, .ornt = sp.ornt, .mask = maskFor(fr, sp.rrid) }), + dataset.RCNM_COMPOSITE => try appendComposite(a, &refs, &comp_index, sp.rrid, sp.ornt, 0, fr), + dataset.RCNM_SURFACE => { + if (surf_index.get(sp.rrid)) |surf| { + for (surf.rings) |ring| { + switch (ring.rrnm) { + dataset.RCNM_CURVE => try refs.append(a, .{ .name = .{ .rcnm = VE, .rcid = ring.rrid }, .ornt = ring.ornt, .usag = ring.usag, .mask = maskFor(fr, ring.rrid) }), + dataset.RCNM_COMPOSITE => try appendComposite(a, &refs, &comp_index, ring.rrid, ring.ornt, ring.usag, fr), + else => {}, + } + } + } + }, + else => {}, + } + } + + // Surrogate objl + the code-keyed attrs scene.zig reads directly. + const objl = surrogateObjl(class); + var s57attrs = std.ArrayList(s57.Attr).empty; + for (fr.attrs) |at| { + if (at.paix != 0) continue; // only top-level simple attrs surrogate + const nm = ds.attrName(at) orelse continue; + if (surrogateAttr(nm)) |code| { + const v = std.mem.trim(u8, at.val, " "); + if (v.len > 0) try s57attrs.append(a, .{ .code = code, .value = try a.dupe(u8, v) }); + } + } + if (objl == 302) try s57attrs.append(a, .{ .code = 18, .value = "1" }); // CATCOV=1 (coverage) + + const fi = features.items.len; + try features.append(a, .{ + .rcnm = 100, + .rcid = fr.rcid, + .prim = prim, + .objl = objl, + .foid = (@as(u64, fr.foid.agen) << 48) | (@as(u64, fr.foid.fidn) << 16) | fr.foid.fids, + .refs = refs.items, + .attrs = s57attrs.items, + }); + + // Native Adapted: class name + CNode tree from ATTR (no adapter). + var points: []const [3]f64 = &.{}; + if (prim == 1 and fr.spas.len > 0 and fr.spas[0].rrnm == dataset.RCNM_POINT) { + if (nodes.get((@as(u64, VC) << 32) | fr.spas[0].rrid)) |pt| { + const one = try a.alloc([3]f64, 1); + one[0] = .{ pt.lon(), pt.lat(), 0 }; + points = one; + } + } + try adapted.append(a, .{ + .feature_index = fi, + .code = try a.dupe(u8, class), + .primitive = primitiveName(prim), + .root = try buildNode(a, ds.*, fr.attrs, 0), + .points = points, + }); + } + + // Coast-coincident masking set (COALNE/LNDARE/SLCONS edges). + var coast_edges: std.AutoHashMapUnmanaged(u32, void) = .{}; + for (features.items) |f| { + if (!s57.isCoastDefiner(f.objl)) continue; + for (f.refs) |ref| if (ref.name.rcnm == VE) try coast_edges.put(a, ref.name.rcid, {}); + } + + // FOID -> feature index (feature-to-feature association resolution). + var foid_index: std.AutoHashMapUnmanaged(u64, usize) = .{}; + for (features.items, 0..) |f, i| { + if (f.foid != 0) try foid_index.put(a, f.foid, i); + } + + const cell = s57.Cell{ + .params = .{ .comf = @intFromFloat(ds.params.cmfx), .somf = @intFromFloat(ds.params.cmfz) }, + .vectors = vectors.items, + .features = features.items, + .nodes = nodes, + .edges = edges, + .sounding_vecs = sounding_vecs, + .coast_edges = coast_edges, + .foid_index = foid_index, + .arena = arena, + }; + return .{ .cell = cell, .adapted = adapted.items }; +} + +/// The MASK indicator (1=mask/not-drawn, else 0) a feature applies to boundary +/// spatial record `rrid`, if any. +fn maskFor(fr: dataset.FeatureRec, rrid: u32) u8 { + for (fr.spas) |sp| if (sp.rrid == rrid and sp.mask == 1) return 1; + return 0; +} + +/// Expand a composite curve into its constituent curve edges as feature refs, +/// composing the outer orientation with each member's. +fn appendComposite( + a: Allocator, + refs: *std.ArrayList(s57.SpatialRef), + comp_index: *std.AutoHashMap(u32, dataset.CompositeRec), + comp_rcid: u32, + ornt: u8, + usag: u8, + fr: dataset.FeatureRec, +) !void { + const comp = comp_index.get(comp_rcid) orelse return; + for (comp.members) |m| { + try refs.append(a, .{ + .name = .{ .rcnm = VE, .rcid = m.rrid }, + .ornt = combineOrnt(ornt, m.ornt), + .usag = usag, + .mask = maskFor(fr, m.rrid), + }); + } +} + +fn primitiveName(prim: u8) []const u8 { + return switch (prim) { + 1 => "Point", + 2 => "Curve", + 3 => "Surface", + else => "", + }; +} + +/// Build the `CNode` sub-tree rooted at the attribute occupying 1-based position +/// `parent_pos` (0 = the feature root). Direct children are the ATTR records whose +/// `paix == parent_pos`; a child that is itself a parent (some record's `paix` +/// equals its position) becomes a nested complex `ChildEntry`, otherwise a simple +/// `NameVal` leaf. Repeated simple values under the same name are comma-joined, so +/// the portrayal host splits them exactly as it does S-57 list attributes. +fn buildNode(a: Allocator, ds: dataset.Dataset, attrs: []const dataset.Attr, parent_pos: u16) !CNode { + var simple = std.ArrayList(NameVal).empty; + // Parallel insertion-ordered lists: a complex-attribute NAME and its instances. + // A feature has only a handful of distinct complex children, so a linear scan + // to group instances by name is cheaper than a map. + var child_names = std.ArrayList([]const u8).empty; + var child_lists = std.ArrayList(std.ArrayList(CNode)).empty; + + for (attrs, 0..) |at, i| { + if (at.paix != parent_pos) continue; + const pos: u16 = @intCast(i + 1); + const name = ds.attrName(at) orelse continue; + if (isParent(attrs, pos)) { + const node = try buildNode(a, ds, attrs, pos); + var slot: ?usize = null; + for (child_names.items, 0..) |nm, k| if (std.mem.eql(u8, nm, name)) { + slot = k; + }; + if (slot) |k| { + try child_lists.items[k].append(a, node); + } else { + try child_names.append(a, name); + var list = std.ArrayList(CNode).empty; + try list.append(a, node); + try child_lists.append(a, list); + } + } else { + const v = std.mem.trim(u8, at.val, " "); + if (v.len == 0) continue; // a blank simple attr means "absent" + try appendSimple(a, &simple, name, v); + } + } + + var children = try a.alloc(ChildEntry, child_names.items.len); + for (child_names.items, child_lists.items, 0..) |nm, list, k| { + children[k] = .{ .code = try a.dupe(u8, nm), .nodes = list.items }; + } + return .{ .simple = simple.items, .children = children }; +} + +/// True when some ATTR record names `pos` as its parent (so `pos` is a complex +/// container, not a simple leaf). +fn isParent(attrs: []const dataset.Attr, pos: u16) bool { + for (attrs) |at| if (at.paix == pos) return true; + return false; +} + +/// Add a simple value under `name`, comma-joining a repeated name into a list. +fn appendSimple(a: Allocator, simple: *std.ArrayList(NameVal), name: []const u8, v: []const u8) !void { + for (simple.items) |*nv| if (std.mem.eql(u8, nv.name, name)) { + nv.value = try std.fmt.allocPrint(a, "{s},{s}", .{ nv.value, v }); + return; + }; + try simple.append(a, .{ .name = try a.dupe(u8, name), .value = try a.dupe(u8, v) }); +} + +// ------------------------------------------------------------------------- +test "buildNode reconstructs the complex-attribute tree from PAIX links" { + // A minimal dataset with an attribute code table and a nested tree: + // pos1 simpleTop = "5" (paix 0) + // pos2 container = "" (paix 0, complex) + // pos3 childA = "1" (paix 2) + // pos4 childB = "2" (paix 2) + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const ar = arena.allocator(); + + var ds: dataset.Dataset = .{ .arena = undefined, .iso_file = undefined }; + try ds.attr_codes.by_code.put(ar, 1, "simpleTop"); + try ds.attr_codes.by_code.put(ar, 2, "container"); + try ds.attr_codes.by_code.put(ar, 3, "childA"); + try ds.attr_codes.by_code.put(ar, 4, "childB"); + const attrs = [_]dataset.Attr{ + .{ .natc = 1, .atix = 1, .paix = 0, .atin = 1, .val = "5" }, + .{ .natc = 2, .atix = 1, .paix = 0, .atin = 1, .val = "" }, + .{ .natc = 3, .atix = 1, .paix = 2, .atin = 1, .val = "1" }, + .{ .natc = 4, .atix = 1, .paix = 2, .atin = 1, .val = "2" }, + }; + const root = try buildNode(ar, ds, &attrs, 0); + try std.testing.expectEqualStrings("5", root.simpleValue("simpleTop").?); + try std.testing.expectEqual(@as(usize, 1), root.childCount("container")); + const c = root.resolve("container:1").?; + try std.testing.expectEqualStrings("1", c.simpleValue("childA").?); + try std.testing.expectEqualStrings("2", c.simpleValue("childB").?); +} diff --git a/src/s101/s101.zig b/src/s101/s101.zig index 5c25530..cd496b6 100644 --- a/src/s101/s101.zig +++ b/src/s101/s101.zig @@ -7,15 +7,18 @@ //! * adapter — S-57 cell features -> S-101 feature/attribute records //! * instructions — the portrayal instruction stream (points, lines, text) //! * dataset — native S-101 (S-100 Part 10a) dataset reader (.000 files) +//! * native — native S-101 dataset -> s57.Cell shell + adapter.Adapted pub const catalogue = @import("catalogue.zig"); pub const adapter = @import("adapter.zig"); pub const instructions = @import("instructions.zig"); pub const dataset = @import("dataset.zig"); +pub const native = @import("native.zig"); test { _ = catalogue; _ = adapter; _ = instructions; _ = dataset; + _ = native; } diff --git a/tools/s101dump.zig b/tools/s101dump.zig index 2aa7d03..8b092df 100644 --- a/tools/s101dump.zig +++ b/tools/s101dump.zig @@ -90,4 +90,45 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } shown += 1; } + + // --- Geometry-shell assembly (the s57.Cell the renderer consumes) -------- + var loaded = try s101.native.parseDataset(a, data); + defer loaded.cell.deinit(); + const cell = loaded.cell; + std.debug.print("\n assembled shell: features={d} adapted={d} vectors={d} nodes={d} edges={d} soundingVecs={d}\n", .{ + cell.features.len, loaded.adapted.len, cell.vectors.len, cell.nodes.count(), cell.edges.count(), cell.sounding_vecs.count(), + }); + if (cell.bounds()) |b| std.debug.print(" geometry bounds: lon [{d:.5}, {d:.5}] lat [{d:.5}, {d:.5}]\n", .{ b[0], b[2], b[1], b[3] }); + // Spot-check assembled geometry per primitive: pick the first area, line, point, + // and sounding feature and report the vertex/part counts the accessors return. + var did_area = false; + var did_line = false; + var did_pt = false; + var did_snd = false; + for (cell.features, loaded.adapted) |f, ad| { + if (f.prim == 3 and !did_area) { + const parts = cell.geometryParts(a, f) catch &[_][]engine.s57.LonLat{}; + var verts: usize = 0; + for (parts) |pp| verts += pp.len; + std.debug.print(" area {s}: {d} parts / {d} verts\n", .{ ad.code, parts.len, verts }); + did_area = true; + } else if (f.prim == 2 and !did_line) { + const parts = cell.geometryParts(a, f) catch &[_][]engine.s57.LonLat{}; + var verts: usize = 0; + for (parts) |pp| verts += pp.len; + std.debug.print(" line {s}: {d} parts / {d} verts\n", .{ ad.code, parts.len, verts }); + did_line = true; + } else if (f.prim == 1 and f.objl != 129 and !did_pt) { + if (cell.pointGeometry(f)) |pt| { + std.debug.print(" point {s}: lon={d:.5} lat={d:.5}\n", .{ ad.code, pt.lon(), pt.lat() }); + did_pt = true; + } + } else if (f.objl == 129 and !did_snd) { + const snds = cell.soundingsFor(a, f) catch &[_]engine.s57.Sounding{}; + if (snds.len > 0) { + std.debug.print(" sounding {s}: {d} depth pts, first depth={d:.1}m\n", .{ ad.code, snds.len, snds[0].depth }); + did_snd = true; + } + } + } } From c1eb35a9211166775900507759aebbb07225c022 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 14:41:09 -0400 Subject: [PATCH 03/12] feat(s101): auto-detect + render native S-101 through the existing pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the native S-101 path into every .000 load site so the same API renders S-101 and S-57 charts transparently: * portray: add portrayCellWithAdapted / portrayCellVariantsAdapted — the native entry points that portray a pre-built adapter.Adapted set, skipping adapter.adaptCell. portrayCellWith / portrayCellVariants now delegate to them. * chart.zig: parseAnyCell() detects S-101 vs S-57 from the file and routes to s101.native.parseDataset or s57.parseCellWithUpdates; the lazy loader, the query backend, and the bake worker use it + portrayVariantsAny. * render.zig (tile57 png/pdf): same detect-and-route for the live-context path. * s57.Cell gains a flag; scene.zig's 'unknown feature -> QUESMRK1' fallback is gated on !cell.native (a native feature always has a valid S-101 class, so a null portrayal stream means 'nothing to draw', not 'unknown'). * native.zig: mark the shell native; exclude SOUNDG + geometry-less features from the adapted set (scene emits soundings directly). A French S-101 cell now renders a full S-52 chart (depth areas, soundings, contours, coastline, buoys/beacons, wrecks, dangers, text, routes); a known S-57 cell renders unchanged. --- src/chart.zig | 41 ++++++++++++++++++++++++++++++++++------- src/portray/portray.zig | 18 +++++++++++++++--- src/s101/native.zig | 35 +++++++++++++++++++++-------------- src/s57/s57.zig | 7 +++++++ src/scene/scene.zig | 6 +++++- tools/render.zig | 15 ++++++++++++--- tools/s101dump.zig | 25 ++++++++++++++----------- 7 files changed, 108 insertions(+), 39 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index 40a6a25..bd9e196 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -27,6 +27,7 @@ const scene = @import("scene"); const portray = @import("portray"); const bake_enc = @import("scene").bake_enc; const catalogue = @import("s101").catalogue; +const s101 = @import("s101"); const tile = @import("tiles").tile; const render = @import("render"); const sprite = @import("sprite"); @@ -326,6 +327,30 @@ fn streamRead(ls: *LazySource, lc: *LazyCell) bool { return true; } +/// A parsed .000 chart: the geometry cell plus, for a NATIVE S-101 dataset, its +/// pre-built portrayal records (so portray bypasses the S-57 -> S-101 adapter). +const CellLoad = struct { cell: s57.Cell, adapted: ?[]const s101.adapter.Adapted = null }; + +/// Parse a .000 chart, auto-detecting S-101 vs S-57 from the file itself. A native +/// S-101 dataset (S-100 Part 10a) assembles via s101.native; an S-57 cell parses +/// with its update chain applied (S-101 update-record merge is not yet modeled, so +/// `updates` are ignored for a native base). Returns null on a parse failure. +fn parseAnyCell(base: []const u8, updates: []const []const u8) ?CellLoad { + if (s101.dataset.detect(base)) { + const l = s101.native.parseDataset(gpa, base) catch return null; + return .{ .cell = l.cell, .adapted = l.adapted }; + } + const cell = s57.parseCellWithUpdates(gpa, base, updates) catch return null; + return .{ .cell = cell }; +} + +/// Portray a cell three ways, using the native adapted set (S-101) when present, +/// else the S-57 adapter. +fn portrayVariantsAny(arena: std.mem.Allocator, cell: *const s57.Cell, adapted: ?[]const s101.adapter.Adapted, dir: []const u8) !portray.CellPortrayal { + if (adapted) |ad| return portray.portrayCellVariantsAdapted(arena, cell, ad, dir); + return portray.portrayCellVariants(arena, cell, dir); +} + // Parse + portray a lazy cell if not already loaded, and stamp its LRU tick. fn lazyEnsureLoaded(ls: *LazySource, lc: *LazyCell) void { ls.tick += 1; @@ -334,11 +359,12 @@ fn lazyEnsureLoaded(ls: *LazySource, lc: *LazyCell) void { if (lc.streaming and lc.base.len == 0) { if (!streamRead(ls, lc)) return; } - var cell = s57.parseCellWithUpdates(gpa, lc.base, lc.updates) catch return; + const loaded = parseAnyCell(lc.base, lc.updates) orelse return; + var cell = loaded.cell; cell.name = lc.name; // pick-report source-cell badge (gpa-owned, lives with the source) if (gpa.create(std.heap.ArenaAllocator)) |p| { p.* = std.heap.ArenaAllocator.init(gpa); - if (portray.portrayCellVariants(p.allocator(), &cell, ls.rules_dir)) |cp| { + if (portrayVariantsAny(p.allocator(), &cell, loaded.adapted, ls.rules_dir)) |cp| { lc.portrayal = cp.base; lc.portrayal_plain = cp.plain; lc.portrayal_simplified = cp.simplified; @@ -492,14 +518,14 @@ pub fn openPmtilesPath(io: std.Io, path: []const u8) !*Chart { // Parse (+ apply updates) + portray one cell into a CellBackend. Reads the bytes // but does not take ownership. Portrayal failure is non-fatal (classify() fallback). fn buildCellBackend(base: []const u8, updates: []const []const u8, dir: []const u8) ?CellBackend { - const cell = s57.parseCellWithUpdates(gpa, base, updates) catch return null; - var cb = CellBackend{ .cell = cell, .cscl = s57.peekScale(gpa, base) orelse 0 }; + const loaded = parseAnyCell(base, updates) orelse return null; + var cb = CellBackend{ .cell = loaded.cell, .cscl = s57.peekScale(gpa, base) orelse 0 }; const pa = gpa.create(std.heap.ArenaAllocator) catch return cb; pa.* = std.heap.ArenaAllocator.init(gpa); cb.portray_arena = pa; // Real M_COVR data-coverage polygons for the host to report as chart coverage. cb.coverage = cb.cell.mcovrCoverage(pa.allocator()); - if (portray.portrayCellVariants(pa.allocator(), &cb.cell, dir)) |cp| { + if (portrayVariantsAny(pa.allocator(), &cb.cell, loaded.adapted, dir)) |cp| { cb.portrayal = cp.base; cb.portrayal_plain = cp.plain; cb.portrayal_simplified = cp.simplified; @@ -2348,7 +2374,8 @@ const BakeWork = struct { _ = scratch; // owns persistent backends via its own arenas / `gpa` const c: *BakeWork = @ptrCast(@alignCast(uptr)); const src = c.sources[i]; - var cell = s57.parseCellWithUpdates(gpa, src.base, src.updates) catch return; + const loaded = parseAnyCell(src.base, src.updates) orelse return; + var cell = loaded.cell; cell.name = src.name; // pick-report badge (borrowed for the bake call) const b = cell.bounds() orelse { cell.deinit(); @@ -2363,7 +2390,7 @@ const BakeWork = struct { const pa: ?*std.heap.ArenaAllocator = gpa.create(std.heap.ArenaAllocator) catch null; if (pa) |p| { p.* = std.heap.ArenaAllocator.init(gpa); - if (portray.portrayCellVariants(p.allocator(), &cell, c.rules_dir)) |cp| { + if (portrayVariantsAny(p.allocator(), &cell, loaded.adapted, c.rules_dir)) |cp| { portrayal = cp.base; portrayal_plain = cp.plain; portrayal_simplified = cp.simplified; diff --git a/src/portray/portray.zig b/src/portray/portray.zig index 2f58e63..a277488 100644 --- a/src/portray/portray.zig +++ b/src/portray/portray.zig @@ -330,7 +330,7 @@ fn dedupKey(arena: std.mem.Allocator, ad: adapter.Adapted, f: s57.Feature, refer return kb.items; } -fn runAdapted(arena: std.mem.Allocator, cell: *const s57.Cell, adapted: []adapter.Adapted, rules_dir: []const u8, pctx: Context) ![]?[]const u8 { +fn runAdapted(arena: std.mem.Allocator, cell: *const s57.Cell, adapted: []const adapter.Adapted, rules_dir: []const u8, pctx: Context) ![]?[]const u8 { // Portrayal dedup: a Curve/Surface feature with no FFPT relationship portrays as a // pure function of (class, primitive, attributes) — see dedupKey. Run the S-101 // rules ONCE per distinct key and fan the instruction stream out to the rest, so a @@ -404,7 +404,12 @@ pub fn portrayCell(arena: std.mem.Allocator, cell: *const s57.Cell, rules_dir: [ /// boundary + point styles evaluate inside the rules, so the output needs none /// of the tile path's live-swap props. One pass, one context. pub fn portrayCellWith(arena: std.mem.Allocator, cell: *const s57.Cell, rules_dir: []const u8, ctx: Context) ![]?[]const u8 { - const adapted = try adapter.adaptCell(arena, cell); + return portrayCellWithAdapted(arena, cell, try adapter.adaptCell(arena, cell), rules_dir, ctx); +} + +/// Like `portrayCellWith` but over a PRE-BUILT adapted set — the native-S-101 entry +/// point (skips `adapter.adaptCell`; see `portrayCellVariantsAdapted`). +pub fn portrayCellWithAdapted(arena: std.mem.Allocator, cell: *const s57.Cell, adapted: []const adapter.Adapted, rules_dir: []const u8, ctx: Context) ![]?[]const u8 { return runAdapted(arena, cell, adapted, rules_dir, ctx); } @@ -427,7 +432,14 @@ pub const CellPortrayal = struct { /// vary (areas for bnd, points for pts) — lines/soundings never read either /// override — so the extra rule evaluation is bounded to the relevant features. pub fn portrayCellVariants(arena: std.mem.Allocator, cell: *const s57.Cell, rules_dir: []const u8) !CellPortrayal { - const adapted = try adapter.adaptCell(arena, cell); + return portrayCellVariantsAdapted(arena, cell, try adapter.adaptCell(arena, cell), rules_dir); +} + +/// Like `portrayCellVariants` but over a PRE-BUILT adapted set — the native-S-101 +/// entry point. A native cell's features already carry S-101 class + attribute +/// records (`s101.native`), so this bypasses `adapter.adaptCell` entirely: the +/// dedup / three-variant machinery is identical, only the adaptation source differs. +pub fn portrayCellVariantsAdapted(arena: std.mem.Allocator, cell: *const s57.Cell, adapted: []const adapter.Adapted, rules_dir: []const u8) !CellPortrayal { const base = try runAdapted(arena, cell, adapted, rules_dir, .{}); // Partition the adapted features by the variant they can contribute. "Surface" diff --git a/src/s101/native.zig b/src/s101/native.zig index 9e3ef6f..4c39ad8 100644 --- a/src/s101/native.zig +++ b/src/s101/native.zig @@ -203,22 +203,28 @@ fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { .attrs = s57attrs.items, }); - // Native Adapted: class name + CNode tree from ATTR (no adapter). - var points: []const [3]f64 = &.{}; - if (prim == 1 and fr.spas.len > 0 and fr.spas[0].rrnm == dataset.RCNM_POINT) { - if (nodes.get((@as(u64, VC) << 32) | fr.spas[0].rrid)) |pt| { - const one = try a.alloc([3]f64, 1); - one[0] = .{ pt.lon(), pt.lat(), 0 }; - points = one; + // Native Adapted: class name + CNode tree from ATTR (no adapter). SOUNDG + // (objl 129) is emitted directly as a multipoint by scene, and a feature with + // no spatial primitive can't portray — neither goes through the rules (the + // Sounding rule would error on the multipoint the rule path doesn't model). + const primitive = primitiveName(prim); + if (objl != 129 and primitive.len > 0) { + var points: []const [3]f64 = &.{}; + if (prim == 1 and fr.spas.len > 0 and fr.spas[0].rrnm == dataset.RCNM_POINT) { + if (nodes.get((@as(u64, VC) << 32) | fr.spas[0].rrid)) |pt| { + const one = try a.alloc([3]f64, 1); + one[0] = .{ pt.lon(), pt.lat(), 0 }; + points = one; + } } + try adapted.append(a, .{ + .feature_index = fi, + .code = try a.dupe(u8, class), + .primitive = primitive, + .root = try buildNode(a, ds.*, fr.attrs, 0), + .points = points, + }); } - try adapted.append(a, .{ - .feature_index = fi, - .code = try a.dupe(u8, class), - .primitive = primitiveName(prim), - .root = try buildNode(a, ds.*, fr.attrs, 0), - .points = points, - }); } // Coast-coincident masking set (COALNE/LNDARE/SLCONS edges). @@ -243,6 +249,7 @@ fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { .sounding_vecs = sounding_vecs, .coast_edges = coast_edges, .foid_index = foid_index, + .native = true, .arena = arena, }; return .{ .cell = cell, .adapted = adapted.items }; diff --git a/src/s57/s57.zig b/src/s57/s57.zig index ccd5fc6..d3210c3 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -618,6 +618,13 @@ pub const Cell = struct { /// single-tile path falls back to an on-demand search). Allocated in the baker's /// geometry arena, not the cell arena. label_cache: ?[]const ?LonLat = null, + /// True when this cell was assembled from a NATIVE S-101 dataset (s101.native) + /// rather than parsed from an S-57 source. It carries the S-57 geometry model + /// but its features draw their portrayal from S-101-native `adapter.Adapted` + /// (not `adaptCell`), so an `objl` may be a surrogate or 0 even though the + /// feature has a valid S-101 class. Downstream code that would treat "no S-57 + /// class" as "unknown feature" must exempt native cells. + native: bool = false, arena: std.heap.ArenaAllocator, pub fn deinit(self: *Cell) void { diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 8160081..06ccec7 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -2246,7 +2246,11 @@ fn appendCellFeatures( try emitNavSystemFallback(a, cell.*, f, fi, geo, z, x, y, tb, box, fopts, surf); continue; } - if (f.objl != s57.OBJL_TOPMAR and adapter.resolveClass(f) == null) { + // "Unknown feature -> ?" fallback: only for S-57 cells, where an object class + // with no S-101 mapping was never portrayed. A NATIVE S-101 feature always has + // a valid class (it came from the dataset's own FTCS table); a null/empty + // portrayal stream there means the rule ran and emitted nothing, not "unknown". + if (!cell.native and f.objl != s57.OBJL_TOPMAR and adapter.resolveClass(f) == null) { if (!fopts.suppress_points) try emitCentredSymbol(a, cell.*, f, fi, geo, "QUESMRK1", 6, 1, z, x, y, tb, fopts, surf); continue; } diff --git a/tools/render.zig b/tools/render.zig index 402ad61..309909d 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -133,12 +133,11 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: // Auto-apply the cell's sequential .001.. updates beside it (like the // streaming chart loader and `tile57 explore`) — a bare-.000 render of a // real NOAA cell without its updates shows stale/deleted features. - cell = try engine.s57.parseCellWithUpdates(a, data, readUpdates(io, a, path)); engine.portray.setQuiet(true); // LIVE portrayal context: the mariner's real safety contour / depth / // contours / styles evaluate INSIDE the rules — the native win over // the tile path's fixed bake context. - streams = try engine.portray.portrayCellWith(a, &cell, resolveRulesDir(rules), .{ + const pctx = engine.portray.Context{ .safety_contour = m.safety_contour, .safety_depth = m.safety_depth, .shallow_contour = m.shallow_contour, @@ -146,7 +145,17 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: .plain_boundaries = m.boundary_style == .plain, .simplified_symbols = m.simplified_points, .full_light_lines = m.show_full_sector_lines, - }); + }; + // A native S-101 dataset (.000, S-100 Part 10a) assembles + portrays without + // the S-57 -> S-101 adapter; an S-57 cell parses with its .001.. updates. + if (engine.s101.dataset.detect(data)) { + const loaded = try engine.s101.native.parseDataset(a, data); + cell = loaded.cell; + streams = try engine.portray.portrayCellWithAdapted(a, &cell, loaded.adapted, resolveRulesDir(rules), pctx); + } else { + cell = try engine.s57.parseCellWithUpdates(a, data, readUpdates(io, a, path)); + streams = try engine.portray.portrayCellWith(a, &cell, resolveRulesDir(rules), pctx); + } } defer if (!from_bundle) cell.deinit(); diff --git a/tools/s101dump.zig b/tools/s101dump.zig index 8b092df..1ced818 100644 --- a/tools/s101dump.zig +++ b/tools/s101dump.zig @@ -99,13 +99,13 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { cell.features.len, loaded.adapted.len, cell.vectors.len, cell.nodes.count(), cell.edges.count(), cell.sounding_vecs.count(), }); if (cell.bounds()) |b| std.debug.print(" geometry bounds: lon [{d:.5}, {d:.5}] lat [{d:.5}, {d:.5}]\n", .{ b[0], b[2], b[1], b[3] }); - // Spot-check assembled geometry per primitive: pick the first area, line, point, - // and sounding feature and report the vertex/part counts the accessors return. + // Spot-check assembled geometry per primitive: pick the first area/line/point + // adapted feature and report the vertex/part counts the accessors return. var did_area = false; var did_line = false; var did_pt = false; - var did_snd = false; - for (cell.features, loaded.adapted) |f, ad| { + for (loaded.adapted) |ad| { + const f = cell.features[ad.feature_index]; if (f.prim == 3 and !did_area) { const parts = cell.geometryParts(a, f) catch &[_][]engine.s57.LonLat{}; var verts: usize = 0; @@ -118,17 +118,20 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { for (parts) |pp| verts += pp.len; std.debug.print(" line {s}: {d} parts / {d} verts\n", .{ ad.code, parts.len, verts }); did_line = true; - } else if (f.prim == 1 and f.objl != 129 and !did_pt) { + } else if (f.prim == 1 and !did_pt) { if (cell.pointGeometry(f)) |pt| { std.debug.print(" point {s}: lon={d:.5} lat={d:.5}\n", .{ ad.code, pt.lon(), pt.lat() }); did_pt = true; } - } else if (f.objl == 129 and !did_snd) { - const snds = cell.soundingsFor(a, f) catch &[_]engine.s57.Sounding{}; - if (snds.len > 0) { - std.debug.print(" sounding {s}: {d} depth pts, first depth={d:.1}m\n", .{ ad.code, snds.len, snds[0].depth }); - did_snd = true; - } + } + } + // Soundings are emitted directly (not in `adapted`); report the first one. + for (cell.features) |f| { + if (f.objl != 129) continue; + const snds = cell.soundingsFor(a, f) catch &[_]engine.s57.Sounding{}; + if (snds.len > 0) { + std.debug.print(" sounding: {d} depth pts, first depth={d:.1}m\n", .{ snds.len, snds[0].depth }); + break; } } } From cb0a188cea846d6f1cc15d0a6938c14a8e472286 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 14:49:37 -0400 Subject: [PATCH 04/12] feat(s101): wire SCAMIN culling + add portrayal audit to the s101 tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map the S-101 scaleMinimum attribute onto the S-57 SCAMIN code (133) so native features are scale-culled by the existing S-52 gate — a low-zoom view of a French cell drops from 11159 to 2093 draw ops, matching S-52 generalization (small-scale soundings/marks hidden until zoomed in). The 'tile57 s101' command now also runs the rules and reports ok/empty/ERROR portrayal-stream counts by class: all 8 France cells portray with 0 errors and 0 empty streams. --- src/s101/native.zig | 1 + tools/s101dump.zig | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/s101/native.zig b/src/s101/native.zig index 4c39ad8..9ec3dcb 100644 --- a/src/s101/native.zig +++ b/src/s101/native.zig @@ -79,6 +79,7 @@ fn surrogateObjl(class: []const u8) u16 { /// bridge for the code-keyed scene branches. fn surrogateAttr(name: []const u8) ?u16 { const eql = std.mem.eql; + if (eql(u8, name, "scaleMinimum")) return 133; // SCAMIN — S-52 scale-based feature culling if (eql(u8, name, "valueOfSounding")) return s57.ATTR_VALSOU; // 179 (danger depth) if (eql(u8, name, "valueOfDepthContour")) return s57.ATTR_VALDCO; // 174 (contour label) if (eql(u8, name, "depthRangeMinimumValue")) return s57.ATTR_DRVAL1; // 87 diff --git a/tools/s101dump.zig b/tools/s101dump.zig index 1ced818..279d27e 100644 --- a/tools/s101dump.zig +++ b/tools/s101dump.zig @@ -134,4 +134,31 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { break; } } + + // --- Portrayal audit: run the rules and count ok / empty / ERROR streams ----- + engine.portray.setQuiet(true); + const streams = engine.portray.portrayCellWithAdapted(a, &cell, loaded.adapted, "", .{}) catch { + std.debug.print(" portrayal FAILED to run\n", .{}); + return; + }; + var ok: usize = 0; + var empty: usize = 0; + var errored: usize = 0; + var err_by_class = std.StringHashMap(usize).init(a); + for (loaded.adapted) |ad| { + const s = if (ad.feature_index < streams.len) streams[ad.feature_index] else null; + if (s == null) { + empty += 1; + } else if (std.mem.startsWith(u8, s.?, "ERROR:")) { + errored += 1; + const gop = try err_by_class.getOrPut(ad.code); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += 1; + } else ok += 1; + } + std.debug.print("\n portrayal: ok={d} empty={d} ERROR={d} (of {d} adapted)\n", .{ ok, empty, errored, loaded.adapted.len }); + if (errored > 0) { + var eit = err_by_class.iterator(); + while (eit.next()) |e| std.debug.print(" ERROR x{d}: {s}\n", .{ e.value_ptr.*, e.key_ptr.* }); + } } From abd5e791cd516de7aff10ccb1ccde2c106b0eebf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 14:52:39 -0400 Subject: [PATCH 05/12] docs: native S-101 chart loading tile57 reads both native S-101 (S-100 Part 10a) and legacy S-57 charts, auto- detected from the .000 file. Update intro + architecture pipeline to show the two converging entry paths, document the 'tile57 s101' inspect command, and note in limitations what native S-101 does not yet wire (update files; feature/info association surfacing in queries). --- docs/docs/architecture.md | 42 ++++++++++++++++++++++++--------------- docs/docs/cli.md | 14 +++++++++++++ docs/docs/intro.md | 19 +++++++++--------- docs/docs/limitations.md | 26 ++++++++++++++++++------ 4 files changed, 70 insertions(+), 31 deletions(-) diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index f723865..f8b5374 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -6,21 +6,26 @@ sidebar_position: 7 # Architecture -This page explains how tile57 turns an S-57 chart — an ENC *cell*, in the -spec's vocabulary — into vector tiles, how the codebase is layered, and the -memory design that keeps it small. +This page explains how tile57 turns an ENC chart — a *cell*, in the spec's +vocabulary — into vector tiles, how the codebase is layered, and the memory +design that keeps it small. ## The pipeline -A chart flows through these stages, all inside the engine: +A chart flows through these stages, all inside the engine. Both source formats +converge on the same S-101 feature + attribute records — a native S-101 dataset +produces them directly; an S-57 chart is adapted into them: ``` -S-57 ENC cell (.000) - │ decode the binary container src/iso8211/ (module: iso8211) - ▼ -S-57 feature + geometry model src/s57/ (module: s57) - │ adapt S-57 → S-101 features src/s101/ adapter.zig - ▼ +S-101 ENC (.000) S-57 ENC cell (.000) + │ decode 8211 │ decode 8211 src/iso8211/ (iso8211) + ▼ ▼ +S-100 spatial + feature S-57 feature + geometry src/s57/ (s57) + records model + │ assemble to the │ adapt S-57 → S-101 src/s101/ native.zig + │ S-101 model │ features / adapter.zig + └───────────┬─────────────┘ + ▼ S-101 feature + attribute records │ apply S-101 portrayal src/portray/ + embedded Lua 5.4 ▼ (vendor/S-101_Portrayal-Catalogue) @@ -35,11 +40,16 @@ render Surface src/render/surface.zig └─► pixel surfaces: PNG raster · vector PDF · terminal text (src/render/) ``` -1. **Decode (ISO 8211).** S-57 charts use the ISO 8211 binary container format; - the decoder reads its raw records and fields. -2. **Build the S-57 model.** Features (depth areas, buoys, coastlines, …), their - attributes, and their geometry (assembled from the vector topology) become a - queryable in-memory model. +1. **Decode (ISO 8211).** Both S-101 and S-57 charts use the ISO 8211 binary + container format; the decoder reads its raw records and fields. The chart + format is detected from the file's own record schema. +2. **Build the S-101 model.** A native S-101 dataset (S-100 Part 10a) is read + straight into S-101 feature + attribute records — its own in-band code tables + already carry the S-101 class and attribute names, and its complex attributes + are explicit, so no conversion is needed. An S-57 chart instead builds the + S-57 feature + geometry model (depth areas, buoys, coastlines, …) and adapts + it into the same S-101 records. Geometry (assembled from the vector topology) + and attributes become a queryable in-memory model either way. 3. **Apply S-101 portrayal.** The official IHO S-101 Portrayal Catalogue — the real Lua rule files — runs in embedded Lua 5.4 and decides how to draw each feature: symbol, colour, line style, conditional symbology. Zig implements the @@ -102,7 +112,7 @@ libc/Lua) and target-agnostic: |--------|------| | `iso8211` | the ISO/IEC 8211 container reader (the bottom layer; std-only) | | `s57` | the S-57 chart parser + geometry model (reads 8211 records through `iso8211`) | -| `s101` | the S-101 catalogue, the S-57 → S-101 adapter, and the portrayal instruction stream | +| `s101` | the S-101 catalogue, the native S-101 dataset reader, the S-57 → S-101 adapter, and the portrayal instruction stream | | `portray` | the embedded-Lua S-101 runner (links libc) | | `tiles` | MVT + MLT encoders, gzip, the PMTiles container, web-mercator tile math | | `render` | the Surface contract, the resolver (colours, display gates), and the pixel machinery (Canvas, PNG, PDF, ASCII) | diff --git a/docs/docs/cli.md b/docs/docs/cli.md index 9fe0b1c..c33e881 100644 --- a/docs/docs/cli.md +++ b/docs/docs/cli.md @@ -147,6 +147,20 @@ edition, update, issue date, agency, bbox. `cell` summarises one chart. GeoJSON FeatureCollection. `catalog` decodes an exchange-set catalogue into its entries (file, title, bbox). +### `s101` + +``` +tile57 s101 [--features N] +``` + +Inspects a native S-101 (S-100 Part 10a) chart: confirms detection, prints the +coordinate factors and record counts, the in-band code-table sizes, a +feature-class histogram, and the assembled geometry summary. It then runs the +portrayal rules and reports how many features drew, were empty, or errored. Use +`--features N` to also dump the first N features with their attributes. Charts +are auto-detected everywhere — `png`, `bake`, and the C API read a native S-101 +`.000` transparently — so this command is for inspection, not a separate load path. + ### `inspect` / `tiledump` ``` diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 915cb2c..2df239c 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -16,15 +16,16 @@ real-world navigation.** See [Known limitations](./limitations.md). ::: -**tile57** is a nautical chart engine. It reads IHO **S-57** ENC cells — each -cell is one electronic navigational chart, compiled for one scale; NOAA and -other hydrographic offices publish thousands — converts each chart's features -to the newer **S-101** data model, and runs the official IHO **S-101 -Portrayal Catalogue** in embedded Lua to decide how each feature is drawn, -producing **S-101 symbology** (the successor to S-52). The conversion is an -interim step, and a best-effort one — S-57 has no perfect S-101 translation -(see [Known limitations](./limitations.md)); the goal is S-101 throughout, -reading native S-101 charts directly as hydrographic offices publish them. +**tile57** is a nautical chart engine. It reads both native IHO **S-101** ENC +charts — the S-100-based successor format hydrographic offices are beginning to +publish — and legacy IHO **S-57** ENC cells (each cell is one electronic +navigational chart, compiled for one scale; NOAA and others publish thousands). +The format is detected from the chart file: a native S-101 dataset feeds the +portrayal model directly, while an S-57 chart is first converted to the S-101 +data model — a best-effort step, since S-57 has no perfect S-101 translation +(see [Known limitations](./limitations.md)). Either way, tile57 runs the +official IHO **S-101 Portrayal Catalogue** in embedded Lua to decide how each +feature is drawn, producing **S-101 symbology** (the successor to S-52). The primary output is **vector tiles**, fetched by `(z, x, y)`: [MapLibre Tiles](https://github.com/maplibre/maplibre-tile-spec) (MLT, the diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 600b660..834444e 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -22,14 +22,28 @@ See the warning on the [introduction](./intro.md). NOAA ENC charts are U.S. publ domain and not for navigation; this renderer adds its own gaps on top. ::: +## Native S-101 charts + +A native S-101 (S-100 Part 10a) chart is read directly into the S-101 portrayal +model with no conversion — the format is auto-detected from the file, so `png`, +`bake`, and the C API accept a native S-101 `.000` transparently. On the SHOM +France test datasets every feature portrays (no errors), including complex +attributes such as light sectors and buoy topmarks. Two areas are not yet +wired for native S-101: + +- **Update files.** Only the base `.000` is read; S-101 update-record merging + (the `.001…` sequence) is not applied yet. +- **Feature and information associations.** `FASC`/`INAS` records are parsed but + not yet surfaced in the feature-query / pick report. They carry no portrayal + weight for the test charts, so rendering is unaffected. + ## S-57 → S-101 conversion -tile57's input is S-57 but its portrayal rules are S-101, so every chart passes -through an S-57 → S-101 adapter (`src/s101/adapter.zig`) before the rules run. -The adapter is an interim solution — the goal is S-101 throughout, reading -native S-101 charts directly as hydrographic offices publish them. S-57 has no -perfect S-101 translation; the adapter follows the IHO S-65 conversion -guidance, and the result is **best effort**: +An S-57 chart's portrayal rules are S-101, so an S-57 chart passes through an +S-57 → S-101 adapter (`src/s101/adapter.zig`) before the rules run (a native +S-101 chart skips this — it is already in the S-101 model). S-57 has no perfect +S-101 translation; the adapter follows the IHO S-65 conversion guidance, and the +result is **best effort**: - **Missing S-101 content stays missing.** S-101 attributes and features with no S-57 source are never invented; rules that test them take their fallback From afc5274f70da02d04ed693c35f14bf04f46b2de3 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 18:59:49 -0400 Subject: [PATCH 06/12] feat(s101): apply native S-101 update files (.001+) parseWithUpdates merges a native S-101 base cell with its sequential update files at the record level, by (RCNM, RCID): RUIN 1=insert, 2=delete, 3=modify (S-100 Part 10a). A feature MODIFY applies the per-association SAUI edits (2=drop the matching spatial association, 1=add) and replaces attributes/FASC when the update carries them. Because each file (base and every update) has its OWN in-band code tables, class/attribute NAMES are resolved per file at decode time and stored on the records; all kept bytes are duped into the dataset arena, so the per-file ISO readers are transient (the Dataset no longer holds one). detect + parse are unchanged for a base-only cell. Wired through s101.native.parseDataset, chart.zig parseAnyCell, render.zig, and the 'tile57 s101' inspector (all auto-apply the sibling .001.. chain). Validated on the S-164 10100AA_X01SW conformance set (edition-1.0 base + updates 1-5): the merged result is byte-for-byte identical to an independent record-level merge simulation (all 795 features, every class), and portrays with 0 errors. Unit tests cover the rcid merge index and the SAUI modify path. --- src/chart.zig | 9 +- src/s101/dataset.zig | 352 ++++++++++++++++++++++++++++++++++--------- src/s101/native.zig | 13 +- tools/render.zig | 4 +- tools/s101dump.zig | 24 ++- 5 files changed, 314 insertions(+), 88 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index bd9e196..0d8c641 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -331,13 +331,12 @@ fn streamRead(ls: *LazySource, lc: *LazyCell) bool { /// pre-built portrayal records (so portray bypasses the S-57 -> S-101 adapter). const CellLoad = struct { cell: s57.Cell, adapted: ?[]const s101.adapter.Adapted = null }; -/// Parse a .000 chart, auto-detecting S-101 vs S-57 from the file itself. A native -/// S-101 dataset (S-100 Part 10a) assembles via s101.native; an S-57 cell parses -/// with its update chain applied (S-101 update-record merge is not yet modeled, so -/// `updates` are ignored for a native base). Returns null on a parse failure. +/// Parse a .000 chart, auto-detecting S-101 vs S-57 from the file itself, and apply +/// its sequential `.001…` update chain. A native S-101 dataset (S-100 Part 10a) +/// assembles via s101.native; an S-57 cell parses via s57. Returns null on failure. fn parseAnyCell(base: []const u8, updates: []const []const u8) ?CellLoad { if (s101.dataset.detect(base)) { - const l = s101.native.parseDataset(gpa, base) catch return null; + const l = s101.native.parseDataset(gpa, base, updates) catch return null; return .{ .cell = l.cell, .adapted = l.adapted }; } const cell = s57.parseCellWithUpdates(gpa, base, updates) catch return null; diff --git a/src/s101/dataset.zig b/src/s101/dataset.zig index 12dbf42..bbed88b 100644 --- a/src/s101/dataset.zig +++ b/src/s101/dataset.zig @@ -109,7 +109,8 @@ pub const CodeTable = struct { /// (container) attributes carry an empty `val` and are referenced as a `paix` by /// their sub-attributes; simple attributes carry a value. pub const Attr = struct { - natc: u16, // numeric attribute code (resolve via the ATCS table -> name) + name: []const u8 = "", // resolved S-101 attribute name (from THIS file's ATCS) + natc: u16, // numeric attribute code (per the source file's table) atix: u16, paix: u16, atin: u8, @@ -124,6 +125,7 @@ pub const SpatialAssoc = struct { ornt: u8, // 1=forward, 2=reverse, 255=null usag: u8 = 0, // 1=exterior, 2=interior, 3=truncated by data limit (from RIAS; SPAS carries none) mask: u8 = 0, // from the MASK field, joined by rrid + saui: u8 = 1, // Spatial Association Update Indicator: 1=insert, 2=delete (in a MODIFY update) }; /// One FASC entry: a feature-to-feature association. `rrid` is the associated @@ -136,35 +138,44 @@ pub const FeatureAssoc = struct { pub const Foid = struct { agen: u16 = 0, fidn: u32 = 0, fids: u16 = 0 }; +// Every record carries its RUIN (update instruction) + RVER (version). A base record +// is RUIN=1 (insert) / RVER=1; an update record's RUIN drives the record-level merge. pub const FeatureRec = struct { rcid: u32, - nftc: u16, // feature type code -> FTCS name (the S-101 class) + class: []const u8 = "", // resolved S-101 feature class (from THIS file's FTCS) + nftc: u16, // feature type code (per the source file's table) + ruin: u8 = 1, + version: u16 = 1, foid: Foid = .{}, attrs: []const Attr = &.{}, spas: []SpatialAssoc = &.{}, fasc: []const FeatureAssoc = &.{}, }; -pub const PointRec = struct { rcid: u32, lon: f64, lat: f64 }; -pub const MultiRec = struct { rcid: u32, soundings: []const s57.Sounding }; +pub const PointRec = struct { rcid: u32, lon: f64, lat: f64, ruin: u8 = 1, version: u16 = 1 }; +pub const MultiRec = struct { rcid: u32, soundings: []const s57.Sounding, ruin: u8 = 1, version: u16 = 1 }; pub const CurveRec = struct { rcid: u32, begin_rcid: u32 = 0, // referenced point record RCID (PTAS TOPI=1) end_rcid: u32 = 0, // PTAS TOPI=2 interior: []const s57.LonLat = &.{}, // C2IL vertices between the begin and end nodes + ruin: u8 = 1, + version: u16 = 1, }; pub const CompositeMember = struct { rrid: u32, ornt: u8 }; -pub const CompositeRec = struct { rcid: u32, members: []CompositeMember }; +pub const CompositeRec = struct { rcid: u32, members: []CompositeMember, ruin: u8 = 1, version: u16 = 1 }; pub const RingRef = struct { rrnm: u8, rrid: u32, ornt: u8, usag: u8 }; -pub const SurfaceRec = struct { rcid: u32, rings: []RingRef }; +pub const SurfaceRec = struct { rcid: u32, rings: []RingRef, ruin: u8 = 1, version: u16 = 1 }; -pub const InfoRec = struct { rcid: u32, nitc: u16, attrs: []const Attr }; +pub const InfoRec = struct { rcid: u32, itype: []const u8 = "", nitc: u16, attrs: []const Attr = &.{}, ruin: u8 = 1, version: u16 = 1 }; pub const Dataset = struct { + // All record bytes (names, values, geometry) are duped into this arena, so the + // per-file ISO 8211 readers are transient — none is retained. This lets records + // from several files (a base plus its updates) coexist after their sources close. arena: std.heap.ArenaAllocator, - iso_file: iso.File, params: Params = .{}, - feature_codes: CodeTable = .{}, // FTCS + feature_codes: CodeTable = .{}, // FTCS (the base file's, for tooling; records carry resolved names) attr_codes: CodeTable = .{}, // ATCS info_codes: CodeTable = .{}, // ITCS assoc_codes: CodeTable = .{}, // ARCS (roles) @@ -178,14 +189,17 @@ pub const Dataset = struct { infos: []InfoRec = &.{}, pub fn deinit(self: *Dataset) void { - self.iso_file.deinit(); self.arena.deinit(); } + // Names resolved at decode time (via the source file's tables) so a merged + // dataset is consistent even though each file has its OWN numeric code space. pub fn featureName(self: Dataset, f: FeatureRec) ?[]const u8 { + if (f.class.len > 0) return f.class; return self.feature_codes.name(f.nftc); } pub fn attrName(self: Dataset, a: Attr) ?[]const u8 { + if (a.name.len > 0) return a.name; return self.attr_codes.name(a.natc); } }; @@ -238,84 +252,215 @@ fn digit(c: u8) ?u8 { return if (c >= '0' and c <= '9') c - '0' else null; } -/// Parse a native S-101 dataset from in-memory bytes (borrowed; the returned -/// `Dataset` owns an ISO 8211 file that references `bytes`, so keep `bytes` alive -/// until `deinit`). Caller must `deinit` the result. +/// The header (coordinate factors + code tables) of one ISO 8211 file. Each file — +/// a base cell OR one of its updates — has its OWN numeric code space, so its records +/// resolve their class/attribute NAMES against ITS tables before merging. +const Tables = struct { + params: Params = .{}, + fc: CodeTable = .{}, // FTCS (feature classes) + ac: CodeTable = .{}, // ATCS (attributes) + ic: CodeTable = .{}, // ITCS (information types) + arc: CodeTable = .{}, // ARCS (association roles) +}; + +/// A record set being merged by (RCNM, RCID). `Idx(T)` keeps insertion order with a +/// last-wins rcid index; a delete tombstones the slot, `flatten` drops tombstones. +fn Idx(comptime T: type) type { + return struct { + const Self = @This(); + list: std.ArrayList(?T) = .empty, + idx: std.AutoHashMapUnmanaged(u32, usize) = .{}, + + fn upsert(self: *Self, a: Allocator, rcid: u32, rec: T) !void { + if (self.idx.get(rcid)) |i| { + self.list.items[i] = rec; + } else { + try self.list.append(a, rec); + try self.idx.put(a, rcid, self.list.items.len - 1); + } + } + fn remove(self: *Self, rcid: u32) void { + if (self.idx.fetchRemove(rcid)) |kv| self.list.items[kv.value] = null; + } + fn get(self: *Self, rcid: u32) ?*T { + const i = self.idx.get(rcid) orelse return null; + return if (self.list.items[i]) |*r| r else null; + } + fn flatten(self: *Self, a: Allocator) ![]T { + var out = std.ArrayList(T).empty; + for (self.list.items) |m| if (m) |x| try out.append(a, x); + return out.items; + } + }; +} + +const Merge = struct { + features: Idx(FeatureRec) = .{}, + points: Idx(PointRec) = .{}, + multis: Idx(MultiRec) = .{}, + curves: Idx(CurveRec) = .{}, + composites: Idx(CompositeRec) = .{}, + surfaces: Idx(SurfaceRec) = .{}, + infos: Idx(InfoRec) = .{}, +}; + +/// Parse a native S-101 base cell (no updates). Caller must `deinit` the result. pub fn parse(gpa: Allocator, bytes: []const u8) !Dataset { - var iso_file = try iso.parse(gpa, bytes); - errdefer iso_file.deinit(); + return parseWithUpdates(gpa, bytes, &.{}); +} +/// Parse a native S-101 base cell and apply its sequential update files (`.001`, +/// `.002`, … in order), merging records by (RCNM, RCID): RUIN 1=insert, 2=delete, +/// 3=modify (S-100 Part 10a §4a-4.5). Names resolve per file (each carries its own +/// code tables). All kept bytes are duped into the result's arena, so the base and +/// update ISO readers are transient. Pass an empty `updates` for a plain base cell. +pub fn parseWithUpdates(gpa: Allocator, base: []const u8, updates: []const []const u8) !Dataset { var arena = std.heap.ArenaAllocator.init(gpa); errdefer arena.deinit(); const a = arena.allocator(); - // Parse into locals (the arena is MOVED into the returned Dataset only at the - // end, so its final state travels with the result — never snapshot it mid-parse). - var params: Params = .{}; - var feature_codes: CodeTable = .{}; - var attr_codes: CodeTable = .{}; - var info_codes: CodeTable = .{}; - var assoc_codes: CodeTable = .{}; - - var features = std.ArrayList(FeatureRec).empty; - var points = std.ArrayList(PointRec).empty; - var multis = std.ArrayList(MultiRec).empty; - var curves = std.ArrayList(CurveRec).empty; - var composites = std.ArrayList(CompositeRec).empty; - var surfaces = std.ArrayList(SurfaceRec).empty; - var infos = std.ArrayList(InfoRec).empty; + var m: Merge = .{}; + + var base_iso = try iso.parse(gpa, base); + defer base_iso.deinit(); + const base_tables = try readHeader(a, base_iso); + try decodeInto(a, &m, base_iso, base_tables); + + for (updates) |u| { + var up_iso = iso.parse(gpa, u) catch break; // reject a corrupt update; keep prior state + defer up_iso.deinit(); + const t = readHeader(a, up_iso) catch break; + decodeInto(a, &m, up_iso, t) catch break; + } + + return .{ + .arena = arena, + .params = base_tables.params, + .feature_codes = base_tables.fc, + .attr_codes = base_tables.ac, + .info_codes = base_tables.ic, + .assoc_codes = base_tables.arc, + .features = try m.features.flatten(a), + .points = try m.points.flatten(a), + .multis = try m.multis.flatten(a), + .curves = try m.curves.flatten(a), + .composites = try m.composites.flatten(a), + .surfaces = try m.surfaces.flatten(a), + .infos = try m.infos.flatten(a), + }; +} +/// Decode one ISO file's data records into the merge set, applying each record's +/// RUIN. A delete needs only the identity; insert/modify decode fully (resolving +/// names via `t`'s tables). Spatial-record modify replaces; feature modify applies +/// the SAUI-indicated spatial-association edits (see `modifyFeature`). +fn decodeInto(a: Allocator, m: *Merge, iso_file: iso.File, t: Tables) !void { for (iso_file.records) |rec| { if (rec.fields.len == 0) continue; const lead = rec.fields[0].tag; - if (std.mem.eql(u8, lead, "DSID")) { - try parseDatasetRecord(a, rec, ¶ms, &feature_codes, &attr_codes, &info_codes, &assoc_codes); - } else if (std.mem.eql(u8, lead, "PRID")) { - if (rec.field("C2IT")) |c| { - const p = rec.field("PRID").?; - try points.append(a, .{ - .rcid = u32le(p, 1), - .lat = @as(f64, @floatFromInt(i32le(c, 0))) / params.cmfy, - .lon = @as(f64, @floatFromInt(i32le(c, 4))) / params.cmfx, + if (std.mem.eql(u8, lead, "PRID")) { + const p = rec.field("PRID").?; + const rcid = u32le(p, 1); + if (u8at(p, 7) == 2) { + m.points.remove(rcid); + } else if (rec.field("C2IT")) |c| { + try m.points.upsert(a, rcid, .{ + .rcid = rcid, + .lat = @as(f64, @floatFromInt(i32le(c, 0))) / t.params.cmfy, + .lon = @as(f64, @floatFromInt(i32le(c, 4))) / t.params.cmfx, + .ruin = u8at(p, 7), + .version = u16le(p, 5), }); } } else if (std.mem.eql(u8, lead, "MRID")) { - const m = rec.field("MRID").?; - const snds = if (rec.field("C3IL")) |c| try parseSoundings(a, c, params) else &[_]s57.Sounding{}; - try multis.append(a, .{ .rcid = u32le(m, 1), .soundings = snds }); + const md = rec.field("MRID").?; + const rcid = u32le(md, 1); + if (u8at(md, 7) == 2) { + m.multis.remove(rcid); + } else { + const snds = if (rec.field("C3IL")) |c| try parseSoundings(a, c, t.params) else &[_]s57.Sounding{}; + try m.multis.upsert(a, rcid, .{ .rcid = rcid, .soundings = snds, .ruin = u8at(md, 7), .version = u16le(md, 5) }); + } } else if (std.mem.eql(u8, lead, "CRID")) { - try curves.append(a, try parseCurve(a, rec, params)); + const c = rec.field("CRID").?; + if (u8at(c, 7) == 2) m.curves.remove(u32le(c, 1)) else try m.curves.upsert(a, u32le(c, 1), try parseCurve(a, rec, t.params)); } else if (std.mem.eql(u8, lead, "CCID")) { - try composites.append(a, try parseComposite(a, rec)); + const c = rec.field("CCID").?; + if (u8at(c, 7) == 2) m.composites.remove(u32le(c, 1)) else try m.composites.upsert(a, u32le(c, 1), try parseComposite(a, rec)); } else if (std.mem.eql(u8, lead, "SRID")) { - try surfaces.append(a, try parseSurface(a, rec)); + const s = rec.field("SRID").?; + if (u8at(s, 7) == 2) m.surfaces.remove(u32le(s, 1)) else try m.surfaces.upsert(a, u32le(s, 1), try parseSurface(a, rec)); } else if (std.mem.eql(u8, lead, "FRID")) { - try features.append(a, try parseFeature(a, rec)); + const f = rec.field("FRID").?; + const rcid = u32le(f, 1); + if (u8at(f, 9) == 2) { + m.features.remove(rcid); + } else { + const decoded = try parseFeature(a, rec, t); + if (decoded.ruin == 3) { + if (m.features.get(rcid)) |ex| { + try modifyFeature(a, ex, decoded); + continue; + } + } + try m.features.upsert(a, rcid, decoded); + } } else if (std.mem.eql(u8, lead, "IRID")) { const ir = rec.field("IRID").?; - const attrs = if (rec.field("ATTR")) |at| try parseAttrs(a, at) else &[_]Attr{}; - try infos.append(a, .{ .rcid = u32le(ir, 1), .nitc = u16le(ir, 5), .attrs = attrs }); + const rcid = u32le(ir, 1); + if (u8at(ir, 9) == 2) { + m.infos.remove(rcid); + } else { + const nitc = u16le(ir, 5); + const attrs = if (rec.field("ATTR")) |at| try parseAttrs(a, at, t.ac) else &[_]Attr{}; + try m.infos.upsert(a, rcid, .{ + .rcid = rcid, + .itype = try a.dupe(u8, t.ic.name(nitc) orelse ""), + .nitc = nitc, + .attrs = attrs, + .ruin = u8at(ir, 9), + .version = u16le(ir, 7), + }); + } } // CSID/CRSH/CSAX/VDAT (CRS) are WGS84/EPSG:4326 for ENC; the whole engine // already assumes geographic lon/lat, so nothing to carry. } +} - return .{ - .arena = arena, - .iso_file = iso_file, - .params = params, - .feature_codes = feature_codes, - .attr_codes = attr_codes, - .info_codes = info_codes, - .assoc_codes = assoc_codes, - .features = features.items, - .points = points.items, - .multis = multis.items, - .curves = curves.items, - .composites = composites.items, - .surfaces = surfaces.items, - .infos = infos.items, - }; +/// Apply a feature MODIFY update to the existing record. Attributes/FASC are replaced +/// when the update carries them (a MODIFY that omits a field leaves it unchanged); +/// spatial associations are edited per-entry by SAUI (2=delete the matching RRID, +/// 1=add), matching the S-164 reference behaviour (e.g. swap a surface for a re-cut one). +fn modifyFeature(a: Allocator, ex: *FeatureRec, upd: FeatureRec) !void { + if (upd.attrs.len > 0) ex.attrs = upd.attrs; + if (upd.fasc.len > 0) ex.fasc = upd.fasc; + if (upd.foid.agen != 0 or upd.foid.fidn != 0 or upd.foid.fids != 0) ex.foid = upd.foid; + if (upd.spas.len > 0) { + var spas = std.ArrayList(SpatialAssoc).empty; + for (ex.spas) |sp| { + var deleted = false; + for (upd.spas) |usp| { + if (usp.saui == 2 and usp.rrnm == sp.rrnm and usp.rrid == sp.rrid) deleted = true; + } + if (!deleted) try spas.append(a, sp); + } + for (upd.spas) |usp| if (usp.saui != 2) try spas.append(a, usp); + ex.spas = spas.items; + } + ex.version = upd.version; +} + +/// Read a file's coordinate factors + code tables from its DSID record (the first +/// data record). Code-table names are duped into `a`, so they outlive the ISO reader. +fn readHeader(a: Allocator, iso_file: iso.File) !Tables { + var t: Tables = .{}; + for (iso_file.records) |rec| { + if (rec.fields.len == 0 or !std.mem.eql(u8, rec.fields[0].tag, "DSID")) continue; + try parseDatasetRecord(a, rec, &t.params, &t.fc, &t.ac, &t.ic, &t.arc); + break; + } + return t; } fn parseDatasetRecord( @@ -357,9 +502,8 @@ fn parseCodeTable(a: Allocator, data: []const u8) !CodeTable { var i: usize = 0; while (i < data.len) { const ut = std.mem.indexOfScalarPos(u8, data, i, UT) orelse break; - const nm = data[i..ut]; const code = u16le(data, ut + 1); - try t.by_code.put(a, code, nm); + try t.by_code.put(a, code, try a.dupe(u8, data[i..ut])); // dupe: the ISO reader is transient i = ut + 3; } return t; @@ -367,7 +511,8 @@ fn parseCodeTable(a: Allocator, data: []const u8) !CodeTable { /// ATTR field: repeating `(3b12,b11,A)` = NATC,ATIX,PAIX (u16), ATIN (u8), then a /// UT-terminated ASCII value. Preserves order (PAIX references the 1-based position). -fn parseAttrs(a: Allocator, data: []const u8) ![]const Attr { +/// Resolves each NATC to its S-101 name via `ac` and dupes name + value into `a`. +fn parseAttrs(a: Allocator, data: []const u8, ac: CodeTable) ![]const Attr { var out = std.ArrayList(Attr).empty; var i: usize = 0; while (i + 7 <= data.len) { @@ -379,7 +524,14 @@ fn parseAttrs(a: Allocator, data: []const u8) ![]const Attr { const ut = std.mem.indexOfScalarPos(u8, data, i, UT) orelse data.len; const val = data[i..ut]; i = ut + 1; - try out.append(a, .{ .natc = natc, .atix = atix, .paix = paix, .atin = atin, .val = val }); + try out.append(a, .{ + .name = try a.dupe(u8, ac.name(natc) orelse ""), + .natc = natc, + .atix = atix, + .paix = paix, + .atin = atin, + .val = try a.dupe(u8, val), + }); } return out.items; } @@ -463,23 +615,31 @@ fn parseSurface(a: Allocator, rec: iso.Record) !SurfaceRec { return .{ .rcid = u32le(s, 1), .rings = rings.items }; } -fn parseFeature(a: Allocator, rec: iso.Record) !FeatureRec { +fn parseFeature(a: Allocator, rec: iso.Record, t: Tables) !FeatureRec { const f = rec.field("FRID").?; - var fr: FeatureRec = .{ .rcid = u32le(f, 1), .nftc = u16le(f, 5) }; + const nftc = u16le(f, 5); + var fr: FeatureRec = .{ + .rcid = u32le(f, 1), + .class = try a.dupe(u8, t.fc.name(nftc) orelse ""), + .nftc = nftc, + .ruin = u8at(f, 9), + .version = u16le(f, 7), + }; if (rec.field("FOID")) |o| { fr.foid = .{ .agen = u16le(o, 0), .fidn = u32le(o, 2), .fids = u16le(o, 6) }; } - if (rec.field("ATTR")) |at| fr.attrs = try parseAttrs(a, at); + if (rec.field("ATTR")) |at| fr.attrs = try parseAttrs(a, at, t.ac); // SPAS: repeating `(b11,b14,b11,2b14,b11)` = RRNM,RRID,ORNT,SMIN,SMAX,SAUI. A // feature may carry several (a surface plus a masked line, etc.), and the writer - // may split them across multiple SPAS fields, so collect every SPAS field. + // may split them across multiple SPAS fields, so collect every SPAS field. SAUI + // (last byte) drives association edits in a MODIFY update (see modifyFeature). var spas = std.ArrayList(SpatialAssoc).empty; for (rec.fields) |fld| { if (!std.mem.eql(u8, fld.tag, "SPAS")) continue; var o: usize = 0; while (o + 15 <= fld.data.len) : (o += 15) { - try spas.append(a, .{ .rrnm = u8at(fld.data, o), .rrid = u32le(fld.data, o + 1), .ornt = u8at(fld.data, o + 5) }); + try spas.append(a, .{ .rrnm = u8at(fld.data, o), .rrid = u32le(fld.data, o + 1), .ornt = u8at(fld.data, o + 5), .saui = u8at(fld.data, o + 14) }); } } fr.spas = spas.items; @@ -538,3 +698,49 @@ test "detect distinguishes an S-101 DDR from an S-57 DDR" { try std.testing.expectEqual(case[1], detect(buf.items)); } } + +test "Idx merges records by rcid: upsert / delete / flatten" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + var ix: Idx(PointRec) = .{}; + try ix.upsert(a, 10, .{ .rcid = 10, .lon = 0, .lat = 0 }); + try ix.upsert(a, 11, .{ .rcid = 11, .lon = 1, .lat = 1 }); + try ix.upsert(a, 12, .{ .rcid = 12, .lon = 2, .lat = 2 }); + ix.remove(11); // delete 11 + try ix.upsert(a, 10, .{ .rcid = 10, .lon = 9, .lat = 9 }); // modify-in-place 10 + const out = try ix.flatten(a); + try std.testing.expectEqual(@as(usize, 2), out.len); // 11 tombstoned + try std.testing.expectEqual(@as(u32, 10), out[0].rcid); + try std.testing.expectEqual(@as(f64, 9), out[0].lon); // replaced value + try std.testing.expectEqual(@as(u32, 12), out[1].rcid); +} + +test "modifyFeature applies SAUI spatial-association edits" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + var ex: FeatureRec = .{ + .rcid = 1, + .nftc = 0, + .spas = try a.dupe(SpatialAssoc, &.{ + .{ .rrnm = 130, .rrid = 10, .ornt = 1 }, + .{ .rrnm = 130, .rrid = 11, .ornt = 1 }, + }), + }; + // A MODIFY that removes the association to surface 11 and adds surface 12. + const upd: FeatureRec = .{ + .rcid = 1, + .nftc = 0, + .version = 2, + .spas = try a.dupe(SpatialAssoc, &.{ + .{ .rrnm = 130, .rrid = 11, .ornt = 1, .saui = 2 }, // delete + .{ .rrnm = 130, .rrid = 12, .ornt = 1, .saui = 1 }, // insert + }), + }; + try modifyFeature(a, &ex, upd); + try std.testing.expectEqual(@as(usize, 2), ex.spas.len); + try std.testing.expectEqual(@as(u32, 10), ex.spas[0].rrid); // kept + try std.testing.expectEqual(@as(u32, 12), ex.spas[1].rrid); // inserted (11 removed) + try std.testing.expectEqual(@as(u16, 2), ex.version); +} diff --git a/src/s101/native.zig b/src/s101/native.zig index 9ec3dcb..36ed93d 100644 --- a/src/s101/native.zig +++ b/src/s101/native.zig @@ -39,11 +39,12 @@ pub const Loaded = struct { adapted: []const adapter.Adapted, }; -/// Detect + parse + assemble a native S-101 dataset. On a non-S-101 file returns -/// `error.NotS101` so the caller can fall through to the S-57 reader. -pub fn parseDataset(gpa: Allocator, bytes: []const u8) !Loaded { - if (!dataset.detect(bytes)) return error.NotS101; - var ds = try dataset.parse(gpa, bytes); +/// Detect + parse + assemble a native S-101 dataset, applying its `.001…` update +/// chain. On a non-S-101 base returns `error.NotS101` so the caller can fall through +/// to the S-57 reader. +pub fn parseDataset(gpa: Allocator, base: []const u8, updates: []const []const u8) !Loaded { + if (!dataset.detect(base)) return error.NotS101; + var ds = try dataset.parseWithUpdates(gpa, base, updates); defer ds.deinit(); // the shell dupes everything it keeps into its own arena return assemble(gpa, &ds); } @@ -368,7 +369,7 @@ test "buildNode reconstructs the complex-attribute tree from PAIX links" { defer arena.deinit(); const ar = arena.allocator(); - var ds: dataset.Dataset = .{ .arena = undefined, .iso_file = undefined }; + var ds: dataset.Dataset = .{ .arena = undefined }; try ds.attr_codes.by_code.put(ar, 1, "simpleTop"); try ds.attr_codes.by_code.put(ar, 2, "container"); try ds.attr_codes.by_code.put(ar, 3, "childA"); diff --git a/tools/render.zig b/tools/render.zig index 309909d..6b3eddf 100644 --- a/tools/render.zig +++ b/tools/render.zig @@ -147,9 +147,9 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8, output: .full_light_lines = m.show_full_sector_lines, }; // A native S-101 dataset (.000, S-100 Part 10a) assembles + portrays without - // the S-57 -> S-101 adapter; an S-57 cell parses with its .001.. updates. + // the S-57 -> S-101 adapter; either format applies its .001.. update chain. if (engine.s101.dataset.detect(data)) { - const loaded = try engine.s101.native.parseDataset(a, data); + const loaded = try engine.s101.native.parseDataset(a, data, readUpdates(io, a, path)); cell = loaded.cell; streams = try engine.portray.portrayCellWithAdapted(a, &cell, loaded.adapted, resolveRulesDir(rules), pctx); } else { diff --git a/tools/s101dump.zig b/tools/s101dump.zig index 279d27e..1dbef32 100644 --- a/tools/s101dump.zig +++ b/tools/s101dump.zig @@ -7,6 +7,21 @@ const std = @import("std"); const engine = @import("engine"); const s101 = engine.s101; +/// The sequential `.001`, `.002`, … update files beside a `.000` base, read in +/// order until the first gap. Returns an empty slice for a non-.000 path. +fn readUpdates(io: std.Io, a: std.mem.Allocator, base_path: []const u8) []const []const u8 { + if (!std.mem.endsWith(u8, base_path, ".000")) return &.{}; + const stem = base_path[0 .. base_path.len - 3]; + var list = std.ArrayList([]const u8).empty; + var n: u32 = 1; + while (n < 1000) : (n += 1) { + const p = std.fmt.allocPrint(a, "{s}{d:0>3}", .{ stem, n }) catch break; + const bytes = std.Io.Dir.cwd().readFileAlloc(io, p, a, .unlimited) catch break; + list.append(a, bytes) catch break; + } + return list.items; +} + pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { if (args.len < 3) { std.debug.print("usage: tile57 s101 [--features N]\n", .{}); @@ -25,7 +40,12 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { return; } - var ds = try s101.dataset.parse(a, data); + // Auto-apply the sequential .001.. update files beside the base (like the render + // path), so the inspection reflects the merged, up-to-date dataset. + const updates = readUpdates(io, a, path); + if (updates.len > 0) std.debug.print(" applied {d} update file(s)\n", .{updates.len}); + + var ds = try s101.dataset.parseWithUpdates(a, data, updates); defer ds.deinit(); const p = ds.params; std.debug.print( @@ -92,7 +112,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } // --- Geometry-shell assembly (the s57.Cell the renderer consumes) -------- - var loaded = try s101.native.parseDataset(a, data); + var loaded = try s101.native.parseDataset(a, data, updates); defer loaded.cell.deinit(); const cell = loaded.cell; std.debug.print("\n assembled shell: features={d} adapted={d} vectors={d} nodes={d} edges={d} soundingVecs={d}\n", .{ From 8f0db84ef35bc8b32d277d4ec37b2c8751ef7f4c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 19:03:24 -0400 Subject: [PATCH 07/12] docs: native S-101 update files are now applied Update the limitations note: native S-101 charts apply their .001.. update chain (record-level insert/delete/modify), validated on the S-164 conformance set. Feature/info association surfacing in the query report remains the one gap. --- docs/docs/limitations.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 834444e..12354d5 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -26,13 +26,13 @@ domain and not for navigation; this renderer adds its own gaps on top. A native S-101 (S-100 Part 10a) chart is read directly into the S-101 portrayal model with no conversion — the format is auto-detected from the file, so `png`, -`bake`, and the C API accept a native S-101 `.000` transparently. On the SHOM -France test datasets every feature portrays (no errors), including complex -attributes such as light sectors and buoy topmarks. Two areas are not yet -wired for native S-101: +`bake`, and the C API accept a native S-101 `.000` transparently, and its +sequential `.001…` update files are applied (record-level insert / delete / +modify by identity). On the SHOM France test datasets and the S-164 update +conformance set every feature portrays (no errors), including complex attributes +such as light sectors and buoy topmarks. One area is not yet wired for native +S-101: -- **Update files.** Only the base `.000` is read; S-101 update-record merging - (the `.001…` sequence) is not applied yet. - **Feature and information associations.** `FASC`/`INAS` records are parsed but not yet surfaced in the feature-query / pick report. They carry no portrayal weight for the test charts, so rendering is unaffected. From 1e7500d098d5dbb98fa6fa0179b85f301ee2f6dd Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 20:10:44 -0400 Subject: [PATCH 08/12] feat(s101): surface native S-101 attributes (UTF-8) in the pick report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cursor-pick / feature-info report read S-57 f.objl + f.attrs, which for a native S-101 cell are only surrogates — so a feature's real S-101 attributes (including complex/information text like a French leading-line note) never appeared, and any that did risked mangled encoding. native.zig now builds, per feature, the S-101 class name + a JSON of the S-101 attribute tree (simple values + nested complex attributes) straight from the CNode, and stores them on the s57.Cell shell (pick_class / pick_json). The JSON encoder escapes only the JSON-mandatory characters and passes UTF-8 bytes through untouched, so accented text survives intact (e.g. "...doivent etre considerees comme approximatives et simplifiees..." renders with correct accents, no replacement characters). scene.zig's FeatureMeta serves these for native cells (via pickClass/pickJson) in place of acronymByObjl + encodeS57Attrs; S-57 cells are unchanged. Verified on the SHOM France cells: the pick report now carries the full S-101 attribute tree with correct UTF-8. --- src/s101/native.zig | 63 ++++++++++++++++++++++++++++++++++++++++++++- src/s57/s57.zig | 7 +++++ src/scene/scene.zig | 40 ++++++++++++++++++---------- tools/s101dump.zig | 18 +++++++++++++ 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/s101/native.zig b/src/s101/native.zig index 36ed93d..67adb55 100644 --- a/src/s101/native.zig +++ b/src/s101/native.zig @@ -148,6 +148,10 @@ fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { // --- Features -> shell features + native Adapted ---------------------- var features = std.ArrayList(s57.Feature).empty; var adapted = std.ArrayList(adapter.Adapted).empty; + // Per-feature pick-report data (parallel to `features`): S-101 class + a JSON of + // the S-101 attribute tree, so the cursor-pick report serves real S-101 data. + var pick_classes = std.ArrayList([]const u8).empty; + var pick_jsons = std.ArrayList([]const u8).empty; for (ds.features) |fr| { const class = ds.featureName(fr) orelse continue; // unresolved code: skip @@ -205,6 +209,14 @@ fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { .attrs = s57attrs.items, }); + // The S-101 attribute tree, reused for the pick report (all features) and the + // portrayal Adapted (non-SOUNDG). Class + attribute JSON feed the cursor pick. + const root = try buildNode(a, ds.*, fr.attrs, 0); + var jbuf = std.ArrayList(u8).empty; + try cnodeJson(a, &jbuf, root); + try pick_classes.append(a, try a.dupe(u8, class)); + try pick_jsons.append(a, jbuf.items); + // Native Adapted: class name + CNode tree from ATTR (no adapter). SOUNDG // (objl 129) is emitted directly as a multipoint by scene, and a feature with // no spatial primitive can't portray — neither goes through the rules (the @@ -223,7 +235,7 @@ fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { .feature_index = fi, .code = try a.dupe(u8, class), .primitive = primitive, - .root = try buildNode(a, ds.*, fr.attrs, 0), + .root = root, .points = points, }); } @@ -252,11 +264,60 @@ fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { .coast_edges = coast_edges, .foid_index = foid_index, .native = true, + .pick_class = pick_classes.items, + .pick_json = pick_jsons.items, .arena = arena, }; return .{ .cell = cell, .adapted = adapted.items }; } +/// Serialize a `CNode` attribute tree to a JSON object for the pick report: simple +/// sub-attributes as `"name":"value"` and each complex sub-attribute as +/// `"name":[ {…}, … ]` (its ordered instances). Values are emitted verbatim except +/// for the JSON-mandatory escapes — UTF-8 bytes pass through, so accented text +/// (e.g. a French leading-line note) survives intact. +fn cnodeJson(a: Allocator, buf: *std.ArrayList(u8), node: CNode) !void { + try buf.append(a, '{'); + var n: usize = 0; + for (node.simple) |nv| { + if (n > 0) try buf.append(a, ','); + try jsonString(a, buf, nv.name); + try buf.append(a, ':'); + try jsonString(a, buf, nv.value); + n += 1; + } + for (node.children) |ch| { + if (n > 0) try buf.append(a, ','); + try jsonString(a, buf, ch.code); + try buf.appendSlice(a, ":["); + for (ch.nodes, 0..) |sub, i| { + if (i > 0) try buf.append(a, ','); + try cnodeJson(a, buf, sub); + } + try buf.append(a, ']'); + n += 1; + } + try buf.append(a, '}'); +} + +/// Append `s` as a JSON string (quotes included): escape `"`, `\`, and C0 control +/// characters; every other byte — including UTF-8 continuation bytes — passes through. +fn jsonString(a: Allocator, buf: *std.ArrayList(u8), s: []const u8) !void { + try buf.append(a, '"'); + for (s) |c| switch (c) { + '"' => try buf.appendSlice(a, "\\\""), + '\\' => try buf.appendSlice(a, "\\\\"), + '\n' => try buf.appendSlice(a, "\\n"), + '\r' => try buf.appendSlice(a, "\\r"), + '\t' => try buf.appendSlice(a, "\\t"), + else => if (c < 0x20) { + var hb: [6]u8 = undefined; + try buf.appendSlice(a, std.fmt.bufPrint(&hb, "\\u{x:0>4}", .{c}) catch unreachable); + } else try buf.append(a, c), + }; + try buf.append(a, '"'); +} + /// The MASK indicator (1=mask/not-drawn, else 0) a feature applies to boundary /// spatial record `rrid`, if any. fn maskFor(fr: dataset.FeatureRec, rrid: u32) u8 { diff --git a/src/s57/s57.zig b/src/s57/s57.zig index d3210c3..48879ab 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -625,6 +625,13 @@ pub const Cell = struct { /// feature has a valid S-101 class. Downstream code that would treat "no S-57 /// class" as "unknown feature" must exempt native cells. native: bool = false, + /// Native S-101 pick-report data, indexed by feature index (native cells only; + /// null otherwise): the feature's S-101 class name and a JSON object of its + /// S-101 attributes (the CNode tree, UTF-8 preserved). The cursor-pick report + /// serves these for a native cell in place of the S-57 acronym + `encodeS57Attrs`, + /// which would report only the surrogate attributes. Arena-backed (freed with the cell). + pick_class: ?[]const []const u8 = null, + pick_json: ?[]const []const u8 = null, arena: std.heap.ArenaAllocator, pub fn deinit(self: *Cell) void { diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 06ccec7..ea8195b 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -1374,9 +1374,23 @@ fn appendDepthVals(a: Allocator, props: *std.ArrayList(mvt.Prop), f: s57.Feature /// every primitive with the pass's meta (draw_prio/cat/vg/scamin/bnd/pts + pick /// attrs). The engine work happens here — geometry assembly, projection, tile /// clipping/simplification, anchoring — so surfaces only ever see draw calls. +/// The cursor-pick report's class name for a feature. A native S-101 cell serves +/// its S-101 class; an S-57 cell serves the S-57 acronym. +fn pickClass(cell: s57.Cell, f: s57.Feature, fi: usize) []const u8 { + if (cell.native) return if (cell.pick_class) |pc| (if (fi < pc.len) pc[fi] else "") else ""; + return catalogue.acronymByObjl(f.objl) orelse ""; +} +/// The cursor-pick report's attribute JSON for a feature. A native S-101 cell serves +/// its S-101 attribute tree (UTF-8 preserved); an S-57 cell serves `encodeS57Attrs`. +/// Empty unless `pick` (the pick-enabled path); never fails (report metadata). +fn pickJson(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, pick: bool) []const u8 { + if (!pick) return ""; + if (cell.native) return if (cell.pick_json) |pj| (if (fi < pj.len) pj[fi] else "") else ""; + return encodeS57Attrs(a, f) catch ""; +} + fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, geo: ?GeoParts, geo_world: ?GeoWorld, p: instructions.Portrayal, bnd: i64, pts: i64, z: u8, x: u32, y: u32, tb: [4]f64, box: tile.Box, opts: CellOpts, surf: rs.Surface) !void { const scamin = effScamin(f, opts); - const s57_json = if (opts.pick_attrs) try encodeS57Attrs(a, f) else ""; const cell_name = if (opts.pick_attrs) cell.name else ""; const fmeta = rs.FeatureMeta{ .draw_prio = p.draw_prio, @@ -1385,8 +1399,8 @@ fn processFeatureParsed(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, .vg = p.vg, .scamin = scamin, .oscl = opts.oscl, - .class = catalogue.acronymByObjl(f.objl) orelse "", - .s57_json = s57_json, + .class = pickClass(cell, f, fi), + .s57_json = pickJson(a, cell, f, fi, opts.pick_attrs), .cell_name = cell_name, .band = opts.band, .date_start = p.date_start, @@ -1629,8 +1643,8 @@ fn emitSweptAreaFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize const fmeta = rs.FeatureMeta{ .draw_prio = 6, .scamin = effScamin(f, opts), - .class = catalogue.acronymByObjl(f.objl) orelse "", - .s57_json = if (opts.pick_attrs) try encodeS57Attrs(a, f) else "", + .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, }; @@ -1692,8 +1706,8 @@ fn emitNavSystemFallback(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize const fmeta = rs.FeatureMeta{ .draw_prio = 12, .scamin = effScamin(f, opts), - .class = catalogue.acronymByObjl(f.objl) orelse "", - .s57_json = if (opts.pick_attrs) try encodeS57Attrs(a, f) else "", + .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, }; @@ -1785,8 +1799,8 @@ fn emitDashedBoundary(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, g const fmeta = rs.FeatureMeta{ .draw_prio = 6, .scamin = effScamin(f, opts), - .class = catalogue.acronymByObjl(f.objl) orelse "", - .s57_json = if (opts.pick_attrs) try encodeS57Attrs(a, f) else "", + .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, }; @@ -2079,8 +2093,8 @@ fn emitCentredSymbol(a: Allocator, cell: s57.Cell, f: s57.Feature, fi: usize, ge .draw_prio = prio, .cat = cat, .scamin = effScamin(f, opts), - .class = catalogue.acronymByObjl(f.objl) orelse "", - .s57_json = if (opts.pick_attrs) try encodeS57Attrs(a, f) else "", + .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, }; @@ -2209,8 +2223,8 @@ fn appendCellFeatures( const smeta = rs.FeatureMeta{ .draw_prio = 18, .cat = 2, - .class = "SOUNDG", - .s57_json = if (fopts.pick_attrs) try encodeS57Attrs(a, f) else "", + .class = if (cell.native) pickClass(cell.*, f, fi) else "SOUNDG", + .s57_json = pickJson(a, cell.*, f, fi, fopts.pick_attrs), .cell_name = if (fopts.pick_attrs) cell.name else "", .scamin = effScamin(f, opts), .band = fopts.band, diff --git a/tools/s101dump.zig b/tools/s101dump.zig index 1dbef32..7b8de6f 100644 --- a/tools/s101dump.zig +++ b/tools/s101dump.zig @@ -181,4 +181,22 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { var eit = err_by_class.iterator(); while (eit.next()) |e| std.debug.print(" ERROR x{d}: {s}\n", .{ e.value_ptr.*, e.key_ptr.* }); } + + // Pick-report spot check: the first feature whose S-101 attribute JSON carries + // non-ASCII (UTF-8) text — verifies the pick report surfaces native attributes + // with the encoding intact. + if (loaded.cell.pick_json) |pjs| { + for (pjs, 0..) |pj, fi| { + var non_ascii = false; + for (pj) |c| if (c >= 0x80) { + non_ascii = true; + break; + }; + if (non_ascii and pj.len > 40) { + const cls = if (loaded.cell.pick_class) |pc| pc[fi] else "?"; + std.debug.print("\n pick report (feature {d}, class {s}) — UTF-8 attrs:\n {s}\n", .{ fi, cls, pj }); + break; + } + } + } } From 55a3557d5ef58482e1dcd073860b244431946237 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 20:11:31 -0400 Subject: [PATCH 09/12] docs: native S-101 pick report serves S-101 class + attribute tree (UTF-8) Update the limitations note: the cursor-pick report now surfaces each native feature's S-101 class and full attribute tree (nested complex attributes, UTF-8 text). The remaining gap is StructureEquipment feature-to-feature association resolution for related-feature grouping. --- docs/docs/limitations.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 12354d5..7b69d10 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -30,12 +30,15 @@ model with no conversion — the format is auto-detected from the file, so `png` sequential `.001…` update files are applied (record-level insert / delete / modify by identity). On the SHOM France test datasets and the S-164 update conformance set every feature portrays (no errors), including complex attributes -such as light sectors and buoy topmarks. One area is not yet wired for native +such as light sectors and buoy topmarks. The cursor-pick report serves each +feature's S-101 class and full attribute tree (nested complex attributes +included), with text preserved as UTF-8. One area is not yet wired for native S-101: -- **Feature and information associations.** `FASC`/`INAS` records are parsed but - not yet surfaced in the feature-query / pick report. They carry no portrayal - weight for the test charts, so rendering is unaffected. +- **Feature-to-feature associations.** `FASC`/`INAS` records are parsed but the + StructureEquipment relationship (e.g. a light *supportedBy* its beacon) is not + yet resolved for the query report's related-feature grouping. It carries no + portrayal weight for the test charts, so rendering is unaffected. ## S-57 → S-101 conversion From e6d1d2335a2e698fd4c5a8a956d33369d3d5cca7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 20:49:14 -0400 Subject: [PATCH 10/12] fix(s101): band native charts by their DataCoverage display scale A native S-101 cell had no compilation scale, so bakeChartBytes (which read the scale by parsing the file as S-57) fell back to cscl=0 -> the approach band [z11,13] for EVERY native chart. A small-scale chart with a large coverage then baked an enormous fine-zoom tile pyramid: an Arctic overview test cell (101AA00AA1NPOL3, a 95 KB / 238-feature chart spanning 35deg x 9deg) took >200 s and 1.35 trillion instructions, ~47% of it in memset (per-tile buffers) + gzip. native.zig now reads the chart's scale from the DataCoverage feature's optimumDisplayScale (falling back to maximum/minimum) and sets it as the cell's cscl. bakeChartBytes and buildCellBackend parse native-aware (parseAnyCell) so the band + coverage come from the real cell, not an S-57 misparse. Result: NPOL3 200s+ -> 1.1s; 101US002EC03M 13.6s -> 0.4s; NPOLA/B 11s -> 0.1s; harbor charts unchanged. Bands now match the S-101 navigational purpose (US2->general, US3->coastal, US4->approach, US5->berthing), and equal the S-57 CSCL for the same chart (US2EC03M = 1:1,200,000 both ways). --- src/chart.zig | 14 ++++++++++---- src/s101/native.zig | 21 ++++++++++++++++++++- tools/s101dump.zig | 3 +++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index 0d8c641..f0e2d5e 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -518,7 +518,10 @@ pub fn openPmtilesPath(io: std.Io, path: []const u8) !*Chart { // but does not take ownership. Portrayal failure is non-fatal (classify() fallback). fn buildCellBackend(base: []const u8, updates: []const []const u8, dir: []const u8) ?CellBackend { const loaded = parseAnyCell(base, updates) orelse return null; - var cb = CellBackend{ .cell = loaded.cell, .cscl = s57.peekScale(gpa, base) orelse 0 }; + // Use the parsed cell's compilation scale (S-57 DSPM CSCL, or a native S-101 + // chart's DataCoverage display scale) — `peekScale` reads only the S-57 DSPM and + // returns 0 for native, which would mis-band a native chart in the live compositor. + var cb = CellBackend{ .cell = loaded.cell, .cscl = loaded.cell.params.cscl }; const pa = gpa.create(std.heap.ArenaAllocator) catch return cb; pa.* = std.heap.ArenaAllocator.init(gpa); cb.portray_arena = pa; @@ -629,16 +632,19 @@ pub fn bakeChartBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 { var cov_arena = std.heap.ArenaAllocator.init(gpa); defer cov_arena.deinit(); var coverage_json: ?[]const u8 = null; + // Parse native-aware (S-101 or S-57): the band scale + the archive's coverage + // sidecar both come from the real cell, so a native S-101 chart bands by its + // DataCoverage display scale (not the S-57-misparsed cscl=0 approach default). var cscl: i32 = s57.peekScale(gpa, cf.base) orelse 0; - if (s57.parseCellWithUpdates(gpa, cf.base, cf.updates)) |parsed| { - var cell = parsed; + if (parseAnyCell(cf.base, cf.updates)) |loaded| { + var cell = loaded.cell; defer cell.deinit(); cscl = cell.params.cscl; const stem = std.fs.path.stem(std.fs.path.basename(cell_path)); const band: u8 = @intFromEnum(bake_enc.bandOf(cscl)); const cc = scene.coverage.fromCell(cov_arena.allocator(), &cell, stem, band); coverage_json = scene.coverage.encodeJson(cov_arena.allocator(), cc) catch null; - } else |_| {} + } // The cell's band window, plus the extend_min fill DOWN to z0: sub-band tiles // (scamin-thinned by the scene cull) let the compositor pull this cell up into diff --git a/src/s101/native.zig b/src/s101/native.zig index 67adb55..3496b35 100644 --- a/src/s101/native.zig +++ b/src/s101/native.zig @@ -148,6 +148,13 @@ fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { // --- Features -> shell features + native Adapted ---------------------- var features = std.ArrayList(s57.Feature).empty; var adapted = std.ArrayList(adapter.Adapted).empty; + // The chart's compilation-scale denominator (S-57 CSCL analog), read from the + // DataCoverage feature's display-scale metadata — WITHOUT it every native cell + // defaults to the approach band [z11,13], so a small-scale overview chart with + // a huge coverage bakes an enormous z13 tile pyramid. The optimum display scale + // is the intended viewing scale (the natural band); fall back to the max/min. + // Finest (smallest denominator) across the chart's coverages preserves detail. + var chart_cscl: i32 = 0; // Per-feature pick-report data (parallel to `features`): S-101 class + a JSON of // the S-101 attribute tree, so the cursor-pick report serves real S-101 data. var pick_classes = std.ArrayList([]const u8).empty; @@ -217,6 +224,18 @@ fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { try pick_classes.append(a, try a.dupe(u8, class)); try pick_jsons.append(a, jbuf.items); + // The chart's band scale, from a DataCoverage's display-scale metadata. + if (std.mem.eql(u8, class, "DataCoverage")) { + const sv = root.simpleValue("optimumDisplayScale") orelse + root.simpleValue("maximumDisplayScale") orelse + root.simpleValue("minimumDisplayScale"); + if (sv) |s| { + if (std.fmt.parseInt(i32, std.mem.trim(u8, s, " "), 10)) |n| { + if (n > 0 and (chart_cscl == 0 or n < chart_cscl)) chart_cscl = n; + } else |_| {} + } + } + // Native Adapted: class name + CNode tree from ATTR (no adapter). SOUNDG // (objl 129) is emitted directly as a multipoint by scene, and a feature with // no spatial primitive can't portray — neither goes through the rules (the @@ -255,7 +274,7 @@ fn assemble(gpa: Allocator, ds: *dataset.Dataset) !Loaded { } const cell = s57.Cell{ - .params = .{ .comf = @intFromFloat(ds.params.cmfx), .somf = @intFromFloat(ds.params.cmfz) }, + .params = .{ .comf = @intFromFloat(ds.params.cmfx), .somf = @intFromFloat(ds.params.cmfz), .cscl = chart_cscl }, .vectors = vectors.items, .features = features.items, .nodes = nodes, diff --git a/tools/s101dump.zig b/tools/s101dump.zig index 7b8de6f..a3525c5 100644 --- a/tools/s101dump.zig +++ b/tools/s101dump.zig @@ -118,6 +118,9 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { std.debug.print("\n assembled shell: features={d} adapted={d} vectors={d} nodes={d} edges={d} soundingVecs={d}\n", .{ cell.features.len, loaded.adapted.len, cell.vectors.len, cell.nodes.count(), cell.edges.count(), cell.sounding_vecs.count(), }); + const band = engine.bake_enc.bandOf(cell.params.cscl); + const zr = engine.bake_enc.bandZooms(band); + std.debug.print(" band scale: cscl=1:{d} -> {s} band, bake z{d}..{d}\n", .{ cell.params.cscl, @tagName(band), zr.min, zr.max }); if (cell.bounds()) |b| std.debug.print(" geometry bounds: lon [{d:.5}, {d:.5}] lat [{d:.5}, {d:.5}]\n", .{ b[0], b[2], b[1], b[3] }); // Spot-check assembled geometry per primitive: pick the first area/line/point // adapted feature and report the vertex/part counts the accessors return. From 142746580b0ed173d8da49166f66ead080d72ff0 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 21:08:27 -0400 Subject: [PATCH 11/12] fix(iso8211): tolerate nested parentheses in DDR format controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UKHO S-101 datasets write nested parenthesized groups in the ISO 8211 field format controls — e.g. (b11,b14,7A,A(8),3A,(b11)) and (b11,(3b24)) — where SHOM cells use a flat list. parseSubfields treated a nested '(' as a subfield width and fed the group body ('3b24') to the integer parser -> BadAsciiInt, which aborted iso.parse for the WHOLE cell (every UKHO chart failed to load). parseSubfields now recurses into balanced groups, and the width parse is tolerant (a mis-aligned scan over binary notation like 'b24' yields width 0 instead of throwing). The subfield defs are informational — s57/s101 decode records by their fixed schema — so the only requirement is that a legal DDR parses. Validated on 27 UKHO GB charts (all parse, 0 unresolved codes, 0 portrayal errors) and the SHOM FR charts incl. their .001/.002 update chains; S-57 cells and the iso8211 tests are unchanged. --- src/iso8211/iso8211.zig | 56 ++++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/src/iso8211/iso8211.zig b/src/iso8211/iso8211.zig index f899f29..e75488c 100644 --- a/src/iso8211/iso8211.zig +++ b/src/iso8211/iso8211.zig @@ -230,37 +230,65 @@ fn parseFieldControls(a: Allocator, ddr: Record, field_control_length: usize) ![ return list.items; } -/// Parse format controls like "(A,I(4),B(40))" into subfield defs. +/// Parse format controls like "(A,I(4),B(40))" into subfield defs. ISO 8211 allows +/// nested parenthesized groups with an optional leading repeat count — e.g. UKHO's +/// "(b11,b14,7A,A(8),3A,(b11))" and "(b11,(3b24))" — where the group's formats repeat +/// `repeat` times. (These defs are informational: s57/s101 decode records by their +/// fixed schema, so the value here is that a legal DDR parses; a group must NOT be +/// mistaken for a `(width)` and fed to the integer parser — SHOM cells lack nesting, +/// UKHO cells have it.) pub fn parseSubfields(a: Allocator, fmt_in: []const u8) ![]SubfieldDef { var list = std.ArrayList(SubfieldDef).empty; var fmt = std.mem.trim(u8, fmt_in, " "); if (fmt.len == 0) return list.items; if (fmt[0] == '(') fmt = fmt[1..]; if (fmt.len > 0 and fmt[fmt.len - 1] == ')') fmt = fmt[0 .. fmt.len - 1]; + try parseFmtList(a, &list, fmt); + return list.items; +} +/// Parse one comma-separated format list into `list`, recursing into parenthesized +/// groups. Each recursion consumes a shorter (parens-stripped) slice, so it terminates. +fn parseFmtList(a: Allocator, list: *std.ArrayList(SubfieldDef), fmt: []const u8) !void { var i: usize = 0; while (i < fmt.len) { - // optional leading repeat count, e.g. "2A(3)" or "3I" + // optional leading repeat count, e.g. "2A(3)", "3I", "2(A,I)" var repeat: usize = 1; const rstart = i; while (i < fmt.len and fmt[i] >= '0' and fmt[i] <= '9') i += 1; - if (i > rstart) repeat = try asciiInt(fmt[rstart..i]); + if (i > rstart) repeat = asciiInt(fmt[rstart..i]) catch 1; if (i >= fmt.len) break; - const ftype = fmt[i]; - i += 1; - var width: usize = 0; - if (i < fmt.len and fmt[i] == '(') { + + if (fmt[i] == '(') { + // A nested group: find its balanced ')' and expand its contents `repeat` times. + const gstart = i + 1; + var depth: usize = 1; + var j = gstart; + while (j < fmt.len and depth > 0) : (j += 1) { + if (fmt[j] == '(') depth += 1 else if (fmt[j] == ')') depth -= 1; + } + const inner = fmt[gstart .. j - @intFromBool(depth == 0)]; // drop the closing ')' + var k: usize = 0; + while (k < repeat) : (k += 1) try parseFmtList(a, list, inner); + i = j; + } else { + const ftype = fmt[i]; i += 1; - const wstart = i; - while (i < fmt.len and fmt[i] != ')') i += 1; - width = try asciiInt(fmt[wstart..i]); - if (i < fmt.len) i += 1; // skip ')' + var width: usize = 0; + if (i < fmt.len and fmt[i] == '(') { + i += 1; + const wstart = i; + while (i < fmt.len and fmt[i] != ')') i += 1; + // Tolerant: a width is informational and unused; a mis-aligned scan + // over binary notation ("b24") must not abort the whole DDR parse. + width = asciiInt(fmt[wstart..i]) catch 0; + if (i < fmt.len) i += 1; // skip ')' + } + var k: usize = 0; + while (k < repeat) : (k += 1) try list.append(a, .{ .format_type = ftype, .width = width }); } - var k: usize = 0; - while (k < repeat) : (k += 1) try list.append(a, .{ .format_type = ftype, .width = width }); if (i < fmt.len and fmt[i] == ',') i += 1; } - return list.items; } /// Parse a whole ISO 8211 file from in-memory bytes (borrowed; keep alive). From 43a419f444a2ecfeb4810b5b31030e2ad7328a9d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 12 Jul 2026 21:52:08 -0400 Subject: [PATCH 12/12] fix(s57): transcode Latin-1 attribute text to UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-57 ENC attribute text is ISO 8859-1 (Latin-1) at the standard lexical level — a French SHOM chart's national name 'La Crabière Est' carries a lone 0xE8 for 'è'. parseATTF stored the ATVL bytes verbatim, so the tile / pick report / rendered label carried invalid UTF-8 and a client showed the replacement char. Transcode in parseATTF (covering ATTF and, via mergeNatf, the national NATF names where accents live): a value already valid UTF-8 (ASCII or a UTF-8 producer) is kept as-is; otherwise each byte is taken as a Latin-1 codepoint and UTF-8-encoded. Verified on FR471610 — the baked tile now carries 'Crabière' as C3 A8; S-57 ASCII charts and native S-101 (already UTF-8) are unchanged. --- src/s57/s57.zig | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/s57/s57.zig b/src/s57/s57.zig index 48879ab..7893172 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -1112,8 +1112,29 @@ fn isDelMarker(v: []const u8) bool { return true; } -/// ATTF/NATF: repeated [ATTL(2 LE), ATVL(ASCII, UT-terminated)]. Values are -/// copied into `a` (the source field bytes are not retained). +/// S-57 attribute text is ISO 8859-1 (Latin-1) at the standard lexical level — a +/// French chart's "La Crabière Est" carries a lone 0xE8 for 'è'. Everything +/// downstream (tiles, pick report, rendered labels) is UTF-8, so passing those bytes +/// through verbatim yields invalid UTF-8 (the renderer shows the replacement char). +/// Transcode here: a value already valid UTF-8 (ASCII, or a producer that emitted +/// UTF-8) is duped unchanged; otherwise each byte is taken as a Latin-1 codepoint and +/// UTF-8-encoded (0xE8 -> C3 A8). (UCS-2 lexical level 2 is rare in ENC; not handled.) +fn toUtf8(a: Allocator, s: []const u8) ![]const u8 { + if (std.unicode.utf8ValidateSlice(s)) return a.dupe(u8, s); + var out = std.ArrayList(u8).empty; + for (s) |c| { + if (c < 0x80) { + try out.append(a, c); + } else { + try out.append(a, 0xC0 | (c >> 6)); + try out.append(a, 0x80 | (c & 0x3F)); + } + } + return out.items; +} + +/// ATTF/NATF: repeated [ATTL(2 LE), ATVL(ASCII, UT-terminated)]. Values are copied +/// into `a` (the source field bytes are not retained) and transcoded to UTF-8. fn parseATTF(a: Allocator, data: []const u8) ![]Attr { var list = std.ArrayList(Attr).empty; var off: usize = 0; @@ -1131,7 +1152,7 @@ fn parseATTF(a: Allocator, data: []const u8) ![]Attr { // verbatim made the S-101 framework build a malformed ScaledDecimal{Value=nil} // that crashed the rule -> QUESMRK1. Either way attr()/attrFloat() see it absent. if (val.len > 0 and !isDelMarker(val)) - try list.append(a, .{ .code = code, .value = try a.dupe(u8, val) }); + try list.append(a, .{ .code = code, .value = try toUtf8(a, val) }); off = end + 1; // skip UT } return list.items;