Skip to content

Commit edfb436

Browse files
authored
Merge pull request #34 from beetlebugorg/fix/portrayal-conformance
Fix/portrayal conformance
2 parents 266f3ef + f976d32 commit edfb436

10 files changed

Lines changed: 261 additions & 24 deletions

File tree

‎Makefile‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,20 @@ NOAA_BANDS := overview general coastal approach harbor berthing
6464
NOAA_STAMPS := $(foreach d,$(DISTRICTS),noaa-d$(d).stamp)
6565

6666
S101_EMBED_DIR := internal/engine/s101catalog/catalog
67+
# Our own additions to the catalogue (symbols/rules the upstream S-101 PortrayalCatalog
68+
# lacks, e.g. the NEWOBJ "!" symbol). Committed here and re-applied OVER the upstream
69+
# sync, so they survive a re-sync and live in this repo — not the external catalogue.
70+
S101_CUSTOM := internal/engine/s101catalog/custom-overlay
6771

6872
# Copy the external S-101 catalogue into the (gitignored) embed dir so a
6973
# `-tags embed_s101` build bakes it into the binary. Files never enter the repo.
70-
sync-s101: ## Sync the external S-101 PortrayalCatalog + FeatureCatalogue into the embed dir
74+
sync-s101: ## Sync the external S-101 PortrayalCatalog + our custom overlay into the embed dir
7175
@rm -rf "$(S101_EMBED_DIR)"
7276
@mkdir -p "$(S101_EMBED_DIR)/PortrayalCatalog"
7377
@cp -a "$(S101_PC)/." "$(S101_EMBED_DIR)/PortrayalCatalog/"
7478
@cp -a "$(S101_FC)" "$(S101_EMBED_DIR)/FeatureCatalogue.xml"
75-
@echo "synced S-101 catalogue → $(S101_EMBED_DIR)"
79+
@cp -a "$(S101_CUSTOM)/." "$(S101_EMBED_DIR)/PortrayalCatalog/"
80+
@echo "synced S-101 catalogue (+ custom overlay) → $(S101_EMBED_DIR)"
7681

7782
# Embed the S-101 catalogue when it's available locally (the normal dev/deploy
7883
# case); otherwise build without it (the binary then needs --s101 at runtime).

‎internal/engine/bake/complexline.go‎

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,22 @@ type lsEmbed struct {
2929
name string
3030
}
3131

32+
// lsPen is one stroke of a (possibly composite) line style. A compositeLineStyle
33+
// (double line) has several, listed background-first: e.g. INDHLT02 = a wide black
34+
// backing then a narrow yellow pen ON TOP, drawn in order so the yellow highlight
35+
// sits inside a black outline.
36+
type lsPen struct {
37+
colorToken string
38+
widthPx float64
39+
}
40+
3241
// lsInfo is the per-zoom-independent geometry of one complex linestyle.
3342
type lsInfo struct {
34-
periodPx float64
35-
onRuns []lsOnRun
36-
symbols []lsEmbed
43+
periodPx float64
44+
onRuns []lsOnRun
45+
symbols []lsEmbed
46+
pens []lsPen // ≥1, background→foreground; the dash geometry is stroked once per pen
47+
// colorToken/widthPx mirror the foreground (last) pen — the prim's fallback tag.
3748
colorToken string
3849
widthPx float64
3950
}
@@ -68,8 +79,16 @@ func (b *Baker) emitComplexLine(r *routed, proj tile.Projector, rect tile.Rect,
6879
}
6980

7081
full := b.attrsFor(r, attrScratch) // rebuild base+variable (aliases attrScratch; stable here)
71-
dashAttrs := append(append([]mvt.KeyValue(nil), full...),
72-
mvt.KeyValue{Key: "width_px", Value: mvt.IntVal(int64(info.widthPx + 0.5))})
82+
// color_token + width_px are added PER PEN below (a composite line strokes each),
83+
// so strip any the prim carried — otherwise the foreground colour would leak onto
84+
// the backing pen.
85+
dashBase := make([]mvt.KeyValue, 0, len(full)+2)
86+
for _, kv := range full {
87+
if kv.Key == "color_token" || kv.Key == "width_px" {
88+
continue
89+
}
90+
dashBase = append(dashBase, kv)
91+
}
7392
symBase := full // class/cell/draw_prio/cat/bnd (+inspector extras)
7493
symScale := float64(0.01 / 0.35278)
7594

