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..7b69d10 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -22,14 +22,31 @@ 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, 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. 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-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 -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 diff --git a/src/chart.zig b/src/chart.zig index 40a6a25..f0e2d5e 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,29 @@ 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, 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, updates) 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 +358,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 +517,17 @@ 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; + // 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; // 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; @@ -604,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 @@ -2348,7 +2379,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 +2395,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/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). 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/dataset.zig b/src/s101/dataset.zig new file mode 100644 index 0000000..bbed88b --- /dev/null +++ b/src/s101/dataset.zig @@ -0,0 +1,746 @@ +//! 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 { + 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, + 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 + 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 +/// 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 }; + +// 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, + 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, 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, 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, ruin: u8 = 1, version: u16 = 1 }; + +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, + params: Params = .{}, + 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) + + features: []FeatureRec = &.{}, + points: []PointRec = &.{}, + multis: []MultiRec = &.{}, + curves: []CurveRec = &.{}, + composites: []CompositeRec = &.{}, + surfaces: []SurfaceRec = &.{}, + infos: []InfoRec = &.{}, + + pub fn deinit(self: *Dataset) void { + 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); + } +}; + +/// 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; +} + +/// 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 { + 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(); + + 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, "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 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")) { + 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")) { + 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")) { + 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")) { + 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 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. + } +} + +/// 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( + 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 code = u16le(data, ut + 1); + 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; +} + +/// 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). +/// 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) { + 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, .{ + .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; +} + +/// 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, t: Tables) !FeatureRec { + const f = rec.field("FRID").?; + 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, 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. 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), .saui = u8at(fld.data, o + 14) }); + } + } + 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)); + } +} + +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 new file mode 100644 index 0000000..3496b35 --- /dev/null +++ b/src/s101/native.zig @@ -0,0 +1,469 @@ +//! 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, 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); +} + +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, "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 + 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; + // 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; + var pick_jsons = std.ArrayList([]const u8).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, + }); + + // 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); + + // 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 + // 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 = root, + .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), .cscl = chart_cscl }, + .vectors = vectors.items, + .features = features.items, + .nodes = nodes, + .edges = edges, + .sounding_vecs = sounding_vecs, + .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 { + 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 }; + 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 8d5d2d0..cd496b6 100644 --- a/src/s101/s101.zig +++ b/src/s101/s101.zig @@ -6,13 +6,19 @@ //! * 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) +//! * 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/src/s57/s57.zig b/src/s57/s57.zig index ccd5fc6..7893172 100644 --- a/src/s57/s57.zig +++ b/src/s57/s57.zig @@ -618,6 +618,20 @@ 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, + /// 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 { @@ -1098,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; @@ -1117,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; diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 8160081..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, @@ -2246,7 +2260,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/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/render.zig b/tools/render.zig index 402ad61..6b3eddf 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; either format applies its .001.. update chain. + if (engine.s101.dataset.detect(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 { + 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 new file mode 100644 index 0000000..a3525c5 --- /dev/null +++ b/tools/s101dump.zig @@ -0,0 +1,205 @@ +//! `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; + +/// 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", .{}); + 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; + } + + // 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( + " 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; + } + + // --- Geometry-shell assembly (the s57.Cell the renderer consumes) -------- + 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", .{ + 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. + var did_area = false; + var did_line = false; + var did_pt = false; + 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; + 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 !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; + } + } + } + // 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; + } + } + + // --- 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.* }); + } + + // 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; + } + } + } +}