From 7273a3892109b3bc366567f078b1b33a274614a6 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 07:13:47 -0400 Subject: [PATCH] fix(bake): stop coarse lines double-drawing across bands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-band best-available suppression used one whole-tile (centre + 4 corners) test for every non-point prim. That relaxation exists so opaque area FILLS don't get seam holes — a finer fill is drawn on top and occludes the coarse one. But LINES don't occlude: each per-band archive is a separate client source with no maxzoom cap on the fine bands, so a coarse line kept in any partially-covered seam tile draws twice — the finer band redraws the same RESARE/CTNARE boundary, depth contour or coastline beside it (the reported doubled restricted-area outline at the approach/harbor overlap zoom). Split the suppression by geometry: lines suppress by the tile centre (yield only where the centre has no finer cell); fills keep the 5-corner whole-tile test. Fills are unchanged; line double-draw drops to at most ~1-tile seam slivers where the finer cell genuinely has no data. Add TestCrossBandLineNoDoubleDraw (real US4MD81M+US5MD1MC pair): asserts no tile carrying a line in both bands has its centre covered by a finer cell — fails on the old code (interior double-draws), passes now. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 28 +++++-- internal/engine/bake/crossband_line_test.go | 91 +++++++++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 internal/engine/bake/crossband_line_test.go diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index d66e6c9..50fdc37 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -1568,23 +1568,39 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64 } } else if r.cscl != 0 && r.layer != "scale_boundaries" && (r.kind == mvt.GeomPoint || r.kind == mvt.GeomLineString || r.layer == "area_patterns" || r.layer == "areas") { - if r.kind == mvt.GeomPoint { + switch { + case r.kind == mvt.GeomPoint: // A point tests its OWN position — a boundary tile keeps coarse points // that fall outside the finer coverage. if s := b.coverageScaleAt(unnormY(r.wMinY), r.wMinX*360-180, bandZ); s != 0 && s < r.cscl { suppressed = true } - } else { - // Lines/fills SPAN the tile, so testing the tile CENTRE alone punched a - // hole at cell SEAMS: a tile straddling the boundary between a coarse cell + case r.kind == mvt.GeomLineString: + // Lines (incl. complex/symbolised) do NOT occlude. Each per-band archive + // is a SEPARATE client source with no maxzoom cap on the fine bands, so a + // coarse line kept in a tile a finer cell also covers DOUBLE-DRAWS: the + // finer band redraws the same stroke (a restricted-area/CTNARE boundary, a + // depth contour, a coastline) offset beside it — there is no opaque fill to + // hide the coarse copy. The whole-tile (5-corner) relaxation below is for + // FILLS, where a seam gap would show through; it must NOT apply to strokes, + // or every partially-covered seam tile keeps a doubled line. So suppress a + // coarse line by the tile CENTRE (a line spans the tile, so the centre is + // its representative point): it yields only where the centre has no finer + // cell — best-available where the finer cell genuinely carries no data. + if s := b.coverageScaleAt(ctrLat, ctrLon, bandZ); s != 0 && s < r.cscl { + suppressed = true + } + default: + // Area/pattern FILLS span the tile, so testing the tile CENTRE alone punched + // a hole at cell SEAMS: a tile straddling the boundary between a coarse cell // and an adjacent finer cell has its centre in the finer cell, which // suppressed the coarse cell's portion on the OTHER side of the seam where // the finer cell has NO data (e.g. US4MD1ED's depth fill just north of the // 39.0 line it shares with the finer US4MD1DD — the "bottom half disappears" // gap). Suppress only when a finer cell covers the WHOLE tile (centre + 4 // corners); a partially-covered seam tile keeps the coarse fill, and the - // finer cell — drawn later, on top — covers it where it actually has data. - // No visible double-draw (finer wins on top); no seam gap. + // finer cell — drawn later, on top — OCCLUDES it where it actually has data. + // No visible double-draw (opaque finer fill wins on top); no seam gap. wLon, eLon := float64(coord.X)/n*360-180, float64(coord.X+1)/n*360-180 nLat, sLat := unnormY(float64(coord.Y)/n), unnormY(float64(coord.Y+1)/n) suppressed = true diff --git a/internal/engine/bake/crossband_line_test.go b/internal/engine/bake/crossband_line_test.go new file mode 100644 index 0000000..5118347 --- /dev/null +++ b/internal/engine/bake/crossband_line_test.go @@ -0,0 +1,91 @@ +package bake + +import ( + "testing" + + "github.com/beetlebugorg/chartplotter/internal/engine/mvt" + "github.com/beetlebugorg/chartplotter/internal/engine/tile" + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// TestCrossBandLineNoDoubleDraw guards the best-available LINE suppression on the +// real US4MD81M (approach) + US5MD1MC (harbor) overlapping pair. On the client +// both per-band sources render at the overlap zoom (the fine bands carry no +// maxzoom cap), so a coarse line kept in a tile a finer cell also draws into +// DOUBLE-DRAWS — the RESARE/CTNARE boundary, depth contour or coastline appears +// twice (a doubled stroke). Unlike opaque area FILLS, a line does not occlude, so +// the whole-tile (5-corner) seam relaxation must NOT keep it: a coarse line is +// suppressed by the tile CENTRE. The invariant: no tile that carries a line in +// BOTH bands may have its centre covered by a strictly-finer cell (that would be +// an interior double-draw, not an unavoidable ≤1-tile seam sliver). +func TestCrossBandLineNoDoubleDraw(t *testing.T) { + b := New() + b.SetPortrayer(testS101Portrayer(t)) + for _, cell := range []string{goldenCell, "../../../testdata/US5MD1MC.000"} { + chart, err := s57.Parse(cell) + if err != nil { + t.Fatalf("parse %s: %v", cell, err) + } + b.AddCell(chart) + } + + const ext = mvt.ExtentDefault + const buf = 64.0 + const z = 14 // a zoom where the client shows both approach and harbor + + // Coarse (approach) cell compilation scale, for the finer-cover comparison. + var approachCscl uint32 + for i := range b.covMeta { + if cm := &b.covMeta[i]; cm.bandMax == BandApproach.ZoomRange().Max { + approachCscl = cm.cscl + } + } + if approachCscl == 0 { + t.Fatal("approach cell compilation scale not found in coverage metadata") + } + + lineLayers := map[string]bool{"lines": true, "complex_lines": true} + emitLineTiles := func(bandMax uint32) map[tile.TileCoord]bool { + b.BuildEmitIndexBand(ext, buf, bandMax) + defer b.ClearEmitIndex() + out := map[tile.TileCoord]bool{} + var ts TileScratch + for _, c := range b.TileCoordsBand(ext, bandMax, bandMax) { + if c.Z != z { + continue + } + data := b.EmitTileBandInto(c, ext, buf, &ts, bandMax) + if data == nil { + continue + } + for name, l := range decodeLayers(data) { + if lineLayers[name] && len(l.feats) > 0 { + out[c] = true + break + } + } + } + return out + } + + app := emitLineTiles(BandApproach.ZoomRange().Max) + har := emitLineTiles(BandHarbor.ZoomRange().Max) + + n := float64(int64(1) << uint(z)) + var doubled, interior int + for c := range app { + if !har[c] { + continue + } + doubled++ + ctrLon := (float64(c.X)+0.5)/n*360 - 180 + ctrLat := unnormY((float64(c.Y) + 0.5) / n) + // A strictly-finer cell covers this tile's centre, yet a coarse line was + // kept here AND the finer band also drew one: an interior double-draw. + if s := b.coverageScaleAt(ctrLat, ctrLon, z); s != 0 && s < approachCscl { + interior++ + t.Errorf("interior line double-draw at %v: tile centre covered by finer cell (cscl %d < approach %d)", c, s, approachCscl) + } + } + t.Logf("z%d: %d tiles carry a line in both bands; %d of them are interior (must be 0), the rest are ≤1-tile seam slivers", z, doubled, interior) +}