Skip to content
66 changes: 56 additions & 10 deletions internal/engine/bake/bake.go
Original file line number Diff line number Diff line change
Expand Up @@ -564,9 +564,49 @@ func (b *Baker) ScaminValues() []uint32 {
return out
}

// CellPortrayal carries a cell's precomputed portrayal from the parallel
// PortrayCell step to the serial AddCellPortrayed step. It is opaque to callers,
// which only shuttle it between the two; supported is false when the portrayer
// can't precompute (then AddCellPortrayed portrays inline, like AddCell).
type CellPortrayal struct {
cp cellPortrayal
supported bool
}

// PortrayCell runs a cell's (expensive) S-101 portrayal without mutating the
// Baker, so callers can do it on a worker pool. The result is replayed by
// AddCellPortrayed during the serial route/merge. Concurrency-safe: it only reads
// the read-only portrayer/catalogue and the internally-locked proto cache.
func (b *Baker) PortrayCell(chart *s57.Chart) CellPortrayal {
pp, ok := b.portrayer.(precomputingPortrayer)
if !ok {
return CellPortrayal{}
}
return CellPortrayal{cp: pp.portray(featurePtrs(chart.Features())), supported: true}
}

// featurePtrs adapts a cell's feature slice to the []*Feature the portrayer takes.
func featurePtrs(features []s57.Feature) []*s57.Feature {
fps := make([]*s57.Feature, len(features))
for i := range features {
fps[i] = &features[i]
}
return fps
}