@@ -135,7 +154,16 @@ func (b *Baker) emitComplexLine(r *routed, proj tile.Projector, rect tile.Rect,
135154
// feature carries SCAMIN (set by route via scaminLayer) so the dashes land
136155
// in the SCAMIN-bucketed source-layer. The embedded symbols below stay in
137156
// point_symbols (already bucketed; they carry `scamin` via attrsFor).
138-
tb.Layer(r.layer).AddLines(dashPaths, dashAttrs)
157+
// Stroke the geometry once per pen, background→foreground, so a composite
158+
// double line (e.g. INDHLT02: black backing + yellow top) renders the bright
159+
// pen inside a dark outline — same paths, each pen's own colour + width.
160+
lay := tb.Layer(r.layer)
161+
for _, pen := range info.pens {
162+
attrs := append(append([]mvt.KeyValue(nil), dashBase...),
163+
mvt.KeyValue{Key: "color_token", Value: mvt.StringVal(pen.colorToken)},
164+
mvt.KeyValue{Key: "width_px", Value: mvt.IntVal(int64(pen.widthPx + 0.5))})
165+
lay.AddLines(dashPaths, attrs)
166+
}
139167
}
140168
// AddPoints shares one attr set per call, so emit each symbol on its own (their
141169
// rotations differ).

