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 139a7b107..0a903fc44 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 @@ -81,6 +81,51 @@ the render on mobile; the full grid is the desktop birdview. - **Sentinel-2 skin** — swap the ESRI source for `GrandCanyon_S2_20260620.tif` (source-agnostic; the skin just comes from the demgrid rgb). +## Museum lighting — the NatGeo art direction (2026-07-10, operator brief) + +> "Stop thinking like a GIS, start thinking like a landscape photographer. … +> Don't optimize against Google Maps. Optimize against National Geographic." + +The operator's six directives + river protagonist, implemented in the ver-9 skin +branch of the vertex shader (GeoHelix VERT, `uSkin` path) + one bake fix: + +1. **Museum lighting** — warm morning KEY (wrap diffuse `(n·L+0.35)/1.35`), WEAK + cool sky fill, and a **warm umber shadow floor** (`vec3(0.46,0.33,0.26)` — + reddish-black, never grey): gallery light on a relief model, not orbit light. +2. **Colour separation** — hue-preserving chroma ×1.34 so the strata split into + cream limestone / ochre / orange sandstone / deep red. +3. **Warm shadows** — the umber floor above; the canyon glows in its own bounce. +4. **Warm atmospheric perspective** — distance lifts toward pale warm amber + (`vec3(0.93,0.86,0.74)`), never grey fog; Arizona's dry air. +5. **Terrain first, imagery second** — partial DE-LIGHT: the photo's baked-in + orbital luma is pulled 50% toward a mid reference so OUR key re-shades the + surfel form; the satellite palette remains as tint. +6. **Relief-model mindset** — emerges from 1–5. + +**The river is the protagonist** — the ONLY material with dynamic lighting: +- `aWet` vertex attribute from the Garmin KIND grid (blue-dominant palette slot; + LAKE_SUBTLE stays below threshold by design). Gouraud interpolation feathers + the banks for free. +- Deep blue-green channel colour + faint riparian-green fringe at the banks. +- Two view-dependent Blinn lobes (pow 80 broad sun-path + pow 420 sparkle) that + SLIDE along the bends as the camera orbits — sandstone stays matte; a few + moving glints read as living water without animation. +- **Bake fix:** under `--crop` the KIND grid was rasterized against the FULL tile + bbox (misregistered — invisible while the skin covered it). `garmin_bake` now + builds a deg→mu `kbbox` from the crop window for `kind_grid`/`river_fill_grid`, + so the Colorado's Water cells land on the real river (6,050 cells in the crop). + +Also this round: **hue-preserving skin brighten** (operator: color "on the dark +side"; satellite haze + atmospheric degradation + the eye idealizing diffuse +morning light) — global luma p2→p98 stretch to [12,242] (NOT per-channel — that +would grey the red rock), gamma 0.94 shadow-open, sat ×1.12, warm bias +(R×1.02/B×0.97): luma mean 108→139, warmth kept R155>G140>B89. + +**Follow-up (river continuity):** Garmin's 0x4c river-FILL polys rasterize the +thin inner-gorge reaches sub-cell (dashed segments); the CC filter keeps the wide +reaches (4,084 cells at full res). For continuous material coverage, rasterize the +river as a width-stamped POLYLINE from the Garmin line features instead. + ## /osm dual basemap (2026-07-10, follow-up shipped) - The `/osm` slippy cockpit gets a **basemap toggle**: OSM map ↔ ESRI World Imagery satellite — the SAME keyless imagery the ver-9 terrain skins drape from, so the flat diff --git a/cockpit/public/canyon.v8grid.soa.gz b/cockpit/public/canyon.v8grid.soa.gz index c005fa559..251fb30e1 100644 Binary files a/cockpit/public/canyon.v8grid.soa.gz and b/cockpit/public/canyon.v8grid.soa.gz differ diff --git a/cockpit/src/GeoHelix.tsx b/cockpit/src/GeoHelix.tsx index 46f47cd2d..fb3603088 100644 --- a/cockpit/src/GeoHelix.tsx +++ b/cockpit/src/GeoHelix.tsx @@ -124,6 +124,7 @@ interface Decoded { isGrid?: boolean; stride?: number; // grid LOD stride actually decoded at (1 = full res) skin?: boolean; // ver-9: vertex colours are the raw satellite photo (Diaprojektor skin) + wets?: Uint8Array; // ver-9: per-vertex water flag from the Garmin KIND grid (the river material) } 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 @@ -254,6 +255,38 @@ function decodeGrid(buf: ArrayBuffer, stride = 1): Decoded { // boundary (intra-family discontinuity → blocky texture); this flows // continuously across cells — the surfel the body has. Corner values cached. const colors = new Uint8Array(nV * 3); + // ver-9 river material: the photo can't reveal water by colour (the Colorado is + // dark green-brown in imagery), but the wire still carries the Garmin KIND grid — + // mark blue-dominant-KIND vertices (the Water palette slot; LAKE_SUBTLE's b−max=4 + // stays below the threshold by design) so the shader can give the river its own + // dynamic material. Gouraud interpolation of the flag feathers the banks for free. + let wets: Uint8Array | undefined; + if (skin) { + const wetKind = new Uint8Array(nK); + for (let k = 0; k < nK; k++) { + wetKind[k] = pal[k * 3 + 2] - Math.max(pal[k * 3], pal[k * 3 + 1]) > 15 ? 255 : 0; + } + const raw = new Uint8Array(nV); + for (let i = 0; i < nV; i++) raw[i] = wetKind[kinds[i]] ?? 0; + // Morphological OPENING (erode, then dilate the survivors): the Garmin 0x4c class + // also stamps hundreds of isolated 1–2-cell flecks across the plateau — at + // altitude the region must read DRY (operator art direction), so only the wide + // ×2-dilated Colorado ribbon survives to get the river material. + const er = new Uint8Array(nV); + for (let r = 1; r < H - 1; r++) { + for (let c = 1; c < W - 1; c++) { + const i = r * W + c; + if (raw[i] && raw[i - 1] && raw[i + 1] && raw[i - W] && raw[i + W]) er[i] = 255; + } + } + wets = new Uint8Array(nV); + for (let r = 1; r < H - 1; r++) { + for (let c = 1; c < W - 1; c++) { + const i = r * W + c; + if (er[i] || er[i - 1] || er[i + 1] || er[i - W] || er[i + W]) wets[i] = 255; + } + } + } if (skin) { // ver-9: the satellite photo IS the colour — copy it straight in (Diaprojektor // sunk once), no palette/CurveRuler recolour. The shader applies only a gentle @@ -308,7 +341,7 @@ function decodeGrid(buf: ArrayBuffer, stride = 1): Decoded { 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): 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 }; + return { nVerts: nV, nTris: nT, positions, index, colors, normals, layer, vrow: rowArr, concepts: nC, conceptList, isGrid: true, stride, skin: !!skin, wets }; } // Terrain LOD is a VERTEX BUDGET, not a blind ratio. A phone's per-frame ceiling is @@ -463,6 +496,7 @@ function decode(buf: ArrayBuffer, stride = 1): Decoded { const VERT = ` precision highp float; attribute vec3 aColor; attribute vec3 aNormal; +attribute float aWet; // ver-9 river material: 1 on Garmin Water-KIND cells (the Colorado), 0 elsewhere uniform float uGeo; // 1 = geo scene → height-profile terrain palette · 0 = anatomy → aColor (byte-identical) uniform float uYMin; // decoded height range (display.y), measured once at load from the position buffer uniform float uYMax; @@ -528,16 +562,48 @@ void main(){ vec4 mvp = modelViewMatrix * vec4(dpos, 1.0); vec3 lit; if (uGeo > 0.5 && uSkin > 0.5) { - // ── ver-9 SATELLITE SKIN — the photo IS the truth (Diaprojektor sunk into the - // grid). It already carries the sun's own shadows, so add ONLY a soft - // directional relief lift so the 3-D form reads, plus a whisper of saturation. - // No hypsometric ramp, no water re-tint, no topo paper — those would fight the - // real imagery. ── - vec3 SUN = normalize(vec3(-0.55, 0.42, 0.72)); - float ndl = max(dot(n, SUN), 0.0); - lit = aColor * (0.80 + 0.32 * ndl); - float lum = dot(lit, vec3(0.299, 0.587, 0.114)); - lit = mix(vec3(lum), lit, 1.08); // +8% saturation, keep it natural + // ── ver-9 SKIN under MUSEUM LIGHTING (art direction 2026-07-10: think landscape + // photographer, not GIS — "optimize against National Geographic, not Google + // Maps"; a relief model carved from sandstone under gallery light). ── + // (a) TERRAIN FIRST, imagery second: partially DE-LIGHT the photo — pull its + // baked-in orbital illumination toward a mid reference so OUR key light + // re-shades the form; the satellite palette remains as the TINT. + float plum = dot(aColor, vec3(0.299, 0.587, 0.114)); + // gain CLAMPED [0.7, 1.9]: an unclamped 0.60/plum lifts dark forest ~3x into + // lime — deep pine must stay deep; bright limestone must not crush. + vec3 tint = aColor * clamp(0.60 / max(plum, 0.12), 0.7, 1.9); + vec3 base = mix(aColor, tint, 0.50); + // (b) COLOUR SEPARATION: chroma boost (hue-preserving) so the strata split — + // cream limestone / ochre / orange sandstone / deep red — without cartooning. + float blum = dot(base, vec3(0.299, 0.587, 0.114)); + base = blum + (base - blum) * 1.20; + // (c) MUSEUM LIGHT: soft warm morning KEY (wrap diffuse — diffuse light bends + // around form), a WEAK cool sky fill, and a WARM UMBER shadow floor — the + // canyon glows in its own bounce light; shadows are never grey. + vec3 SUN = normalize(vec3(-0.55, 0.38, 0.72)); + float wrap = clamp((dot(n, SUN) + 0.26) / 1.26, 0.0, 1.0); + vec3 keyC = vec3(1.16, 1.03, 0.85); // warm key (morning sun) + vec3 shadC = vec3(0.46, 0.33, 0.26); // deep warm umber — reddish-black floor + vec3 fillC = vec3(0.42, 0.50, 0.64); // cool but WEAK sky fill + vec3 light = mix(shadC, keyC, wrap) + fillC * (0.16 * (0.5 + 0.5 * n.y)); + lit = base * light; + // (d) THE RIVER IS THE PROTAGONIST — the only material with dynamic lighting. + // Sandstone stays matte; the Colorado gets a deep blue-green channel colour, + // a moisture/vegetation fringe where the interpolated flag feathers at the + // banks, and view-dependent Blinn glints that SLIDE along the bends as the + // camera orbits — a few sparkling highlights read as living water. + float chan = smoothstep(0.45, 0.95, aWet); // main channel core + float bank = smoothstep(0.03, 0.40, aWet) * (1.0 - chan); // feathered banks + lit = mix(lit, vec3(0.13, 0.38, 0.42) * (0.6 + 0.5 * wrap), chan * 0.9); + lit *= 1.0 + bank * vec3(-0.06, 0.10, 0.02); // faint riparian green + vec3 V = normalize(-mvp.xyz); + float nh = max(dot(n, normalize(SUN + V)), 0.0); + lit += vec3(1.35, 1.30, 1.10) * pow(nh, 80.0) * chan * 0.55; // broad sun-path + lit += vec3(1.60, 1.55, 1.30) * pow(nh, 420.0) * chan * 1.10; // sharp sparkle + // (e) WARM ATMOSPHERIC PERSPECTIVE — Arizona's dry air: distance lifts toward a + // pale warm amber, never grey fog; distant mesas stay readable and warm. + float hz = smoothstep(1.6, 4.6, length(mvp.xyz)) * 0.26; + lit = mix(lit, vec3(0.93, 0.86, 0.74), hz); } else if (uGeo > 0.5) { // (1) HYPSOMETRIC tint — blend the baked KIND colour (aColor) with the height ramp. vec3 base = mix(aColor, terrainColor(position.y), 0.55); @@ -756,6 +822,9 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir geom.setAttribute('position', new THREE.BufferAttribute(d.positions, 3)); geom.setAttribute('aColor', new THREE.Uint8BufferAttribute(d.colors, 3, true)); geom.setAttribute('aNormal', new THREE.Int8BufferAttribute(d.normals, 3, true)); // rim normal, normalized i8 + // water flag (ver-9 river material) — always bound (zeros when absent) so the + // shader's aWet read never hits an unbound attribute on non-skin scenes. + geom.setAttribute('aWet', new THREE.Uint8BufferAttribute(d.wets ?? new Uint8Array(d.positions.length / 3), 1, true)); // Draw ONLY enabled layers, as GEOMETRY (rebuild the index on toggle) — never a // fragment discard. A discard still rasterises every triangle, then throws the pixels diff --git a/geo/src/bin/garmin_bake.rs b/geo/src/bin/garmin_bake.rs index 667b9d096..256b70d72 100644 --- a/geo/src/bin/garmin_bake.rs +++ b/geo/src/bin/garmin_bake.rs @@ -163,8 +163,18 @@ fn main() { // ── KIND overlay: natural landcover (rivers / lakes / forest) on bare // terrain — roads/paths are excluded so the mountain scene reads as - // terrain, not a street grid. ── - let mut kinds = terrain::kind_grid(&dec, bbox, w, h, &terrain::LANDCOVER); + // terrain, not a street grid. The rasterization bbox is the GRID's window + // (the --crop sub-window when cropping), NOT the tile bbox — otherwise the + // Water mask lands misregistered on a cropped grid and the river material + // (ver-9 aWet) paints the wrong cells. deg→mu is the inverse of mu2deg. ── + let deg2mu = |d: f64| (d * (1u32 << 24) as f64 / 360.0).round() as i32; + let kbbox = geo_hhtl::garmin::tre::Bbox { + north: deg2mu(gn), + east: deg2mu(ge), + south: deg2mu(gs), + west: deg2mu(gw), + }; + let mut kinds = terrain::kind_grid(&dec, kbbox, w, h, &terrain::LANDCOVER); // On an arid scene, blue is reserved for the ONE persistent water body — the // flowing Colorado (Garmin river-fill type 0x4c). Its stamp rasterizes to a // ~1-cell hairline, so widen ONLY the river ×2 (ribbon, the way it dominates @@ -175,8 +185,49 @@ fn main() { // stamp for everything.) if arid { let wtag = geo_hhtl::garmin::GeoKind::Water.tag(); - let river = - terrain::dilate_kind(&terrain::river_fill_grid(&dec, bbox, w, h), w, h, wtag, 2); + // Keep only LARGE connected components of the river fill before widening: + // Garmin's 0x4c class also stamps hundreds of isolated 1–4-cell flecks across + // the plateau (intermittent pools) — at altitude the region must read DRY + // (operator art direction), and the ver-9 river material must paint ONLY the + // Colorado. The ribbon is thousands of connected cells; 25 kills the flecks + // at every grid scale. Dropped flecks fall through to the still-water branch + // below (→ LAKE_TAG subtle), exactly like the stock tanks. + let raw_river = terrain::river_fill_grid(&dec, kbbox, w, h); + let mut big = vec![0u8; w * h]; + let mut seen = vec![false; w * h]; + let mut stack = Vec::new(); + for start in 0..w * h { + if raw_river[start] != wtag || seen[start] { + continue; + } + let mut comp = vec![start]; + seen[start] = true; + stack.push(start); + while let Some(i) = stack.pop() { + let (r, c) = (i / w, i % w); + for (nr, nc) in [ + (r.wrapping_sub(1), c), + (r + 1, c), + (r, c.wrapping_sub(1)), + (r, c + 1), + ] { + if nr < h && nc < w { + let j = nr * w + nc; + if raw_river[j] == wtag && !seen[j] { + seen[j] = true; + stack.push(j); + comp.push(j); + } + } + } + } + if comp.len() >= 25 { + for i in comp { + big[i] = wtag; + } + } + } + let river = terrain::dilate_kind(&big, w, h, wtag, 2); let (mut river_cells, mut widened, mut lakes) = (0usize, 0usize, 0usize); for (i, k) in kinds.iter_mut().enumerate() { if river[i] == wtag {