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
29 changes: 29 additions & 0 deletions claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion cockpit/public/body.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down
Binary file added cockpit/public/havel.v8grid.soa.gz
Binary file not shown.
19 changes: 15 additions & 4 deletions cockpit/src/GeoHelix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<location> → scene "garmin:<location>". 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.
Expand Down Expand Up @@ -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];

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 Mirror registered overlays with the terrain

For any Garmin scene that has a garmin_drapes or garmin_contours entry, this z flip moves only the terrain: the DRP1 sidecars are still decoded in their original display frame and buildDrape/buildContours copy src[i + 2] unchanged, while garmin::drape bakes those points by sampling the pre-flip pos grid. As soon as a scene uses those existing sidecars, roads/rivers/contours will be north-south mirrored relative to the terrain; negate the overlay z in the same path or rebake/update the sidecar convention together with this decode change.

Useful? React with 👍 / 👎.

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;
Expand Down Expand Up @@ -287,16 +295,18 @@ 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));
const cen = new Float32Array(buf.slice(cenOff, cenOff + 12 * nC));
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 };
}
Expand Down Expand Up @@ -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 (
<div style={{ position: 'fixed', inset: 0, background: `#${PAGE_BG.toString(16).padStart(6, '0')}` }}>
Expand Down
1 change: 1 addition & 0 deletions cockpit/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ createRoot(document.getElementById('root')!).render(
GeoHelix parses the slug from the path (pathScene). */}
<Route path="/geo" element={<GeoHelix />} />
<Route path="/ice" element={<GeoHelix />} />
<Route path="/havel" element={<GeoHelix />} />
<Route path="/garmin/:location" element={<GeoHelix />} />
{/* /cpic — CPIC pharmacogenomics cockpit (gene-first): {gene, diplotype, drug}
→ phenotype → recommendation, 2-hop NARS deduction over the real CPIC tables
Expand Down
15 changes: 11 additions & 4 deletions geo/src/bin/iceland_dem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <in.demgrid> <out.soa> [--grid]");
eprintln!("usage: iceland_dem <in.demgrid> <out.soa> [--grid] [--skin]");
std::process::exit(2);
};
let flags: Vec<String> = 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) => {
Expand Down Expand Up @@ -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<f32> = (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)
Expand All @@ -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()
);
Expand Down