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
80 changes: 80 additions & 0 deletions claude-notes/plans/2026-07-08-osm-garmin-drape.md
Original file line number Diff line number Diff line change
@@ -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 `<stem>.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.
4 changes: 4 additions & 0 deletions cockpit/public/body.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/<loc> is SPA-handled (BodyHelix parses the path); the DATA is served by the cockpit-server param route /api/garmin/:location, which resolves <loc> 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.",
Expand Down
Binary file added cockpit/public/canyon.v8grid.drape.soa.gz
Binary file not shown.
122 changes: 120 additions & 2 deletions cockpit/src/GeoHelix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -763,17 +856,38 @@ export default function GeoHelix() {
const [on, setOn] = useState<Record<number, boolean>>({ 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<Record<number, boolean>>({ 4: true }); // expanded layer groups
const enabledRef = useRef(new Float32Array([0, 0, 1, 1, 1, 1, 1, 1, 1]));
const dirtyRef = useRef(true); // request a redraw (the render loop is on-demand)
const focusRef = useRef<Focus | null>(null);
const xrayRef = useRef(false);
const lodRef = useRef(false);
const featuresRef = useRef(true);
const drapeRef = useRef<DrapeData | null>(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(() => {
Expand All @@ -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 };
Expand Down Expand Up @@ -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) => <option key={o.path} value={o.path}>{o.label}</option>)}
</select>
{hasDrape && (
<button style={btn(features)} onClick={() => setFeatures((v) => !v)} title="features: the OSM ⊕ Garmin vector overlay — roads, trails and rivers draped onto the terrain surface (fused ↔ Garmin-only)">features {features ? 'on' : 'off'}</button>
)}
<button style={btn(xray)} onClick={() => setXray((x) => !x)} title="x-ray: make the whole body translucent so deeper structures show through">x-ray</button>
<button style={btn(lod)} onClick={() => setLod((v) => !v)} title="LOD: the HHTL depth-cascade culls off-frustum structures as you zoom in — the living database deciding what's worth drawing">LOD {lod ? 'on' : 'off'}</button>
{activeLayers.map((l) => (
Expand Down
Loading