diff --git a/claude-notes/plans/2026-07-08-osm-garmin-drape.md b/claude-notes/plans/2026-07-08-osm-garmin-drape.md index 5baef38d4..c1bff399e 100644 --- a/claude-notes/plans/2026-07-08-osm-garmin-drape.md +++ b/claude-notes/plans/2026-07-08-osm-garmin-drape.md @@ -69,6 +69,50 @@ lifts the drape `y` by the same `uExagVal`. Co-registered by construction. network + grey roads read ON the surface; `features off` → bare terrain. Co-registered, toggleable (fused ↔ Garmin-only). +## Arid drainage recolor (2026-07-08 follow-up) + +The dendritic `Stream` network on a desert tile is DRY drainage (washes / gullies +/ arroyos), not water — painting it river-blue made the Grand Canyon plateau read +as wet. Fix: reserve blue for the actual `Water` bodies (the Colorado + permanent +lakes) and recolour the drainage rust-brown. + +- [x] `GeoKind::ARID_DRAINAGE = [120,68,44]` + `GeoKind::arid_palette()` + (`geo/src/garmin/classify.rs`): the KIND palette with `Stream` browned, + every other class (incl. blue `Water`) unchanged. Unit-tested. +- [x] `garmin_bake --arid` (`geo/src/bin/garmin_bake.rs`): uses `arid_palette()` + for the ONE palette that feeds both the ver-8 terrain KIND block AND the DRP1 + drape → drainage browns consistently across both. Diff vs the non-arid bake is + exactly the 3-byte Stream RGB entry in each wire; terrain geometry byte-identical. +- [x] `GeoHelix.tsx` — `uArid` uniform (1 = non-glacial scene): gates the glacial + turquoise OFF (canyon Water stays plain river-blue, not teal) and re-asserts a + clean deep river-blue for the (blue-KIND) Water cells so the Colorado survives + the 55%-terrain-blend + warm-sunset key as the focal point. Iceland (uArid=0) + keeps turquoise. Drainage is browned in the bake → `wet`=0 → shader leaves it. +- [x] Rebake: `garmin_bake .claude/maps/garmin-grand-canyon/47505316.img + canyon.v8grid.soa --arid` → `gzip -9` the `.soa` + `.drape.soa` into + `cockpit/public/canyon.v8grid{,.drape}.soa.gz`. Headless `/garmin/grand-canyon` + verified: plateau = warm desert earth + brown incised drainage; the Colorado a + thin blue ribbon in the canyon depth (pixel-checked: 0 → 475 blue water px). + +## Colorado as focal point (2026-07-08, from reference photos) + +The drainage-brown pass left the Colorado a ~1-cell hairline — present but not the +FOCAL POINT the real canyon has. Two fixes, both correctness-shaped: + +- [x] **Neatline exclusion** — Garmin Poly type `0x4b` (5-pt rectangle spanning + 98%×99% of the tile) is the background / definition-area, mis-classed as + `Water`; its outline drew a blue border around the whole tile. Carved `0x4b → + Other` in `classify.rs` (before the `0x3c..=0x4f → Water` range). Golden + histogram updated: Water 2255→2251, Other 3664→3668 (the 4 `0x4b` polys). +- [x] **River widening** — `terrain::dilate_kind()` (tested): binary-dilate the + `Water` cells ×2 on `--arid` so the Colorado's thin river-fill reads as a + ribbon (5802→13184 cells after the neatline fix). Real lakes/tanks (0x46/0x47) + widen too — still discrete blue dots, not a wet plateau. +- [x] **Luminous river shader** — brighter arid-water base (`0.16,0.44,0.66`) + a + post-grade silvery-blue lift (step 7, `wet·uArid·0.55`) so the warm sunset key + no longer mutes the river; it catches the light as the focal point. Verified: + blue terrain fraction 0.46%→1.31%, interior (canyon + lakes), perimeter clean. + ## Follow-ups (own PRs) - **Iceland drape** — same `build_drape` over `otm-iceland.img` line features diff --git a/cockpit/public/canyon.v8grid.drape.soa.gz b/cockpit/public/canyon.v8grid.drape.soa.gz index 540acfc7a..ec331199f 100644 Binary files a/cockpit/public/canyon.v8grid.drape.soa.gz and b/cockpit/public/canyon.v8grid.drape.soa.gz differ diff --git a/cockpit/public/canyon.v8grid.soa.gz b/cockpit/public/canyon.v8grid.soa.gz index c14d2fea5..4f4744eed 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 131f779f2..a9f18a3ac 100644 --- a/cockpit/src/GeoHelix.tsx +++ b/cockpit/src/GeoHelix.tsx @@ -405,6 +405,9 @@ uniform float uExag; // geo relief exaggeration: the Iceland bake is true-scal // [-1,1] frame), so raise the geometry to read as terrain. 1 for anatomy (untouched). uniform float uTime; // retained-but-0: the Kurvenlineal residue is baked into the mesh, not animated. uniform float uRuler; // retained-but-0: no shader ruler (the golden-spiral residue is baked in). +uniform float uMoss; // 1 on vegetated scenes (Iceland) → aspect-based moss; 0 on the desert canyon. +uniform float uArid; // 1 on arid/desert scenes → NO glacial turquoise (water stays plain river-blue); + // 0 on the glacial Iceland scene → meltwater teal. Drainage-brown is baked, not here. varying vec3 vColor; // THE KURVENLINEAL is now baked into the mesh, not approximated here. The real // helix::CurveRuler golden-spiral residue (stride-4-over-17) is applied at BAKE time in @@ -429,8 +432,8 @@ vec3 terrainColor(float h){ vec3 ocean = vec3(0.05, 0.15, 0.30); vec3 coast = vec3(0.28, 0.42, 0.24); // lowest green coastal fringe vec3 moss = vec3(0.34, 0.36, 0.22); // khaki moss/tundra — the dominant quantized lowland - vec3 rock = vec3(0.44, 0.38, 0.30); // brown highland rock - vec3 scree = vec3(0.56, 0.55, 0.55); // grey scree + vec3 rock = vec3(0.49, 0.34, 0.23); // COPPER volcanic/desert rock (was neutral brown) + vec3 scree = vec3(0.61, 0.47, 0.34); // light copper highlight (was grey scree) vec3 ice = vec3(0.90, 0.93, 0.98); // snow / glacier cap vec3 land = coast; land = mix(land, moss, smoothstep(0.18, 0.30, hs)); @@ -443,15 +446,6 @@ vec3 terrainColor(float h){ float water = 1.0 - smoothstep(0.002, 0.010, hl); // ocean is EXACTLY 0; first land ≈ 0.0155 normalized return mix(land, ocean, water); } -// Cheap value-noise → drifting cloud shadows (2 octaves, world-fixed xz, scrolled by uTime). -float hash21(vec2 p){ p = fract(p * vec2(123.34, 345.45)); p += dot(p, p + 34.345); return fract(p.x * p.y); } -float vnoise(vec2 p){ - vec2 i = floor(p), f = fract(p); - vec2 u = f * f * (3.0 - 2.0 * f); - float a = hash21(i), b = hash21(i + vec2(1,0)), c = hash21(i + vec2(0,1)), d = hash21(i + vec2(1,1)); - return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); -} -float clouds(vec2 p){ return 0.65 * vnoise(p) + 0.35 * vnoise(p * 2.03 + 7.1); } void main(){ // GOURAUD: shade per-vertex from the cheap rim normal, interpolate the COLOUR across the // face. At 6.8 M sub-pixel tris this matches per-fragment lighting visually but leaves the @@ -464,35 +458,59 @@ void main(){ vec4 mvp = modelViewMatrix * vec4(dpos, 1.0); vec3 lit; if (uGeo > 0.5) { - // (1) HYPSOMETRIC tint — blend the baked KIND colour (aColor: water blue, forest - // green, else bare-terrain grey) with the height ramp so elevation READS as - // colour (green lowland → brown → grey → white peak). terrainColor() normalizes - // by the measured [uYMin,uYMax] and sqrt-spreads the quantized tail. + // (1) HYPSOMETRIC tint — blend the baked KIND colour (aColor) with the height ramp. vec3 base = mix(aColor, terrainColor(position.y), 0.55); - // (2) SUNSET lighting — a low WARM key + a cool SKY fill. This colours the light - // (lit slopes go golden, shadowed slopes cool-blue), the depth cue that makes a - // range feel alive — instead of the old grey brightness multiplier. + // KIND masks from the RAW aColor (not the render): water = blue-dominant, + // snow/ice = near-white. Scene-safe — the canyon's grey terrain matches neither. + float wet = smoothstep(0.06, 0.22, aColor.b - max(aColor.r, aColor.g)); + float snow = smoothstep(0.74, 0.88, min(aColor.r, min(aColor.g, aColor.b))); + // (2) TURQUOISE water — push blue-KIND cells to a vivid glacial-meltwater teal + // (as turquoise as it gets). KIND-gated (only blue cells) AND scene-gated by + // uArid: glacial teal is Iceland's look; on the arid canyon the Water KIND + // (the Colorado) stays a natural deep river-blue, never tropical turquoise. + base = mix(base, vec3(0.07, 0.62, 0.66), wet * 0.9 * (1.0 - uArid)); + // (2b) ARID scenes: the actual Water bodies (the Colorado) are the blue FOCAL + // POINT the user asked to reserve — but the 55% terrain blend + warm sunset + // key wash the thin river cells to neutral copper. Re-assert a clean deep + // river-blue for the (blue-KIND) water cells so the Colorado reads as water, + // not warm rock. Drainage (Stream) is baked rust-brown → wet=0 → untouched. + base = mix(base, vec3(0.16, 0.44, 0.66), wet * uArid * 0.92); vec3 SUN = normalize(vec3(-0.55, 0.42, 0.72)); // low azimuth, ~25° elevation + // (3) MOSS by ASPECT — moss/lichen favours the shaded, WEATHER-facing slopes (the + // "north-face holds the moisture" rule; ~ aspect × insolation). Tint vivid moss + // onto the vegetated mid-band where the face turns AWAY from the sun; sun-baked + // faces stay bare copper rock — the mottled green/copper of the reference. uMoss + // gates it to Iceland; the desert canyon (uMoss=0) stays bare rock. + float hl = clamp((position.y - uYMin) / max(uYMax - uYMin, 1e-6), 0.0, 1.0); + float veg = smoothstep(0.02, 0.10, hl) * (1.0 - smoothstep(0.34, 0.66, sqrt(hl))); // low-mid, not ocean/ice + float lee = smoothstep(-0.05, 0.65, dot(n, -SUN)); // 1 = weather/shaded (lee) side + base = mix(base, vec3(0.22, 0.47, 0.12), uMoss * veg * (0.5 + 0.5 * lee) * (1.0 - wet) * 0.95); + // (4) SUNSET lighting — a low WARM key + a cool SKY fill (golden lit slopes, + // cool-blue shadows) — the depth cue that makes a range feel alive. float ndl = max(dot(n, SUN), 0.0); float sky = 0.5 + 0.5 * n.y; // hemispheric fill, more from above vec3 light = vec3(1.26, 0.95, 0.66) * (0.16 + 0.95 * ndl) // warm golden key + vec3(0.40, 0.50, 0.66) * (0.34 * sky); // cool sky fill - // (3) DRIFTING CLOUD SHADOWS — a world-fixed value-noise field scrolled by uTime - // dapples soft moving shade over the terrain (atmosphere + a real sense of scale). - float cl = clouds(position.xz * 2.4 + vec2(uTime * 0.015, uTime * 0.009)); - float shadow = mix(0.60, 1.0, smoothstep(0.34, 0.72, cl)); // darker under thick cloud - lit = base * light * shadow; - // (4) MAGIC HIGHLIGHTS — specular sun-glint, gated by KIND (raw aColor, NOT the - // hypsometric render): ocean (blue-dominant) → a tight bright sun-glitter; - // ice/snow (near-white) → a broad soft sheen. The canyon's grey terrain KIND - // triggers neither, so its white peaks stay matte — no false glacier shine. + lit = base * light; + // (5) VIVID GRADE — boost saturation + a touch of contrast for the electric + // Icelandic-highland look (moss greens pop, volcanic rock deepens, turquoise + // glows). Scene palettes live in aColor, so each scene punches its OWN colours — + // the desert canyon deepens to richer browns, it does NOT go green. + float lum = dot(lit, vec3(0.299, 0.587, 0.114)); + lit = mix(vec3(lum), lit, 1.22); // +22% saturation (rich, not radioactive) + lit = (lit - 0.5) * 1.06 + 0.5; // gentle contrast + // (6) MAGIC HIGHLIGHTS — specular sun-glint on water (now turquoise) + ice sheen, + // KIND-gated as above so the canyon's peaks stay matte (no false glacier shine). vec3 V = normalize(-mvp.xyz); float nh = max(dot(n, normalize(SUN + V)), 0.0); - float wet = smoothstep(0.06, 0.22, aColor.b - max(aColor.r, aColor.g)); - float snow = smoothstep(0.74, 0.88, min(aColor.r, min(aColor.g, aColor.b))); - float glint = pow(nh, 220.0) * wet; // sharp sun-glitter on water — brighter where clouds part + float glint = pow(nh, 220.0) * wet; // sharp sun-glitter on the turquoise water float sheen = pow(nh, 26.0) * snow; // soft glacier sheen - lit += vec3(1.5, 1.28, 0.92) * (glint * 1.4 * (0.4 + 0.6 * shadow) + sheen * 0.45); + lit += vec3(1.5, 1.28, 0.92) * (glint * 1.4 + sheen * 0.45); + // (7) LUMINOUS RIVER — on arid scenes the warm key would still mute the Colorado; + // lift the water cells (post-grade) toward a bright silvery river-blue so the + // river catches the light and stays the FOCAL POINT against the dark rock, the + // way it dominates the real canyon. Iceland (uArid=0) is untouched. + lit = mix(lit, vec3(0.34, 0.58, 0.76), wet * uArid * 0.55); } else { // Anatomy path — DEAD in GeoHelix (only /geo /ice /garmin route here), kept // byte-identical to BodyHelix so the fork stays a faithful copy. @@ -523,12 +541,44 @@ precision highp float; varying vec3 vDir; void main(){ vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`; const SKY_FRAG = ` -precision mediump float; +precision highp float; varying vec3 vDir; uniform vec3 uHorizon; uniform vec3 uZenith; +uniform float uTime; +// value-noise +float h21(vec2 p){ p = fract(p * vec2(123.34, 345.45)); p += dot(p, p + 34.345); return fract(p.x * p.y); } +float vn(vec2 p){ + vec2 i = floor(p), f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + float a = h21(i), b = h21(i + vec2(1,0)), c = h21(i + vec2(0,1)), d = h21(i + vec2(1,1)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} +// TRIBONACCI constant (Σ of the previous three ≈ 1.8393). Using it as the octave +// frequency ratio (lacunarity) means successive octaves never land on a repeating +// lattice → the drifting clouds read ORGANIC and non-repeating — the number-theoretic +// cousin of the workspace's golden-spiral kurvenlineal. +const float TRIB = 1.8392867552; +float fbm(vec2 p){ + float s = 0.0, a = 0.5, norm = 0.0; + for (int i = 0; i < 5; i++){ + s += a * vn(p); + norm += a; + p = p * TRIB + vec2(1.7, -2.9); // tribonacci lacunarity + offset breaks octave alignment + a *= 0.5437; // 1/TRIB amplitude gain + } + return s / norm; +} void main(){ - float t = clamp(normalize(vDir).y * 0.5 + 0.5, 0.0, 1.0); - gl_FragColor = vec4(mix(uHorizon, uZenith, smoothstep(0.12, 0.92, t)), 1.0); + vec3 dir = normalize(vDir); + float t = clamp(dir.y * 0.5 + 0.5, 0.0, 1.0); + vec3 sky = mix(uHorizon, uZenith, smoothstep(0.05, 0.95, t)); + // project the view direction onto a virtual cloud layer, drift it over time + vec2 uv = dir.xz / (abs(dir.y) + 0.30) * 0.9 + vec2(uTime * 0.006, uTime * 0.0042); + float c = smoothstep(0.28, 0.86, fbm(uv)); // billowy structure + float cover = mix(0.32, 0.96, smoothstep(0.04, 0.66, t)); // broad cover, thicker higher up + float cloud = c * cover; + vec3 cloudCol = mix(vec3(0.55, 0.57, 0.62), vec3(0.98, 0.99, 1.0), smoothstep(0.30, 0.95, c)); // shaded → lit tops + gl_FragColor = vec4(mix(sky, cloudCol, cloud), 1.0); }`; type Focus = { x: number; y: number; z: number; d: number }; @@ -664,7 +714,11 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir // sits below the cap, so it is UNCHANGED. const EXAG_CAP = 4.2; const uExagVal = isTerrainScene ? Math.min(EXAG_CAP, Math.max(1.5, 0.11 / Math.max(yMax - yMin, 1e-6))) : 1; - const uniforms = { uAlpha: { value: 1 }, uGeo: { value: isTerrainScene ? 1 : 0 }, uYMin: { value: yMin }, uYMax: { value: yMax }, uExag: { value: uExagVal }, uTime: { value: 0 }, uRuler: { value: 0 } }; + const isIcelandScene = sceneName === 'iceland' || sceneName === 'garmin:iceland'; // moss = green Iceland, not the desert canyon + // Glacial turquoise is Iceland's look; every other terrain scene keeps plain river-blue + // water (the canyon's Colorado). uArid = "not the glacial Iceland scene". + const uAridVal = isTerrainScene && !isIcelandScene ? 1 : 0; + const uniforms = { uAlpha: { value: 1 }, uGeo: { value: isTerrainScene ? 1 : 0 }, uYMin: { value: yMin }, uYMax: { value: yMax }, uExag: { value: uExagVal }, uTime: { value: 0 }, uRuler: { value: 0 }, uMoss: { value: isIcelandScene ? 1 : 0 }, uArid: { value: uAridVal } }; const mat = new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG, side: THREE.FrontSide }); const mesh = new THREE.Mesh(geom, mat); scene.add(mesh); @@ -709,7 +763,7 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir skyGeom = new THREE.SphereGeometry(40, 32, 16); // radius 40 < camera far (100), surrounds the orbit skyMat = new THREE.ShaderMaterial({ side: THREE.BackSide, depthWrite: false, - uniforms: { uHorizon: { value: SKY_HORIZON.clone() }, uZenith: { value: SKY_ZENITH.clone() } }, + uniforms: { uHorizon: { value: SKY_HORIZON.clone() }, uZenith: { value: SKY_ZENITH.clone() }, uTime: { value: 0 } }, vertexShader: SKY_VERT, fragmentShader: SKY_FRAG, }); const sky = new THREE.Mesh(skyGeom, skyMat); @@ -791,7 +845,7 @@ function mount(container: HTMLDivElement, d: Decoded, enabled: Float32Array, dir // cloud shadows keep moving — THROTTLED to ~15 fps (slow clouds need no more; caps // the continuous redraw of a heavy DEM so it's cheap on battery). Anatomy/buildings // never enter this branch and stay fully on-demand (no idle cost). - if (isTerrainScene && tnow - lastCloud >= 66) { lastCloud = tnow; uniforms.uTime.value = (tnow - t0) * 0.001; dirty.current = true; } + if (isTerrainScene && skyMat && tnow - lastCloud >= 66) { lastCloud = tnow; skyMat.uniforms.uTime.value = (tnow - t0) * 0.001; dirty.current = true; } // render ON DEMAND (non-terrain): a static body (no drag/zoom/toggle) costs nothing — // 6.8 M tris are only redrawn when something actually changes (idle + heat sane). if (!dirty.current && !dragging) { last = performance.now(); return; } diff --git a/geo/src/bin/garmin_bake.rs b/geo/src/bin/garmin_bake.rs index a45b637b7..20a888b18 100644 --- a/geo/src/bin/garmin_bake.rs +++ b/geo/src/bin/garmin_bake.rs @@ -28,18 +28,22 @@ const FAMILY_TERRAIN: u32 = 4; fn main() { let mut args = std::env::args().skip(1); let (Some(input), Some(output)) = (args.next(), args.next()) else { - eprintln!("usage: garmin_bake [--level N] [--dim WxH] [--metres]"); + eprintln!( + "usage: garmin_bake [--level N] [--dim WxH] [--metres] [--arid]" + ); std::process::exit(2); }; let mut level: u8 = 4; let mut dim: Option<(usize, usize)> = None; let mut feet = true; // US-topo default; --metres for OTM + let mut arid = false; // --arid: desert scene — dry-wash drainage is rust-brown, not blue let rest: Vec = args.collect(); let mut it = rest.iter(); while let Some(a) = it.next() { match a.as_str() { "--level" => level = it.next().and_then(|s| s.parse().ok()).unwrap_or(4), "--metres" | "--meters" => feet = false, + "--arid" => arid = true, "--dim" => { if let Some(d) = it.next() { if let Some((w, h)) = d.split_once('x') { @@ -99,7 +103,23 @@ 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 kinds = terrain::kind_grid(&dec, bbox, w, h, &terrain::LANDCOVER); + let mut kinds = terrain::kind_grid(&dec, bbox, w, h, &terrain::LANDCOVER); + // On an arid scene the ONE real water body (the Colorado) is the visual focal + // point, but its river-fill rasterizes to a ~1-cell hairline at grid resolution. + // Widen the Water cells so the river reads as a ribbon, the way it dominates the + // real canyon. (Non-arid scenes keep the raw stamp.) + if arid { + let before = kinds + .iter() + .filter(|&&k| k == geo_hhtl::garmin::GeoKind::Water.tag()) + .count(); + kinds = terrain::dilate_kind(&kinds, w, h, geo_hhtl::garmin::GeoKind::Water.tag(), 2); + let after = kinds + .iter() + .filter(|&&k| k == geo_hhtl::garmin::GeoKind::Water.tag()) + .count(); + eprintln!("arid river: Water cells {before} → {after} (dilated ×2 so the Colorado reads)"); + } // ── Equirectangular metric projection about the tile centre (matches osm_read / // iceland_dem so the cockpit decoder is shared). ── @@ -177,11 +197,22 @@ fn main() { }); } - // ── ver-8 radix-grid encode: height F16 + kind u8 + GeoKind palette. ── - let palette: Vec<[u8; 3]> = geo_hhtl::garmin::GeoKind::PALETTE - .iter() - .map(|k| k.color()) - .collect(); + // ── ver-8 radix-grid encode: height F16 + kind u8 + GeoKind palette. On an + // arid desert scene the dendritic `Stream` network is DRY drainage (washes / + // gullies), so recolour it rust-brown and keep blue for the actual `Water` + // bodies (the Colorado). One palette feeds BOTH the terrain KIND block and the + // DRP1 drape below, so the drainage browns consistently across both. ── + let palette: Vec<[u8; 3]> = if arid { + geo_hhtl::garmin::GeoKind::arid_palette() + } else { + geo_hhtl::garmin::GeoKind::PALETTE + .iter() + .map(|k| k.color()) + .collect() + }; + if arid { + eprintln!("arid palette: Stream drainage → rust-brown, Water stays river-blue"); + } let ymax = pos.iter().map(|p| p[1]).fold(1e-9f32, f32::max); let heights: Vec = pos.iter().map(|p| p[1] / ymax).collect(); let x0 = pos[0][0]; diff --git a/geo/src/garmin/classify.rs b/geo/src/garmin/classify.rs index c5c627532..5f3894306 100644 --- a/geo/src/garmin/classify.rs +++ b/geo/src/garmin/classify.rs @@ -47,6 +47,26 @@ impl GeoKind { self as u8 } + /// Dark rust-brown for DRY drainage (washes / gullies / arroyos) on an **arid** + /// desert scene. There, the dendritic `Stream` network is incised earth carved by + /// runoff — *not* water — so painting it river-blue makes the plateau read as wet. + /// This complements the copper desert rock and reserves blue for the actual + /// [`GeoKind::Water`] bodies (the Colorado + permanent lakes), keeping the river the + /// visual focal point. + pub const ARID_DRAINAGE: [u8; 3] = [120, 68, 44]; + + /// The KIND palette with `Stream` recoloured to [`ARID_DRAINAGE`](Self::ARID_DRAINAGE) + /// for a desert scene; every other class — including blue [`Water`](Self::Water) — + /// is its canonical [`color`](Self::color). Indexed by [`tag`](Self::tag), so it + /// drops in wherever the default `PALETTE.map(color)` palette is used (the ver-8 + /// terrain KIND block AND the DRP1 drape share one palette). + #[must_use] + pub fn arid_palette() -> Vec<[u8; 3]> { + let mut pal: Vec<[u8; 3]> = Self::PALETTE.iter().map(|k| k.color()).collect(); + pal[GeoKind::Stream.tag() as usize] = Self::ARID_DRAINAGE; + pal + } + /// A sweet, clean base colour (sRGB 0..255) — the `/helix` look: no muddy /// browns, water reads as water, vegetation as vegetation. #[must_use] @@ -81,16 +101,17 @@ impl GeoKind { match kind { Kind::Poly => match type_code { 0x13 | 0x60..=0x6f => GeoKind::Building, // building + urban variants - 0x3c..=0x4f => GeoKind::Water, // sea / lake / river-fill - 0x50 => GeoKind::Woods, // woods / forest - 0x14..=0x1a => GeoKind::Park, // park / reserve / grass + 0x4b => GeoKind::Other, // background / definition-area rectangle — NOT water + 0x3c..=0x4f => GeoKind::Water, // sea / lake / river-fill + 0x50 => GeoKind::Woods, // woods / forest + 0x14..=0x1a => GeoKind::Park, // park / reserve / grass _ => GeoKind::Other, }, Kind::Line => match type_code { - 0x00..=0x07 => GeoKind::Street, // roads - 0x0a | 0x0b | 0x16 => GeoKind::Path, // trails / walking paths - 0x18 | 0x1f | 0x26 => GeoKind::Stream, // streams / rivers - 0x20..=0x25 => GeoKind::Contour, // land + depth contours + 0x00..=0x07 => GeoKind::Street, // roads + 0x0a | 0x0b | 0x16 => GeoKind::Path, // trails / walking paths + 0x18 | 0x1f | 0x26 => GeoKind::Stream, // streams / rivers + 0x20..=0x25 => GeoKind::Contour, // land + depth contours _ => GeoKind::Other, }, Kind::Point | Kind::IPoint => GeoKind::Other, // POIs — not a surface @@ -125,6 +146,25 @@ mod tests { } } + #[test] + fn arid_palette_browns_drainage_keeps_water_blue() { + let pal = GeoKind::arid_palette(); + // Stream (dry wash) → rust-brown: warm (red-dominant), reads as earth. + let s = pal[GeoKind::Stream.tag() as usize]; + assert_eq!(s, GeoKind::ARID_DRAINAGE); + assert!(s[0] > s[2], "arid drainage is warm (r>b), not water-blue"); + // Water (the Colorado) stays clean river blue — unchanged, blue-dominant. + let w = pal[GeoKind::Water.tag() as usize]; + assert_eq!(w, GeoKind::Water.color()); + assert!(w[2] > w[0], "actual water stays blue-dominant"); + // Every non-Stream class keeps its canonical colour. + for (i, k) in GeoKind::PALETTE.iter().enumerate() { + if *k != GeoKind::Stream { + assert_eq!(pal[i], k.color(), "{k:?} unchanged by arid remap"); + } + } + } + #[test] fn spot_check_type_mappings() { // Representative codes from the real tile's histograms. @@ -155,8 +195,10 @@ mod tests { assert_eq!(count(GeoKind::Street), 32_154); assert_eq!(count(GeoKind::Stream), 15_161); assert_eq!(count(GeoKind::Path), 3_600); - assert_eq!(count(GeoKind::Other), 3_664); - assert_eq!(count(GeoKind::Water), 2_255); + // Other gained the 4 background/definition-area (0x4b) polys that were + // previously mis-classed as Water — they hug the tile boundary, not a lake. + assert_eq!(count(GeoKind::Other), 3_668); + assert_eq!(count(GeoKind::Water), 2_251); assert_eq!(count(GeoKind::Park), 766); assert_eq!(count(GeoKind::Building), 0); // wilderness tile — buildings are urban assert_eq!(count(GeoKind::Woods), 0); diff --git a/geo/src/garmin/terrain.rs b/geo/src/garmin/terrain.rs index ebbecda0d..4d42047be 100644 --- a/geo/src/garmin/terrain.rs +++ b/geo/src/garmin/terrain.rs @@ -224,6 +224,36 @@ pub fn kind_grid(dec: &Decoded, bbox: Bbox, w: usize, h: usize, overlay: &[GeoKi kinds } +/// Grow every cell tagged `tag` by one 4-neighbourhood ring (binary dilation), +/// repeated `iters` times. Widens a feature that rasterizes to a hairline at coarse +/// grid resolution — e.g. the Colorado's `Water` cells, which stamp ~1 cell wide and +/// would otherwise be invisible as the scene's focal point. Growing `Water` over an +/// adjacent `Other`/`Stream` cell is intended (a bank / confluence is still water); +/// the pass is a no-op where `iters == 0`. +#[must_use] +pub fn dilate_kind(kinds: &[u8], w: usize, h: usize, tag: u8, iters: usize) -> Vec { + let mut cur = kinds.to_vec(); + for _ in 0..iters { + let src = cur.clone(); + for r in 0..h { + for c in 0..w { + let idx = r * w + c; + if src[idx] == tag { + continue; + } + let hit = (c > 0 && src[idx - 1] == tag) + || (c + 1 < w && src[idx + 1] == tag) + || (r > 0 && src[idx - w] == tag) + || (r + 1 < h && src[idx + w] == tag); + if hit { + cur[idx] = tag; + } + } + } + } + cur +} + /// Parse a contour label ("6000") to an elevation. `feet` converts US-topo feet /// to metres (OTM labels are already metres). fn label_elevation(text: &str, feet: bool) -> Option { @@ -270,6 +300,32 @@ pub fn heightfield_for_level( mod tests { use super::*; + #[test] + fn dilate_kind_widens_a_hairline_river() { + // A 1-cell-wide horizontal "river" (tag 1) down the middle of a 7×7 grid. + let (w, h, tag) = (7usize, 7usize, 1u8); + let mut kinds = vec![0u8; w * h]; + for c in 0..w { + kinds[3 * w + c] = tag; // row 3 = the thread + } + assert_eq!(kinds.iter().filter(|&&k| k == tag).count(), 7); + // One dilation → rows 2,3,4 carry the river (3-wide ribbon) plus the caps. + let d1 = dilate_kind(&kinds, w, h, tag, 1); + for c in 0..w { + assert_eq!(d1[2 * w + c], tag, "row above grew"); + assert_eq!(d1[3 * w + c], tag, "core stays"); + assert_eq!(d1[4 * w + c], tag, "row below grew"); + } + assert_eq!(d1[0 * w + 0], 0, "far corner untouched by one ring"); + // iters = 0 is a no-op. + assert_eq!(dilate_kind(&kinds, w, h, tag, 0), kinds); + // Two dilations widen further (rows 1..=5 all carry it). + let d2 = dilate_kind(&kinds, w, h, tag, 2); + for r in 1..=5 { + assert_eq!(d2[r * w + 3], tag, "row {r} carries the widened river"); + } + } + #[test] fn label_elevation_feet_and_metres() { assert!((label_elevation("6000", true).unwrap() - 1828.8).abs() < 0.1);