diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 50fdc37..ff5000c 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -418,12 +418,30 @@ func New() *Baker { return &Baker{bbox: geo.EmptyBox()} } // Bounds is the union lat/lon bbox of every ingested cell's primitives. func (b *Baker) Bounds() geo.BoundingBox { return b.bbox } +// lightCharText returns the light-characteristic label the S-101 rule (LITDSN02, +// via LightFlareAndDescription) produced for a LIGHTS feature — the first DrawText +// in its portrayal. The catalogue is the single source of truth for the +// characteristic string (S-52 LITDSN is a CSP), so the inspector and the +// co-located merge harvest THIS rather than re-deriving it in Go. +func lightCharText(passes []portrayal.FeatureBuildPass) string { + for _, pass := range passes { + for _, p := range pass.Build.Primitives { + if t, ok := p.(portrayal.DrawText); ok { + return t.Text + } + } + } + return "" +} + // groupCoLocatedLights finds LIGHTS features that share an exact position and, // for each such group, returns the merged multi-line characteristic keyed by the // first (primary) feature index, plus the set of non-primary indices to suppress. // S-52 PresLib §LIGHTS06: co-located lights combine into one flare + one stacked // characteristic label rather than drawing N flares/labels on top of each other. -func groupCoLocatedLights(features []s57.Feature) (primaryText map[int]string, skip map[int]bool) { +// Each light's characteristic comes from the catalogue (lightCharText), not a +// second Go reimplementation. +func (b *Baker) groupCoLocatedLights(features []s57.Feature) (primaryText map[int]string, skip map[int]bool) { type pos struct{ lat, lon float64 } groups := map[pos][]int{} for i := range features { @@ -446,7 +464,7 @@ func groupCoLocatedLights(features []s57.Feature) (primaryText map[int]string, s var lines []string seen := map[string]bool{} for _, i := range idxs { - ch := BuildLightCharacteristic(features[i].Attributes()) + ch := lightCharText(b.portrayer.Passes(&features[i])) if ch == "" || seen[ch] { continue } @@ -618,7 +636,7 @@ func (b *Baker) AddCell(chart *s57.Chart) { } // Combine co-located lights (S-52 LIGHTS06): one flare + one merged label. - lightPrimary, lightSkip := groupCoLocatedLights(features) + lightPrimary, lightSkip := b.groupCoLocatedLights(features) b.seenSector = make(map[sectorKey]struct{}) for i := range features { f := &features[i] @@ -630,20 +648,22 @@ func (b *Baker) AddCell(chart *s57.Chart) { b.curLight = "" b.curLightSkip = false b.curLightText = "" + // Boundary symbolization (S-52 §8.6.1): a style-variant area is built + // twice (plain bnd=0 / symbolized bnd=1) so the client toggles boundary + // style live; everything else is one pass tagged bnd=2. + // The portrayer (the build-time embedded catalogue, or --s101) runs the + // S-101 rules to produce the passes. + passes := b.portrayer.Passes(f) if f.ObjectClass() == "LIGHTS" { - b.curLight = BuildLightCharacteristic(f.Attributes()) + // The characteristic string comes from the catalogue rule (LITDSN02), + // harvested from the portrayal — not re-derived in Go. + b.curLight = lightCharText(passes) b.curLightSkip = lightSkip[i] if merged, ok := lightPrimary[i]; ok { b.curLightText = merged b.curLight = merged // inspector shows the combined characteristic } } - // Boundary symbolization (S-52 §8.6.1): a style-variant area is built - // twice (plain bnd=0 / symbolized bnd=1) so the client toggles boundary - // style live; everything else is one pass tagged bnd=2. - // The portrayer (the build-time embedded catalogue, or --s101) runs the - // S-101 rules to produce the passes. - passes := b.portrayer.Passes(f) for _, pass := range passes { fb := pass.Build bnd := int64(pass.Bnd) diff --git a/internal/engine/bake/lightchar.go b/internal/engine/bake/lightchar.go deleted file mode 100644 index 13ed220..0000000 --- a/internal/engine/bake/lightchar.go +++ /dev/null @@ -1,235 +0,0 @@ -package bake - -import ( - "fmt" - "strconv" - "strings" -) - -// LITCHR code to characteristic abbreviation mapping. -var litchrMap = map[int]string{ - 1: "F", // Fixed - 2: "Fl", // Flashing - 3: "LFl", // Long Flashing - 4: "Q", // Quick - 5: "VQ", // Very Quick - 6: "UQ", // Ultra Quick - 7: "Iso", // Isophase - 8: "Oc", // Occulting - 9: "IQ", // Interrupted Quick - 10: "IVQ", // Interrupted Very Quick - 11: "IUQ", // Interrupted Ultra Quick - 12: "Mo", // Morse - 13: "FFl", // Fixed and Flashing - 14: "Al", // Alternating - 15: "LAlO", // Long Alternating Occulting - 16: "LAlFl", // Long Alternating Flashing - 17: "OcFl", // Occulting + Flashing - 18: "FFlA", // Fixed, Flash, Alternating - 19: "AlOc", // Alternating Occulting -} - -// COLOUR code to color abbreviation mapping (S-57 Appendix A). -var colourCodeMap = map[int]string{ - 1: "W", // White - 2: "B", // Black - 3: "R", // Red - 4: "G", // Green - 5: "Bu", // Blue - 6: "Y", // Yellow - 7: "Gr", // Grey - 8: "Br", // Brown - 9: "Am", // Amber - 10: "Vi", // Violet - 11: "O", // Orange - 12: "Mg", // Magenta - 13: "Pk", // Pink -} - -// BuildLightCharacteristic is the exported form of buildLightCharacteristic — an -// S-52 light characteristic string (e.g. "Fl.R.4s") for a LIGHTS feature's -// attributes, or "" if none. Used by the baker to carry light data for display. -func BuildLightCharacteristic(attributes map[string]interface{}) string { - return buildLightCharacteristic(attributes) -} - -// buildLightCharacteristic builds a light characteristic string from attributes -// Returns strings like "Fl.W.5s", "Q.R", "Oc.G.10s", etc. -// Per S-57 Appendix A and S-52 LIGHTS06 procedure -func buildLightCharacteristic(attributes map[string]interface{}) string { - // Get LITCHR (light characteristic code) - // Check both friendly name and attribute code (ATTR_107) - litchr := 0 - if val, ok := attributes["LITCHR"]; ok { - litchr = lightInt(val) - } else if val, ok := attributes["ATTR_107"]; ok { - litchr = lightInt(val) - } - if litchr == 0 { - return "" // No characteristic specified - } - - // Get COLOUR (light color) — single or multiple values. - colours := lightColours(attributes) - - // Get SIGPER (signal period, s), SIGGRP (flash group), HEIGHT (m), VALNMR (range, M) - sigper := 0.0 - if val, ok := attributes["SIGPER"]; ok { - sigper = lightFloat(val) - } - siggrp := lightGroup(attributes) - height := 0.0 - if val, ok := attributes["HEIGHT"]; ok { - height = lightFloat(val) - } - valnmr := 0.0 - if val, ok := attributes["VALNMR"]; ok { - valnmr = lightFloat(val) - } - - // Map LITCHR code to abbreviation - charStr := litchrMap[litchr] - if charStr == "" { - // Unknown characteristic code - return numeric representation - charStr = fmt.Sprintf("LITCHR(%d)", litchr) - } - - // Build color string (concatenated, e.g. "WR" for alternating white/red) - var colorStr string - for _, c := range colours { - colorStr += mapColorCode(c) - } - - // Assemble in S-52 / NOAA form, e.g. "Fl(1)R 3s 4.3m 5M": - // () s m M (parts omitted if absent) - result := charStr - if siggrp != "" { - result += "(" + siggrp + ")" + colorStr // group parens separate the colour - } else if colorStr != "" { - result += " " + colorStr // no group → space before colour ("Fl R") - } - if sigper > 0 { - result += " " + trimFloat(sigper) + "s" - } - if height > 0 { - result += " " + trimFloat(height) + "m" - } - if valnmr > 0 { - result += " " + trimFloat(valnmr) + "M" - } - return result -} - -// lightColours parses the COLOUR (or ATTR_75) attribute into the set of S-52 -// colour codes present. COLOUR may be a single value, a comma/colon-separated -// string ("3,1" or "(1:1)"), or a list. -func lightColours(attributes map[string]interface{}) []int { - val, ok := attributes["COLOUR"] - if !ok { - val, ok = attributes["ATTR_75"] - } - if !ok { - return nil - } - switch v := val.(type) { - case int: - return []int{v} - case float64: - return []int{int(v)} - case string: - cleaned := strings.Trim(v, "()") - cleaned = strings.ReplaceAll(cleaned, ":", ",") - var out []int - for _, p := range strings.Split(cleaned, ",") { - out = append(out, lightStringToInt(strings.TrimSpace(p))) - } - return out - case []int: - return v - case []interface{}: - out := make([]int, 0, len(v)) - for _, item := range v { - out = append(out, lightInt(item)) - } - return out - } - return nil -} - -// lightGroup returns the SIGGRP flash-group string (e.g. "1", "2+1"), or "". -func lightGroup(attributes map[string]interface{}) string { - v, ok := attributes["SIGGRP"] - if !ok { - return "" - } - switch t := v.(type) { - case string: - s := strings.TrimSpace(t) - // S-57 encodes groups like "(1)" or "(2+1)"; strip the wrapping parens. - s = strings.Trim(s, "()") - if s == "" || s == "0" { - return "" - } - return s - case int: - if t <= 0 { - return "" - } - return fmt.Sprintf("%d", t) - case float64: - if t <= 0 { - return "" - } - return fmt.Sprintf("%d", int(t)) - } - return "" -} - -// trimFloat formats a float without a trailing ".0" (3.0 → "3", 4.3 → "4.3"). -func trimFloat(v float64) string { - return strconv.FormatFloat(v, 'f', -1, 64) -} - -// mapColorCode maps S-57 COLOUR attribute values to abbreviations. -// Returns empty string for unknown codes. -func mapColorCode(code int) string { - return colourCodeMap[code] -} - -// lightInt coerces an attribute value (int / float64 / string) to int. -func lightInt(val interface{}) int { - switch v := val.(type) { - case int: - return v - case float64: - return int(v) - case string: - return lightStringToInt(v) - default: - return 0 - } -} - -// lightFloat coerces an attribute value (float64 / int / string) to float64. -func lightFloat(val interface{}) float64 { - switch v := val.(type) { - case float64: - return v - case int: - return float64(v) - case string: - if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { - return f - } - return 0.0 - default: - return 0.0 - } -} - -// lightStringToInt parses a leading integer out of s, returning 0 on failure. -func lightStringToInt(s string) int { - var result int - fmt.Sscanf(s, "%d", &result) - return result -} diff --git a/internal/engine/bake/lightverify_test.go b/internal/engine/bake/lightverify_test.go new file mode 100644 index 0000000..e0a3699 --- /dev/null +++ b/internal/engine/bake/lightverify_test.go @@ -0,0 +1,131 @@ +package bake + +import ( + "os" + "strings" + "testing" + + "github.com/beetlebugorg/chartplotter/internal/engine/mvt" + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// TestLightTagFromCatalogue bakes a real cell and confirms the baked `light` +// inspector tag carries the catalogue (LITDSN02) string — no spurious single +// group "(1)", and the aero-light category prefix present — proving the Go +// reimplementation is gone and the value comes from Lua. Run with CELL set. +func TestLightTagFromCatalogue(t *testing.T) { + cell := os.Getenv("CELL") + if cell == "" { + cell = goldenCell // committed harbour cell, rich in lights + } + chart, err := s57.Parse(cell) + if err != nil { + t.Fatal(err) + } + b := New() + b.SetPortrayer(testS101Portrayer(t)) + b.AddCell(chart) + + lights := map[string]bool{} + for _, c := range b.TileCoords(mvt.ExtentDefault) { + data := b.EmitTile(c, mvt.ExtentDefault, 64) + if data == nil { + continue + } + for _, v := range lightValues(data) { + lights[v] = true + } + } + if len(lights) == 0 { + t.Fatal("no baked light tags found") + } + var aero bool + for v := range lights { + if strings.Contains(v, "(1)") { + t.Errorf("baked light tag still has spurious single group: %q", v) + } + if strings.HasPrefix(v, "Aero ") { + aero = true + } + } + if !aero { + t.Error("expected at least one Aero-prefixed light (catalogue category prefix)") + } + t.Logf("%d distinct baked light tags, e.g.:", len(lights)) + n := 0 + for v := range lights { + t.Logf(" %q", v) + if n++; n >= 12 { + break + } + } +} + +// lightValues extracts every `light` string-tag value from an MVT tile. +func lightValues(data []byte) []string { + var out []string + r := &rdr{d: data} + for { + f, _, b, _, ok := r.next() + if !ok { + break + } + if f != 3 { + continue + } + var keys []string + var vals []string + var valIsStr []bool + var feats [][]uint32 + lr := &rdr{d: b} + for { + lf, _, lb, _, lok := lr.next() + if !lok { + break + } + switch lf { + case 2: + var tags []uint32 + fr := &rdr{d: lb} + for { + ff, _, fb, _, fok := fr.next() + if !fok { + break + } + if ff == 2 { + tr := &rdr{d: fb} + for !tr.end() { + tags = append(tags, uint32(tr.uv())) + } + } + } + feats = append(feats, tags) + case 3: + keys = append(keys, string(lb)) + case 4: + s, isStr := "", false + vr := &rdr{d: lb} + for { + vf, _, vb, _, vok := vr.next() + if !vok { + break + } + if vf == 1 { + isStr, s = true, string(vb) + } + } + vals = append(vals, s) + valIsStr = append(valIsStr, isStr) + } + } + for _, tags := range feats { + for i := 0; i+1 < len(tags); i += 2 { + ki, vi := int(tags[i]), int(tags[i+1]) + if ki < len(keys) && keys[ki] == "light" && vi < len(vals) && valIsStr[vi] { + out = append(out, vals[vi]) + } + } + } + } + return out +} diff --git a/internal/engine/s101/colocation_test.go b/internal/engine/s101/colocation_test.go new file mode 100644 index 0000000..c245b73 --- /dev/null +++ b/internal/engine/s101/colocation_test.go @@ -0,0 +1,55 @@ +package s101 + +import ( + "strings" + "testing" +) + +// TestCoLocatedLightsStack proves HostSpatialGetAssociatedFeatureIDs: two LIGHTS +// features sharing a node are reported as co-located, so the catalogue's +// LightFlareAndDescription rule stacks the second description via +// GetColocatedTextCount -> TextVerticalOffset. A control pair at distinct points +// must NOT stack — confirming the offset comes from real co-location, not every +// light. +func TestCoLocatedLightsStack(t *testing.T) { + rulesDir, cat := testEnv(t) + + light := func(id string, pt [3]float64, colour string) Feature { + return Feature{ + ID: id, ObjectClass: "LIGHTS", Primitive: "Point", + Points: [][3]float64{pt}, + Attributes: map[string]string{"COLOUR": colour, "LITCHR": "2", "VALNMR": "4", "SIGPER": "4"}, + } + } + countOffsets := func(t *testing.T, feats []Feature) int { + e, err := NewEngine(rulesDir, cat) + if err != nil { + t.Fatal(err) + } + defer e.Close() + res, err := e.Portray(feats) + if err != nil { + t.Fatal(err) + } + n := 0 + for _, f := range feats { + if strings.HasPrefix(res[f.ID], "ERROR:") { + t.Fatalf("rule error for %s: %s", f.ID, res[f.ID]) + } + if strings.Contains(res[f.ID], "TextVerticalOffset") { + n++ + } + } + return n + } + + same := [3]float64{-122.4, 45.5, 0} + if n := countOffsets(t, []Feature{light("a", same, "3"), light("b", same, "4")}); n != 1 { + t.Errorf("co-located pair: want exactly one stacked (offset) description, got %d", n) + } + + apart := [3]float64{-122.3, 45.6, 0} + if n := countOffsets(t, []Feature{light("a", same, "3"), light("b", apart, "4")}); n != 0 { + t.Errorf("lights at distinct points must not stack, got %d offset(s)", n) + } +} diff --git a/internal/engine/s101/engine.go b/internal/engine/s101/engine.go index 9ec0855..28884c3 100644 --- a/internal/engine/s101/engine.go +++ b/internal/engine/s101/engine.go @@ -76,6 +76,29 @@ type Engine struct { // per-run feature data (set by Portray); keyed by feature ID. adapted map[string]*adapted order []string + // coLocated indexes mapped point features by their exact position, so the + // host can answer HostSpatialGetAssociatedFeatureIDs — i.e. which features + // share a node. Co-located lights use it (S-101 LightFlareAndDescription) to + // stack their descriptions (GetColocatedTextCount) and fan their flares. + coLocated map[[2]float64][]string +} + +// coLocatedFeatureIDs returns the IDs of mapped point features that share the +// given feature's node (including the feature itself; the caller filters self). +// It backs HostSpatialGetAssociatedFeatureIDs for point spatials — the data the +// S-101 LightFlareAndDescription rule needs to stack co-located light +// descriptions and fan their flares. Non-point / unknown features yield nil. +func (e *Engine) coLocatedFeatureIDs(featureID string) []string { + a := e.adapted[featureID] + if a == nil || a.primitive != "Point" || len(a.points) == 0 { + return nil + } + k := [2]float64{a.points[0][0], a.points[0][1]} + ids := e.coLocated[k] + if len(ids) < 2 { + return nil // only itself: no co-located neighbours + } + return ids } // SetContextOverrides overrides context-parameter defaults (e.g. @@ -219,6 +242,7 @@ func (e *Engine) Close() { e.L.Close() } func (e *Engine) Portray(features []Feature) (map[string]string, error) { e.adapted = map[string]*adapted{} e.order = nil + e.coLocated = map[[2]float64][]string{} results := map[string]string{} for _, f := range features { @@ -242,6 +266,13 @@ func (e *Engine) Portray(features []Feature) (map[string]string, error) { } e.adapted[f.ID] = a e.order = append(e.order, f.ID) + // Index point features by exact position for co-location queries. Only + // point primitives carry a single resolvable node; lines/areas don't + // participate in the LIGHTS06 co-located stacking/fanning. + if f.Primitive == "Point" && len(f.Points) > 0 { + k := [2]float64{f.Points[0][0], f.Points[0][1]} + e.coLocated[k] = append(e.coLocated[k], f.ID) + } } if len(e.order) > 0 { diff --git a/internal/engine/s101/host.go b/internal/engine/s101/host.go index 45e9c07..a22d86f 100644 --- a/internal/engine/s101/host.go +++ b/internal/engine/s101/host.go @@ -2,6 +2,7 @@ package s101 import ( "strconv" + "strings" "github.com/beetlebugorg/chartplotter/pkg/s100/fc" lua "github.com/yuin/gopher-lua" @@ -177,7 +178,25 @@ func (e *Engine) bindHost() { // HostFeatureGetSpatialAssociations + HostGetSpatial are defined in Lua glue // (installSpatialGlue) after the framework loads, so they can use the // framework's CreateSpatialAssociation/CreateSurface constructors. - set("HostSpatialGetAssociatedFeatureIDs", emptyArray) + // + // HostSpatialGetAssociatedFeatureIDs answers "which features share this + // spatial?" The glue's point-spatial IDs are "#P", so resolve the + // feature, then return everything sharing its node — the co-located set the + // LIGHTS06 rule needs to stack descriptions + fan flares. Curve/surface + // spatials carry no node here, so they fall through to empty. + set("HostSpatialGetAssociatedFeatureIDs", func(s *lua.LState) int { + t := s.NewTable() + spatialID := s.CheckString(1) + fid := spatialID + if i := strings.LastIndexByte(spatialID, '#'); i >= 0 { + fid = spatialID[:i] + } + for _, id := range e.coLocatedFeatureIDs(fid) { + t.Append(lua.LString(id)) + } + s.Push(t) + return 1 + }) set("HostSpatialGetAssociatedInformationIDs", emptyArray) set("HostInformationTypeGetCode", func(s *lua.LState) int { s.Push(lua.LString("")); return 1 }) set("HostInformationTypeGetSimpleAttribute", emptyArray)