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
278 changes: 169 additions & 109 deletions internal/engine/bake/bake.go

Large diffs are not rendered by default.

81 changes: 47 additions & 34 deletions internal/engine/bake/bake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (
const goldenCell = "../../../testdata/US4MD81M.000"

func TestBandForScale(t *testing.T) {
if BandForScale(12_000).ZoomRange() != (ZoomRange{14, 16}) {
t.Error("12k should be harbor [14,16]")
if BandForScale(12_000).ZoomRange() != (ZoomRange{13, 16}) {
t.Error("12k should be harbor [13,16]")
}
if BandForScale(3_000_000) != BandOverview {
t.Error("3M should be overview")
Expand Down Expand Up @@ -343,41 +343,40 @@ func TestSoundingGrouping(t *testing.T) {
}

func TestSectorLights(t *testing.T) {
// expandSector: a sector -> 2 short legs (sleg 0) + 2 full-length legs
// (sleg 1) + OUTLW underlay + coloured arc (both sleg -1, always shown).
anchor := mustLatLon(38.97, -76.49)
strokes := expandSector(anchor, sp(0, 90, "LITRD"), 14)
if len(strokes) != 6 {
t.Fatalf("sector strokes = %d, want 6", len(strokes))
}
if !strokes[0].dashed || strokes[0].colorToken != "CHBLK" || strokes[0].sleg != 0 {
// tessellateFigure drives each constructed figure element off the rule's params.
// A leg with a nominal range emits the short (sleg 0) + extended full-length
// (sleg 1) variants, both dashed in the rule's colour, for the client's toggle.
leg := tessellateFigure(legPrim(90, 8), 14)
if len(leg) != 2 {
t.Fatalf("leg strokes = %d, want 2 (short + full)", len(leg))
}
if !leg[0].dashed || leg[0].colorToken != "CHBLK" || leg[0].sleg != 0 {
t.Error("stroke 0 should be the dashed CHBLK short leg (sleg 0)")
}
if !strokes[2].dashed || strokes[2].colorToken != "CHBLK" || strokes[2].sleg != 1 {
t.Error("stroke 2 should be the dashed CHBLK full-length leg (sleg 1)")
}
if strokes[4].colorToken != "OUTLW" || strokes[4].widthPx != 4 || strokes[4].sleg != -1 {
t.Error("stroke 4 should be the 4px OUTLW arc underlay (sleg -1)")
if leg[1].sleg != 1 {
t.Error("stroke 1 should be the full-length leg (sleg 1)")
}
if strokes[5].colorToken != "LITRD" || strokes[5].widthPx != 2 || strokes[5].dashed || strokes[5].sleg != -1 {
t.Error("stroke 5 should be the 2px solid LITRD arc (sleg -1)")
}
// A light with a VALNMR nominal range emits full legs (sleg 1) longer than
// the 25 mm short legs (sleg 0) — the toggle's whole point.
spR := sp(0, 90, "LITRD")
spR.RadiusNM = 8
withR := expandSector(anchor, spR, 14)
legLen := func(s sectorStroke) float64 {
return absf(s.points[1].Lat-s.points[0].Lat) + absf(s.points[1].Lon-s.points[0].Lon)
}
if legLen(withR[2]) <= legLen(withR[0]) {
t.Errorf("full leg (%.6f) should be longer than short leg (%.6f)", legLen(withR[2]), legLen(withR[0]))
if legLen(leg[1]) <= legLen(leg[0]) {
t.Errorf("full leg (%.6f) should be longer than short leg (%.6f)", legLen(leg[1]), legLen(leg[0]))
}
// A leg with no nominal range is a single always-shown stroke (sleg -1).
plain := tessellateFigure(legPrim(90, 0), 14)
if len(plain) != 1 || plain[0].sleg != -1 {
t.Errorf("plain leg = %+v, want 1 stroke sleg -1", plain)
}
// Screen-fixed: lat span ~halves per zoom level.
r14 := expandSector(anchor, sp(0, 0, "LITYW"), 14) // ring
r15 := expandSector(anchor, sp(0, 0, "LITYW"), 15)
span := func(s []sectorStroke) float64 { return absf(s[len(s)-1].points[0].Lat - anchor.Lat) }
if ratio := span(r14) / span(r15); ratio < 1.9 || ratio > 2.1 {
// An arc/ring is one stroke in the rule's colour (white light → yellow LITYW),
// always shown (sleg -1). Screen-fixed: the radius ~halves per zoom level.
arc14 := tessellateFigure(arcPrim(26, 0, 360, "LITYW"), 14)
if len(arc14) != 1 || arc14[0].colorToken != "LITYW" || arc14[0].sleg != -1 {
t.Fatalf("arc = %+v, want 1 LITYW stroke sleg -1", arc14)
}
arc15 := tessellateFigure(arcPrim(26, 0, 360, "LITYW"), 15)
anchor := mustLatLon(38.97, -76.49)
span := func(s []sectorStroke) float64 { return absf(s[0].points[0].Lat - anchor.Lat) }
if ratio := span(arc14) / span(arc15); ratio < 1.9 || ratio > 2.1 {
t.Errorf("ring radius ratio z14/z15 = %.3f, want ~2", ratio)
}
}
Expand All @@ -388,8 +387,8 @@ func TestSectorLights(t *testing.T) {
// suppressed only where a finer prim sits on it. zMax is set > natMax to simulate
// the overzoomed coarse prim and drive the branch directly.
func TestUpSuppressionPointOverlap(t *testing.T) {
coastal := BandCoastal.ZoomRange() // {10,12}
harbor := BandHarbor.ZoomRange() // {14,16}
coastal := BandCoastal.ZoomRange() // {9,11}
harbor := BandHarbor.ZoomRange() // {13,16}
base := geo.LatLon{Lat: 38.97, Lon: -76.49}
mk := func(b *Baker, ll geo.LatLon, layer string, zr ZoomRange, zMax uint32) {
r := routed{layer: layer, kind: mvt.GeomPoint, npoint: normPt(ll),
Expand Down Expand Up @@ -419,8 +418,22 @@ func TestUpSuppressionPointOverlap(t *testing.T) {
}

func mustLatLon(lat, lon float64) geo.LatLon { return geo.LatLon{Lat: lat, Lon: lon} }
func sp(start, end float64, color string) portrayal.SectorParams {
return portrayal.SectorParams{StartAngleDeg: start, EndAngleDeg: end, ColorToken: color}

// legPrim / arcPrim build a one-element sector figure (as the rule emits) at a
// fixed anchor: a dashed CHBLK leg at the given bearing (fullNM>0 enables the
// extended variant), or a coloured arc/ring.
func legPrim(bearingDeg, fullNM float64) *sectorPrim {
return &sectorPrim{fig: portrayal.AugmentedFigure{
Anchor: mustLatLon(38.97, -76.49), Ray: true, BearingDeg: bearingDeg,
LengthMM: 25, ColorToken: "CHBLK", WidthMM: 0.32, Dash: portrayal.DashDashed,
FullLengthNM: fullNM,
}}
}
func arcPrim(radiusMM, startDeg, sweepDeg float64, color string) *sectorPrim {
return &sectorPrim{fig: portrayal.AugmentedFigure{
Anchor: mustLatLon(38.97, -76.49), RadiusMM: radiusMM,
StartDeg: startDeg, SweepDeg: sweepDeg, ColorToken: color, WidthMM: 0.64,
}}
}

func absf(x float64) float64 {
Expand Down
2 changes: 1 addition & 1 deletion internal/engine/bake/complexline.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func (b *Baker) emitComplexLine(r *routed, proj tile.Projector, rect tile.Rect,
}

// Screen px -> tile units. The baker lays figures out in 256-px-per-tile space
// (see sectorRadiusNorm/expandSector); one tile is `extent` units wide.
// (see sectorRadiusNorm/tessellateFigure); one tile is `extent` units wide.
pxScale := float64(extent) / 256.0
period := info.periodPx * pxScale
if period < 1e-6 {
Expand Down
21 changes: 21 additions & 0 deletions internal/engine/portrayal/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ type FeatureBuild struct {
Primitives []Primitive
DisplayPriority int
DisplayCategory int
// DateStart/DateEnd/TimeValid carry the feature's date dependency when it has
// one (S-57 DATSTA/DATEND fixed window or PERSTA/PEREND periodic window), so the
// baker can tag the feature and a date-aware client show/hide it against the
// current date. Empty when the feature is not date-dependent. DateStart/DateEnd
// are S-57 date strings — full "YYYYMMDD" (fixed) or partial "--MMDD" recurring
// each year (periodic); TimeValid is the interval kind (closedInterval /
// geSemiInterval / leSemiInterval). The feature also carries the CHDATD01
// date-dependent marker symbol among its primitives.
DateStart string
DateEnd string
TimeValid string
}

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

// stringAttr returns an attribute's encoded string value, or "" when absent.
func stringAttr(attrs map[string]interface{}, key string) string {
if v, ok := attrs[key]; ok {
if s, ok := encodeAttr(v); ok {
return s
}
}
return ""
}

func floatAttr(attrs map[string]interface{}, key string) (float64, bool) {
v, ok := attrs[key]
if !ok || v == nil {
Expand Down
58 changes: 33 additions & 25 deletions internal/engine/portrayal/primitive.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package portrayal turns one S-57 feature into a stream of viewport-independent
// lat/lon Primitives by running the S-101 portrayal rules and lowering the
// emitted drawing instructions, stopping short of projection and colour
// lat/lon Primitives by running the S-101 portrayal rules and emitting a
// primitive for each drawing instruction, stopping short of projection and colour
// resolution. Colour stays as *token* strings; the tile engine
// projects/clips/encodes the Primitives into MVT and the browser resolves
// Day/Dusk/Night from colortables.json.
Expand Down Expand Up @@ -141,29 +141,37 @@ type TextHalo struct {
WidthPx float32
}

// SectorLight is LIGHTS06 sector geometry (legs / arc / ring). Only the lat/lon
// anchor plus the S-52 sector parameters are cached; the screen-space
// tessellation happens at projection time because radii are display millimetres.
type SectorLight struct {
// AugmentedFigure is one stroked element of a screen-space figure the S-101 rule
// CONSTRUCTED via AugmentedRay / ArcByRadius (a light-sector leg or arc/ring) —
// driven by the catalogue's own bearings, radii, colours and widths rather than a
// Go re-derivation from S-57 attributes. One primitive = one stroked element; a
// sectored light emits several (two dashed legs, then a black-backed coloured
// arc). The mm sizes are screen-fixed, so the baker tessellates per-zoom into
// `sector_lines`; it cannot bake as static geographic geometry.
type AugmentedFigure struct {
Anchor geo.LatLon
Sector SectorParams
}

// SectorParams carries the S-52 LIGHTS06 sector parameters. Populated from the
// s52 SectorInstruction the CS procedure emits.
type SectorParams struct {
StartAngleDeg float64 // SECTR1, 0=North, clockwise
EndAngleDeg float64 // SECTR2, 0=North, clockwise
RadiusNM float64 // VALNMR nominal range, nautical miles
ColorToken string // LITRD/LITGN/LITYW/...
Transparency int // 0=opaque..3=75%
ShowLegs bool
Ray bool // true: a straight leg (Bearing/Length); false: an arc/ring
// Ray params (true-north bearing, already from-seaward-reversed by the rule).
BearingDeg float64
LengthMM float64
// Arc params (centred on Anchor); a full 360° sweep is an all-round ring.
RadiusMM float64
StartDeg float64
SweepDeg float64
// Stroke style, from the rule's LineStyle:_simple_.
ColorToken string
WidthMM float64
Dash Dash
// FullLengthNM is the LIGHTS nominal range (VALNMR); when set on a ray, the
// baker also emits the "full light lines" leg variant extended to that range
// (S-52 LIGHTS06 note 1), tagged for the client's live toggle. 0 = no variant.
FullLengthNM float64
}

func (FillPolygon) isPrimitive() {}
func (StrokeLine) isPrimitive() {}
func (SymbolCall) isPrimitive() {}
func (PatternFill) isPrimitive() {}
func (LinePattern) isPrimitive() {}
func (DrawText) isPrimitive() {}
func (SectorLight) isPrimitive() {}
func (FillPolygon) isPrimitive() {}
func (StrokeLine) isPrimitive() {}
func (SymbolCall) isPrimitive() {}
func (PatternFill) isPrimitive() {}
func (LinePattern) isPrimitive() {}
func (DrawText) isPrimitive() {}
func (AugmentedFigure) isPrimitive() {}
49 changes: 35 additions & 14 deletions internal/engine/portrayal/s101build.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,17 @@ func newS101Builder(catalogFS fs.FS, fcBytes []byte) (*S101Builder, error) {

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

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

// lower turns one feature's emitted instruction stream into its FeatureBuild.
func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild {
// buildFeature turns one feature's emitted instruction stream into its FeatureBuild.
func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild {
// Genuinely-unknown object class (no S-101 alias) → the magenta "unknown
// object" mark (S-52 §10.1.1 parity).
if strings.HasPrefix(stream, "UNMAPPED:") {
Expand All @@ -180,10 +181,16 @@ func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild {
var prims []Primitive
priority := 0
cat := 0 // unset; resolved from the viewing groups the rule emits
var dateStart, dateEnd, timeValid string
for _, c := range cmds {
if c.Priority > priority {
priority = c.Priority
}
// Date dependency is feature-level (one Date:/TimeValid: pair the rule emits
// up front, carried onto every draw): capture it once for the FeatureBuild.
if timeValid == "" && (c.TimeValid != "" || c.DateStart != "" || c.DateEnd != "") {
dateStart, dateEnd, timeValid = c.DateStart, c.DateEnd, c.TimeValid
}
// The shallow-water pattern (SEABED01 emits AreaFillReference:DIAMOND1 in
// viewing group 90000 on every depth area shallower than the safety
// contour) is a MARINER SELECTION, not a fixed portrayal. The client owns
Expand All @@ -203,26 +210,40 @@ func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild {
if dc := displayCategoryForViewingGroup(c.ViewingGroup); dc != 0 && (cat == 0 || dc < cat) {
cat = dc
}
prims = append(prims, LowerS101(c, sg, b.Catalog)...)
prims = append(prims, emitPrimitives(c, sg, b.Catalog)...)
}
// Soundings: tag each lowered glyph with its depth so the baker emits the
// Soundings: tag each emitted glyph with its depth so the baker emits the
// numeric depth + S/G palette variants. Without this the client's depth-unit
// conversion (synthSounding) and SNDFRM04 safety-depth split fall back to the
// static metric glyphs and never react to those settings.
if f.ObjectClass() == "SOUNDG" {
attachSoundingDepths(prims, soundingPoints(f.Geometry()))
}
// Sector / directional light figures: the rule's AugmentedRay / ArcByRadius
// geometry is fixed display-mm (screen size) and isn't lowered onto geographic
// geometry; emit a SectorLight the baker tessellates per-zoom instead.
prims = append(prims, sectorLightPrims(f, anchor)...)
// Sector / directional lights: the rule constructs the legs + arc as screen-space
// AugmentedFigure elements (emitted above from the AugmentedRay / ArcByRadius
// instructions). Tag each leg with the light's nominal range (VALNMR) so the
// baker can also emit the "full light lines" leg variant for the client's live
// toggle (S-52 LIGHTS06 note 1).
if f.ObjectClass() == "LIGHTS" {
if vnr, ok := floatAttr(f.Attributes(), "VALNMR"); ok && vnr > 0 {
for i := range prims {
if fig, ok := prims[i].(AugmentedFigure); ok && fig.Ray {
fig.FullLengthNM = vnr
prims[i] = fig
}
}
}
}
if cat == 0 {
cat = displayStandard // no display-category band emitted (e.g. text-only)
}
return FeatureBuild{
Primitives: prims,
DisplayPriority: priority,
DisplayCategory: cat,
DateStart: dateStart,
DateEnd: dateEnd,
TimeValid: timeValid,
}
}

Expand Down Expand Up @@ -299,7 +320,7 @@ func soundingPoints(g s57.Geometry) [][3]float64 {
return pts
}

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