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/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 } 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..b9e8677 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" @@ -289,6 +290,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 @@ -299,6 +311,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 @@ -409,7 +430,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 @@ -436,6 +457,13 @@ 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) + } + switch f.ObjectClass() { + case "OBSTRN", "WRECKS", "UWTROC": + prims = obstructionPostProcess(f, prims) + } return FeatureBuild{ Primitives: prims, DisplayPriority: priority, @@ -446,6 +474,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, @@ -582,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: 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"}, } 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) + + + + + + + + + + + + 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", 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