From 3e27790456c34b10d14a771b01050760cd22e128 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 27 Jun 2026 09:13:59 -0400 Subject: [PATCH 1/8] fix(bake): render solid & composite line styles (INDHLT02 indication highlight) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baker's complex-line analysis (lsInfoFromCatalog) built geometry only from a line style's dashes. A SOLID style has none, so it produced no on-runs — and when it also declared no interval length it was dropped entirely (nil lsInfo → emitComplexLine bailed). A compositeLineStyle (double line) additionally used only its first component's pen. So the indication highlight INDHLT02 (a solid double line: wide black backing under a narrow yellow pen) baked as nothing on small rings, or as a single wide black stroke that read as a filled blob — the "2 of 3 highlights invisible, the long one filled instead of stroked" bug. - lsInfoFromCatalog: record every component as a pen (background→foreground); for a dashless style emit one full-coverage run so it strokes continuously. - emitComplexLine: stroke the dash geometry once per pen, background first, so the bright foreground pen draws inside the dark outline. Solid and composite catalogue line styles now render correctly (needs a re-bake). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/complexline.go | 40 +++++++++++++++++++---- internal/engine/bake/linestyle_catalog.go | 40 +++++++++++++++++++---- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/internal/engine/bake/complexline.go b/internal/engine/bake/complexline.go index d727d3a..cd55b58 100644 --- a/internal/engine/bake/complexline.go +++ b/internal/engine/bake/complexline.go @@ -29,11 +29,22 @@ type lsEmbed struct { name string } +// lsPen is one stroke of a (possibly composite) line style. A compositeLineStyle +// (double line) has several, listed background-first: e.g. INDHLT02 = a wide black +// backing then a narrow yellow pen ON TOP, drawn in order so the yellow highlight +// sits inside a black outline. +type lsPen struct { + colorToken string + widthPx float64 +} + // lsInfo is the per-zoom-independent geometry of one complex linestyle. type lsInfo struct { - periodPx float64 - onRuns []lsOnRun - symbols []lsEmbed + periodPx float64 + onRuns []lsOnRun + symbols []lsEmbed + pens []lsPen // ≥1, background→foreground; the dash geometry is stroked once per pen + // colorToken/widthPx mirror the foreground (last) pen — the prim's fallback tag. colorToken string widthPx float64 } @@ -68,8 +79,16 @@ func (b *Baker) emitComplexLine(r *routed, proj tile.Projector, rect tile.Rect, } full := b.attrsFor(r, attrScratch) // rebuild base+variable (aliases attrScratch; stable here) - dashAttrs := append(append([]mvt.KeyValue(nil), full...), - mvt.KeyValue{Key: "width_px", Value: mvt.IntVal(int64(info.widthPx + 0.5))}) + // color_token + width_px are added PER PEN below (a composite line strokes each), + // so strip any the prim carried — otherwise the foreground colour would leak onto + // the backing pen. + dashBase := make([]mvt.KeyValue, 0, len(full)+2) + for _, kv := range full { + if kv.Key == "color_token" || kv.Key == "width_px" { + continue + } + dashBase = append(dashBase, kv) + } symBase := full // class/cell/draw_prio/cat/bnd (+inspector extras) symScale := float64(0.01 / 0.35278) @@ -135,7 +154,16 @@ func (b *Baker) emitComplexLine(r *routed, proj tile.Projector, rect tile.Rect, // feature carries SCAMIN (set by route via scaminLayer) so the dashes land // in the SCAMIN-bucketed source-layer. The embedded symbols below stay in // point_symbols (already bucketed; they carry `scamin` via attrsFor). - tb.Layer(r.layer).AddLines(dashPaths, dashAttrs) + // Stroke the geometry once per pen, background→foreground, so a composite + // double line (e.g. INDHLT02: black backing + yellow top) renders the bright + // pen inside a dark outline — same paths, each pen's own colour + width. + lay := tb.Layer(r.layer) + for _, pen := range info.pens { + attrs := append(append([]mvt.KeyValue(nil), dashBase...), + mvt.KeyValue{Key: "color_token", Value: mvt.StringVal(pen.colorToken)}, + mvt.KeyValue{Key: "width_px", Value: mvt.IntVal(int64(pen.widthPx + 0.5))}) + lay.AddLines(dashPaths, attrs) + } } // AddPoints shares one attr set per call, so emit each symbol on its own (their // rotations differ). diff --git a/internal/engine/bake/linestyle_catalog.go b/internal/engine/bake/linestyle_catalog.go index 7475166..fd86728 100644 --- a/internal/engine/bake/linestyle_catalog.go +++ b/internal/engine/bake/linestyle_catalog.go @@ -28,20 +28,30 @@ func buildLinestyleTableFromCatalog(cat *catalog.Catalog) map[string]*lsInfo { // Components onto the longest interval) to the tessellator's lsInfo. func lsInfoFromCatalog(ls *catalog.LineStyle) *lsInfo { info := &lsInfo{colorToken: ls.PenColor, widthPx: ls.PenWidth * lsPxPerMM} + hasDash := false addRuns := func(src *catalog.LineStyle) { if src.IntervalLength*lsPxPerMM > info.periodPx { info.periodPx = src.IntervalLength * lsPxPerMM } - if info.colorToken == "" { + // Each pen of a (possibly composite) line is stroked. A compositeLineStyle + // (double line) stacks a wide dark backing then a narrower bright pen ON TOP — + // e.g. the indication highlight INDHLT02 = black 1.28 under yellow 0.64. + // Components are listed background-first, so emitting them in order draws the + // bright pen inside a dark outline. colorToken/widthPx mirror the foreground + // (last) pen — the prim's fallback tag. + if src.PenColor != "" { + w := src.PenWidth * lsPxPerMM + info.pens = append(info.pens, lsPen{colorToken: src.PenColor, widthPx: w}) info.colorToken = src.PenColor - } - if info.widthPx == 0 && src.PenWidth > 0 { - info.widthPx = src.PenWidth * lsPxPerMM + if src.PenWidth > 0 { + info.widthPx = w + } } for _, d := range src.Dashes { lo, hi := d.Start*lsPxPerMM, (d.Start+d.Length)*lsPxPerMM if hi-lo > 1e-6 { info.onRuns = append(info.onRuns, lsOnRun{lo: lo, hi: hi}) + hasDash = true } } for _, s := range src.Symbols { @@ -55,11 +65,29 @@ func lsInfoFromCatalog(ls *catalog.LineStyle) *lsInfo { } else { addRuns(ls) } + // Solid pen (no dash pattern, e.g. INDHLT02): stroke the whole line continuously. + // Without this the tessellator finds no on-runs and emits nothing — the line is + // invisible. Give it a period (if the style declared none) and one full-coverage + // run so every period is 100% "on" ⇒ a continuous stroke. Mirrors s101Pattern in + // internal/engine/assets/linestyles_s101.go (the client-asset path already does this). + if !hasDash { + if info.periodPx < 0.5 { + info.periodPx = 16 * lsPxPerMM // arbitrary; a 100%-on run makes the value moot + } + info.onRuns = []lsOnRun{{lo: 0, hi: info.periodPx}} + } if info.periodPx < 0.5 { return nil } - if info.widthPx < 0.6 { - info.widthPx = 0.9 + if len(info.pens) == 0 { + info.pens = []lsPen{{colorToken: info.colorToken, widthPx: info.widthPx}} + } + // Sub-pixel pens vanish; floor them (matches the legacy single-width floor). + for i := range info.pens { + if info.pens[i].widthPx < 0.6 { + info.pens[i].widthPx = 0.9 + } } + info.widthPx = info.pens[len(info.pens)-1].widthPx // foreground mirror return info } From 0212632e65e1296303a6541d17570ba0a544f112 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 27 Jun 2026 09:52:28 -0400 Subject: [PATCH 2/8] =?UTF-8?q?feat(portrayal):=20NEWOBJ=20default=20symbo?= =?UTF-8?q?l=20("!")=20=E2=80=94=20point,=20line=20&=20area?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S-52 "default symbol for a new object" (§10.3.3.8) — a magenta "!" — was never ported to the S-101 catalogue, so a NEWOBJ showed only a dashed boundary (line/area) or, for points, fell through the over-broad NEWOBJ→VirtualAISAidToNavigation alias and rendered an untyped "default V-AIS" (VATON00). - custom-overlay/Symbols/NEWOBJ01.svg: our own simple symbol — a solid magenta disc with a reversed (white) "!". Authored here, not derived from the PresLib digital files. Lives in this repo and is re-applied over the upstream catalogue by sync-s101 (Makefile), so it survives a re-sync. - custom-overlay/LineStyles/NEWOBJ01.xml: dashed magenta line that embeds the "!" each period, so a NEWOBJ line renders `— ! — ! —`, tessellated per zoom. - newObjectBuild: area → dashed boundary + centred "!"; line → the NEWOBJ01 line style; point → the "!". - s101build: a point NEWOBJ with no producer SYMINS whose V-AIS alias yields only the generic VATON00 is almost certainly a plain new object → portray the "!". Typed V-AIS (VATON01-12) still portray through the rule, so real AIS aids are unaffected. Needs a re-bake. (NEWOBJ01 is original artwork; not from the S-52 PresLib.) Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 9 ++++++-- internal/engine/portrayal/build.go | 21 ++++++++++++++++++- internal/engine/portrayal/s101build.go | 9 ++++++++ .../custom-overlay/LineStyles/NEWOBJ01.xml | 15 +++++++++++++ .../custom-overlay/Symbols/NEWOBJ01.svg | 17 +++++++++++++++ 5 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 internal/engine/s101catalog/custom-overlay/LineStyles/NEWOBJ01.xml create mode 100644 internal/engine/s101catalog/custom-overlay/Symbols/NEWOBJ01.svg diff --git a/Makefile b/Makefile index 04dd0c3..e6352e7 100644 --- a/Makefile +++ b/Makefile @@ -64,15 +64,20 @@ NOAA_BANDS := overview general coastal approach harbor berthing NOAA_STAMPS := $(foreach d,$(DISTRICTS),noaa-d$(d).stamp) S101_EMBED_DIR := internal/engine/s101catalog/catalog +# Our own additions to the catalogue (symbols/rules the upstream S-101 PortrayalCatalog +# lacks, e.g. the NEWOBJ "!" symbol). Committed here and re-applied OVER the upstream +# sync, so they survive a re-sync and live in this repo — not the external catalogue. +S101_CUSTOM := internal/engine/s101catalog/custom-overlay # Copy the external S-101 catalogue into the (gitignored) embed dir so a # `-tags embed_s101` build bakes it into the binary. Files never enter the repo. -sync-s101: ## Sync the external S-101 PortrayalCatalog + FeatureCatalogue into the embed dir +sync-s101: ## Sync the external S-101 PortrayalCatalog + our custom overlay into the embed dir @rm -rf "$(S101_EMBED_DIR)" @mkdir -p "$(S101_EMBED_DIR)/PortrayalCatalog" @cp -a "$(S101_PC)/." "$(S101_EMBED_DIR)/PortrayalCatalog/" @cp -a "$(S101_FC)" "$(S101_EMBED_DIR)/FeatureCatalogue.xml" - @echo "synced S-101 catalogue → $(S101_EMBED_DIR)" + @cp -a "$(S101_CUSTOM)/." "$(S101_EMBED_DIR)/PortrayalCatalog/" + @echo "synced S-101 catalogue (+ custom overlay) → $(S101_EMBED_DIR)" # Embed the S-101 catalogue when it's available locally (the normal dev/deploy # case); otherwise build without it (the binary then needs --s101 at runtime). diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index dfcb38d..1756b97 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -635,11 +635,27 @@ func newObjectBuild(f *s57.Feature) FeatureBuild { } return StrokeLine{Points: pts, ColorToken: "CHMGF", WidthPx: 1.5, Dash: DashDashed} } + // Centred "!" — the S-52 "default symbol for NEWOBJ" (§10.3.3.8). NEWOBJ01 is our + // own symbol (the PresLib glyph isn't in the S-101 catalogue). Used for areas. + centreMark := func() (SymbolCall, bool) { + anchor, ok := representativePoint(f) + return SymbolCall{Anchor: anchor, SymbolName: "NEWOBJ01", Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32}, ok + } var prims []Primitive switch g.Type { + case s57.GeometryTypePoint: + // A point NEWOBJ only reaches here when the Virtual-AIS rule produced nothing + // (a real V-AIS point portrays via that rule and never gets here), so a generic + // new-object point safely falls back to the bare "!". + if mark, ok := centreMark(); ok { + prims = append(prims, mark) + } case s57.GeometryTypeLineString: + // Dashed magenta line with repeated "!" marks — the NEWOBJ01 line style embeds + // the marks and the complex-line tessellator places them per zoom (so they + // repeat with breaks at every scale, not one symbol on a plain line). if pts := toLL(g.Coordinates); len(pts) >= 2 { - prims = append(prims, dashed(pts, false)) + prims = append(prims, LinePattern{Points: pts, LinestyleName: "NEWOBJ01", ColorToken: "CHMGD"}) } case s57.GeometryTypePolygon: for _, r := range g.Rings { @@ -647,6 +663,9 @@ func newObjectBuild(f *s57.Feature) FeatureBuild { prims = append(prims, dashed(pts, true)) } } + if mark, ok := centreMark(); ok { + prims = append(prims, mark) + } } if len(prims) == 0 { return FeatureBuild{DisplayCategory: displayStandard} diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 3b3aad2..4d8a396 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -299,6 +299,15 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui if fb, ok := parseSYMINS(f); ok { return fb } + // No producer SYMINS, and the V-AIS alias would emit only the generic untyped + // "default V-AIS" (VATON00) — almost always a plain new object, not a real + // virtual AIS aid (those carry a type → VATON01-12). Portray the S-52 NEWOBJ + // "!" instead; typed V-AIS still go through the rule below. + if strings.Contains(stream, "VATON00") { + if nb := newObjectBuild(f); len(nb.Primitives) > 0 { + return nb + } + } } // M_NSYS (navigational system of marks): the S-101 NavigationalSystemOfMarks // rule is an UNOFFICIAL stub (NullInstruction only), so draw the S-52 boundary diff --git a/internal/engine/s101catalog/custom-overlay/LineStyles/NEWOBJ01.xml b/internal/engine/s101catalog/custom-overlay/LineStyles/NEWOBJ01.xml new file mode 100644 index 0000000..0015a88 --- /dev/null +++ b/internal/engine/s101catalog/custom-overlay/LineStyles/NEWOBJ01.xml @@ -0,0 +1,15 @@ + + + + 10 + + CHMGD + + + 0 + 3.5 + + + 7 + + diff --git a/internal/engine/s101catalog/custom-overlay/Symbols/NEWOBJ01.svg b/internal/engine/s101catalog/custom-overlay/Symbols/NEWOBJ01.svg new file mode 100644 index 0000000..69cc269 --- /dev/null +++ b/internal/engine/s101catalog/custom-overlay/Symbols/NEWOBJ01.svg @@ -0,0 +1,17 @@ + + + + NEWOBJ01 + default symbol for a new object (NEWOBJ) — S-52 §10.3.3.8; original artwork (not derived from the S-52 PresLib digital files) + + + + + + + + + + + + From a8e2c7abe4dc6b6c899440ba9e4f5e30ef6a6df7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 27 Jun 2026 10:32:39 -0400 Subject: [PATCH 3/8] fix(portrayal): keep structure lines solid under approximate QUAPOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S-52 approximate-position dashing (DEPCNT03 et al.) was applied to every solid simple stroke whose QUAPOS aggregate looked low-accuracy — but that's for natural features (depth contours, coastline, rivers), not engineered structures. A bridge or road whose edges inherit a low-accuracy QUAPOS (often from a shared coastline edge) was wrongly drawn dashed. Exclude BRIDGE/ROADWY/RAILWY/CAUSWY/DAMCON/GATCON. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/s101build.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 4d8a396..7708b3b 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -289,6 +289,17 @@ func hasAdditionalInfo(attrs map[string]any) bool { return false } +// quaposSolidClass: man-made structures drawn with a definite (solid) line regardless +// of QUAPOS. The S-52 approximate-position dashing (DEPCNT03 and friends) is for natural +// features whose position is uncertain — depth contours, coastline, rivers — not +// engineered structures whose charted extent is definite. Without this, a bridge or road +// whose edges carry a low-accuracy QUAPOS (often inherited from a shared coastline edge) +// is wrongly dashed. +var quaposSolidClass = map[string]bool{ + "BRIDGE": true, "ROADWY": true, "RAILWY": true, + "CAUSWY": true, "DAMCON": true, "GATCON": true, +} + // buildFeatureBody turns one feature's emitted instruction stream into its FeatureBuild. func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBuild { // NEWOBJ with a SYMINS attribute: portray the producer's explicit symbol @@ -418,7 +429,7 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui // from a per-edge spatial-quality association we don't model, so apply it here // from the parsed per-feature QUAPOS aggregate: switch the feature's solid simple // strokes to dashed. Complex line styles and point symbols keep their look. - if q := f.Geometry().Quapos; q != 0 && q != 1 && q != 10 && q != 11 { + if q := f.Geometry().Quapos; q != 0 && q != 1 && q != 10 && q != 11 && !quaposSolidClass[f.ObjectClass()] { for i, p := range prims { if sl, ok := p.(StrokeLine); ok && sl.Dash == DashSolid { sl.Dash = DashDashed From aae34b89d5817c9b321073f8162943e911a3acdf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 27 Jun 2026 10:55:59 -0400 Subject: [PATCH 4/8] fix(portrayal): bridge vertical clearance + drop BRIDGE01 on fixed bridges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S-101 Bridge feature type binds no verticalClearance* attribute (the model puts clearance on the related SpanFixed feature) and resolves openingBridge → true even for S-57 CATBRG:1 fixed bridges (CATBRG maps onto categoryOfOpeningBridge). So the Bridge rule could never label the clearance, and stamped the opening-bridge symbol BRIDGE01 on fixed bridges (the stray "double circle"). Post-process BRIDGE builds in Go (the established pattern for S-101-model gaps, cf. newObjectBuild/navSystemBuild): emit the "clr " label from the S-57 VERCLR directly, and drop BRIDGE01 unless CATBRG is an opening category (2–8). Needs a re-bake. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/s101build.go | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 7708b3b..6dc6c0e 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -1,6 +1,7 @@ package portrayal import ( + "fmt" "io/fs" "math" "os" @@ -456,6 +457,9 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui if cat == 0 { cat = displayStandard // no display-category band emitted (e.g. text-only) } + if f.ObjectClass() == "BRIDGE" { + prims = bridgePostProcess(f, prims) + } return FeatureBuild{ Primitives: prims, DisplayPriority: priority, @@ -466,6 +470,42 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui } } +// bridgePostProcess fixes two S-101-model gaps the Bridge rule can't, because the +// S-101 Bridge feature type binds neither a verticalClearance* attribute (the model +// puts clearance on the related SpanFixed feature) nor a true openingBridge for S-57 +// CATBRG:1 (the framework still resolves openingBridge → true, so the rule stamps the +// opening-bridge symbol BRIDGE01 on fixed bridges): +// - drop BRIDGE01 unless CATBRG is an opening category (2–8); +// - emit the "clr " vertical-clearance label from the S-57 VERCLR directly. +func bridgePostProcess(f *s57.Feature, prims []Primitive) []Primitive { + catbrg, _ := floatAttr(f.Attributes(), "CATBRG") + opening := catbrg >= 2 && catbrg <= 8 + if !opening { + out := prims[:0] + for _, p := range prims { + if sc, ok := p.(SymbolCall); ok && sc.SymbolName == "BRIDGE01" { + continue + } + out = append(out, p) + } + prims = out + } + if v, ok := floatAttr(f.Attributes(), "VERCLR"); ok && v > 0 { + if anchor, ok := representativePoint(f); ok { + prims = append(prims, DrawText{ + Anchor: anchor, + Text: fmt.Sprintf("clr %.1f", v), + FontSizePx: 12, + ColorToken: "CHBLK", + Halo: &TextHalo{ColorToken: "CHWHT", WidthPx: 1}, + Group: 11, // S-52 text group 11: clearances / important + OffsetYPx: 11, + }) + } + } + return prims +} + // commandsNeedAnchor reports whether any reduced draw command consumes the // feature anchor — the anchored ops emitPrimitives reads geom.Anchor for: point // symbols, text, and sector/augmented figures. Fills and boundary lines don't, From 3fba290bc23dc17eaa05413b47eef387d48ecf5e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 27 Jun 2026 11:35:05 -0400 Subject: [PATCH 5/8] fix(s57): don't coastline-mask production-area (PRDARE) boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derived coastline-coincident masking builds its "coast edge set" from COALNE, LNDARE and SLCONS edges, then drops any non-exempt area's boundary edge in that set. A PRDARE sitting on an LNDARE shares the land area's box edge, so its dashed production-area boundary was masked away entirely — leaving a borderless box (the §17.2 rule is only about redundant coast lines, not a distinct symbolized boundary that merely overlaps the land edge). Exempt PRDARE from masking (boundaryNeverMaskedClasses). Confirmed against the PresLib Chart 1 C,D,E cell: PRDARE boundary lines go from 2→10 surviving with masking on. Needs a re-bake. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/s57/parser/parser.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/internal/s57/parser/parser.go b/internal/s57/parser/parser.go index 13b74f6..88cc2b7 100644 --- a/internal/s57/parser/parser.go +++ b/internal/s57/parser/parser.go @@ -507,10 +507,21 @@ var coastDefinerClasses = map[string]bool{ "SLCONS": true, } -// isCoastlineMaskExempt reports whether an area object class is a coast-definer and -// therefore exempt from derived coastline-coincident boundary masking. +// boundaryNeverMaskedClasses are area classes whose drawn boundary is a DISTINCT +// symbolized line (not the plain coast/shore), so it must NOT be coastline-masked even +// where it happens to share an edge with a coast-definer. A production/storage area +// (PRDARE) sitting on an LNDARE shares the box edge with the land area, but its dashed +// boundary is meaningful symbology — masking it (S-52 §17.2 is only about redundant +// coast lines) would drop the production-area outline entirely. +var boundaryNeverMaskedClasses = map[string]bool{ + "PRDARE": true, +} + +// isCoastlineMaskExempt reports whether an area object class is exempt from derived +// coastline-coincident boundary masking — either a coast-definer (keeps the shore) or +// a class whose boundary is a distinct symbolized line (boundaryNeverMaskedClasses). func isCoastlineMaskExempt(objClass string) bool { - return coastDefinerClasses[objClass] + return coastDefinerClasses[objClass] || boundaryNeverMaskedClasses[objClass] } // contains checks if a slice contains a string From 2b8da9eaf6e3026984adb8312eb9a90413b08fed Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 27 Jun 2026 11:47:27 -0400 Subject: [PATCH 6/8] fix(s57): map ARCSLN/ASLXIS (archipelagic sea lane) object classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-57 object codes 161 (ARCSLN, archipelagic sea lane area) and 162 (ASLXIS, its axis) were left out of the class table — a stale comment claimed they were "deliberately-unknown test objects → QUESMRK". They're real Ed 3.1 classes the S-101 catalogue portrays (ArchipelagicSeaLaneArea / ArchipelagicSeaLaneAxis, aliased ARCSLN/ASLXIS), so they rendered as a "?" placeholder. Map them; needs a re-bake. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/s57/parser/objectclass.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/s57/parser/objectclass.go b/internal/s57/parser/objectclass.go index e96f3d9..46d2d66 100644 --- a/internal/s57/parser/objectclass.go +++ b/internal/s57/parser/objectclass.go @@ -172,11 +172,12 @@ var objectClassNames = map[int]string{ 158: "WEDKLP", 159: "WRECKS", 160: "TS_FEB", // Tidal stream - flood/ebb (S-57 App A §1.173; was missing → rendered QUESMRK) + 161: "ARCSLN", // Archipelagic Sea Lane (area) → S-101 ArchipelagicSeaLaneArea + 162: "ASLXIS", // Archipelagic Sea Lane Axis (line) → S-101 ArchipelagicSeaLaneAxis // NEWOBJ (code 163) is not in the base Ed 3.1 catalogue but is the ENC // convention for producer "new objects"; it carries SYMINS and the PresLib // NEWOBJ lookup routes SYMINS-bearing features through CS(SYMINS02). Used by - // the S-64 test data (V-AIS, temporary/preliminary NtoM). Codes 161/162 are - // deliberately-unknown test objects (no catalogue entry) → QUESMRK, correct. + // the S-64 test data (V-AIS, temporary/preliminary NtoM). 163: "NEWOBJ", 300: "M_ACCY", 301: "M_CSCL", From 3e74e67cc16682d7f8e6d14d4e5e1cdc026f686d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 27 Jun 2026 11:58:42 -0400 Subject: [PATCH 7/8] fix(portrayal): obstruction depth labels + isolated-danger only for real dangers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A submerged line obstruction (OBSTRN/WRECKS/UWTROC) showed the isolated-danger mark ISODGR01 regardless of depth, and never its sounding — so all four PresLib samples (deeper/shallower/low-accuracy/dangerous) rendered the same ⊗. obstructionPostProcess now: - tags ISODGR01 with the feature's VALSOU (danger_depth) so the client shows the ⊗ only when the hazard is shallower than the live safety contour (S-52 UDWHAZ05); - emits the VALSOU as a depth (sounding) label, parenthesised for a low-accuracy (unreliable-QUAPOS) sounding. At safety contour 10 m the samples now read: deeper→"25", shallower→"15", low-accuracy→"(15)", dangerous→⊗. Needs a re-bake. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/s101build.go | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 6dc6c0e..b9e8677 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -460,6 +460,10 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui if f.ObjectClass() == "BRIDGE" { prims = bridgePostProcess(f, prims) } + switch f.ObjectClass() { + case "OBSTRN", "WRECKS", "UWTROC": + prims = obstructionPostProcess(f, prims) + } return FeatureBuild{ Primitives: prims, DisplayPriority: priority, @@ -642,6 +646,53 @@ func attachSoundingDepths(prims []Primitive, pts [][3]float64) { } } +// attachDangerDepth tags the isolated-danger symbol (ISODGR01) on an under/awash hazard +// with the hazard's sounding depth (S-57 VALSOU), which the baker bakes as danger_depth. +// The client then hides / swaps the mark when the hazard is DEEPER than the mariner's +// safety contour (S-52 UDWHAZ05: only a sub-safety-contour danger is an isolated danger). +// Without it DangerDepthM stays NaN and every obstruction shows ISODGR01 regardless of +// depth (a line obstruction deeper than the safety contour is not an isolated danger). +func obstructionPostProcess(f *s57.Feature, prims []Primitive) []Primitive { + d, ok := floatAttr(f.Attributes(), "VALSOU") + if !ok { + return prims + } + // Tag the isolated-danger mark (ISODGR01) with the sounding so the client shows the + // ⊗ only when the hazard is shallower than the live safety contour (S-52 UDWHAZ05); + // a deeper-than-safety obstruction is portrayed by its depth label, not the mark. + for i := range prims { + if sc, ok := prims[i].(SymbolCall); ok && sc.SymbolName == "ISODGR01" { + sc.DangerDepthM = float32(d) + prims[i] = sc + } + } + // The obstruction carries its VALSOU as a depth (sounding) label. A low-accuracy + // sounding (unreliable QUAPOS) is parenthesised — the S-52 approximate convention. + if anchor, ok := representativePoint(f); ok { + txt := formatSounding(d) + if q := f.Geometry().Quapos; q != 0 && q != 1 && q != 10 && q != 11 { + txt = "(" + txt + ")" + } + prims = append(prims, DrawText{ + Anchor: anchor, + Text: txt, + FontSizePx: 10, + ColorToken: "CHBLK", + Halo: &TextHalo{ColorToken: "CHWHT", WidthPx: 1}, + Group: 11, + }) + } + return prims +} + +// formatSounding renders a sounding depth: an integer for whole metres, else one decimal. +func formatSounding(d float64) string { + if d == math.Trunc(d) { + return strconv.FormatFloat(d, 'f', 0, 64) + } + return strconv.FormatFloat(d, 'f', 1, 64) +} + func primitiveName(t s57.GeometryType) string { switch t { case s57.GeometryTypeLineString: From f976d328b4c5f55ac735a627e2797626cf826995 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 27 Jun 2026 13:37:12 -0400 Subject: [PATCH 8/8] =?UTF-8?q?fix(portrayal):=20map=20VERCSA=20=E2=86=92?= =?UTF-8?q?=20verticalClearanceSafe=20("sf=20clr"=20labels)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S-101 catalogue models a feature's safe vertical clearance as the complex attribute verticalClearanceSafe; CableOverhead (and the other overhead rules) already emit the "sf clr %4.1f" label from it. But our S-57→S-101 attribute bridge synthesized only VERCCL/VERCLR/VERCOP/HORCLR — VERCSA was never mapped, so verticalClearanceSafe was always nil and the rule's safe-clearance branches fell through. Overhead cables/pipes carrying VERCSA (no VERCLR) showed just the dashed line with no clearance text. Add verticalClearanceSafe ← VERCSA to the clearances map. Verified on S-64 cell AA5DBASE (§3.1 Base, ref p100): now emits "sf clr 15.0" ×3 alongside "clr 10.0" ×3, matching the reference plot. Same class of S-101-model gap as the bridge vertical-clearance fix. Needs a re-bake. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/s101/host.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/engine/s101/host.go b/internal/engine/s101/host.go index a22d86f..e695024 100644 --- a/internal/engine/s101/host.go +++ b/internal/engine/s101/host.go @@ -207,12 +207,14 @@ func (e *Engine) bindHost() { } // clearances maps each S-101 clearance complex attribute to the S-57 simple -// attribute that backs it and the value sub-attribute the rules read. Bridges -// carry clearances as S-57 simple attributes (VERCCL/VERCLR/HORCLR/VERCOP); the -// S-101 catalogue models them as complex attributes wrapping a *Value field. +// attribute that backs it and the value sub-attribute the rules read. Bridges and +// overhead cables/pipes carry clearances as S-57 simple attributes +// (VERCCL/VERCLR/VERCSA/HORCLR/VERCOP); the S-101 catalogue models them as complex +// attributes wrapping a *Value field. var clearances = map[string]struct{ s57, value string }{ "verticalClearanceClosed": {"VERCCL", "verticalClearanceValue"}, "verticalClearanceFixed": {"VERCLR", "verticalClearanceValue"}, + "verticalClearanceSafe": {"VERCSA", "verticalClearanceValue"}, "verticalClearanceOpen": {"VERCOP", "verticalClearanceValue"}, "horizontalClearanceFixed": {"HORCLR", "horizontalClearanceValue"}, }