feat(gggs): LOD level selection + demand-driven tile loading from the overviews/ sidecar (ADR-0013) - #183
Conversation
Level-selection + demand-driven loading: parseTileLevel, selectLodLevel, overviews/ sidecar scan, level-keyed itemsIntersecting / loadTilesWorker, resetPixels, generic multi-level wiring, ADR-0013.
…ilter defaults, ground-metres basis, inline/ODR, resetPixels invariant
…ixels() (#103) parseTileLevel shares isValueTile's anchored grammar with the level captured, so the two can never disagree. resetPixels() is the shared CPU-clear body (setBand delegates) for the LOD level-switch release; see the header invariant on pairing it with releaseGL().
selectLodLevel picks the finest available level coarser-or-equal to the gggs::Level::fromCellSize ideal (ground metres per pixel), degrading toward coarser on sparse ladders — the generic multi-level wiring for both the overviews/ sidecar and a future natively-laddered chart layer. camp_map now links marine_autonomy PUBLIC (previously only camp_map_ros).
…e load (#103) loadDirectory scans the uma ADR-0011 overviews/ sidecar; paint() selects the level by view scale (ground metres) and kicks a snapshot-filtered loader that reads only visible tiles at the selected level — replacing the eager whole-store load (3.6 GB observed). Re-kicks on level change and on pan/zoom exposing unloaded visible tiles (idle-only + filter-moved guard). selected_level_ == -1 / null viewport = no filter, preserving the headless path bit-for-bit. sceneBounds() unions finest-level extents only (overview tiles are padded to their coarse grid cell).
…riven load (#103) Covers both plan-review must-fixes: HeadlessDefaultsLoadEverything guards the -1/no-viewport no-filter defaults that keep the pre-existing headless tests meaningful; UnloadedVisibleTilesTriggerRekick unit-tests the pan re-kick predicate.
std::max passes NaN through into fromCellSize's ceil() — UB. Unreachable from paint()'s valid-extent viewport; hardens the seam for future chart-ladder callers.
There was a problem hiding this comment.
Pull request overview
Implements the LOD-selection + demand-driven loading half of GGGS store rendering (#103 / ADR-0013) in camp_map, consuming the overviews/ sidecar pyramid to pick an appropriate GGGS level from true ground metres-per-pixel and loading only the visible tiles at that level (while preserving headless/test behavior via the selected_level_ == -1 no-filter default).
Changes:
- Add filename-level parsing (
parseTileLevel) and a header-onlyselectLodLevel()helper for view-scale-driven GGGS level selection. - Extend
GggsTileLayerto scan both fine tiles andoverviews/, track available levels, select LOD inpaint(), and apply level+viewport filtering in the worker and render paths. - Add ADR-0013 and expand tests to cover level parsing, LOD selection, overview enumeration, demand-driven filtering, headless defaults, and pan re-kick behavior; link
marine_autonomyPUBLIC to satisfy the new header dependency.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
test/test_gggs_tile.cpp |
Adds unit tests for parseTileLevel() and selectLodLevel(). |
test/test_gggs_render.cpp |
Adds integration tests for overview-sidecar enumeration, demand-driven level filtering, headless defaults, and pan re-kick predicate. |
src/camp_map/raster/lod_level_selector.h |
Introduces the header-only LOD selection function based on ground metres-per-pixel. |
src/camp_map/raster/gggs_tile.h |
Adds per-tile GGGS level() and resetPixels() API for LOD switching. |
src/camp_map/raster/gggs_tile.cpp |
Parses level from filename and shares pixel-reset logic with band switching. |
src/camp_map/raster/gggs_tile_util.h |
Adds parseTileLevel() using the same anchored grammar as isValueTile(). |
src/camp_map/raster/gggs_tile_layer.h |
Adds LOD selection state, available-levels exposure, and test seams for demand-driven behavior. |
src/camp_map/raster/gggs_tile_layer.cpp |
Implements overview scanning, finest-level extent semantics, demand-driven worker filtering, LOD selection in paint(), and re-kick logic. |
docs/decisions/0013-lod-level-selection-demand-driven-load.md |
Documents the decision and behavioral consequences (ADR-0013). |
CMakeLists.txt |
Links marine_autonomy::marine_autonomy PUBLIC for the new header-level dependency. |
.agent/work-plans/issue-103/progress.md |
Work-log updates supporting the implementation/testing narrative. |
.agent/work-plans/issue-103/plan.md |
Plan updated to reflect the LOD/demand-driven scope and implementation details. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| prepareGeometryChange(); | ||
| rebuildLevelIndex(); | ||
| if(!had_extent && !scene_bounds_.isNull()) | ||
| { | ||
| setTransform(QTransform::fromScale(1.0, -1.0)); |
The item pos is derived from scene_bounds_; a west/north extension or a finest-level re-base moves the NW corner, and the old first-extent-only guard left the added footprint outside boundingRect() — clipped.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/camp_map/raster/gggs_tile_layer.cpp:603
- In the LOD-switch path, tiles are always resetPixels()'d but releaseGL() is conditional on
have_context(hasContext && makeCurrent). If the renderer already has a context but makeCurrent() fails (a case applyBand() explicitly anticipates), this leaves existing tile textures intact while clearing pixelsLoaded/data_. Later, a reload can be shadowed by the staletexture_(texture() won’t re-upload whiletexture_is non-null). Consider skipping resetPixels() when a context exists but can’t be made current, or otherwise ensuring textures are not left stale relative to pixels_loaded_.
const bool have_context = renderer_.hasContext() && renderer_.makeCurrent();
for(auto& tile : tiles_)
{
if(tile->level() == selected_level_ || !tile->pixelsLoaded())
continue;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.agent/work-plans/issue-103/progress.md:145
- This progress entry is marked resolved, but this PR still recomputes geoToMap-derived scene rects per-tile at runtime (via tileSceneRect), and it also references SonarLiveCacheLayer even though it isn’t modified here. Update the checkbox/text so the progress log reflects the current state accurately.
- [x] (suggestion) Per-frame geoToMap trig in itemsIntersecting on GUI thread during pan (O(tiles)); cache tile scene-rect at load for large stores — `gggs_tile_layer.cpp:404` · `sonar_live_cache_layer.cpp:848`
src/camp_map/raster/gggs_tile_layer.cpp:369
- hasUnloadedVisibleTiles() calls tileSceneRect(*tile) (which runs web_mercator::geoToMap twice) while scanning tiles. On large stores this can become a noticeable per-frame CPU cost during pan/zoom because the predicate may scan many unloaded/offscreen tiles to conclude “idle”. Consider caching each tile’s scene-space QRectF once (e.g., store it alongside tiles_ or inside GggsTile) and using the cached rect for all intersection checks.
if(tile->pixelsLoaded())
continue;
if(selected_level_ != -1 && tile->level() != selected_level_)
continue;
if(!viewport_scene.isNull() && !tileSceneRect(*tile).intersects(viewport_scene))
…103) Field verify (2026-07-31, real sidescan store): zooming across a level boundary blanked the layer while the new level loaded. Keep the outgoing level's tiles resident and draw them as the backdrop (stale levels coarse-first, selected level on top); tilesReady() releases them once the selected level's visible set completes and no worker is running. waitForLoad() gains the matching idle re-kick so tests can drive a level switch headlessly. Two regression tests: residency across the switch and the mid-transition non-blank render.
…rred stale-level release
…oc precision (#103) waitForLoad gains paint()'s moved-since-last-kick guard (a permanently failing tile no longer re-kicks per call); tilesReady asserts the GUI-thread invariant its tile mutation rests on; comments + ADR-0013 cover both zoom directions and the transient multi-level-sweep stacking.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/camp_map/raster/gggs_tile_util.h:40
- The comment claims
parseTileLevel()andisValueTile()“can never disagree on what parses”, butparseTileLevel()can still return -1 if the captured level cannot be converted toint(e.g., overflow) even when the regex matches. This should be documented accurately to avoid relying on a stronger invariant than the code provides.
/// [camp#103 / ADR-0013] The GGGS level encoded in a value-tile filename
/// (`<level>_<row>_<col>.tif[f]`), or -1 if @p filename is not a value tile.
/// Same anchored grammar as isValueTile() with the level captured, so the two
/// can never disagree on what parses. Match @p filename only, never a path —
/// a fine tile (`dir/13_r_c.tif`) and an overview sidecar tile
src/camp_map/raster/gggs_tile_layer.cpp:133
directory_is canonicalized to an absolute path in the constructor and is later used byrescan()for dedup/persistence, butloadDirectory()currently scans using itsdirectoryparameter instead. If the layer is constructed with a non-canonical path (relative, trailing slash, symlink), the initially stored tile paths can differ from the laterrescan()paths, breaking theknown.contains(path)dedup and allowing duplicate tiles to be added.
// [camp#103 / ADR-0013] Scan the fine tiles AND the derived `overviews/`
// sidecar (uma ADR-0011: flat dir, same `<level>_<row>_<col>.tif` grammar).
// All tiles share tiles_; their filename-parsed level() distinguishes them.
const QDir fine_dir(directory);
const QDir overview_dir(directory + "/overviews");
…103) rescan()'s known-path dedup compares against the initial scan's tile paths, so both must build from the same (canonical) directory string — a raw relative/trailing-slash ctor argument would have made every rescan duplicate every tile. Latent (both callers canonicalize) but one token to close. Also drop the over-strong 'can never disagree' claim from parseTileLevel's doc (int-overflow digit strings parse -1).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/camp_map/raster/gggs_tile_layer.cpp:182
- rebuildLevelIndex() currently indexes every tile's parsed level, including
-1(parse failure / int overflow). Because-1is also the sentinel meaning "no LOD selection / no filtering", a single malformed-but-matching filename could makeavailable_levels_contain-1, andpaint()could then legitimately select-1(e.g., when all other levels are finer than the ideal), disabling demand-driven filtering and potentially reintroducing full-store loads.
Consider filtering out negative levels here (or treating them as invalid tiles earlier) so -1 remains reserved for the sentinel state.
available_levels_.clear();
for(const auto& tile : tiles_)
{
const int level = tile->level();
auto it = std::lower_bound(available_levels_.begin(), available_levels_.end(), level);
if(it == available_levels_.end() || *it != level)
available_levels_.insert(it, level);
# Conflicts: # src/camp_map/raster/gggs_tile.h # src/camp_map/raster/gggs_tile_util.h # test/test_gggs_tile.cpp
Summary
The LOD half of the GGGS store layer work — step 3 of the world-store LOD sequence, consuming the
overviews/pyramid sidecar that landed producer-side in rolker/unh_marine_autonomy#188 (uma ADR-0011). The visible-region half merged earlier in #173.raster/lod_level_selector.h, camp ADR-0013):selectLodLevel()picks the finest available level coarser-or-equal to thegggs::Level::fromCellSizeideal, from true ground metres per pixel (Web-Mercator scene metres are ~sec(lat) inflated — ~half a level at 43°N). Deliberately layer-agnostic:GggsTileLayerfeeds it the fine + sidecar levels; a future natively multi-level layer (chart ENC scale ladder, uma ADR-0010) feeds its own ladder.paint()derives the viewport clip first, selects the level, and kicks a snapshot-filtered loader — only visible tiles at the selected level are read, replacing the eager whole-store load (3.6 GB observed on the sidescan store). Re-kicks on level change and when a pan/zoom exposes unloaded visible tiles (idle-only + filter-moved guard, so pan storms don't pay per-frame abort+join and a permanently failing tile can't re-kick forever).selected_level_ == -1/ null viewport = no filtering anywhere, preserving the pre-LOD behavior exactly for the existingwaitForLoad()+renderImage()tests (regression-guarded byHeadlessDefaultsLoadEverything).sceneBounds()unions finest-level tile extents only — overview tiles are padded to their coarse GGGS grid cell (the L0 apex spans a whole 8° grid), so uniting them would balloon fit-to-extent.SonarLiveCacheLayerneeds; deliberately enabled, not implemented (ADR-0013 §hook).Review
Local-first lifecycle: plan reviewed (2 must-fix findings folded in before implementation — pan re-kick and the headless defaults), then a Deep pre-push review round: approved, Ship: recommended, 0 must-fix; two disjoint-lens adversarial passes verified the worker concurrency (value-copy filter snapshot, abort+join at all five tiles_-mutation sites) and the
resetPixels()/releaseGL()pairing. Both [LOW] suggestions fixed before this PR. Timeline in.agent/work-plans/issue-103/progress.md.Test plan
selectLodLevelmath incl. sparse-ladder + empty (5), overview-sidecar enumeration + finest-level extent, demand-driven level filter, headless no-filter defaults, pan re-kick predicate (4)camp_map→marine_autonomyPUBLIC linkCloses #103
Authored-By:
Claude Code AgentModel:
Claude Fable 5