// AddCell expands every feature of a parsed cell into routed primitives at the
// cell's scale band, with per-feature SCAMIN display z-min.
func (b *Baker) AddCell(chart *s57.Chart) {
// cell's scale band, with per-feature SCAMIN display z-min. It portrays the cell
// inline; use PortrayCell + AddCellPortrayed to portray off-thread in parallel.
func (b *Baker) AddCell(chart *s57.Chart) { b.addCell(chart, CellPortrayal{}) }

// AddCellPortrayed is AddCell with the cell's portrayal already computed (by
// PortrayCell, typically on a worker pool). The routing/merge it does is the same
// serial, deterministic work as AddCell — call it in a fixed cell order.
func (b *Baker) AddCellPortrayed(chart *s57.Chart, pc CellPortrayal) { b.addCell(chart, pc) }

// addCell is the shared body: portray (replaying pc if precomputed, else inline)
// then route the cell's features serially.
func (b *Baker) addCell(chart *s57.Chart, pc CellPortrayal) {
if b.portrayer == nil {
panic("bake: no S-101 portrayer set — build with `make` (-tags embed_s101) or pass --s101")
}
Expand Down Expand Up @@ -607,14 +647,20 @@ func (b *Baker) AddCell(chart *s57.Chart) {
}
// S-101: portray the whole cell in one engine pass (one Lua chunk + context,
// fresh Lua state) up front instead of per-feature — the per-feature path
// recompiled the chunk and leaked the catalogue's file-local caches.
if bp, ok := b.portrayer.(BatchPortrayer); ok {
fps := make([]*s57.Feature, len(features))
for i := range features {
fps[i] = &features[i]
}
bp.Begin(fps)
defer bp.End()
// recompiled the chunk and leaked the catalogue's file-local caches. Either
// replay a precomputed portrayal (parallel bake) or portray inline now; the
// result is released after this cell's features are routed.
switch pp := b.portrayer.(type) {
case precomputingPortrayer:
if pc.supported {
pp.install(pc.cp)
} else {
pp.install(pp.portray(featurePtrs(features)))
}
defer pp.End()
case BatchPortrayer:
pp.Begin(featurePtrs(features))
defer pp.End()
}

// Combine co-located lights (S-52 LIGHTS06): one flare + one merged label.
Expand Down
66 changes: 58 additions & 8 deletions internal/engine/bake/s101portrayer.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ type BatchPortrayer interface {
End()
}

// precomputingPortrayer is a BatchPortrayer that can portray a cell off the
// routing thread — portray is concurrency-safe and returns an opaque result that
// install replays onto the active state before that cell's serial routing. This
// lets the baker portray cells in parallel (the dominant bake cost) while keeping
// the stateful routing/merge serial and deterministic. The S-101 portrayer
// implements it; other portrayers fall back to the serial Begin/End path.
type precomputingPortrayer interface {
BatchPortrayer
portray(features []*s57.Feature) cellPortrayal
install(cp cellPortrayal)
}

// SetPortrayer installs the portrayal engine on the Baker. Set the S-101
// portrayer (NewS101Portrayer) to bake with S-101 symbology. Set before AddCell.
func (b *Baker) SetPortrayer(p Portrayer) { b.portrayer = p }
Expand Down Expand Up @@ -76,23 +88,61 @@ func NewS101PortrayerFS(catalogFS fs.FS, featureCatalogueXML []byte) (Portrayer,
return &s101Portrayer{builder: bld}, nil
}

// cellPortrayal is one cell's three portrayal-pass result maps. portray produces
// it off the routing thread; install replays it onto the active state just before
// that cell's serial routing — so cells can be portrayed in parallel and merged
// serially (deterministically).
type cellPortrayal struct {
cache, plain, simplified map[int64]portrayal.FeatureBuild
}

// Begin portrays the whole cell up front and caches results. It runs the default
// pass plus the PlainBoundaries / SimplifiedSymbols variant passes so the client
// can toggle boundary + point-symbol style live. Variant passes are best-effort:
// if one fails, that axis degrades to the common pass.
func (p *s101Portrayer) Begin(features []*s57.Feature) {
func (p *s101Portrayer) Begin(features []*s57.Feature) { p.install(p.portray(features)) }

// portray computes the cell's three portrayal passes WITHOUT touching the
// portrayer's active state, so different cells can be portrayed concurrently:
// each pass builds its own Lua engine and the builder is read-only (the proto
// cache is internally locked). The result is replayed by install.
func (p *s101Portrayer) portray(features []*s57.Feature) cellPortrayal {
m, err := p.builder.BuildBatch(features)
if err != nil {
p.cache = nil // Passes falls back to per-feature builds
return
return cellPortrayal{} // empty → Passes falls back to per-feature builds
}
p.cache = m
if v, err := p.builder.BuildBatchOverrides(features, map[string]string{"PlainBoundaries": "true"}); err == nil {
p.plain = v
cp := cellPortrayal{cache: m}
// Each override pass portrays ONLY the geometry type whose variant it
// contributes — PlainBoundaries varies area boundaries (consumed only for
// polygons in Passes), SimplifiedSymbols varies point symbols (consumed only
// for non-SOUNDG points). Lines and soundings never read either variant, so
// portraying them here is wasted rule evaluation. The predicates mirror the
// consumption in Passes exactly, so output is unchanged.
if v, err := p.builder.BuildBatchFiltered(features, map[string]string{"PlainBoundaries": "true"}, isAreaFeature); err == nil {
cp.plain = v
}
if v, err := p.builder.BuildBatchOverrides(features, map[string]string{"SimplifiedSymbols": "true"}); err == nil {
p.simplified = v
if v, err := p.builder.BuildBatchFiltered(features, map[string]string{"SimplifiedSymbols": "true"}, isSimplifiablePoint); err == nil {
cp.simplified = v
}
return cp
}

// install replays a precomputed portrayal onto the active state Passes reads.
func (p *s101Portrayer) install(cp cellPortrayal) {
p.cache, p.plain, p.simplified = cp.cache, cp.plain, cp.simplified
}

// isAreaFeature selects Surface features — the only consumers of the
// PlainBoundaries (plain area-boundary) variant in Passes.
func isAreaFeature(f *s57.Feature) bool {
return f.Geometry().Type == s57.GeometryTypePolygon
}

// isSimplifiablePoint selects non-SOUNDG point features — the only consumers of
// the SimplifiedSymbols (simplified point-symbol) variant in Passes. SOUNDG digit
// glyphs don't vary, so they stay a single common pass.
func isSimplifiablePoint(f *s57.Feature) bool {
return f.Geometry().Type == s57.GeometryTypePoint && f.ObjectClass() != "SOUNDG"
}

// End drops the per-cell caches so their memory is released before the next cell.
Expand Down
170 changes: 122 additions & 48 deletions internal/engine/baker/baker.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,19 +141,84 @@ func BuildBaker(cells map[string][]byte, onSkip func(name string, err error)) (*

b := bake.New()
applyPortrayer(b)
var ok []string
for _, name := range names {
chart, err := ParseCellBytes(name, cells[name])
if err != nil {
ok := addCellsParallel(b, names, func(name string) (*s57.Chart, error) {
return ParseCellBytes(name, cells[name])
}, onSkip)
return b, ok, nil
}

// parseInOrder parses cells on a bounded worker pool and delivers each to consume
// serially in `names` order. parse and precompute run in the worker (concurrent,
// for the independent per-cell work — S-57 parse + S-101 portrayal); consume runs
// on the calling goroutine in order, so it may mutate shared baker state and the
// result stays deterministic. At most ~NumCPU cells are resident at once: a token
// is taken before a worker starts and released by the consumer after that cell is
// handled. onSkip fires in `names` order for parse errors.
func parseInOrder[T any](
names []string,
parse func(name string) (*s57.Chart, error),
precompute func(name string, chart *s57.Chart) T,
consume func(name string, chart *s57.Chart, pre T),
onSkip func(name string, err error),
) {
workers := runtime.NumCPU()
if workers > len(names) {
workers = len(names)
}
if workers < 1 {
return
}
type result struct {
chart *s57.Chart
pre T
err error
}
slots := make([]chan result, len(names))
for i := range slots {
slots[i] = make(chan result, 1)
}
sem := make(chan struct{}, workers)
go func() {
for i, name := range names {
sem <- struct{}{}
go func(i int, name string) {
chart, err := parse(name)
if err != nil {
slots[i] <- result{err: err}
return
}
slots[i] <- result{chart: chart, pre: precompute(name, chart)}
}(i, name)
}
}()
for i, name := range names {
r := <-slots[i]
if r.err != nil {
if onSkip != nil {
onSkip(name, err)
onSkip(name, r.err)
}
<-sem
continue
}
b.AddCell(chart)
ok = append(ok, name)
consume(name, r.chart, r.pre)
<-sem
}
return b, ok, nil
}

// addCellsParallel parses and portrays cells on a worker pool (parseInOrder), then
// routes them into b serially in `names` order. Parsing and S-101 portrayal are
// the dominant bake cost and are independent per cell, so they run concurrently;
// the stateful route/merge stays single-threaded and ordered, so the archive is
// byte-for-byte identical to the serial path. Returns the routed cell names.
func addCellsParallel(b *bake.Baker, names []string, parse func(name string) (*s57.Chart, error), onSkip func(name string, err error)) []string {
ok := make([]string, 0, len(names))
parseInOrder(names, parse,
func(_ string, chart *s57.Chart) bake.CellPortrayal { return b.PortrayCell(chart) },
func(name string, chart *s57.Chart, pc bake.CellPortrayal) {
b.AddCellPortrayed(chart, pc)
ok = append(ok, name)
}, onSkip)
return ok
}

// CellData is a base cell (.000) plus its sequential update files (.001, .002, …)
Expand All @@ -164,10 +229,10 @@ type CellData struct {
Updates map[string][]byte
}

// ParseCellWithUpdates parses a base cell with its update files applied. The base
// + every update are staged on an in-memory filesystem so the parser discovers
// and applies the .001/.002/… chain (vs ParseCellBytes, which parses base-only).
func ParseCellWithUpdates(name string, base []byte, updates map[string][]byte) (*s57.Chart, error) {
// cellParseOpts stages a base cell + its update files on an in-memory filesystem
// (so the parser discovers and applies the .001/.002/… chain) and returns the
// path + the baker's standard parse options.
func cellParseOpts(name string, base []byte, updates map[string][]byte) (string, s57.ParseOptions) {
p := "/" + path.Base(name)
fsys := iso8211.MemFS{p: base}
dir := path.Dir(p)
Expand All @@ -178,6 +243,26 @@ func ParseCellWithUpdates(name string, base []byte, updates map[string][]byte) (
opts.Fs = fsys
opts.ApplyUpdates = true
opts.MaskCoastlineCoincidentBoundaries = true
return p, opts
}

// ParseCellWithUpdates parses a base cell with its update files applied (vs
// ParseCellBytes, which parses base-only).
func ParseCellWithUpdates(name string, base []byte, updates map[string][]byte) (*s57.Chart, error) {
p, opts := cellParseOpts(name, base, updates)
return s57.ParseWithOptions(p, opts)
}

// ParseCellCoverage parses ONLY a cell's M_COVR coverage features, skipping every
// other feature's geometry construction (the expensive topology/ring assembly).
// The streaming bake's coverage pass reads only M_COVR (extractCoverage) and the
// cell's band comes from the header scale, so this yields the same covMeta far
// cheaper than a full parse. Updates are still applied (the filter acts after
// them), so the coverage reflects the cell's current edition.
func ParseCellCoverage(name string, base []byte, updates map[string][]byte) (*s57.Chart, error) {
p, opts := cellParseOpts(name, base, updates)
opts.ObjectClassFilter = []string{"M_COVR"}
opts.MaskCoastlineCoincidentBoundaries = false // irrelevant to M_COVR rings; skip the coastline-edge setup
return s57.ParseWithOptions(p, opts)
}

Expand All @@ -197,19 +282,10 @@ func BuildBakerWithUpdates(cells map[string]CellData, overzoom bool, onSkip func
b := bake.New()
applyPortrayer(b)
b.OverzoomAllBands = overzoom
var ok []string
for _, name := range names {
ok := addCellsParallel(b, names, func(name string) (*s57.Chart, error) {
cd := cells[name]
chart, err := ParseCellWithUpdates(name, cd.Base, cd.Updates)
if err != nil {
if onSkip != nil {
onSkip(name, err)
}
continue
}
b.AddCell(chart)
ok = append(ok, name)
}
return ParseCellWithUpdates(name, cd.Base, cd.Updates)
}, onSkip)
return b, ok, nil
}

Expand Down Expand Up @@ -314,22 +390,27 @@ func BakeToPMTilesBandsStreaming(cells map[string]CellData, maxZoom uint32, onSk
}
sort.Strings(names)

// Pass 1: coverage + band per cell (no routing).
parse := func(name string) (*s57.Chart, error) {
cd := cells[name]
return ParseCellWithUpdates(name, cd.Base, cd.Updates)
}

// Pass 1: coverage + band per cell (no routing). Parse in parallel — and only
// the M_COVR coverage (extractCoverage reads nothing else), so the geometry of
// every other feature isn't built here; the full parse happens once, in pass 2.
// The coverage merge (covMeta) stays serial and ordered.
byBand := map[uint32][]string{}
parsed := 0
for _, name := range names {
parseInOrder(names, func(name string) (*s57.Chart, error) {
cd := cells[name]
chart, err := ParseCellWithUpdates(name, cd.Base, cd.Updates)
if err != nil {
if onSkip != nil {
onSkip(name, err)
}
continue
}
band := b.AddCellCoverage(chart)
byBand[band.ZoomRange().Max] = append(byBand[band.ZoomRange().Max], name)
parsed++
}
return ParseCellCoverage(name, cd.Base, cd.Updates)
},
func(string, *s57.Chart) struct{} { return struct{}{} },
func(name string, chart *s57.Chart, _ struct{}) {
band := b.AddCellCoverage(chart)
byBand[band.ZoomRange().Max] = append(byBand[band.ZoomRange().Max], name)
parsed++
}, onSkip)
b.SetSkipCoverage(true) // covMeta is now global; don't re-derive it per band

// Pass 2: per band, re-parse + route + bake + free.
Expand All @@ -339,17 +420,10 @@ func BakeToPMTilesBandsStreaming(cells map[string]CellData, maxZoom uint32, onSk
continue
}
b.ResetPrims()
for _, name := range bandCells {
cd := cells[name]
chart, err := ParseCellWithUpdates(name, cd.Base, cd.Updates)
if err != nil {
if onSkip != nil {
onSkip(name, err)
}
continue
}
b.AddCell(chart)
}
// Parse + portray this band's cells in parallel; route them serially in
// order (deterministic, same as the per-cell loop). Coverage is skipped
// (SetSkipCoverage above), so re-parsing is just for this band's geometry.
addCellsParallel(b, bandCells, parse, onSkip)
coords := b.TileCoordsBand(MVTExtent, bd.Min, bd.Max)
if len(coords) == 0 {
continue
Expand Down
Loading