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
45 changes: 45 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 @@ -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
Expand Down
Binary file modified cockpit/public/canyon.v8grid.soa.gz
Binary file not shown.
91 changes: 80 additions & 11 deletions cockpit/src/GeoHelix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
59 changes: 55 additions & 4 deletions geo/src/bin/garmin_bake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down