Skip to content

Use declared WMTS TileMatrix identifiers in tile URLs (fixes BlueTopo 400s) - #182

Merged
rolker merged 19 commits into
jazzyfrom
feature/issue-178
Jul 31, 2026
Merged

Use declared WMTS TileMatrix identifiers in tile URLs (fixes BlueTopo 400s)#182
rolker merged 19 commits into
jazzyfrom
feature/issue-178

Conversation

@rolker

@rolker rolker commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Closes #178

Summary

GeoServer GWC (BlueTopo / nowCOAST) names WMTS tile matrices with gridset-prefixed
identifiers (EPSG:3857:<z>), but TileLayout::getUrl() substituted {TileMatrix}
with the bare numeric zoom index — so the BlueTopo presets got HTTP 400
(Unknown TILEMATRIX) on every tile.

  • Fix: {TileMatrix} now emits zoom_levels[z].id — the identifier already
    parsed from the WMTS capabilities <ows:Identifier> — falling back to the bare
    numeric index when id is empty, so bare-numeric servers (OSM/XYZ, NOAA ArcGIS)
    keep working byte-for-byte.
  • Hardening (review findings, operator-approved): the server-controlled
    substitution values (TileMatrix id in getUrl(); Style / TileMatrixSet in
    wmts::Capabilities::getLayout()) are percent-encoded colon-aware
    (QUrl::toPercentEncoding(value, ":"): preserved as load-bearing for GWC
    ids), closing a URL-delimiter injection surface from hostile capabilities
    documents. The layer identifier has no discrete seam (fused into the
    server-provided resource URL template) and is unchanged.
  • Tests: five new cases in test/test_wms_url_generation.cpp — bare-numeric
    backward compat, gridset-prefixed id, empty-id fallback, mismatched
    id-over-index, and percent-encoding with colon survival.

Lifecycle (run-issue): issue review → plan → plan review (approve-with-suggestions,
folded in) → implementation → 3 pre-push review rounds (final: approved, 0 must-fix,
Ship: recommended). Full timeline in .agent/work-plans/issue-178/progress.md.

Test plan

  • ./ui_ws/build.sh camp && ./ui_ws/test.sh camp on a fully-sourced host env:
    193 tests, 0 errors, 0 failures, 1 skipped (ADR-0018).
  • Field verification: BlueTopo bathymetry/hillshade presets should now load tiles
    (prefixed URL previously verified to return 200 image/png).

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

Claude Code Agent added 18 commits July 31, 2026 17:11
Use zoom_levels[].id for TileMatrix URL substitution instead of bare numeric zoom index
TileLayout::getUrl() substituted the bare numeric zoom index for the
{TileMatrix} key. GeoServer GWC (e.g. the BlueTopo WMTS presets) names
its tile matrices <gridset>:<z> (EPSG:3857:<z>), so every tile request
returned HTTP 400 (Unknown TILEMATRIX) and the presets never rendered.

Emit zoom_levels[address.zoomLevel()].id, already parsed from the WMTS
<ows:Identifier> in capabilities.cpp. Fall back to the bare numeric
index when id is empty so bare-numeric servers (OSM/XYZ, NOAA ArcGIS)
keep working unchanged.

Refs #178
Extend the existing getUrl() unit tests with the TileMatrix cases that
would have caught #178: a gridset-prefixed id (EPSG:3857:<z>, GeoServer
GWC/BlueTopo), the bare-numeric id path (OSM/XYZ) that must stay
byte-for-byte compatible, and the empty-id numeric fallback.

Refs #178
The review-issue premise that no URL generation tests exist was wrong:
test/test_wms_url_generation.cpp already covers getUrl(). Per the plan
checkpoint decision, fold the plan-review suggestion in — extend that
file with the new TileMatrix cases instead of adding a new test file
plus CMake registration.

Refs #178
Record the TileMatrix id fix, the extended URL-generation tests, the
plan sync, and the standalone + counterfactual verification (multi-layer
build unavailable in-container).

Refs #178
…178)

