diff --git a/claude-notes/plans/2026-07-08-osm-garmin-drape.md b/claude-notes/plans/2026-07-08-osm-garmin-drape.md new file mode 100644 index 000000000..5baef38d4 --- /dev/null +++ b/claude-notes/plans/2026-07-08-osm-garmin-drape.md @@ -0,0 +1,80 @@ +# OSM ⊕ Garmin drape — semantic features lifted onto the terrain surface + +> Started 2026-07-08. Follows the merged terrain arc (#91 typed pipeline, #93 +> shader, #94 surfel). Branch `claude/geo-osm-drape` → PR (the "drape beast"). + +## Design spine (operator-endorsed, via ChatGPT) + +- **Garmin owns height + terrain material** — the ver-8 radix-grid heightfield + (already baked, `canyon.v8grid.soa.gz` / `iceland_dem.v8grid.soa.gz`). +- **OSM / vector features own the semantic layer** — roads, trails, rivers, + (later) labels. For the **canyon** the feature source is the Garmin IMG's own + typed line features (Street / Path / Stream — already decoded). For **Iceland** + the feature source is the *huge* `otm-iceland.img` database (71.9 MB: NET/NOD + routing + typed features + per-tile DEM). +- **HHTL owns alignment** — both grid vertices and feature lines share the one + `point_to_hhtl4` address space and the same equirectangular projection about the + tile centre, so a feature lands on the right cell / height. +- **Renderer owns fusion** — GeoHelix draws the terrain mesh + the draped feature + overlay, toggleable. Think **surfaces**, not "terrain". + +## Architecture — least-risk, terrain wire untouched + +The proven ver-8 terrain wire + decoder are **not** touched. The drape is a +**separate sidecar** (`*.drape.soa`) rendered as a `THREE.LineSegments` overlay: + +1. **Bake** (`geo/src/garmin/drape.rs`): for each line feature of the wanted kinds + at the bake's LOD level, project each `(lon,lat)` vertex to fractional grid + `(col,row)` via `terrain::project`, **bilinear-sample the terrain `pos` grid** + (the exact display-frame surface point the ver-8 grid encodes), and emit a + polyline. This reuses the ver-8 bake's own `pos` array + normalization + constants, so the drape is guaranteed co-registered with the surface. +2. **Wire** (`DRP1`): `b"DRP1" | ver u16 | nLines u32 | nKind u8 | palette(nK×3) | + per line: kind u8, nPts u16, pts(nPts×3 f32)`. Tiny, self-describing, gzipped. + Palette = `GeoKind::PALETTE` so colours match the terrain KIND palette. +3. **Serve**: committed to `cockpit/public/*.drape.soa.gz`; new + `/api/garmin-drape/:location` route resolves a `garmin_drapes` manifest map + (mirror of `garmin_scenes`). 404 → client silently skips the drape. +4. **Render** (`GeoHelix.tsx`): fetch the drape sidecar in parallel for garmin + terrain scenes, decode DRP1, build a `LineSegments` whose vertices are the + sampled surface point with `y *= uExagVal` (matching the terrain shader's + `dpos.y = position.y * uExag`) plus a hair of lift, coloured per KIND. A + **features** toggle shows/hides it. + +## Alignment proof (why the lines sit on the surface) + +ver-8 client reconstructs `positions = (x0 + c·dx, HALF_LUT[hf]·yscale, zrow[r])`, +no sign flips (GeoHelix `decodeGrid`). The bake's `pos[i]` **is** that display +point pre-encode. The drape samples `pos` bilinearly at the feature's fractional +`(col,row)` → identical frame. The shader lifts terrain `y` by `uExag`; the client +lifts the drape `y` by the same `uExagVal`. Co-registered by construction. + +## Work items + +- [x] `geo/src/garmin/drape.rs` — `DrapeLine`, `build_drape`, `encode_drape` + (DRP1 i16 wire), `DRAPE_KINDS = [Street, Path, Stream]`; 2 tests (canyon + level-4 lifts streets+rivers, all endpoints finite + on-surface; DRP1 + byte-exact header). +- [x] `garmin_bake.rs` — emit `.drape.soa` beside the ver-8 wire. +- [x] `cargo test` geo lib: 22 green (2 drape + regression). Terrain bake is + **byte-identical** to the deployed `canyon.v8grid.soa.gz` (drape additive). +- [x] Bake canyon drape → 29,235 lines (Stream 11,629 · Street 15,860 · Path + 1,746) → 1.3 MB gz → `cockpit/public/canyon.v8grid.drape.soa.gz`. +- [x] cockpit-server `/api/garmin-drape/:location` + `garmin_drapes` resolver + + test (`drape_resolves_from_its_own_map_and_is_optional`); compiled + green. +- [x] `body.manifest.json` — `garmin_drapes` map entry + note. +- [x] `GeoHelix.tsx` — DRP1 decode + LineSegments overlay (y·uExag + lift) + + `features` toggle (shown only when a drape is present). `tsc --noEmit` clean. +- [x] Headless screenshot of `/garmin/grand-canyon`: the blue dendritic river + network + grey roads read ON the surface; `features off` → bare terrain. + Co-registered, toggleable (fused ↔ Garmin-only). + +## Follow-ups (own PRs) + +- **Iceland drape** — same `build_drape` over `otm-iceland.img` line features + (needs the otm-iceland reader path; the IMG parser already handles the format). +- **Iceland height dequantize** — `otm-iceland.img` DEM subfiles as the height + source (real elevation, kills the ver-8 F16-quantization needle field). +- **Layer-toggle matrix** — Garmin-only / features-only / fused / x-ray ontology + (this PR ships terrain + a single features on/off = fused vs Garmin-only). +- **Label anchors** — lift LBL place-names as billboarded anchors. diff --git a/cockpit/public/body.manifest.json b/cockpit/public/body.manifest.json index 8572107d4..8e6a56a04 100644 --- a/cockpit/public/body.manifest.json +++ b/cockpit/public/body.manifest.json @@ -6,6 +6,10 @@ "iceland": "iceland_dem.v8grid.soa.gz", "grand-canyon": "canyon.v8grid.soa.gz" }, + "garmin_drapes": { + "grand-canyon": "canyon.v8grid.drape.soa.gz" + }, + "garmin_drape_note": "garmin_drapes = the DRP1 vector overlay (roads/trails/rivers) that drapes onto the garmin_scenes terrain of the same slug, served same-origin by /api/garmin-drape/:location. Baked beside the terrain by geo/src/bin/garmin_bake.rs (drape::encode_drape): every typed line feature (Street/Path/Stream) is lifted onto the terrain surface, co-registered with the ver-8 grid (bilinear-sampled from the same pos grid). A scene with no entry renders bare terrain (the overlay is additive, the terrain wire untouched). Design spine: Garmin owns height+material, features own the semantic layer, HHTL owns alignment, renderer owns fusion. See claude-notes/plans/2026-07-08-osm-garmin-drape.md.", "garmin_note": "garmin_scenes = the /garmin/:location registry (mod-rewrite style). The page route /garmin/ is SPA-handled (BodyHelix parses the path); the DATA is served by the cockpit-server param route /api/garmin/:location, which resolves through this map to an embedded dist file. Adding a scene = bake the .soa.gz into dist (Dockerfile) + one entry here — no server code change. 'iceland' aliases the existing DEM bake; 'canyon' lands with the Garmin typed-rebuild bake (claude-notes/plans/2026-07-07-garmin-img-typed-rebuild.md).", "iceland_note": "iceland_dem.helix.soa.gz = full-resolution TEXTURED Iceland DEM (BSO2 ver 7 = F16 pos + Signed360 normal + per-vertex RGB drape): 16,515,072 verts / 32,997,378 tris / 3,584 HHTL tiles. Colour = KIND classification (ocean/green/rock/scree/ice/lava, from ESRI World Imagery luminance+saturation + elevation) x helix::CurveRuler golden-spiral residue brightness x imagery luminance. Equirectangular, true-scale elevation, sea level -> y=0. Baked by geo/src/bin/iceland_dem.rs (encode_mesh_bso2, feature 'helix'); DEM elevation + ESRI imagery fetched on one grid by scripts/fetch_iceland_dem.py (DEMG v2). 151 MB gz -> version-controlled as a 2-part split zip under .claude/maps/ (iceland_dem_16m.z01 + .zip, each < 100 MB); the Dockerfile reassembles + extracts it into cockpit/dist/ so it serves same-origin. Replaces the sparse iceland.helix.soa.gz scatter (kept on disk, unreferenced).", "note": "20260629c re-emit from soa_v2 (geometry identical to 20260629b): 39 connective structures (ligaments / tendons / interosseous membranes / fascia / retinacula / iliotibial tract) reclassified out of the ORGAN and SKIN layers into the now-live CONNECTIVE layer 7 — they were FMA-filed under /viscera/solid_organ/ligament_organ, so the is_a walk tagged them viscus->organ and they floated in the organ view as tan limb-shaped strays (interosseous membrane of leg/forearm, calcaneal tendon, long plantar ligament). Carries the 20260629b fixes (teeth->skeleton, per-vessel slicer-fill diameter). BSO2 ver 6 = F16 pos + Signed360 NORMAL + HXFL trailer; Gouraud per-vertex shading. Published to fma-body-soa-v3-v1; Dockerfile pulls same-origin.", diff --git a/cockpit/public/canyon.v8grid.drape.soa.gz b/cockpit/public/canyon.v8grid.drape.soa.gz new file mode 100644 index 000000000..540acfc7a Binary files /dev/null and b/cockpit/public/canyon.v8grid.drape.soa.gz differ diff --git a/cockpit/src/GeoHelix.tsx b/cockpit/src/GeoHelix.tsx index 9a8afe2ac..b9c1ecfda 100644 --- a/cockpit/src/GeoHelix.tsx +++ b/cockpit/src/GeoHelix.tsx @@ -120,6 +120,12 @@ interface Decoded { concepts: number; conceptList: ConceptMeta[]; } interface ConceptMeta { row: number; name: string; layer: number; cx: number; cy: number; cz: number; } +/// The draped feature network (DRP1): segment-paired vertices in the terrain's +/// DISPLAY frame PRE-exaggeration (mount scales `y` by the same `uExag` the grid +/// shader applies) + a per-vertex KIND colour. Rendered as a `LineSegments` overlay. +/// Fetched INDEPENDENTLY of the terrain (never blocks the terrain render) and +/// attached to the live scene via a ref when it arrives. +interface DrapeData { positions: Float32Array; colors: Uint8Array; segCount: number; kindCount: number; } // ── ver-8 radix-grid decode ───────────────────────────────────────────────── // The wire stores ONLY height (F16) + kind (u8 → header palette). Everything @@ -502,7 +508,55 @@ void main(){ }`; type Focus = { x: number; y: number; z: number; d: number }; -function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dirty: { current: boolean }, focus: { current: Focus | null }, xray: { current: boolean }, lod: { current: boolean }): () => void { +// ── DRP1 drape decode — the OSM ⊕ Garmin vector overlay ────────────────────── +// b"DRP1" | ver u16 | nLines u32 | nKind u8 | palette(nK×3) | scale f32 +// | per line: kind u8 | nPts u16 | pts(nPts × 3 i16, coord = i16/scale) +// Each polyline expands to LineSegments pairs; each vertex is coloured by its +// KIND from the palette. Positions are the terrain surface point PRE-exag (the +// bake bilinear-sampled the ver-8 pos grid), so mount lifts y by the same uExag. +function decodeDrape(buf: ArrayBuffer): DrapeData { + const dv = new DataView(buf); + if (!(dv.getUint8(0) === 0x44 && dv.getUint8(1) === 0x52 && dv.getUint8(2) === 0x50 && dv.getUint8(3) === 0x31)) { + throw new Error('not a DRP1 drape'); + } + const nLines = dv.getUint32(6, true); + const nK = dv.getUint8(10); + let o = 11; + const pal = new Uint8Array(buf.slice(o, o + 3 * nK)); o += 3 * nK; + const inv = 1 / dv.getFloat32(o, true); o += 4; + const body = o; + // Pass 1 — count segments (Σ nPts−1) for exact typed allocation. + let segs = 0; + for (let l = 0; l < nLines; l++) { + const nPts = dv.getUint16(o + 1, true); o += 3 + nPts * 6; + if (nPts >= 2) segs += nPts - 1; + } + const positions = new Float32Array(segs * 2 * 3); + const colors = new Uint8Array(segs * 2 * 3); + // Pass 2 — fill segment pairs + per-vertex KIND colour. + o = body; + let w = 0; + for (let l = 0; l < nLines; l++) { + const kind = dv.getUint8(o); const nPts = dv.getUint16(o + 1, true); o += 3; + const cb = (kind * 3) % (nK * 3); + const r = pal[cb], g = pal[cb + 1], b = pal[cb + 2]; + let px = 0, py = 0, pz = 0; + for (let p = 0; p < nPts; p++, o += 6) { + const x = dv.getInt16(o, true) * inv, y = dv.getInt16(o + 2, true) * inv, z = dv.getInt16(o + 4, true) * inv; + if (p > 0) { + positions[w] = px; positions[w + 1] = py; positions[w + 2] = pz; + positions[w + 3] = x; positions[w + 4] = y; positions[w + 5] = z; + colors[w] = r; colors[w + 1] = g; colors[w + 2] = b; + colors[w + 3] = r; colors[w + 4] = g; colors[w + 5] = b; + w += 6; + } + px = x; py = y; pz = z; + } + } + return { positions, colors, segCount: segs, kindCount: nK }; +} + +function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dirty: { current: boolean }, focus: { current: Focus | null }, xray: { current: boolean }, lod: { current: boolean }, features: { current: boolean }, drape: { current: DrapeData | null }): () => void { let w = container.clientWidth || window.innerWidth, h = container.clientHeight || window.innerHeight; const scene = new THREE.Scene(); scene.background = new THREE.Color(PAGE_BG); const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100); camera.position.set(0, 0.05, 3.0); @@ -581,6 +635,39 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir const mat = new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG, side: THREE.FrontSide }); const mesh = new THREE.Mesh(geom, mat); scene.add(mesh); + // ── OSM ⊕ Garmin drape overlay — the semantic vector network (roads / trails / + // rivers) lifted onto the SAME surface. The bake stored the display-frame + // surface point PRE-exaggeration; here we lift y by the identical uExag the + // terrain shader applies (dpos.y = position.y * uExag), plus a hair of offset + // so the lines ride just above the surface instead of z-fighting it. Rendered + // as vertex-coloured LineSegments; toggled live via `features`. ── + let drapeGeom: THREE.BufferGeometry | null = null; + let drapeMat: THREE.LineBasicMaterial | null = null; + let drapeLines: THREE.LineSegments | null = null; + // Built LAZILY the first frame the drape ref is populated — so the terrain + // renders immediately and the overlay pops in when its (optional) fetch lands, + // without blocking the scene or forcing a remount. + const buildDrape = (dd: DrapeData) => { + if (drapeLines || dd.segCount <= 0) return; + const src = dd.positions; + const dp = new Float32Array(src.length); + const lift = 0.0025; // display units above the surface (post-exag) + for (let i = 0; i < src.length; i += 3) { + dp[i] = src[i]; + dp[i + 1] = src[i + 1] * uExagVal + lift; + dp[i + 2] = src[i + 2]; + } + drapeGeom = new THREE.BufferGeometry(); + drapeGeom.setAttribute('position', new THREE.BufferAttribute(dp, 3)); + drapeGeom.setAttribute('color', new THREE.Uint8BufferAttribute(dd.colors, 3, true)); + drapeMat = new THREE.LineBasicMaterial({ vertexColors: true }); + drapeLines = new THREE.LineSegments(drapeGeom, drapeMat); + drapeLines.visible = features.current; + scene.add(drapeLines); + dirty.current = true; + }; + if (drape.current) buildDrape(drape.current); // already arrived (warm cache / fast net) + // Sky dome — geo scenes only (anatomy keeps the flat PAGE_BG set above, byte-identical). let skyGeom: THREE.SphereGeometry | null = null; let skyMat: THREE.ShaderMaterial | null = null; @@ -683,6 +770,10 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir const wantX = xray.current; if (mat.transparent !== wantX) { mat.transparent = wantX; mat.depthWrite = !wantX; mat.needsUpdate = true; } uniforms.uAlpha.value = wantX ? 0.4 : 1.0; + // drape overlay (roads/trails/rivers): build it the frame its optional fetch lands + // (never blocked the terrain), then on/off is a cheap visibility flag — no rebuild. + if (!drapeLines && drape.current) buildDrape(drape.current); + if (drapeLines && drapeLines.visible !== features.current) drapeLines.visible = features.current; // browser pick → glide the orbit target + dolly onto the chosen concept if (focus.current) { const f = focus.current; @@ -703,6 +794,8 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir window.removeEventListener('pointermove', onMove); el2.removeEventListener('wheel', onWheel); window.removeEventListener('resize', onResize); geom.dispose(); mat.dispose(); + if (drapeGeom) drapeGeom.dispose(); + if (drapeMat) drapeMat.dispose(); if (skyGeom) skyGeom.dispose(); if (skyMat) skyMat.dispose(); renderer.dispose(); @@ -763,6 +856,8 @@ export default function GeoHelix() { const [on, setOn] = useState>({ 1: false, 2: false, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true }); const [xray, setXray] = useState(false); const [lod, setLod] = useState(false); // server HHTL LOD — opt-in (off = full render) + const [features, setFeatures] = useState(true); // OSM ⊕ Garmin drape overlay on/off + const [hasDrape, setHasDrape] = useState(false); // a drape overlay loaded → show its toggle const [query, setQuery] = useState(''); const [open, setOpen] = useState>({ 4: true }); // expanded layer groups const enabledRef = useRef(new Float32Array([0, 0, 1, 1, 1, 1, 1, 1, 1])); @@ -770,10 +865,29 @@ export default function GeoHelix() { const focusRef = useRef(null); const xrayRef = useRef(false); const lodRef = useRef(false); + const featuresRef = useRef(true); + const drapeRef = useRef(null); useEffect(() => { let cancelled = false; + // Terrain: render as soon as it decodes — NEVER gated on the optional drape. fetchSoa().then((b) => decode(b)).then((x) => { if (!cancelled) setD(x); }).catch((e) => { if (!cancelled) setError(String(e)); }); + // OSM ⊕ Garmin drape: fetched INDEPENDENTLY for garmin terrain scenes and + // attached to the live scene via drapeRef when it lands (the overlay is purely + // additive — a 404 or a slow fetch never blocks or delays the terrain render). + const scene = new URLSearchParams(window.location.search).get('scene') ?? pathScene(); + if (scene?.startsWith('garmin:')) { + (async () => { + try { + const dr = await fetch(`/api/garmin-drape/${scene.slice('garmin:'.length)}`); + if (dr.ok && !cancelled) { + drapeRef.current = decodeDrape(await inflate(dr)); + setHasDrape(true); // reveal the `features` toggle + dirtyRef.current = true; // wake the render loop → mount lazily builds the overlay + } + } catch { /* no drape overlay → bare terrain */ } + })(); + } return () => { cancelled = true; }; }, []); useEffect(() => { @@ -784,7 +898,8 @@ export default function GeoHelix() { }, [on]); useEffect(() => { xrayRef.current = xray; dirtyRef.current = true; }, [xray]); useEffect(() => { lodRef.current = lod; dirtyRef.current = true; }, [lod]); - useEffect(() => { const c = ref.current; if (!c || !d) return; return mount(c, d, enabledRef.current, dirtyRef, focusRef, xrayRef, lodRef); }, [d]); + useEffect(() => { featuresRef.current = features; dirtyRef.current = true; }, [features]); + useEffect(() => { const c = ref.current; if (!c || !d) return; return mount(c, d, enabledRef.current, dirtyRef, focusRef, xrayRef, lodRef, featuresRef, drapeRef); }, [d]); const focusOn = (c: ConceptMeta) => { focusRef.current = { x: c.cx, y: c.cy, z: c.cz, d: 0.6 }; @@ -863,6 +978,9 @@ export default function GeoHelix() { style={{ background: '#0e1219', color: '#cdd9e5', border: '1px solid #243244', borderRadius: 7, font: '12px ui-monospace, monospace', padding: '4px 6px' }}> {sceneOptions.map((o) => )} + {hasDrape && ( + + )} {activeLayers.map((l) => ( diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index 614821fe8..de4790b27 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -250,6 +250,9 @@ async fn main() { // fallback serves index.html and BodyHelix parses the path — one param route, // N scenes; adding a scene = Dockerfile bake + one manifest entry, no code. .route("/api/garmin/:location", get(garmin_scene_handler)) + // The DRP1 vector overlay (roads/trails/rivers) for a garmin terrain scene, + // resolved through the manifest's `garmin_drapes` map; 404 = no drape. + .route("/api/garmin-drape/:location", get(garmin_drape_handler)) // Health .route("/health", get(health_handler)); @@ -328,7 +331,7 @@ async fn shutdown_signal() { /// filename for a known slug, `None` for unknown/malformed ones. Slugs are /// lowercase `[a-z0-9-]` — anything else is rejected before the map lookup /// (no path traversal into the dist). -fn resolve_garmin_scene(manifest_json: &str, location: &str) -> Option { +fn resolve_garmin_map(manifest_json: &str, map_key: &str, location: &str) -> Option { if location.is_empty() || !location .chars() @@ -337,10 +340,18 @@ fn resolve_garmin_scene(manifest_json: &str, location: &str) -> Option { return None; } let v: serde_json::Value = serde_json::from_str(manifest_json).ok()?; - v.get("garmin_scenes")? - .get(location)? - .as_str() - .map(String::from) + v.get(map_key)?.get(location)?.as_str().map(String::from) +} + +fn resolve_garmin_scene(manifest_json: &str, location: &str) -> Option { + resolve_garmin_map(manifest_json, "garmin_scenes", location) +} + +/// Resolve a `/garmin-drape/:location` slug through the manifest's `garmin_drapes` +/// map — the DRP1 vector overlay (roads / trails / rivers) that drapes onto the +/// `garmin_scenes` terrain of the same slug. Absent = the scene has no drape. +fn resolve_garmin_drape(manifest_json: &str, location: &str) -> Option { + resolve_garmin_map(manifest_json, "garmin_drapes", location) } /// List the available scene slugs (for the self-documenting 404). @@ -406,6 +417,38 @@ async fn garmin_scene_handler(axum::extract::Path(location): axum::extract::Path } } +/// GET /api/garmin-drape/:location — serve the DRP1 vector overlay for `location` +/// (the roads / trails / rivers that drape onto its terrain). A scene with no +/// registered drape returns 404 quietly; the client treats that as "no overlay" +/// and renders the bare terrain (graceful — the drape is purely additive). +async fn garmin_drape_handler(axum::extract::Path(location): axum::extract::Path) -> Response { + #[cfg(feature = "embed-cockpit")] + { + let Some(man) = COCKPIT_DIST + .get_file("body.manifest.json") + .and_then(|f| std::str::from_utf8(f.contents()).ok()) + else { + return (StatusCode::INTERNAL_SERVER_ERROR, "manifest missing from embedded dist").into_response(); + }; + if let Some(fname) = resolve_garmin_drape(man, &location) { + if let Some(file) = COCKPIT_DIST.get_file(&fname) { + return ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/gzip")], + file.contents(), + ) + .into_response(); + } + } + return (StatusCode::NOT_FOUND, "no drape for this scene").into_response(); + } + #[cfg(not(feature = "embed-cockpit"))] + { + let _ = location; + (StatusCode::SERVICE_UNAVAILABLE, "garmin drape needs the embed-cockpit build").into_response() + } +} + // ── Static file handler with SPA fallback ──────────────────────────────────── /// Serves embedded Vite build files. Falls back to index.html for SPA routing. @@ -1231,11 +1274,12 @@ async fn health_handler() -> Json { #[cfg(test)] mod garmin_scene_tests { - use super::{garmin_scene_slugs, resolve_garmin_scene}; + use super::{garmin_scene_slugs, resolve_garmin_drape, resolve_garmin_scene}; const MAN: &str = r#"{ "helix_latest": "body.soa.gz", - "garmin_scenes": { "iceland": "iceland_dem.helix.soa.gz", "canyon": "garmin_canyon.helix.soa.gz" } + "garmin_scenes": { "iceland": "iceland_dem.helix.soa.gz", "canyon": "garmin_canyon.helix.soa.gz" }, + "garmin_drapes": { "grand-canyon": "canyon.v8grid.drape.soa.gz" } }"#; #[test] @@ -1272,4 +1316,18 @@ mod garmin_scene_tests { assert!(garmin_scene_slugs("{}").is_empty()); assert_eq!(resolve_garmin_scene("not json", "iceland"), None); } + + #[test] + fn drape_resolves_from_its_own_map_and_is_optional() { + // The drape map is independent of garmin_scenes: a scene with a drape + // resolves; a scene without one is None (client renders bare terrain). + assert_eq!( + resolve_garmin_drape(MAN, "grand-canyon").as_deref(), + Some("canyon.v8grid.drape.soa.gz") + ); + assert_eq!(resolve_garmin_drape(MAN, "iceland"), None); // no drape registered + assert_eq!(resolve_garmin_drape(MAN, "atlantis"), None); + assert_eq!(resolve_garmin_drape(MAN, "../etc"), None); // traversal rejected + assert_eq!(resolve_garmin_drape("{}", "grand-canyon"), None); + } } diff --git a/geo/src/bin/garmin_bake.rs b/geo/src/bin/garmin_bake.rs index 65166fa79..a45b637b7 100644 --- a/geo/src/bin/garmin_bake.rs +++ b/geo/src/bin/garmin_bake.rs @@ -17,7 +17,7 @@ //! Usage: `garmin_bake [--level N] [--dim WxH] [--metres]` use geo_hhtl::bso2::{encode_grid_bso2, MeshConcept, CLASSID_GEO_V3}; -use geo_hhtl::garmin::{mu2deg, terrain, Img}; +use geo_hhtl::garmin::{drape, mu2deg, terrain, Img}; use geo_hhtl::hhtl::point_to_hhtl4; use geo_hhtl::osm_read::M_PER_DEG; use lance_graph_contract::canonical_node::{NodeGuid, TailVariant}; @@ -201,12 +201,28 @@ fn main() { blocks.len() ); - let blocks_path = format!("{}.blocks", output.strip_suffix(".soa").unwrap_or(&output)); - if let Err(e) = - std::fs::write(&output, &soa).and_then(|()| std::fs::write(&blocks_path, &blocks)) + // ── OSM ⊕ Garmin drape: lift the typed line network (roads / trails / rivers) + // onto the SAME terrain surface (`pos`), co-registered with the ver-8 grid. + // Emitted as a separate DRP1 sidecar so the proven grid wire is untouched. ── + let drape_lines = drape::build_drape(&dec, bbox, &pos, w, h, level, &drape::DRAPE_KINDS); + let drape_bytes = drape::encode_drape(&drape_lines, &palette); + let drape_pts: usize = drape_lines.iter().map(|l| l.pts.len()).sum(); + eprintln!( + "DRP1 drape: {} lines · {} pts · {} B (level {level} road/trail/river network)", + drape_lines.len(), + drape_pts, + drape_bytes.len(), + ); + + let stem = output.strip_suffix(".soa").unwrap_or(&output); + let blocks_path = format!("{stem}.blocks"); + let drape_path = format!("{stem}.drape.soa"); + if let Err(e) = std::fs::write(&output, &soa) + .and_then(|()| std::fs::write(&blocks_path, &blocks)) + .and_then(|()| std::fs::write(&drape_path, &drape_bytes)) { eprintln!("garmin_bake: write: {e}"); std::process::exit(1); } - eprintln!("wrote {output} + {blocks_path}"); + eprintln!("wrote {output} + {blocks_path} + {drape_path}"); } diff --git a/geo/src/garmin/drape.rs b/geo/src/garmin/drape.rs new file mode 100644 index 000000000..43fa00e24 --- /dev/null +++ b/geo/src/garmin/drape.rs @@ -0,0 +1,255 @@ +//! The drape stage: lift typed **vector line features** (roads / trails / rivers) +//! onto the terrain height surface as polylines, co-registered with the ver-8 +//! radix grid. This is the "OSM ⊕ Garmin" fusion's Garmin half — the Garmin IMG +//! already carries typed `Street` / `Path` / `Stream` polylines, so the semantic +//! network is *decoded data*, not colour-guessing. +//! +//! Co-registration is exact **by construction**: a feature vertex `(lon, lat)` is +//! projected to fractional grid `(col, row)` with the SAME [`super::terrain::project`] +//! the heightfield uses, then the display-frame surface point is **bilinear-sampled +//! from the ver-8 bake's own `pos` grid**. The ver-8 client reconstructs its +//! vertices as `(x0 + c·dx, height·yscale, zrow[r])`; `pos[i]` *is* that point +//! pre-encode — so a drape point sampled from `pos` lands on the decoded surface, +//! and the renderer lifts it by the identical `uExag` the terrain shader applies. +//! +//! Landcover (Water / Woods / Park areas) is the terrain *material* — drawn by the +//! grid's `kind` overlay — so the drape is only the LINE network that reads as +//! roads and rivers over the relief. + +use super::terrain::project; +use super::tre::Bbox; +use super::{Decoded, GeoKind, Kind}; + +/// The semantic line classes lifted onto the surface: road, trail, river. +pub const DRAPE_KINDS: [GeoKind; 3] = [GeoKind::Street, GeoKind::Path, GeoKind::Stream]; + +/// A draped polyline: its semantic [`GeoKind`] tag and the lifted display-frame +/// points, already on the terrain surface (pre-exaggeration — the renderer scales +/// `y` by `uExag`, matching the grid). +#[derive(Debug, Clone)] +pub struct DrapeLine { + pub kind: u8, + pub pts: Vec<[f32; 3]>, +} + +/// Bilinear-sample the display-frame surface point at fractional grid `(col, row)` +/// from the ver-8 `pos` grid (row-major `w×h`). Clamps to the grid edge. +fn sample_pos(pos: &[[f32; 3]], w: usize, h: usize, col: f32, row: f32) -> [f32; 3] { + let c0 = (col.floor() as isize).clamp(0, w as isize - 1) as usize; + let r0 = (row.floor() as isize).clamp(0, h as isize - 1) as usize; + let c1 = (c0 + 1).min(w - 1); + let r1 = (r0 + 1).min(h - 1); + let fc = (col - c0 as f32).clamp(0.0, 1.0); + let fr = (row - r0 as f32).clamp(0.0, 1.0); + let p00 = pos[r0 * w + c0]; + let p10 = pos[r0 * w + c1]; + let p01 = pos[r1 * w + c0]; + let p11 = pos[r1 * w + c1]; + let mut out = [0.0f32; 3]; + for k in 0..3 { + let top = p00[k] * (1.0 - fc) + p10[k] * fc; + let bot = p01[k] * (1.0 - fc) + p11[k] * fc; + out[k] = top * (1.0 - fr) + bot * fr; + } + out +} + +/// Lift every line feature of the wanted `kinds` at LOD `level` onto the terrain +/// surface. Each point is the display-frame surface point sampled from `pos`, so +/// the polylines are co-registered with the ver-8 grid (same `level` as the +/// heightfield keeps the drape at the terrain's own generalization). +#[must_use] +pub fn build_drape( + dec: &Decoded, + bbox: Bbox, + pos: &[[f32; 3]], + w: usize, + h: usize, + level: u8, + kinds: &[GeoKind], +) -> Vec { + debug_assert_eq!(pos.len(), w * h, "pos must be a w×h grid"); + let mut lines = Vec::new(); + for f in &dec.features { + if f.kind != Kind::Line || f.level != level || f.coords.len() < 2 { + continue; + } + let k = f.geo_kind(); + if !kinds.contains(&k) { + continue; + } + let pts: Vec<[f32; 3]> = f + .coords + .iter() + .map(|&(lon, lat)| { + let (col, row) = project(bbox, lon, lat, w, h); + sample_pos(pos, w, h, col, row) + }) + .collect(); + lines.push(DrapeLine { kind: k.tag(), pts }); + } + lines +} + +/// Fixed-point scale for DRP1 points: display coords live in `[-1, 1]` (x/z) and +/// `[0, 1]` (y, pre-exag), so `coord · 16384` fits `i16` with precision `6e-5` — +/// far finer than the ~`2/844` grid cell, i.e. sub-cell exact for the canyon. +pub const DRAPE_SCALE: f32 = 16384.0; + +/// Encode draped polylines into the **DRP1** wire — a tiny, self-describing line +/// sidecar the ver-8 terrain wire is unaware of (kept separate so the proven grid +/// decoder is untouched): +/// +/// ```text +/// b"DRP1" | ver=1 u16 | nLines u32 | nKind u8 | palette(nKind × rgb u8) | scale f32 LE +/// | per line: kind u8 | nPts u16 | pts(nPts × 3 i16 LE, coord = i16 / scale) +/// ``` +/// +/// `palette` is [`GeoKind::PALETTE`] so the drape colours match the terrain's KIND +/// palette; `kind` indexes it. Points are display-frame surface points (pre-exag), +/// quantized `i16` at [`DRAPE_SCALE`] — half the f32 payload, sub-cell exact. +#[must_use] +pub fn encode_drape(lines: &[DrapeLine], palette: &[[u8; 3]]) -> Vec { + assert!(palette.len() <= 255, "palette must fit a u8 count"); + let pts_total: usize = lines.iter().map(|l| l.pts.len()).sum(); + let mut o = Vec::with_capacity(15 + palette.len() * 3 + lines.len() * 3 + pts_total * 6); + o.extend_from_slice(b"DRP1"); + o.extend_from_slice(&1u16.to_le_bytes()); + o.extend_from_slice(&(lines.len() as u32).to_le_bytes()); + o.push(palette.len() as u8); + for p in palette { + o.extend_from_slice(p); + } + o.extend_from_slice(&DRAPE_SCALE.to_le_bytes()); + let q = |v: f32| -> i16 { (v * DRAPE_SCALE).round().clamp(-32768.0, 32767.0) as i16 }; + for l in lines { + o.push(l.kind); + let n = l.pts.len().min(u16::MAX as usize); + o.extend_from_slice(&(n as u16).to_le_bytes()); + for p in l.pts.iter().take(n) { + for &c in p { + o.extend_from_slice(&q(c).to_le_bytes()); + } + } + } + o +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::garmin::terrain::heightfield_for_level; + + fn village() -> (Decoded, Vec) { + let path = format!( + "{}/../.claude/maps/garmin-grand-canyon/47505316.img", + env!("CARGO_MANIFEST_DIR") + ); + let img = crate::garmin::Img::read(&path).expect("read tile"); + let lbl = img.lbl().expect("lbl").to_vec(); + (img.decode().expect("decode"), lbl) + } + + /// Build a trivial display-frame `pos` grid from a heightfield: x,z in [-1,1], + /// y = the heightfield elevation normalized. Mirrors the bake's frame shape + /// closely enough to prove sampling + co-registration mechanically. + fn pos_grid(hf: &super::super::HeightField) -> Vec<[f32; 3]> { + let (w, h) = (hf.w, hf.h); + let (lo, hi) = hf.range(); + let span = (hi - lo).max(1.0); + let mut pos = vec![[0.0f32; 3]; w * h]; + for r in 0..h { + for c in 0..w { + let x = c as f32 / (w - 1).max(1) as f32 * 2.0 - 1.0; + let z = r as f32 / (h - 1).max(1) as f32 * 2.0 - 1.0; + let y = (hf.at(c, r) - lo) / span; // [0,1] + pos[r * w + c] = [x, y, z]; + } + } + pos + } + + #[test] + fn canyon_level4_lifts_roads_and_rivers_onto_the_surface() { + let (dec, lbl_bytes) = village(); + let lbl = crate::garmin::lbl::parse(&lbl_bytes); + let (w, h) = (128usize, 128usize); + let hf = heightfield_for_level(&dec, &lbl, 4, w, h, true); + let pos = pos_grid(&hf); + + let lines = build_drape(&dec, dec.tre.bbox, &pos, w, h, 4, &DRAPE_KINDS); + assert!( + lines.len() > 100, + "level-4 drape should carry a real network: {} lines", + lines.len() + ); + + // The three wanted classes are all present at the detail level. + let has = |g: GeoKind| lines.iter().any(|l| l.kind == g.tag()); + assert!(has(GeoKind::Stream), "rivers lifted"); + assert!(has(GeoKind::Street), "roads lifted"); + + // Only the wanted line classes — never contours/areas — are draped. + for l in &lines { + assert!( + DRAPE_KINDS.iter().any(|k| k.tag() == l.kind), + "unexpected drape kind {}", + l.kind + ); + assert!(l.pts.len() >= 2, "a polyline has ≥2 points"); + } + + // Every lifted point is finite and inside the display grid's convex hull + // ([-1,1]³ here) — i.e. it sits ON the sampled surface, never floating. + let (mut ylo, mut yhi) = (f32::INFINITY, f32::NEG_INFINITY); + for l in &lines { + for p in &l.pts { + assert!(p.iter().all(|v| v.is_finite()), "finite drape point"); + assert!(p[0] >= -1.001 && p[0] <= 1.001, "x on grid: {}", p[0]); + assert!(p[2] >= -1.001 && p[2] <= 1.001, "z on grid: {}", p[2]); + assert!( + p[1] >= -0.001 && p[1] <= 1.001, + "y in surface range: {}", + p[1] + ); + ylo = ylo.min(p[1]); + yhi = yhi.max(p[1]); + } + } + // Rivers run the canyon floor, roads climb — the drape spans real relief. + assert!( + yhi - ylo > 0.2, + "drape follows the relief: y span {}", + yhi - ylo + ); + } + + #[test] + fn drp1_header_round_trips() { + let lines = vec![ + DrapeLine { + kind: GeoKind::Stream.tag(), + pts: vec![[0.0, 0.1, 0.0], [0.5, 0.2, 0.5], [1.0, 0.3, 1.0]], + }, + DrapeLine { + kind: GeoKind::Street.tag(), + pts: vec![[-1.0, 0.0, -1.0], [-0.5, 0.05, -0.5]], + }, + ]; + let palette: Vec<[u8; 3]> = GeoKind::PALETTE.iter().map(|k| k.color()).collect(); + let buf = encode_drape(&lines, &palette); + + assert_eq!(&buf[0..4], b"DRP1"); + assert_eq!(u16::from_le_bytes([buf[4], buf[5]]), 1); + assert_eq!(u32::from_le_bytes([buf[6], buf[7], buf[8], buf[9]]), 2); + assert_eq!(buf[10] as usize, GeoKind::PALETTE.len()); + let scale_off = 11 + GeoKind::PALETTE.len() * 3; + assert_eq!( + f32::from_le_bytes(buf[scale_off..scale_off + 4].try_into().unwrap()), + DRAPE_SCALE + ); + // header(11) + palette(9×3) + scale(4) + line0(1+2 + 3×6=18) + line1(1+2 + 2×6=12) + let expect = 11 + GeoKind::PALETTE.len() * 3 + 4 + (3 + 18) + (3 + 12); + assert_eq!(buf.len(), expect, "byte-exact DRP1 size"); + } +} diff --git a/geo/src/garmin/mod.rs b/geo/src/garmin/mod.rs index a70929dd1..5a2f0ffa7 100644 --- a/geo/src/garmin/mod.rs +++ b/geo/src/garmin/mod.rs @@ -36,6 +36,7 @@ use std::fmt; pub mod classify; pub mod container; +pub mod drape; pub mod lbl; pub mod rgn; pub mod terrain;