Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
40 changes: 34 additions & 6 deletions internal/engine/bake/complexline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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).
Expand Down
40 changes: 34 additions & 6 deletions internal/engine/bake/linestyle_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
21 changes: 20 additions & 1 deletion internal/engine/portrayal/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -635,18 +635,37 @@ 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 {
if pts := toLL(r.Coordinates); len(pts) >= 2 {
prims = append(prims, dashed(pts, true))
}
}
if mark, ok := centreMark(); ok {
prims = append(prims, mark)
}
}
if len(prims) == 0 {
return FeatureBuild{DisplayCategory: displayStandard}
Expand Down
113 changes: 112 additions & 1 deletion internal/engine/portrayal/s101build.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package portrayal

import (
"fmt"
"io/fs"
"math"
"os"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 <value>" 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,
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions internal/engine/s101/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Expand Down
15 changes: 15 additions & 0 deletions internal/engine/s101catalog/custom-overlay/LineStyles/NEWOBJ01.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<?S100Meta name="NEWOBJ01" exposition="default symbol for a new object — line (dashed magenta with repeated ! marks)"?>
<ls:lineStyle xmlns:ls="http://www.iho.int/S100LineStyle/5.2">
<intervalLength>10</intervalLength>
<pen width="0.45">
<color>CHMGD</color>
</pen>
<dash>
<start>0</start>
<length>3.5</length>
</dash>
<symbol reference="NEWOBJ01">
<position>7</position>
</symbol>
</ls:lineStyle>
Loading