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
5 changes: 5 additions & 0 deletions crates/cockpit-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mod dto_bridge;
mod codebook;
mod mock_driver;
mod osint_classview;
mod osm_tiles;

// ── Embed the Vite build at compile time ─────────────────────────────────────
// The cockpit/ directory is built by `cd cockpit && npm run build` which
Expand Down Expand Up @@ -231,6 +232,10 @@ async fn main() {
// /body server-side HHTL LOD — POST camera → per-concept HhtlAction byte
// (cascade over 1658 baked BlockBounds; native SIMD; client gates draw by it).
.route("/api/body/lod", post(body_lod::body_lod_handler))
// OSM tile material (Geo domain 0x0F): WebMercator z/x/y ↔ HHTL key +
// the OSM slippy-tile source URL. /locate maps a lon/lat to its tile.
.route("/api/osm/locate", get(osm_tiles::osm_locate_handler))
.route("/api/osm/tile/:z/:x/:y", get(osm_tiles::osm_tile_meta_handler))
// Health
.route("/health", get(health_handler));

Expand Down
259 changes: 259 additions & 0 deletions crates/cockpit-server/src/osm_tiles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
//! OSM tile material — **where the maps come from**, and how a slippy tile
//! address becomes an HHTL (HEEL/HIP/TWIG) key.
//!
//! Two halves, per `docs/MERCATOR-HHTL-HELIX-MAP.md` (OGAR canon "256×256
//! centroid tile", Geo domain `0x0F`, "OSM: literal x/y"):
//!
//! 1. **The source.** OSM raster tiles come from the standard slippy-map tile
//! servers ([`OSM_TILE_URL`]). `z/x/y` is the WebMercator (EPSG:3857)
//! quadtree — `2^z × 2^z` tiles at zoom `z`.
//! 2. **The address.** A quadtree IS a cascade, so `z/x/y` Morton-interleaves
//! directly into HHTL's three 16-bit tiers (`3 × 256×256` = 48 bits). Coarse
//! zooms land in HEEL, fine in TWIG (`tier = level >> 3`); path distance is
//! then 3 tier-table lookups — no lon/lat materialisation. This is the map
//! pyramid and the semantic cascade being the *same* address (D-BOTHCASC).
//!
//! No external tile crate and no network on the request path: the handlers
//! return the tile **address + source URL + HHTL key**; a client fetches the
//! raster from the source. Pure math, fully unit-tested.

use axum::extract::{Path, Query};
use axum::Json;
use serde::Deserialize;
use std::f64::consts::PI;

/// The canonical OSM raster-tile source (standard slippy-map template). This is
/// the answer to "where OSM gets its maps from" — the tile is fetched from here
/// by the client; the cockpit only computes the address.
pub const OSM_TILE_URL: &str = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";

/// Native HHTL depth: 3 tiers × 8 levels = 24 quadtree levels (zoom 0..=24).
pub const HHTL_DEPTH: u32 = 24;

/// The concrete tile-source URL for a `z/x/y` address.
#[must_use]
pub fn tile_url(z: u32, x: u32, y: u32) -> String {
OSM_TILE_URL
.replace("{z}", &z.to_string())
.replace("{x}", &x.to_string())
.replace("{y}", &y.to_string())
}

/// WebMercator (EPSG:3857) forward: geographic `(lon, lat)` → slippy tile
/// `(x, y)` at zoom `z`. `x` grows east, `y` grows south (TMS-flipped is the
/// caller's concern). Clamped to the valid `0..2^z` range.
#[must_use]
pub fn lonlat_to_tile(lon: f64, lat: f64, z: u32) -> (u32, u32) {
let n = f64::from(2u32.saturating_pow(z));
let x = ((lon + 180.0) / 360.0 * n).floor();
let lat_rad = lat.to_radians();
// asinh(tan φ) = ln(tan φ + sec φ) — the Mercator y, no PROJ needed.
let merc_y = (lat_rad.tan() + 1.0 / lat_rad.cos()).ln();
let y = ((1.0 - merc_y / PI) / 2.0 * n).floor();
let max = (n - 1.0).max(0.0);
(x.clamp(0.0, max) as u32, y.clamp(0.0, max) as u32)
}

/// Morton-interleave two 24-bit lanes into a 48-bit code: `x` bit `i` → code bit
/// `2i`, `y` bit `i` → code bit `2i+1`. Self-inverse with [`morton_deinterleave`].
#[must_use]
pub fn morton_interleave(x24: u32, y24: u32) -> u64 {
let mut code = 0u64;
for i in 0..HHTL_DEPTH {
code |= (u64::from((x24 >> i) & 1)) << (2 * i);
code |= (u64::from((y24 >> i) & 1)) << (2 * i + 1);
}
code
}

/// Inverse of [`morton_interleave`]: 48-bit code → `(x24, y24)`.
#[must_use]
pub fn morton_deinterleave(code: u64) -> (u32, u32) {
let mut x = 0u32;
let mut y = 0u32;
for i in 0..HHTL_DEPTH {
x |= (((code >> (2 * i)) & 1) as u32) << i;
y |= (((code >> (2 * i + 1)) & 1) as u32) << i;
}
(x, y)
}

/// The three HHTL cascade tiers of a slippy tile.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct Hhtl {
/// Coarsest tier (the low-zoom neighbourhood).
pub heel: u16,
/// Middle tier (the palette tier in the HHTL legend).
pub hip: u16,
/// Finest tier (the leaf tile).
pub twig: u16,
}