‎internal/engine/bake/linestyle_catalog.go‎

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,30 @@ func buildLinestyleTableFromCatalog(cat *catalog.Catalog) map[string]*lsInfo {
2828
// Components onto the longest interval) to the tessellator's lsInfo.
2929
func lsInfoFromCatalog(ls *catalog.LineStyle) *lsInfo {
3030
info := &lsInfo{colorToken: ls.PenColor, widthPx: ls.PenWidth * lsPxPerMM}
31+
hasDash := false
3132
addRuns := func(src *catalog.LineStyle) {
3233
if src.IntervalLength*lsPxPerMM > info.periodPx {
3334
info.periodPx = src.IntervalLength * lsPxPerMM
3435
}
35-
if info.colorToken == "" {
36+
// Each pen of a (possibly composite) line is stroked. A compositeLineStyle
37+
// (double line) stacks a wide dark backing then a narrower bright pen ON TOP —
38+
// e.g. the indication highlight INDHLT02 = black 1.28 under yellow 0.64.
39+
// Components are listed background-first, so emitting them in order draws the
40+
// bright pen inside a dark outline. colorToken/widthPx mirror the foreground
41+
// (last) pen — the prim's fallback tag.
42+
if src.PenColor != "" {
43+
w := src.PenWidth * lsPxPerMM
44+
info.pens = append(info.pens, lsPen{colorToken: src.PenColor, widthPx: w})
3645
info.colorToken = src.PenColor
37-
}
38-
if info.widthPx == 0 && src.PenWidth > 0 {
39-
info.widthPx = src.PenWidth * lsPxPerMM
46+
if src.PenWidth > 0 {
47+
info.widthPx = w
48+
}
4049
}
4150
for _, d := range src.Dashes {
4251
lo, hi := d.Start*lsPxPerMM, (d.Start+d.Length)*lsPxPerMM
4352
if hi-lo > 1e-6 {
4453
info.onRuns = append(info.onRuns, lsOnRun{lo: lo, hi: hi})
54+
hasDash = true
4555
}
4656
}
4757
for _, s := range src.Symbols {
@@ -55,11 +65,29 @@ func lsInfoFromCatalog(ls *catalog.LineStyle) *lsInfo {
5565
} else {
5666
addRuns(ls)
5767
}
68+
// Solid pen (no dash pattern, e.g. INDHLT02): stroke the whole line continuously.
69+
// Without this the tessellator finds no on-runs and emits nothing — the line is
70+
// invisible. Give it a period (if the style declared none) and one full-coverage
71+
// run so every period is 100% "on" ⇒ a continuous stroke. Mirrors s101Pattern in
72+
// internal/engine/assets/linestyles_s101.go (the client-asset path already does this).
73+
if !hasDash {
74+
if info.periodPx < 0.5 {
75+
info.periodPx = 16 * lsPxPerMM // arbitrary; a 100%-on run makes the value moot
76+
}
77+
info.onRuns = []lsOnRun{{lo: 0, hi: info.periodPx}}
78+
}
5879
if info.periodPx < 0.5 {
5980
return nil
6081
}
61-
if info.widthPx < 0.6 {
62-
info.widthPx = 0.9
82+
if len(info.pens) == 0 {
83+
info.pens = []lsPen{{colorToken: info.colorToken, widthPx: info.widthPx}}
84+
}
85+
// Sub-pixel pens vanish; floor them (matches the legacy single-width floor).
86+
for i := range info.pens {
87+
if info.pens[i].widthPx < 0.6 {
88+
info.pens[i].widthPx = 0.9
89+
}
6390
}
91+
info.widthPx = info.pens[len(info.pens)-1].widthPx // foreground mirror
6492
return info
6593
}

‎internal/engine/portrayal/build.go‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,18 +635,37 @@ func newObjectBuild(f *s57.Feature) FeatureBuild {
635635
}
636636
return StrokeLine{Points: pts, ColorToken: "CHMGF", WidthPx: 1.5, Dash: DashDashed}
637637
}
638+
// Centred "!" — the S-52 "default symbol for NEWOBJ" (§10.3.3.8). NEWOBJ01 is our
639+
// own symbol (the PresLib glyph isn't in the S-101 catalogue). Used for areas.
640+
centreMark := func() (SymbolCall, bool) {
641+
anchor, ok := representativePoint(f)
642+
return SymbolCall{Anchor: anchor, SymbolName: "NEWOBJ01", Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32}, ok
643+
}
638644
var prims []Primitive
639645
switch g.Type {
646+
case s57.GeometryTypePoint:
647+
// A point NEWOBJ only reaches here when the Virtual-AIS rule produced nothing
648+
// (a real V-AIS point portrays via that rule and never gets here), so a generic
649+
// new-object point safely falls back to the bare "!".
650+
if mark, ok := centreMark(); ok {
651+
prims = append(prims, mark)
652+
}
640653
case s57.GeometryTypeLineString:
654+
// Dashed magenta line with repeated "!" marks — the NEWOBJ01 line style embeds
655+
// the marks and the complex-line tessellator places them per zoom (so they
656+
// repeat with breaks at every scale, not one symbol on a plain line).
641657
if pts := toLL(g.Coordinates); len(pts) >= 2 {
642-
prims = append(prims, dashed(pts, false))
658+
prims = append(prims, LinePattern{Points: pts, LinestyleName: "NEWOBJ01", ColorToken: "CHMGD"})
643659
}
644660
case s57.GeometryTypePolygon:
645661
for _, r := range g.Rings {
646662
if pts := toLL(r.Coordinates); len(pts) >= 2 {
647663
prims = append(prims, dashed(pts, true))
648664
}
649665
}
666+
if mark, ok := centreMark(); ok {
667+
prims = append(prims, mark)
668+
}
650669
}
651670
if len(prims) == 0 {
652671
return FeatureBuild{DisplayCategory: displayStandard}

‎internal/engine/portrayal/s101build.go‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package portrayal
22

33
import (
4+
"fmt"
45
"io/fs"
56
"math"
67
"os"
@@ -289,6 +290,17 @@ func hasAdditionalInfo(attrs map[string]any) bool {
289290
return false
290291
}
291292

293+
// quaposSolidClass: man-made structures drawn with a definite (solid) line regardless
294+
// of QUAPOS. The S-52 approximate-position dashing (DEPCNT03 and friends) is for natural
295+
// features whose position is uncertain — depth contours, coastline, rivers — not
296+
// engineered structures whose charted extent is definite. Without this, a bridge or road
297+
// whose edges carry a low-accuracy QUAPOS (often inherited from a shared coastline edge)
298+
// is wrongly dashed.
299+
var quaposSolidClass = map[string]bool{
300+
"BRIDGE": true, "ROADWY": true, "RAILWY": true,
301+
"CAUSWY": true, "DAMCON": true, "GATCON": true,
302+
}
303+
292304
// buildFeatureBody turns one feature's emitted instruction stream into its FeatureBuild.
293305
func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBuild {
294306
// 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
299311
if fb, ok := parseSYMINS(f); ok {
300312
return fb
301313
}
314+
// No producer SYMINS, and the V-AIS alias would emit only the generic untyped
315+
// "default V-AIS" (VATON00) — almost always a plain new object, not a real
316+
// virtual AIS aid (those carry a type → VATON01-12). Portray the S-52 NEWOBJ
317+
// "!" instead; typed V-AIS still go through the rule below.
318+
if strings.Contains(stream, "VATON00") {
319+
if nb := newObjectBuild(f); len(nb.Primitives) > 0 {
320+
return nb
321+
}
322+
}
302323
}
303324
// M_NSYS (navigational system of marks): the S-101 NavigationalSystemOfMarks
304325
// 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
409430
// from a per-edge spatial-quality association we don't model, so apply it here
410431
// from the parsed per-feature QUAPOS aggregate: switch the feature's solid simple
411432
// strokes to dashed. Complex line styles and point symbols keep their look.
412-
if q := f.Geometry().Quapos; q != 0 && q != 1 && q != 10 && q != 11 {
433+
if q := f.Geometry().Quapos; q != 0 && q != 1 && q != 10 && q != 11 && !quaposSolidClass[f.ObjectClass()] {
413434
for i, p := range prims {
414435
if sl, ok := p.(StrokeLine); ok && sl.Dash == DashSolid {
415436
sl.Dash = DashDashed
@@ -436,6 +457,13 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui
436457
if cat == 0 {
437458
cat = displayStandard // no display-category band emitted (e.g. text-only)
438459
}
460+
if f.ObjectClass() == "BRIDGE" {
461+
prims = bridgePostProcess(f, prims)
462+
}
463+
switch f.ObjectClass() {
464+
case "OBSTRN", "WRECKS", "UWTROC":
465+
prims = obstructionPostProcess(f, prims)
466+
}
439467
return FeatureBuild{
440468
Primitives: prims,
441469
DisplayPriority: priority,
@@ -446,6 +474,42 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui
446474
}
447475
}
448476

477+
// bridgePostProcess fixes two S-101-model gaps the Bridge rule can't, because the
478+
// S-101 Bridge feature type binds neither a verticalClearance* attribute (the model
479+
// puts clearance on the related SpanFixed feature) nor a true openingBridge for S-57
480+
// CATBRG:1 (the framework still resolves openingBridge → true, so the rule stamps the
481+
// opening-bridge symbol BRIDGE01 on fixed bridges):
482+
// - drop BRIDGE01 unless CATBRG is an opening category (2–8);
483+
// - emit the "clr <value>" vertical-clearance label from the S-57 VERCLR directly.
484+
func bridgePostProcess(f *s57.Feature, prims []Primitive) []Primitive {
485+
catbrg, _ := floatAttr(f.Attributes(), "CATBRG")
486+
opening := catbrg >= 2 && catbrg <= 8
487+
if !opening {
488+
out := prims[:0]
489+
for _, p := range prims {
490+
if sc, ok := p.(SymbolCall); ok && sc.SymbolName == "BRIDGE01" {
491+
continue
492+
}
493+
out = append(out, p)
494+
}
495+
prims = out
496+
}
497+
if v, ok := floatAttr(f.Attributes(), "VERCLR"); ok && v > 0 {
498+
if anchor, ok := representativePoint(f); ok {
499+
prims = append(prims, DrawText{
500+
Anchor: anchor,
501+
Text: fmt.Sprintf("clr %.1f", v),
502+
FontSizePx: 12,
503+
ColorToken: "CHBLK",
504+
Halo: &TextHalo{ColorToken: "CHWHT", WidthPx: 1},
505+
Group: 11, // S-52 text group 11: clearances / important
506+
OffsetYPx: 11,
507+
})
508+
}
509+
}
510+
return prims
511+
}
512+
449513
// commandsNeedAnchor reports whether any reduced draw command consumes the
450514
// feature anchor — the anchored ops emitPrimitives reads geom.Anchor for: point
451515
// symbols, text, and sector/augmented figures. Fills and boundary lines don't,
@@ -582,6 +646,53 @@ func attachSoundingDepths(prims []Primitive, pts [][3]float64) {
582646
}
583647
}
584648

649+
// attachDangerDepth tags the isolated-danger symbol (ISODGR01) on an under/awash hazard
650+
// with the hazard's sounding depth (S-57 VALSOU), which the baker bakes as danger_depth.
651+
// The client then hides / swaps the mark when the hazard is DEEPER than the mariner's
652+
// safety contour (S-52 UDWHAZ05: only a sub-safety-contour danger is an isolated danger).
653+
// Without it DangerDepthM stays NaN and every obstruction shows ISODGR01 regardless of
654+
// depth (a line obstruction deeper than the safety contour is not an isolated danger).
655+
func obstructionPostProcess(f *s57.Feature, prims []Primitive) []Primitive {
656+
d, ok := floatAttr(f.Attributes(), "VALSOU")
657+
if !ok {
658+
return prims
659+
}
660+
// Tag the isolated-danger mark (ISODGR01) with the sounding so the client shows the
661+
// ⊗ only when the hazard is shallower than the live safety contour (S-52 UDWHAZ05);
662+
// a deeper-than-safety obstruction is portrayed by its depth label, not the mark.
663+
for i := range prims {
664+
if sc, ok := prims[i].(SymbolCall); ok && sc.SymbolName == "ISODGR01" {
665+
sc.DangerDepthM = float32(d)
666+
prims[i] = sc
667+
}
668+
}
669+
// The obstruction carries its VALSOU as a depth (sounding) label. A low-accuracy
670+
// sounding (unreliable QUAPOS) is parenthesised — the S-52 approximate convention.
671+
if anchor, ok := representativePoint(f); ok {
672+
txt := formatSounding(d)
673+
if q := f.Geometry().Quapos; q != 0 && q != 1 && q != 10 && q != 11 {
674+
txt = "(" + txt + ")"
675+
}
676+
prims = append(prims, DrawText{
677+
Anchor: anchor,
678+
Text: txt,
679+
FontSizePx: 10,
680+
ColorToken: "CHBLK",
681+
Halo: &TextHalo{ColorToken: "CHWHT", WidthPx: 1},
682+
Group: 11,
683+
})
684+
}
685+
return prims
686+
}
687+
688+
// formatSounding renders a sounding depth: an integer for whole metres, else one decimal.
689+
func formatSounding(d float64) string {
690+
if d == math.Trunc(d) {
691+
return strconv.FormatFloat(d, 'f', 0, 64)
692+
}
693+
return strconv.FormatFloat(d, 'f', 1, 64)
694+
}
695+
585696
func primitiveName(t s57.GeometryType) string {
586697
switch t {
587698
case s57.GeometryTypeLineString:

‎internal/engine/s101/host.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,14 @@ func (e *Engine) bindHost() {
207207
}
208208

209209
// clearances maps each S-101 clearance complex attribute to the S-57 simple
210-
// attribute that backs it and the value sub-attribute the rules read. Bridges
211-
// carry clearances as S-57 simple attributes (VERCCL/VERCLR/HORCLR/VERCOP); the
212-
// S-101 catalogue models them as complex attributes wrapping a *Value field.
210+
// attribute that backs it and the value sub-attribute the rules read. Bridges and
211+
// overhead cables/pipes carry clearances as S-57 simple attributes
212+
// (VERCCL/VERCLR/VERCSA/HORCLR/VERCOP); the S-101 catalogue models them as complex
213+
// attributes wrapping a *Value field.
213214
var clearances = map[string]struct{ s57, value string }{
214215
"verticalClearanceClosed": {"VERCCL", "verticalClearanceValue"},
215216
"verticalClearanceFixed": {"VERCLR", "verticalClearanceValue"},
217+
"verticalClearanceSafe": {"VERCSA", "verticalClearanceValue"},
216218
"verticalClearanceOpen": {"VERCOP", "verticalClearanceValue"},
217219
"horizontalClearanceFixed": {"HORCLR", "horizontalClearanceValue"},
218220
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<?S100Meta name="NEWOBJ01" exposition="default symbol for a new object — line (dashed magenta with repeated ! marks)"?>
3+
<ls:lineStyle xmlns:ls="http://www.iho.int/S100LineStyle/5.2">
4+
<intervalLength>10</intervalLength>
5+
<pen width="0.45">
6+
<color>CHMGD</color>
7+
</pen>
8+
<dash>
9+
<start>0</start>
10+
<length>3.5</length>
11+
</dash>
12+
<symbol reference="NEWOBJ01">
13+
<position>7</position>
14+
</symbol>
15+
</ls:lineStyle>

0 commit comments

Comments
 (0)