The {TileMatrix} substitution in getUrl() splices the raw WMTS
<ows:Identifier> (server-controlled) into the URL path. Percent-encode
it so a hostile capabilities document cannot inject path/query/fragment
delimiters. ':' is excluded from encoding (legal path-segment char and
load-bearing for GeoServer GWC ids like EPSG:3857:<z>); the bare-numeric
fallback is digits-only and passes through unchanged.
Two cases the OSM stand-in (id == index) could not exercise:
- TileMatrixPrefersIdOverIndexWhenMismatched: id numeric tail != position,
  proving getUrl() emits the declared id, not the loop index.
- TileMatrixIdIsPercentEncoded: a '/' in the id is encoded to %2F while ':'
  is preserved, covering the R1 percent-encoding fix.
Copilot AI review requested due to automatic review settings July 31, 2026 18:32

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

Fixes WMTS tile URL generation for GeoServer GWC-based sources (e.g., NOAA nowCOAST / BlueTopo) by ensuring {TileMatrix} uses the declared WMTS TileMatrix identifier rather than the numeric zoom index, and hardens WMTS template substitutions against URL-delimiter injection via percent-encoding.

Changes:

  • Update {TileMatrix} substitution to emit zoom_levels[z].id with a numeric fallback, preserving bare-numeric server behavior.
  • Percent-encode server-controlled WMTS substitution values (TileMatrix id in TileLayout::getUrl(), plus Style and TileMatrixSet in wmts::Capabilities::getLayout()), preserving : for GWC compatibility.
  • Add focused gtest coverage for TileMatrix id selection, fallback, and encoding behavior.

Reviewed changes

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

Show a summary per file
File Description
test/test_wms_url_generation.cpp Adds regression tests covering TileMatrix id-over-index behavior, empty-id fallback, and percent-encoding (with : preserved).
src/camp_map/wmts/capabilities.cpp Percent-encodes server-controlled Style and TileMatrixSet substitutions when assembling WMTS resource URL templates.
src/camp_map/map_tiles/tile_layout.cpp Fixes {TileMatrix} URL substitution to use declared per-zoom identifiers, with fallback and delimiter-safe encoding.
.agent/work-plans/issue-178/progress.md Adds the issue work-plan progress log capturing rationale, verification notes, and review iterations.
.agent/work-plans/issue-178/plan.md Adds the implementation plan documenting scope, approach, and affected files for issue #178.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copilot AI review requested due to automatic review settings July 31, 2026 18:51
@rolker
rolker merged commit 92f1285 into jazzy Jul 31, 2026
@rolker
rolker deleted the feature/issue-178 branch July 31, 2026 18:51

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 5 out of 5 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/camp_map/wmts/capabilities.cpp:82

  • layer.styles.front() is called when default_style is empty, but Layer parsing can yield an empty styles vector if the capabilities document has no <Style> elements. That would be undefined behavior. Please guard the front() access so a malformed/partial capabilities document can’t crash the client during URL template assembly.
            auto style = layer.default_style;
            if(style.isEmpty())
              style = layer.styles.front();
            // [camp#178 review R2] style is a raw server-controlled value (the

src/camp_map/wmts/capabilities.cpp:95

  • layer.tile_matrix_set_links.front() is accessed when tile_matrix_set is null, but Layer parsing can yield an empty tile_matrix_set_links vector if the capabilities document has no <TileMatrixSetLink>. That would be undefined behavior. Please guard the front() access so a malformed/partial capabilities document can’t crash the client.
            // \todo check that passed in tile_matrix_set exists.
            if(tile_matrix_set.isNull())
              tile_matrix_set = layer.tile_matrix_set_links.front();
            // [camp#178 review R2] tile_matrix_set is likewise server-controlled

Comment on lines +44 to +45
const std::string& id = zoom_levels[address.zoomLevel()].id;
const std::string value = id.empty() ? std::to_string(address.zoomLevel()) : id;
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.

WMTS TileMatrix built from bare zoom index; GeoServer GWC needs gridset-prefixed identifiers (BlueTopo presets 400)

2 participants