/// Map a slippy tile `z/x/y` onto its HHTL key. The tile is left-aligned to the
/// native 24-level depth so the **coarsest** quadtree bit lands in HEEL's high
/// bit (`tier = level >> 3`); the fine bits fall into TWIG.
///
/// A zoom **deeper** than the native depth resolves to its `z=24` **ancestor**
/// tile — the excess low bits of `x`/`y` are dropped (`x >> (z-24)`), NOT kept
/// in the low Morton lane. So two children of the same z=24 tile share an HHTL
/// key (a correct prefix), and the key is always a valid ancestor of the
/// requested tile. Beyond 24 native levels, depth is the hierarchy's job
/// (registry resolve / ref-escape, per the OGAR canon). [`resolved_tile`]
/// returns the same-granularity `z/x/y` the key actually encodes.
#[must_use]
pub fn tile_to_hhtl(z: u32, x: u32, y: u32) -> Hhtl {
let (_, x, y) = resolved_tile(z, x, y);
let z = z.min(HHTL_DEPTH);
let shift = HHTL_DEPTH - z;
let code = morton_interleave(x << shift, y << shift);
Comment on lines +106 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize coordinates when clamping zoom

In the /api/osm/tile path, this clamps only z before encoding but leaves the over-depth x/y unchanged. For a valid slippy address with z > 24, the HHTL key should either be rejected or represent the z=24 parent tile (for example, z=25 x=2 has parent x=1), but this encodes x=2 and drops any bits above the 24-bit lane, so the returned hhtl no longer identifies the tile URL in the same response and high-zoom tiles can alias incorrectly.

Useful? React with 👍 / 👎.

Hhtl {
heel: (code >> 32) as u16,
hip: (code >> 16) as u16,
twig: code as u16,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Deep zoom HHTL uses wrong bits

Medium Severity

For slippy requests with z above HHTL_DEPTH, tile_to_hhtl only caps z to 24 and sets the left-align shift to zero, leaving x and y at the finer zoom scale. Morton encoding then uses the low 24 bits instead of the quadtree prefix, so /api/osm/tile/:z/:x/:y can return an hhtl key that does not match the requested tile.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 742b540. Configure here.

}

/// The `z/x/y` the HHTL key actually encodes: identity for `z <= 24`, else the
/// `z=24` ancestor (`z=24`, `x >> (z-24)`, `y >> (z-24)`). Lets a handler report
/// a tile address that matches its own HHTL key instead of echoing an
/// over-depth `x/y` the key can't represent.
#[must_use]
pub fn resolved_tile(z: u32, x: u32, y: u32) -> (u32, u32, u32) {
if z > HHTL_DEPTH {
let excess = z - HHTL_DEPTH;
(HHTL_DEPTH, x >> excess, y >> excess)
} else {
(z, x, y)
}
}
Comment on lines +121 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant file and inspect the surrounding implementation.
git ls-files 'crates/cockpit-server/src/osm_tiles.rs'
echo '---'
wc -l crates/cockpit-server/src/osm_tiles.rs
echo '---'
sed -n '1,260p' crates/cockpit-server/src/osm_tiles.rs

Repository: AdaWorldAPI/q2

Length of output: 10125


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat > /tmp/shift_check.rs <<'RS'
fn main() {
    let x = 1u32;
    let s = 32u32;
    println!("{}", x >> s);
}
RS

echo "rustc version:"
rustc --version || true
echo '--- debug build ---'
rustc /tmp/shift_check.rs -o /tmp/shift_check_debug 2>/tmp/shift_check_debug.err && /tmp/shift_check_debug || { code=$?; cat /tmp/shift_check_debug.err; echo "exit=$code"; }
echo '--- release-like build ---'
rustc -O /tmp/shift_check.rs -o /tmp/shift_check_release 2>/tmp/shift_check_release.err && /tmp/shift_check_release || { code=$?; cat /tmp/shift_check_release.err; echo "exit=$code"; }

Repository: AdaWorldAPI/q2

Length of output: 352


Guard resolved_tile against over-wide shifts
z > 24 can make excess >= 32, so x >> excess / y >> excess may panic in debug builds and produce the wrong ancestor on GET /api/osm/tile/:z/:x/:y. Use a checked/saturating shift and add a regression test for resolved_tile(56, u32::MAX, u32::MAX) == (24, 0, 0).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cockpit-server/src/osm_tiles.rs` around lines 121 - 128,
`resolved_tile` can panic or compute the wrong parent when `z - HHTL_DEPTH` is
32 or more, so update the shift logic to use a checked/saturating right shift
before returning the ancestor tile. Make the fix inside `resolved_tile(z, x, y)`
and ensure large zoom values safely clamp to the HHTL_DEPTH ancestor with x/y
resolving to 0 when the shift is too wide. Add a regression test for the
`resolved_tile` case with very large inputs, including `resolved_tile(56,
u32::MAX, u32::MAX)`, to verify it returns the expected clamped tile.

Source: Coding guidelines


// ── HTTP handlers ──────────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
pub struct LocateQuery {
lon: f64,
lat: f64,
#[serde(default = "default_zoom")]
z: u32,
}

fn default_zoom() -> u32 {
14
}

/// `GET /api/osm/locate?lon=&lat=&z=` — geographic point → tile address + source
/// URL + HHTL key. The whole "where's the map, and what's its key" answer.
pub async fn osm_locate_handler(Query(q): Query<LocateQuery>) -> Json<serde_json::Value> {
let z = q.z.min(HHTL_DEPTH);
let (x, y) = lonlat_to_tile(q.lon, q.lat, z);
let hhtl = tile_to_hhtl(z, x, y);
Json(serde_json::json!({
"lon": q.lon, "lat": q.lat, "z": z, "x": x, "y": y,
"tile_url": tile_url(z, x, y),
"source": OSM_TILE_URL,
"hhtl": hhtl,
"geo_domain": "0x0F",
}))
}

/// `GET /api/osm/tile/:z/:x/:y` — a tile address → its source URL + HHTL key.
/// For an over-native-depth zoom (`z > 24`) the HHTL key encodes the `z=24`
/// ancestor; `resolved` names that ancestor explicitly so the key and the
/// address never silently describe different tiles.
pub async fn osm_tile_meta_handler(
Path((z, x, y)): Path<(u32, u32, u32)>,
) -> Json<serde_json::Value> {
let hhtl = tile_to_hhtl(z, x, y);
let (rz, rx, ry) = resolved_tile(z, x, y);
Json(serde_json::json!({
"z": z, "x": x, "y": y,
"tile_url": tile_url(z, x, y),
"source": OSM_TILE_URL,
"hhtl": hhtl,
"resolved": { "z": rz, "x": rx, "y": ry },
"geo_domain": "0x0F",
}))
}
Comment on lines +163 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate x/y against 2^z in the tile-meta handler.

z, x, and y are taken raw from the path with no range check, so out-of-range coordinates (e.g. /api/osm/tile/2/999/1) flow into tile_to_hhtl, where x << shift truncates the high bits and yields a garbage HHTL key / tile_url that silently describes a different tile. Reject coordinates outside 0..2^z (or 0..2^24 after resolving) with a 400 instead of returning misleading metadata. (The z bound is covered by the resolved_tile fix above.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cockpit-server/src/osm_tiles.rs` around lines 163 - 176, The
osm_tile_meta_handler currently accepts raw Path coordinates and can emit
misleading metadata for out-of-range x/y values. Add a bounds check in
osm_tile_meta_handler (before calling tile_to_hhtl, resolved_tile, and tile_url)
to reject x or y outside 0..2^z, or the equivalent resolved 0..2^24 range, and
return a 400 response instead of JSON for invalid tile coordinates.


#[cfg(test)]
mod tests {
use super::*;

#[test]
fn tile_url_fills_the_template() {
assert_eq!(
tile_url(14, 8749, 5677),
"https://tile.openstreetmap.org/14/8749/5677.png"
);
}

#[test]
fn null_island_is_the_center_tile() {
// lon=0, lat=0 at z=1 → the 2×2 grid's (1,1) corner (center of the world).
assert_eq!(lonlat_to_tile(0.0, 0.0, 1), (1, 1));
// z=0 → single tile (0,0).
assert_eq!(lonlat_to_tile(0.0, 0.0, 0), (0, 0));
}

#[test]
fn berlin_lands_in_the_expected_tile() {
// Berlin (13.404954, 52.520008) at z=14 is the well-known (8802, 5373).
assert_eq!(lonlat_to_tile(13.404954, 52.520008, 14), (8802, 5373));
}

#[test]
fn tile_x_grows_east_y_grows_south() {
let (x0, y0) = lonlat_to_tile(0.0, 0.0, 10);
let (xe, _) = lonlat_to_tile(10.0, 0.0, 10);
let (_, ys) = lonlat_to_tile(0.0, -10.0, 10);
assert!(xe > x0, "east increases x");
assert!(ys > y0, "south increases y");
}

#[test]
fn morton_roundtrips() {
for &(x, y) in &[(0u32, 0u32), (1, 0), (0, 1), (12345, 67890), (0xFFFFFF, 0xFFFFFF)] {
assert_eq!(morton_deinterleave(morton_interleave(x, y)), (x, y));
}
}

#[test]
fn hhtl_roundtrips_to_the_tile() {
// A tile at native depth (z=24) round-trips exactly through HHTL.
let (z, x, y) = (24u32, 0x00AB_12u32, 0x00CD_34u32);
let h = tile_to_hhtl(z, x, y);
let code = (u64::from(h.heel) << 32) | (u64::from(h.hip) << 16) | u64::from(h.twig);
assert_eq!(morton_deinterleave(code), (x, y));
}

#[test]
fn coarse_zoom_lives_in_heel_not_twig() {
// A low-zoom tile (z=2) left-aligns so its bits sit in HEEL; HIP/TWIG are 0.
let h = tile_to_hhtl(2, 3, 1);
assert_ne!(h.heel, 0, "coarse zoom must occupy HEEL");
assert_eq!(h.hip, 0);
assert_eq!(h.twig, 0);
}

#[test]
fn over_depth_zoom_folds_to_its_native_ancestor() {
// z=25 x=2 y=3 → its z=24 parent is x=1 y=1; the HHTL key + resolved
// tile must agree, and NOT use the low-24-bit garbage the old code did.
assert_eq!(resolved_tile(25, 2, 3), (24, 1, 1));
assert_eq!(resolved_tile(26, 4, 8), (24, 1, 2));
assert_eq!(tile_to_hhtl(25, 2, 3), tile_to_hhtl(24, 1, 1));
// two children of the same z=24 parent share the key (a correct prefix).
assert_eq!(tile_to_hhtl(25, 2, 2), tile_to_hhtl(25, 3, 3));
// in-range zooms are untouched.
assert_eq!(resolved_tile(14, 8802, 5373), (14, 8802, 5373));
}

#[test]
fn neighbouring_tiles_share_a_heel_prefix() {
// Two adjacent fine tiles differ only in the finest tier — the cascade
// locality the HHTL address is for.
let a = tile_to_hhtl(20, 100_000, 100_000);
let b = tile_to_hhtl(20, 100_001, 100_000);
assert_eq!(a.heel, b.heel, "adjacent tiles share the coarse HEEL tier");
}
}