Skip to content

feat(gggs): LOD level selection + demand-driven tile loading from the overviews/ sidecar (ADR-0013) - #183

Merged
rolker merged 30 commits into
jazzyfrom
feature/issue-103
Jul 31, 2026
Merged

feat(gggs): LOD level selection + demand-driven tile loading from the overviews/ sidecar (ADR-0013)#183
rolker merged 30 commits into
jazzyfrom
feature/issue-103

Conversation

@rolker

@rolker rolker commented Jul 31, 2026

Copy link
Copy Markdown
Owner

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.

  • LOD level selection (raster/lod_level_selector.h, camp ADR-0013): selectLodLevel() picks the finest available level coarser-or-equal to the gggs::Level::fromCellSize ideal, from true ground metres per pixel (Web-Mercator scene metres are ~sec(lat) inflated — ~half a level at 43°N). Deliberately layer-agnostic: GggsTileLayer feeds it the fine + sidecar levels; a future natively multi-level layer (chart ENC scale ladder, uma ADR-0010) feeds its own ladder.
  • Demand-driven loading: 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).
  • Headless compatibility: selected_level_ == -1 / null viewport = no filtering anywhere, preserving the pre-LOD behavior exactly for the existing waitForLoad()+renderImage() tests (regression-guarded by HeadlessDefaultsLoadEverything).
  • Extent semantics: 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.
  • camp#172 hook: the demand-driven pattern is the reload seam SonarLiveCacheLayer needs; 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

  • camp suite: 199 tests, 0 failures (1 pre-existing skip)
  • 11 new tests: level-name parse (2), selectLodLevel math incl. sparse-ladder + empty (5), overview-sidecar enumeration + finest-level extent, demand-driven level filter, headless no-filter defaults, pan re-kick predicate (4)
  • Build: clean with the new camp_mapmarine_autonomy PUBLIC link

Closes #103


Authored-By: Claude Code Agent
Model: Claude Fable 5

Claude Code Agent added 17 commits July 31, 2026 17:43
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.
Copilot AI review requested due to automatic review settings July 31, 2026 19:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-only selectLodLevel() helper for view-scale-driven GGGS level selection.
  • Extend GggsTileLayer to scan both fine tiles and overviews/, track available levels, select LOD in paint(), 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_autonomy PUBLIC 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.

Comment on lines +269 to +273
prepareGeometryChange();
rebuildLevelIndex();
if(!had_extent && !scene_bounds_.isNull())
{
setTransform(QTransform::fromScale(1.0, -1.0));
Claude Code Agent added 3 commits July 31, 2026 15:23
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.
Copilot AI review requested due to automatic review settings July 31, 2026 19:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 stale texture_ (texture() won’t re-upload while texture_ 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;

Copilot AI review requested due to automatic review settings July 31, 2026 19:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Claude Code Agent added 6 commits July 31, 2026 15:50
…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.
…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.
Copilot AI review requested due to automatic review settings July 31, 2026 20:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() and isValueTile() “can never disagree on what parses”, but parseTileLevel() can still return -1 if the captured level cannot be converted to int (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 by rescan() for dedup/persistence, but loadDirectory() currently scans using its directory parameter instead. If the layer is constructed with a non-canonical path (relative, trailing slash, symlink), the initially stored tile paths can differ from the later rescan() paths, breaking the known.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");

Claude Code Agent added 2 commits July 31, 2026 17:04
…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).
Copilot AI review requested due to automatic review settings July 31, 2026 21:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 -1 is also the sentinel meaning "no LOD selection / no filtering", a single malformed-but-matching filename could make available_levels_ contain -1, and paint() 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
Copilot AI review requested due to automatic review settings July 31, 2026 21:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

@rolker
rolker merged commit 84fae59 into jazzy Jul 31, 2026
2 checks passed
@rolker
rolker deleted the feature/issue-103 branch July 31, 2026 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GGGS store layer: visible-region render + pyramid LOD selection

2 participants