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
23 changes: 19 additions & 4 deletions internal/engine/bake/bake.go
Original file line number Diff line number Diff line change
Expand Up @@ -1729,7 +1729,12 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64
// 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, false); s != 0 && s < r.cscl {
// includeDerived=true: a coarse line over a finer cell with only a derived
// extent (no M_COVR, e.g. the PresLib Chart-1 cells) still double-draws
// across bands and must be suppressed (S-52 §10.1.4 largest-scale wins).
// Lines never punch no-data fill holes, so this is hole-safe for a derived
// rect too — and it removes the live cross-band line bleed on Chart 1.
if s := b.coverageScaleAt(ctrLat, ctrLon, bandZ, true); s != 0 && s < r.cscl {
suppressed = true
}
default:
Expand Down Expand Up @@ -2104,16 +2109,26 @@ func (b *Baker) coverageBandAt(lat, lon float64) uint32 {
// across bands AND between cells of different scale that fall in the SAME band (the
// per-band coverageBandAt above can't distinguish those). bandZ-gated so a finer
// cell that isn't shown yet at this zoom doesn't punch a hole in the coarser one.
func (b *Baker) coverageScaleAt(lat, lon float64, bandZ uint32, pointQuery bool) uint32 {
func (b *Baker) coverageScaleAt(lat, lon float64, bandZ uint32, includeDerived bool) uint32 {
var best uint32 // 0 = none found yet; otherwise the finest (smallest) cscl
p := geo.LatLon{Lat: lat, Lon: lon}
for i := range b.covMeta {
cm := &b.covMeta[i]
if cm.cscl == 0 || cm.displayMin > bandZ {
continue // unscaled, or this cell isn't drawn at this zoom
}
if cm.derived && !pointQuery {
continue // a derived extent rectangle suppresses points, not fills (see covMeta.derived)
if cm.derived && !includeDerived {
// A derived rectangle marks where a cell IS, not where it has DATA, so it
// over-claims coverage. Per S-52 §10.1.4 a coarser FILL must remain to fill
// genuine gaps in the finer data (the finer fill occludes it on top where it
// has data) — so derived coverage must NOT suppress fills, or it would punch
// no-data holes inside the finer cell's sparse interior. POINTS and LINES are
// different: a coarse point/line drawn where a finer chart covers violates the
// "largest-scale data takes precedence" rule and double-draws across bands (no
// opaque fill hides it), so callers pass includeDerived=true for those. NB: the
// per-cell preslib harness can't show this (it frames one cell, one band), but
// it IS visible live when bands overlap — don't be fooled by a 0-pixel diff.
continue
}
if best != 0 && cm.cscl >= best {
continue // not finer than the best so far — skip the costly point test
Expand Down
15 changes: 14 additions & 1 deletion internal/engine/portrayal/s101depth.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,25 @@ func DerivedAttrs(f *s57.Feature, idx *DepthIndex) map[string]string {
return nil
}
depth := 0.0
surrounding := ""
if pt, ok := representativePoint(f); ok {
if d, ok := idx.shoalestDRVAL1(pt.Lat, pt.Lon); ok {
depth = d
// surroundingDepth is the depth of the area the danger sits in (S-52
// DEPVAL). UDWHAZ05 reads it: a sub-safety-contour danger whose
// surrounding water is itself shallow is NOT an isolated danger and
// must not get ISODGR01 unless "isolated dangers in shallow water" is
// on. Supply it ONLY when a containing depth area is found — leaving it
// absent (the no-area case) keeps the rule's conservative "unknown ⇒
// dangerous" default, so deep/unknown dangers still flag.
surrounding = strconv.FormatFloat(d, 'f', -1, 64)
}
}
return map[string]string{"defaultClearanceDepth": strconv.FormatFloat(depth, 'f', -1, 64)}
out := map[string]string{"defaultClearanceDepth": strconv.FormatFloat(depth, 'f', -1, 64)}
if surrounding != "" {
out["surroundingDepth"] = surrounding
}
return out
}

// polygonRings returns a polygon's rings as [lon,lat] lists (Rings field first,
Expand Down
73 changes: 52 additions & 21 deletions internal/engine/server/cellindex.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import (
// deliberately simple — a flat JSON map, not a database; the data is tiny (a few
// floats per cell) and read-mostly.
type cellIndex struct {
mu sync.RWMutex
bbox map[string][4]float64 // cell stem → [W,S,E,N]
path string // cells-index.json
encRoot string // <dataDir>/ENC_ROOT
built bool // backfill scan finished
mu sync.RWMutex
cond *sync.Cond // broadcast when a scan finishes (for wait())
bbox map[string][4]float64 // cell stem → [W,S,E,N]
path string // cells-index.json
encRoot string // <dataDir>/ENC_ROOT
scanning bool // a scan goroutine is running
dirty bool // a (re)build was requested during a scan → scan again
}

func newCellIndex(dataDir string) *cellIndex {
Expand All @@ -32,6 +34,7 @@ func newCellIndex(dataDir string) *cellIndex {
path: filepath.Join(dataDir, "cells-index.json"),
encRoot: filepath.Join(dataDir, "ENC_ROOT"),
}
ci.cond = sync.NewCond(&ci.mu)
if data, err := os.ReadFile(ci.path); err == nil {
_ = json.Unmarshal(data, &ci.bbox)
}
Expand Down Expand Up @@ -74,32 +77,60 @@ func (ci *cellIndex) save() {
_ = os.Rename(tmp, ci.path)
}

// rebuild re-opens the backfill (e.g. after an import added new cached cells) and
// indexes any not already present. Run in a goroutine.
func (ci *cellIndex) rebuild() {
// build kicks the initial backfill; rebuild requests a fresh pass after the cache
// changed (import added cells, a set was deleted). Both funnel through kick().
func (ci *cellIndex) build() { ci.kick() }
func (ci *cellIndex) rebuild() { ci.kick() }

// kick ensures the index is (re)scanned. Single-flight with a dirty re-run: if a
// scan is already running it just marks the index dirty so that scan loops once
// more when it finishes — so a (re)build requested mid-scan is never lost (the old
// built-flag reset/claim could drop a concurrent reindex, leaving the index stale).
func (ci *cellIndex) kick() {
ci.mu.Lock()
ci.built = false
ci.dirty = true
if ci.scanning {
ci.mu.Unlock()
return
}
ci.scanning = true
ci.mu.Unlock()
ci.build()
go ci.run()
}

// build backfills the index by reading every cached cell's header once. Runs in a
// background goroutine (started once) so it never blocks a request; queries see
// the index grow as it fills, and it's a no-op after the first complete pass.
func (ci *cellIndex) build() {
ci.mu.Lock()
if ci.built {
func (ci *cellIndex) run() {
for {
ci.mu.Lock()
ci.dirty = false
ci.mu.Unlock()
return
ci.scan()
ci.mu.Lock()
if !ci.dirty { // nothing changed during the scan — done
ci.scanning = false
ci.cond.Broadcast() // wake any wait()ers
ci.mu.Unlock()
return
}
ci.mu.Unlock() // a (re)build arrived mid-scan — scan again
}
}

// wait blocks until no scan is in flight — for tests and any caller that needs the
// index settled. kick() sets scanning before it returns, so a build()/rebuild()
// immediately followed by wait() always observes the in-flight scan and its re-runs.
func (ci *cellIndex) wait() {
ci.mu.Lock()
for ci.scanning {
ci.cond.Wait()
}
ci.built = true // claim the build; reset only if the scan can't start
ci.mu.Unlock()
}

// scan reads every cached cell's header once (bbox cached so repeat scans skip the
// already-indexed) and reconciles: drops index entries for cells no longer on disk.
func (ci *cellIndex) scan() {
entries, err := os.ReadDir(ci.encRoot)
if err != nil {
ci.mu.Lock()
ci.built = false
ci.mu.Unlock()
return
}
present := make(map[string]bool, len(entries))
Expand Down
4 changes: 4 additions & 0 deletions internal/engine/server/cellindex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func TestCellIndexBuild(t *testing.T) {

ci := newCellIndex(dir)
ci.build()
ci.wait()
bb, ok := ci.get(cell)
if !ok {
t.Fatal("cell not indexed after build")
Expand Down Expand Up @@ -72,6 +73,7 @@ func TestCellIndexFreshness(t *testing.T) {
}
ci := newCellIndex(dir)
ci.build()
ci.wait()
if _, ok := ci.get(cell); !ok {
t.Fatal("not indexed")
}
Expand All @@ -81,6 +83,7 @@ func TestCellIndexFreshness(t *testing.T) {
t.Fatal("forget did not drop the entry")
}
ci.rebuild()
ci.wait()
if _, ok := ci.get(cell); !ok {
t.Fatal("rebuild did not re-index after forget")
}
Expand All @@ -89,6 +92,7 @@ func TestCellIndexFreshness(t *testing.T) {
t.Fatal(err)
}
ci.rebuild()
ci.wait()
if _, ok := ci.get(cell); ok {
t.Error("rebuild did not prune a removed cell")
}
Expand Down
92 changes: 92 additions & 0 deletions internal/engine/server/cells_active_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package server

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/beetlebugorg/chartplotter/internal/engine/baker"
)

// activeCells GETs /api/cells?active=1 and returns the cell-name set.
func activeCells(t *testing.T, base string) map[string]bool {
t.Helper()
r, err := http.Get(base + "/api/cells?active=1")
if err != nil {
t.Fatal(err)
}
defer r.Body.Close()
var got struct {
Cells []string `json:"cells"`
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
set := map[string]bool{}
for _, c := range got.Cells {
set[c] = true
}
return set
}

// TestActiveCellsDropOnDelete: a manifest-tracked pack's cells appear under
// ?active=1, and DELETEing the pack drops them AND removes the lingering
// <set>.cells.json manifest. This is the "search shows uninstalled cells" / "stale
// after remove" regression: the active set is driven by the live pack list + its
// manifest, so removing the pack (packDel) and its manifest clears the cells, while
// the source stays in ENC_ROOT for a future re-bake.
func TestActiveCellsDropOnDelete(t *testing.T) {
dir := t.TempDir()
s := New(dir, dir, dir, false)

const cell = "US5MD11M"
// The cell's source dir must exist in ENC_ROOT (serveCells lists it from there).
if err := os.MkdirAll(filepath.Join(dir, "ENC_ROOT", cell), 0o755); err != nil {
t.Fatal(err)
}
// Register a band-set with an exact cell manifest, and add it as an enabled pack.
const set = "noaa-d5-harbor"
if err := s.writeSetCells(set, map[string]baker.CellData{cell + ".000": {}}); err != nil {
t.Fatal(err)
}
manifest := filepath.Join(s.setDir(set), set+".cells.json")
if _, err := os.Stat(manifest); err != nil {
t.Fatalf("manifest not written: %v", err)
}
s.packAdd(set, filepath.Join(s.setDir(set), set+".pmtiles"))

ts := httptest.NewServer(s)
defer ts.Close()

if !activeCells(t, ts.URL)[cell] {
t.Fatalf("cell %s should be active while its pack is installed", cell)
}

// DELETE the district → its band-sets are unregistered and their baked files +
// manifests removed.
req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/set?set=noaa-d5", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete status %d", resp.StatusCode)
}

if activeCells(t, ts.URL)[cell] {
t.Errorf("cell %s still active after its pack was deleted (stale search)", cell)
}
if _, err := os.Stat(manifest); !os.IsNotExist(err) {
t.Errorf("manifest %s not removed on delete (err=%v)", manifest, err)
}
// The source cell is intentionally kept for a future re-bake.
if _, err := os.Stat(filepath.Join(dir, "ENC_ROOT", cell)); err != nil {
t.Errorf("source cell should be kept in ENC_ROOT: %v", err)
}
}
2 changes: 1 addition & 1 deletion internal/engine/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func New(assetsDir, cacheDir, dataDir string, allowRemote bool) *Server {
dataDir = cacheDir
}
s := &Server{assetsDir: assetsDir, cacheDir: cacheDir, dataDir: dataDir, allowRemote: allowRemote, sets: newTileSets(), imports: newImportJobs(), auxIdx: newAuxIndex(), cellIdx: newCellIndex(dataDir)}
go s.cellIdx.build() // backfill cell bounds once, in the background (never blocks a request)
s.cellIdx.build() // backfill cell bounds in the background (kick spawns its own goroutine)
// Discover every baked pack on disk (provider trees + flat tiles/), then
// register the ENABLED ones (disabled packs stay on disk but off the map). State
// lives in <data>/prefs.json so it survives restarts and is shared across clients.
Expand Down
45 changes: 44 additions & 1 deletion internal/engine/server/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -335,7 +336,7 @@ func (s *Server) cacheCells(cells map[string]baker.CellData) {
stems = append(stems, strings.TrimSuffix(name, ".000"))
}
s.cellIdx.forget(stems) // re-imported cells: drop stale bounds so the rebuild re-parses
go s.cellIdx.rebuild() // index the (re-)cached cells' bounds in the background
s.cellIdx.rebuild() // re-index in the background (kick spawns its own goroutine; dirty re-run picks up a reindex that lands mid-scan)
}
}

Expand Down Expand Up @@ -539,6 +540,12 @@ func (s *Server) bakeAndRegister(jobID, set string, cells map[string]baker.CellD
if err := s.writeAndRegister(bandSet, pb, bandAux); err != nil {
return err
}
// Record which cells went into this pack (beside its pmtiles), so
// /api/cells?active returns exactly the installed cells — not every
// cached cell that overlaps the pack's (often global) bounding box.
if err := s.writeSetCells(bandSet, cells); err != nil {
log.Printf("import %s: cell manifest %q: %v", jobID, bandSet, err)
}
first = false
bands++
tiles += pb.Count()
Expand Down Expand Up @@ -590,6 +597,42 @@ func (s *Server) setDir(set string) string {
return filepath.Join(s.cacheDir, "import")
}

// writeSetCells records the cell stems baked into `set` beside its pmtiles
// (<setDir>/<set>.cells.json). /api/cells?active reads these to return exactly the
// installed cells, instead of every cached cell whose bounds overlap the pack's
// (often global, for a worldwide-scattered import) bounding box.
func (s *Server) writeSetCells(set string, cells map[string]baker.CellData) error {
stems := make([]string, 0, len(cells))
for n := range cells {
stems = append(stems, strings.TrimSuffix(n, ".000"))
}
sort.Strings(stems)
dir := s.setDir(set)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
b, err := json.Marshal(stems)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, set+".cells.json"), b, 0o644)
}

// setCells reads the cell-stem manifest written by writeSetCells for `set`, or nil
// (with ok=false) if the pack has none — a legacy pack baked before per-pack cell
// tracking, for which the caller falls back to bbox-overlap.
func (s *Server) setCells(set string) ([]string, bool) {
data, err := os.ReadFile(filepath.Join(s.setDir(set), set+".cells.json"))
if err != nil {
return nil, false
}
var stems []string
if json.Unmarshal(data, &stems) != nil {
return nil, false
}
return stems, true
}

// writeAndRegister writes the baked archive to <setDir>/<set>.pmtiles atomically
// (temp + rename), writes the companion <set>.aux.zip beside it (TXTDSC/PICREP, via
// the auxfiles package), and registers the set (replacing any prior one).
Expand Down
Loading