diff --git a/claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md b/claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md index 0de6a5d19..4fbd2a89a 100644 --- a/claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md +++ b/claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md @@ -40,6 +40,35 @@ the render on mobile; the full grid is the desktop birdview. - Verified headless (803k proxy): the satellite skin renders (forest / red walls / Colorado), sky, `verts · LOD · full grid` HUD. Full-size render is the Railway view. +## Havel canoe map (2026-07-10, same pipeline) +- User's next-10-days canoe region: `--bbox 12.246494,53.011917,13.657894,53.539721` + (upper-left / lower-right corners from satellites.pro), z14/ds3. +- **No Garmin tile** → baked by `iceland_dem --grid --skin` (new flag): sinks the raw + ESRI pixels into the ver-9 grid instead of the classified KIND palette. Vertex i IS + demgrid cell i (1:1, no resampling). +- 5546×3498 = **19.4M verts**, elev 12–174 m (flat lowland — the value is the skin), + gz **69.3 MB**, committed in `public/` (Release-hosted on-demand is the follow-up; + MCP tooling can't create Releases this session). Budget-LOD: 19.4M → stride-2 = + **4.85M** (the "20M is OK if LOD is 5 Mio" operating point). +- Scene = `/garmin/havel` via one manifest entry; the dropdown auto-populates from + `garmin_scenes` — zero cockpit code. + +## North-up fix + white balance + /havel route (2026-07-10, operator alignment check) +- **N–S mirror found by the operator** (Havel vs Google: Kölpinsee rendered SOUTH of + the Müritz). Root cause: bake stores z = (lat−lat0)·M (north = +z) while the default + aerial camera sits on the +z side (screen-up = −z) → every ver-8/9 grid scene rendered + N–S mirrored. Ground truth (demgrid) verified correct — display-side bug only. +- **Fix in `decodeGrid` (no rebake, all grid scenes at once):** negate `zrow` at read + (positions + normals derive), emit winding flipped ((a,d2,b)/(b,d2,e)) so faces stay + +y, negate concept-centroid z. Wire + HHTL keys untouched. +- **White balance (operator: "rather dull"):** ESRI over Mecklenburg is hazy — measured + p98 white point only 171/166/145. Bake-time per-channel p2→p98 stretch (→10..250) + + sat ×1.18 on the demgrid rgb, then rebake — corrected colour sunk once (Diaprojektor), + not shader-corrected per frame. +- **`/havel` first-class route** (like /ice): main.tsx Route + pathScene alias → + `garmin:havel`. First attempt missed the top-level router (path fell through to the + AIWAR cockpit) — caught by the headless shot. + ## Follow-ups - **66 MB from a q2 Release, on-demand** (operator preference) — host the full-tile / higher-zoom asset in a GitHub Release; scene fetches it on demand instead of git. diff --git a/cockpit/public/body.manifest.json b/cockpit/public/body.manifest.json index 4150b7c47..2d1cd3ef7 100644 --- a/cockpit/public/body.manifest.json +++ b/cockpit/public/body.manifest.json @@ -4,7 +4,8 @@ "iceland_latest": "iceland_dem.helix.soa.gz", "garmin_scenes": { "iceland": "iceland_dem.v8grid.soa.gz", - "grand-canyon": "canyon.v8grid.soa.gz" + "grand-canyon": "canyon.v8grid.soa.gz", + "havel": "havel.v8grid.soa.gz" }, "garmin_drapes": {}, "garmin_contours": {}, diff --git a/cockpit/public/havel.v8grid.soa.gz b/cockpit/public/havel.v8grid.soa.gz new file mode 100644 index 000000000..66f9a764a Binary files /dev/null and b/cockpit/public/havel.v8grid.soa.gz differ diff --git a/cockpit/src/GeoHelix.tsx b/cockpit/src/GeoHelix.tsx index c8d1f1e82..46f47cd2d 100644 --- a/cockpit/src/GeoHelix.tsx +++ b/cockpit/src/GeoHelix.tsx @@ -37,6 +37,7 @@ function pathScene(): string | null { const p = window.location.pathname; if (p === '/geo') return 'osm'; if (p === '/ice') return 'iceland'; + if (p === '/havel') return 'garmin:havel'; // the canoe map gets a first-class URL (like /ice) // Mod-rewrite style: /garmin/ → scene "garmin:". The slug is // resolved SERVER-side (/api/garmin/:location → manifest garmin_scenes → dist file), // so a new scene is a manifest entry + a bake — no client change. @@ -178,6 +179,13 @@ function decodeGrid(buf: ArrayBuffer, stride = 1): Decoded { const x0 = dv.getFloat32(o, true), dx = dv.getFloat32(o + 4, true), yscale = dv.getFloat32(o + 8, true); o += 12; const zrow = new Float32Array(buf.slice(o, o + 4 * Hf)); o += 4 * Hf; + // NORTH-UP FIX: the bake stores z = (lat − lat0)·M (north = +z), but the default + // aerial camera sits on the +z side (screen-up = −z), which rendered every grid + // scene N–S MIRRORED against a real map (caught on the Havel bake: Kölpinsee + // appeared SOUTH of the Müritz). Negate z at decode — positions, normals and the + // row table all derive from this one table, and the winding below is emitted in + // the flipped order so faces keep pointing +y. Wire + HHTL keys are untouched. + for (let i = 0; i < Hf; i++) zrow[i] = -zrow[i]; const nK = dv.getUint8(o); o += 1; const pal = new Uint8Array(buf.slice(o, o + 3 * nK)); o += 3 * nK; const hfFull = new Uint16Array(buf.slice(o, o + 2 * nVf)); o += 2 * nVf; @@ -287,8 +295,10 @@ function decodeGrid(buf: ArrayBuffer, stride = 1): Decoded { for (let r = 0; r < H - 1; r++) { for (let c = 0; c < W - 1; c++) { const a = r * W + c, b = a + 1, d2 = a + W, e = d2 + 1; - index[wI++] = a; index[wI++] = b; index[wI++] = d2; - index[wI++] = b; index[wI++] = e; index[wI++] = d2; + // Winding for the NEGATED-z frame (north-up fix above): row r+1 now has + // GREATER z, so emit (a,d2,b)/(b,d2,e) to keep the face normal +y (up). + index[wI++] = a; index[wI++] = d2; index[wI++] = b; + index[wI++] = b; index[wI++] = d2; index[wI++] = e; } } const labelIdx = new Uint32Array(buf.slice(labelOff, labelOff + 4 * nC)); @@ -296,7 +306,7 @@ function decodeGrid(buf: ArrayBuffer, stride = 1): Decoded { const conceptList: ConceptMeta[] = []; for (let c = 0; c < nC; c++) { conceptList.push({ row: c, name: names[labelIdx[c]] ?? `concept ${c}`, layer: cLayer[c] || 8, - cx: -cen[c * 3], cy: cen[c * 3 + 2], cz: cen[c * 3 + 1] }); // source → display (-x,z,y) + cx: -cen[c * 3], cy: cen[c * 3 + 2], cz: -cen[c * 3 + 1] }); // source → display (-x,-z,y): z negated by the north-up fix } return { nVerts: nV, nTris: nT, positions, index, colors, normals, layer, vrow: rowArr, concepts: nC, conceptList, isGrid: true, stride, skin: !!skin }; } @@ -1298,7 +1308,8 @@ export default function GeoHelix() { { label: 'iceland (/ice)', path: '/ice' }, ...garminScenes.map((s) => ({ label: `garmin: ${s}`, path: `/garmin/${s}` })), ]; - const herePath = window.location.pathname; + // /havel is a first-class alias of /garmin/havel — the menu should show it selected. + const herePath = window.location.pathname === '/havel' ? '/garmin/havel' : window.location.pathname; return (
diff --git a/cockpit/src/main.tsx b/cockpit/src/main.tsx index 78dbcb56a..07c0c2bdf 100644 --- a/cockpit/src/main.tsx +++ b/cockpit/src/main.tsx @@ -119,6 +119,7 @@ createRoot(document.getElementById('root')!).render( GeoHelix parses the slug from the path (pathScene). */} } /> } /> + } /> } /> {/* /cpic — CPIC pharmacogenomics cockpit (gene-first): {gene, diplotype, drug} → phenotype → recommendation, 2-hop NARS deduction over the real CPIC tables diff --git a/geo/src/bin/iceland_dem.rs b/geo/src/bin/iceland_dem.rs index 1d542f8d1..9cd67e6e1 100644 --- a/geo/src/bin/iceland_dem.rs +++ b/geo/src/bin/iceland_dem.rs @@ -155,13 +155,18 @@ fn classify_kind(rgb: [u8; 3], elev: f32) -> Kind { fn main() { let mut args = std::env::args().skip(1); let (Some(input), Some(output)) = (args.next(), args.next()) else { - eprintln!("usage: iceland_dem [--grid]"); + eprintln!("usage: iceland_dem [--grid] [--skin]"); std::process::exit(2); }; + let flags: Vec = args.collect(); // --grid → the ver-8 radix-grid wire (height F16 + kind u8 only; location / // connectedness / tilt / residue all client-derived from the address). // Default stays the ver-7 mesh wire the deployed artifact uses. - let grid_mode = args.next().as_deref() == Some("--grid"); + let grid_mode = flags.iter().any(|f| f == "--grid"); + // --skin (grid mode + a v2 demgrid) → ver-9: sink the RAW ESRI satellite pixels into + // the grid as per-vertex colour (the photoreal "Diaprojektor" skin), instead of the + // classified KIND palette. Vertex i IS demgrid cell i, so the drape maps 1:1. + let skin = flags.iter().any(|f| f == "--skin"); let dem = match read_demgrid(&input) { Ok(d) => d, Err(e) => { @@ -329,9 +334,11 @@ fn main() { let x0 = pos[0][0]; let dx = if w > 1 { pos[1][0] - pos[0][0] } else { 0.0 }; let zrow: Vec = (0..h).map(|r| pos[r * w][2]).collect(); + // ver-9 raw satellite skin when --skin + a v2 demgrid; else ver-8 palette grid. + let colors: &[[u8; 3]] = if skin && has_img { &dem.rgb } else { &[] }; encode_grid_bso2( w as u32, h as u32, x0, dx, ymax, &zrow, &heights, &kinds, &palette, &concepts, - labels, &[], + labels, colors, ) } else { encode_mesh_bso2(&pos, &nrm, &rows, &tris, &concepts, labels, &colors) @@ -351,7 +358,7 @@ fn main() { }; eprintln!( "BSO2 ver{}: {nc} concepts · {nv} verts · {nt} tris · {} B soa · {} B blocks", - if grid_mode { 8 } else { 7 }, + u16::from_le_bytes(soa[4..6].try_into().unwrap()), soa.len(), blocks.len() );