Skip to content

Commit 99dc267

Browse files
authored
Merge pull request #10 from beetlebugorg/feat/s101-close-the-gaps
S-101 Fixes
2 parents 475d32b + 6501ec6 commit 99dc267

25 files changed

Lines changed: 1138 additions & 437 deletions

internal/engine/bake/bake.go

Lines changed: 169 additions & 109 deletions
Large diffs are not rendered by default.

internal/engine/bake/bake_test.go

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import (
1515
const goldenCell = "../../../testdata/US4MD81M.000"
1616

1717
func TestBandForScale(t *testing.T) {
18-
if BandForScale(12_000).ZoomRange() != (ZoomRange{14, 16}) {
19-
t.Error("12k should be harbor [14,16]")
18+
if BandForScale(12_000).ZoomRange() != (ZoomRange{13, 16}) {
19+
t.Error("12k should be harbor [13,16]")
2020
}
2121
if BandForScale(3_000_000) != BandOverview {
2222
t.Error("3M should be overview")
@@ -343,41 +343,40 @@ func TestSoundingGrouping(t *testing.T) {
343343
}
344344

345345
func TestSectorLights(t *testing.T) {
346-
// expandSector: a sector -> 2 short legs (sleg 0) + 2 full-length legs
347-
// (sleg 1) + OUTLW underlay + coloured arc (both sleg -1, always shown).
348-
anchor := mustLatLon(38.97, -76.49)
349-
strokes := expandSector(anchor, sp(0, 90, "LITRD"), 14)
350-
if len(strokes) != 6 {
351-
t.Fatalf("sector strokes = %d, want 6", len(strokes))
352-
}
353-
if !strokes[0].dashed || strokes[0].colorToken != "CHBLK" || strokes[0].sleg != 0 {
346+
// tessellateFigure drives each constructed figure element off the rule's params.
347+
// A leg with a nominal range emits the short (sleg 0) + extended full-length
348+
// (sleg 1) variants, both dashed in the rule's colour, for the client's toggle.
349+
leg := tessellateFigure(legPrim(90, 8), 14)
350+
if len(leg) != 2 {
351+
t.Fatalf("leg strokes = %d, want 2 (short + full)", len(leg))
352+
}
353+
if !leg[0].dashed || leg[0].colorToken != "CHBLK" || leg[0].sleg != 0 {
354354
t.Error("stroke 0 should be the dashed CHBLK short leg (sleg 0)")
355355
}
356-
if !strokes[2].dashed || strokes[2].colorToken != "CHBLK" || strokes[2].sleg != 1 {
357-
t.Error("stroke 2 should be the dashed CHBLK full-length leg (sleg 1)")
358-
}
359-
if strokes[4].colorToken != "OUTLW" || strokes[4].widthPx != 4 || strokes[4].sleg != -1 {
360-
t.Error("stroke 4 should be the 4px OUTLW arc underlay (sleg -1)")
356+
if leg[1].sleg != 1 {
357+
t.Error("stroke 1 should be the full-length leg (sleg 1)")
361358
}
362-
if strokes[5].colorToken != "LITRD" || strokes[5].widthPx != 2 || strokes[5].dashed || strokes[5].sleg != -1 {
363-
t.Error("stroke 5 should be the 2px solid LITRD arc (sleg -1)")
364-
}
365-
// A light with a VALNMR nominal range emits full legs (sleg 1) longer than
366-
// the 25 mm short legs (sleg 0) — the toggle's whole point.
367-
spR := sp(0, 90, "LITRD")
368-
spR.RadiusNM = 8
369-
withR := expandSector(anchor, spR, 14)
370359
legLen := func(s sectorStroke) float64 {
371360
return absf(s.points[1].Lat-s.points[0].Lat) + absf(s.points[1].Lon-s.points[0].Lon)
372361
}
373-
if legLen(withR[2]) <= legLen(withR[0]) {
374-
t.Errorf("full leg (%.6f) should be longer than short leg (%.6f)", legLen(withR[2]), legLen(withR[0]))
362+
if legLen(leg[1]) <= legLen(leg[0]) {
363+
t.Errorf("full leg (%.6f) should be longer than short leg (%.6f)", legLen(leg[1]), legLen(leg[0]))
364+
}
365+
// A leg with no nominal range is a single always-shown stroke (sleg -1).
366+
plain := tessellateFigure(legPrim(90, 0), 14)
367+
if len(plain) != 1 || plain[0].sleg != -1 {
368+
t.Errorf("plain leg = %+v, want 1 stroke sleg -1", plain)
375369
}
376-
// Screen-fixed: lat span ~halves per zoom level.
377-
r14 := expandSector(anchor, sp(0, 0, "LITYW"), 14) // ring
378-
r15 := expandSector(anchor, sp(0, 0, "LITYW"), 15)
379-
span := func(s []sectorStroke) float64 { return absf(s[len(s)-1].points[0].Lat - anchor.Lat) }
380-
if ratio := span(r14) / span(r15); ratio < 1.9 || ratio > 2.1 {
370+
// An arc/ring is one stroke in the rule's colour (white light → yellow LITYW),
371+
// always shown (sleg -1). Screen-fixed: the radius ~halves per zoom level.
372+
arc14 := tessellateFigure(arcPrim(26, 0, 360, "LITYW"), 14)
373+
if len(arc14) != 1 || arc14[0].colorToken != "LITYW" || arc14[0].sleg != -1 {
374+
t.Fatalf("arc = %+v, want 1 LITYW stroke sleg -1", arc14)
375+
}
376+
arc15 := tessellateFigure(arcPrim(26, 0, 360, "LITYW"), 15)
377+
anchor := mustLatLon(38.97, -76.49)
378+
span := func(s []sectorStroke) float64 { return absf(s[0].points[0].Lat - anchor.Lat) }
379+
if ratio := span(arc14) / span(arc15); ratio < 1.9 || ratio > 2.1 {
381380
t.Errorf("ring radius ratio z14/z15 = %.3f, want ~2", ratio)
382381
}
383382
}
@@ -388,8 +387,8 @@ func TestSectorLights(t *testing.T) {
388387
// suppressed only where a finer prim sits on it. zMax is set > natMax to simulate
389388
// the overzoomed coarse prim and drive the branch directly.
390389
func TestUpSuppressionPointOverlap(t *testing.T) {
391-
coastal := BandCoastal.ZoomRange() // {10,12}
392-
harbor := BandHarbor.ZoomRange() // {14,16}
390+
coastal := BandCoastal.ZoomRange() // {9,11}
391+
harbor := BandHarbor.ZoomRange() // {13,16}
393392
base := geo.LatLon{Lat: 38.97, Lon: -76.49}
394393
mk := func(b *Baker, ll geo.LatLon, layer string, zr ZoomRange, zMax uint32) {
395394
r := routed{layer: layer, kind: mvt.GeomPoint, npoint: normPt(ll),
@@ -419,8 +418,22 @@ func TestUpSuppressionPointOverlap(t *testing.T) {
419418
}
420419

421420
func mustLatLon(lat, lon float64) geo.LatLon { return geo.LatLon{Lat: lat, Lon: lon} }
422-
func sp(start, end float64, color string) portrayal.SectorParams {
423-
return portrayal.SectorParams{StartAngleDeg: start, EndAngleDeg: end, ColorToken: color}
421+
422+
// legPrim / arcPrim build a one-element sector figure (as the rule emits) at a
423+
// fixed anchor: a dashed CHBLK leg at the given bearing (fullNM>0 enables the
424+
// extended variant), or a coloured arc/ring.
425+
func legPrim(bearingDeg, fullNM float64) *sectorPrim {
426+
return &sectorPrim{fig: portrayal.AugmentedFigure{
427+
Anchor: mustLatLon(38.97, -76.49), Ray: true, BearingDeg: bearingDeg,
428+
LengthMM: 25, ColorToken: "CHBLK", WidthMM: 0.32, Dash: portrayal.DashDashed,
429+
FullLengthNM: fullNM,
430+
}}
431+
}
432+
func arcPrim(radiusMM, startDeg, sweepDeg float64, color string) *sectorPrim {
433+
return &sectorPrim{fig: portrayal.AugmentedFigure{
434+
Anchor: mustLatLon(38.97, -76.49), RadiusMM: radiusMM,
435+
StartDeg: startDeg, SweepDeg: sweepDeg, ColorToken: color, WidthMM: 0.64,
436+
}}
424437
}
425438

426439
func absf(x float64) float64 {

internal/engine/bake/complexline.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func (b *Baker) emitComplexLine(r *routed, proj tile.Projector, rect tile.Rect,
6060
}
6161

6262
// Screen px -> tile units. The baker lays figures out in 256-px-per-tile space
63-
// (see sectorRadiusNorm/expandSector); one tile is `extent` units wide.
63+
// (see sectorRadiusNorm/tessellateFigure); one tile is `extent` units wide.
6464
pxScale := float64(extent) / 256.0
6565
period := info.periodPx * pxScale
6666
if period < 1e-6 {

internal/engine/portrayal/build.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ type FeatureBuild struct {
2121
Primitives []Primitive
2222
DisplayPriority int
2323
DisplayCategory int
24+
// DateStart/DateEnd/TimeValid carry the feature's date dependency when it has
25+
// one (S-57 DATSTA/DATEND fixed window or PERSTA/PEREND periodic window), so the
26+
// baker can tag the feature and a date-aware client show/hide it against the
27+
// current date. Empty when the feature is not date-dependent. DateStart/DateEnd
28+
// are S-57 date strings — full "YYYYMMDD" (fixed) or partial "--MMDD" recurring
29+
// each year (periodic); TimeValid is the interval kind (closedInterval /
30+
// geSemiInterval / leSemiInterval). The feature also carries the CHDATD01
31+
// date-dependent marker symbol among its primitives.
32+
DateStart string
33+
DateEnd string
34+
TimeValid string
2435
}
2536

2637
// geom is the portrayal-space geometry handed to the instruction walk. It mirrors
@@ -110,6 +121,16 @@ func applyDangerDepth(prims []Primitive, class string, attrs map[string]interfac
110121
return prims
111122
}
112123

124+
// stringAttr returns an attribute's encoded string value, or "" when absent.
125+
func stringAttr(attrs map[string]interface{}, key string) string {
126+
if v, ok := attrs[key]; ok {
127+
if s, ok := encodeAttr(v); ok {
128+
return s
129+
}
130+
}
131+
return ""
132+
}
133+
113134
func floatAttr(attrs map[string]interface{}, key string) (float64, bool) {
114135
v, ok := attrs[key]
115136
if !ok || v == nil {

internal/engine/portrayal/primitive.go

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Package portrayal turns one S-57 feature into a stream of viewport-independent
2-
// lat/lon Primitives by running the S-101 portrayal rules and lowering the
3-
// emitted drawing instructions, stopping short of projection and colour
2+
// lat/lon Primitives by running the S-101 portrayal rules and emitting a
3+
// primitive for each drawing instruction, stopping short of projection and colour
44
// resolution. Colour stays as *token* strings; the tile engine
55
// projects/clips/encodes the Primitives into MVT and the browser resolves
66
// Day/Dusk/Night from colortables.json.
@@ -141,29 +141,37 @@ type TextHalo struct {
141141
WidthPx float32
142142
}
143143

144-
// SectorLight is LIGHTS06 sector geometry (legs / arc / ring). Only the lat/lon
145-
// anchor plus the S-52 sector parameters are cached; the screen-space
146-
// tessellation happens at projection time because radii are display millimetres.
147-
type SectorLight struct {
144+
// AugmentedFigure is one stroked element of a screen-space figure the S-101 rule
145+
// CONSTRUCTED via AugmentedRay / ArcByRadius (a light-sector leg or arc/ring) —
146+
// driven by the catalogue's own bearings, radii, colours and widths rather than a
147+
// Go re-derivation from S-57 attributes. One primitive = one stroked element; a
148+
// sectored light emits several (two dashed legs, then a black-backed coloured
149+
// arc). The mm sizes are screen-fixed, so the baker tessellates per-zoom into
150+
// `sector_lines`; it cannot bake as static geographic geometry.
151+
type AugmentedFigure struct {
148152
Anchor geo.LatLon
149-
Sector SectorParams
150-
}
151-
152-
// SectorParams carries the S-52 LIGHTS06 sector parameters. Populated from the
153-
// s52 SectorInstruction the CS procedure emits.
154-
type SectorParams struct {
155-
StartAngleDeg float64 // SECTR1, 0=North, clockwise
156-
EndAngleDeg float64 // SECTR2, 0=North, clockwise
157-
RadiusNM float64 // VALNMR nominal range, nautical miles
158-
ColorToken string // LITRD/LITGN/LITYW/...
159-
Transparency int // 0=opaque..3=75%
160-
ShowLegs bool
153+
Ray bool // true: a straight leg (Bearing/Length); false: an arc/ring
154+
// Ray params (true-north bearing, already from-seaward-reversed by the rule).
155+
BearingDeg float64
156+
LengthMM float64
157+
// Arc params (centred on Anchor); a full 360° sweep is an all-round ring.
158+
RadiusMM float64
159+
StartDeg float64
160+
SweepDeg float64
161+
// Stroke style, from the rule's LineStyle:_simple_.
162+
ColorToken string
163+
WidthMM float64
164+
Dash Dash
165+
// FullLengthNM is the LIGHTS nominal range (VALNMR); when set on a ray, the
166+
// baker also emits the "full light lines" leg variant extended to that range
167+
// (S-52 LIGHTS06 note 1), tagged for the client's live toggle. 0 = no variant.
168+
FullLengthNM float64
161169
}
162170

163-
func (FillPolygon) isPrimitive() {}
164-
func (StrokeLine) isPrimitive() {}
165-
func (SymbolCall) isPrimitive() {}
166-
func (PatternFill) isPrimitive() {}
167-
func (LinePattern) isPrimitive() {}
168-
func (DrawText) isPrimitive() {}
169-
func (SectorLight) isPrimitive() {}
171+
func (FillPolygon) isPrimitive() {}
172+
func (StrokeLine) isPrimitive() {}
173+
func (SymbolCall) isPrimitive() {}
174+
func (PatternFill) isPrimitive() {}
175+
func (LinePattern) isPrimitive() {}
176+
func (DrawText) isPrimitive() {}
177+
func (AugmentedFigure) isPrimitive() {}

internal/engine/portrayal/s101build.go

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,17 @@ func newS101Builder(catalogFS fs.FS, fcBytes []byte) (*S101Builder, error) {
6161

6262
// S101Builder is the feature-build seam: it runs the S-101 portrayal rules (via
6363
// the fc-backed Lua engine) for a batch of features, parses each emitted
64-
// instruction stream, and lowers each draw onto the feature geometry to produce
65-
// the Primitive stream the baker consumes.
64+
// instruction stream, and emits a primitive for each draw onto the feature
65+
// geometry to produce the Primitive stream the baker consumes.
6666
type S101Builder struct {
6767
rulesFS fs.FS
6868
fcCat *fc.Catalogue
6969
Catalog *catalog.Catalog
7070
}
7171

7272
// BuildBatch portrays a whole cell's features in ONE engine pass (one chunk
73-
// compile, one portrayal context) and lowers each onto its geometry. A fresh
73+
// compile, one portrayal context) and emits primitives for each onto its
74+
// geometry. A fresh
7475
// Lua state is used and closed here so the per-cell caches don't accumulate.
7576
// Returns featureID → build for every feature.
7677
func (b *S101Builder) BuildBatch(features []*s57.Feature) (map[int64]FeatureBuild, error) {
@@ -111,7 +112,7 @@ func (b *S101Builder) BuildBatchOverrides(features []*s57.Feature, overrides map
111112
// resolve a REAL point spatial (HostGetSpatial '#P'/'#M'). SOUNDG is a
112113
// multipoint (the Sounding rule iterates each point's depth); other point
113114
// features are a single point. This is required even when the geometry is
114-
// otherwise attached by the Go lowering: a rule that reads feature.Point /
115+
// otherwise attached when the Go side emits primitives: a rule that reads feature.Point /
115116
// feature.Spatial would otherwise hit the framework's GetSpatial infinite
116117
// recursion (it reads self['Spatial'] right after assigning it nil, which
117118
// re-fires __index) — the cause of the OBSTRN/WRECKS stack overflows.
@@ -143,7 +144,7 @@ func (b *S101Builder) BuildBatchOverrides(features []*s57.Feature, overrides map
143144
}
144145
out := make(map[int64]FeatureBuild, len(features))
145146
for _, f := range features {
146-
out[f.ID()] = b.lower(f, streams[strconv.FormatInt(f.ID(), 10)])
147+
out[f.ID()] = b.buildFeature(f, streams[strconv.FormatInt(f.ID(), 10)])
147148
}
148149
return out, nil
149150
}
@@ -158,8 +159,8 @@ func (b *S101Builder) Build(f *s57.Feature) (FeatureBuild, bool) {
158159
return m[f.ID()], true
159160
}
160161

161-
// lower turns one feature's emitted instruction stream into its FeatureBuild.
162-
func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild {
162+
// buildFeature turns one feature's emitted instruction stream into its FeatureBuild.
163+
func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild {
163164
// Genuinely-unknown object class (no S-101 alias) → the magenta "unknown
164165
// object" mark (S-52 §10.1.1 parity).
165166
if strings.HasPrefix(stream, "UNMAPPED:") {
@@ -180,10 +181,16 @@ func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild {
180181
var prims []Primitive
181182
priority := 0
182183
cat := 0 // unset; resolved from the viewing groups the rule emits
184+
var dateStart, dateEnd, timeValid string
183185
for _, c := range cmds {
184186
if c.Priority > priority {
185187
priority = c.Priority
186188
}
189+
// Date dependency is feature-level (one Date:/TimeValid: pair the rule emits
190+
// up front, carried onto every draw): capture it once for the FeatureBuild.
191+
if timeValid == "" && (c.TimeValid != "" || c.DateStart != "" || c.DateEnd != "") {
192+
dateStart, dateEnd, timeValid = c.DateStart, c.DateEnd, c.TimeValid
193+
}
187194
// The shallow-water pattern (SEABED01 emits AreaFillReference:DIAMOND1 in
188195
// viewing group 90000 on every depth area shallower than the safety
189196
// contour) is a MARINER SELECTION, not a fixed portrayal. The client owns
@@ -203,26 +210,40 @@ func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild {
203210
if dc := displayCategoryForViewingGroup(c.ViewingGroup); dc != 0 && (cat == 0 || dc < cat) {
204211
cat = dc
205212
}
206-
prims = append(prims, LowerS101(c, sg, b.Catalog)...)
213+
prims = append(prims, emitPrimitives(c, sg, b.Catalog)...)
207214
}
208-
// Soundings: tag each lowered glyph with its depth so the baker emits the
215+
// Soundings: tag each emitted glyph with its depth so the baker emits the
209216
// numeric depth + S/G palette variants. Without this the client's depth-unit
210217
// conversion (synthSounding) and SNDFRM04 safety-depth split fall back to the
211218
// static metric glyphs and never react to those settings.
212219
if f.ObjectClass() == "SOUNDG" {
213220
attachSoundingDepths(prims, soundingPoints(f.Geometry()))
214221
}
215-
// Sector / directional light figures: the rule's AugmentedRay / ArcByRadius
216-
// geometry is fixed display-mm (screen size) and isn't lowered onto geographic
217-
// geometry; emit a SectorLight the baker tessellates per-zoom instead.
218-
prims = append(prims, sectorLightPrims(f, anchor)...)
222+
// Sector / directional lights: the rule constructs the legs + arc as screen-space
223+
// AugmentedFigure elements (emitted above from the AugmentedRay / ArcByRadius
224+
// instructions). Tag each leg with the light's nominal range (VALNMR) so the
225+
// baker can also emit the "full light lines" leg variant for the client's live
226+
// toggle (S-52 LIGHTS06 note 1).
227+
if f.ObjectClass() == "LIGHTS" {
228+
if vnr, ok := floatAttr(f.Attributes(), "VALNMR"); ok && vnr > 0 {
229+
for i := range prims {
230+
if fig, ok := prims[i].(AugmentedFigure); ok && fig.Ray {
231+
fig.FullLengthNM = vnr
232+
prims[i] = fig
233+
}
234+
}
235+
}
236+
}
219237
if cat == 0 {
220238
cat = displayStandard // no display-category band emitted (e.g. text-only)
221239
}
222240
return FeatureBuild{
223241
Primitives: prims,
224242
DisplayPriority: priority,
225243
DisplayCategory: cat,
244+
DateStart: dateStart,
245+
DateEnd: dateEnd,
246+
TimeValid: timeValid,
226247
}
227248
}
228249

@@ -299,7 +320,7 @@ func soundingPoints(g s57.Geometry) [][3]float64 {
299320
return pts
300321
}
301322

302-
// attachSoundingDepths sets SoundingDepthM on each lowered sounding glyph from the
323+
// attachSoundingDepths sets SoundingDepthM on each emitted sounding glyph from the
303324
// SOUNDG multipoint, matching by anchor (the glyphs are placed at their sounding's
304325
// lon/lat via AugmentedPoint). The depth then reaches the baker, which emits the
305326
// numeric depth + S/G palette variants the client needs for live depth-unit

0 commit comments

Comments
 (0)