Skip to content
Open
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
17 changes: 14 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Settings persist to `dirs::config_dir() / dem_renderer / config.toml` (macOS: `~
|---|---|
| `src/viewer/mod.rs` | `Viewer` (ApplicationHandler), WASD+mouse, sun animation, tile streaming dispatch, key bindings |
| `src/viewer/scene_init.rs` | `prepare_scene_with_ctx` (single-tile / projected-CRS streaming), `prepare_demo_scene_with_ctx` (N×M Copernicus base, camera-centered crop to `GPU_SAFE_PX`), `compute_ao_cropped` |
| `src/viewer/tiers.rs` | `StreamingTier` (drift-detected reload), `BevBaseState` (base/close/fine workers), `TierRadii` + `tier_radii(VramClass)` preset mapping, `cross_crs_world_origin_and_extent`, `select_ifd`, `cap_to_gpu_limit` |
| `src/viewer/tiers.rs` | `StreamingTier<T: TierCentre>` (drift-detected reload; payload-generic), `BevBaseState` (base/close/fine + color_fine/color_close ortho workers), `TierRadii` + `tier_radii(VramClass)`, `OrthoRadii` + `ortho_radii(VramClass)`, `cross_crs_world_origin_and_extent`, `select_ifd`, `select_overview_level`, `cap_to_gpu_limit` |
| `src/viewer/tile_index.rs` | `TileEntry` + `TileIndex` — discover WGS84 bounds of multiple tiles per tier; `tiles_overlapping_wgs84` |
| `src/viewer/geo.rs` | `latlon_to_tile_metres` (handles both geographic and projected CRSes), `sun_position` |
| `src/viewer/hud_renderer.rs` | glyphon HUD overlay, sun indicator, settings panel |
Expand Down Expand Up @@ -83,8 +83,9 @@ dem_renderer/
├── crates/
│ ├── dem_io/src/
│ │ ├── lib.rs
│ │ ├── heightmap.rs # Heightmap (f32 data), parse_bil, fill_nodata, fill_nodata_from_base
│ │ ├── geotiff.rs # parse_geotiff_auto, extract_window, ifd_scales, tile_bounds_wgs84, tile_centre_crs
│ │ ├── heightmap.rs # Heightmap (f32 data), parse_bil, fill_nodata, fill_nodata_from_base, composite_surface_over (DSM-over-DTM)
│ │ ├── geotiff.rs # parse_geotiff_auto, extract_window, ifd_scales, ifd_overview_levels (mask-aware), tile_bounds_wgs84, tile_centre_crs, GDAL_NODATA(42113)-aware NoData predicate
│ │ ├── color.rs # extract_color_window: ortho RGB (JPEG YCbCr→RGB) + land-cover material codes packed RGBA8; landcover_histogram
│ │ ├── grid.rs # assemble_grid (N×M), load_grid_from_paths, crop, stitch_windows[_geographic]
│ │ ├── crs.rs # tile_proj4 / to_wgs84 / from_wgs84 / is_geographic / epsg_towgs84 / read_raw_crs_tags (proj4rs + proj4wkt + crs-definitions; WKT → inline-GeoKey → EPSG fallback chain)
│ │ ├── overview.rs # ensure_overview_cache: build .tmp_dem_pre_calc_*.tif from large single-IFD tiles (copies source CRS tags verbatim so cache is self-describing)
Expand Down Expand Up @@ -175,9 +176,19 @@ For projected single-file mode and demo mode, the viewer spawns three background
| 8 / 9 | AO texture (R8Unorm) + sampler |
| 10–14 | 5 m close tier — heightmap (R32Float) + samp + normals (Rg16Snorm) + samp + shadow buffer |
| 15–19 | 1 m fine tier — same layout as 5 m |
| 20/21 | Fine ortho albedo (Rgba8Unorm, mipped; RGB = orthophoto, A = land-cover material code) + sampler |
| 22/23 | Close ortho albedo + sampler |

`scene::bind_group::rebuild_bind_group()` rebuilds the BG whenever a tier's texture / buffer is recreated (size grows). Steady-state reloads do not allocate — `write_texture` / `write_buffer` overwrite in place.

### DSM + Ortho/Land-Cover Layers (Tirol demo)

- **DSM (surface model, trees/buildings)**: `demo_view.surface_tile_paths` (default: the BEV ALS DSM, EPSG:3035 1 m). The fine worker extracts a DSM window with the same centre/radius as its DTM window and paints it on with `dem_io::composite_surface_over` (6 px feather; DTM stays the canvas so coverage never shrinks). Normals/shadows/bicubic then run on the composite — canopy and buildings get geometry and self-shadowing with zero shader changes. The DSM's `GDAL_NODATA = +3.4028235e38` is caught by the generalized NoData predicate (tag 42113 + `≥1e38` guard) — without it those cells parse as 3.4e38 m elevations and poison `max_terrain_h`.
- **Ortho albedo**: `demo_view.ortho_tile_paths` + `landcover_tile_paths` (defaults: BEV 20 cm RGB + LC mosaics, EPSG:31255). Two `StreamingTier<ColorData>` workers (fine ≈0.8 m/px, close ≈6.4 m/px; radii per `ortho_radii(VramClass)`) read windows via `extract_color_window` — JPEG YCbCr chunks decoded by the tiff crate's zune path, BT.601-converted on the worker, land cover nearest-resampled onto the ortho grid. One RGBA8 texture per window: RGB = albedo, **A = material code ladder** (water 255 / high veg 192 / med veg 128 / building 64 / other 0 — codes ≥64 apart so bilinear boundary mixes degrade gracefully; mip gen box-filters RGB but takes nearest A). LC classes pinned empirically (`examples/inspect_color.rs`): 1=high veg, 2=ground/ice, 3=med veg, 4=buildings, 5=water, 6=low veg, 15=NoData. Note the LC mosaic is **Gray(4)** — 4-bit nibble-packed chunks, unpacked in `extract_u8_window`; the RGB mosaic interleaves **mask IFDs** in its chain, hence `ifd_overview_levels` (NewSubfileType-filtered) instead of raw `ifd_scales`.
- **Shader**: hit-point albedo = procedural ramp → close ortho → fine ortho (edge-smoothstep over `BLEND_MARGIN`, per-window rotation like the height tiers, distance-heuristic mip). Where ortho wins, the brightness floor is lifted (`mix(brightness, clamp(brightness, 0.55, 1.0), w·0.7)`) because the photo carries baked sun shading — full diffuse on top double-shades north faces. Water (A ≥ ~0.9) blends toward a lake tint + mirror-direction sun glint. `T` toggles the drape (`ortho_mode` uniform).
- **OOM ladder step 0**: both ortho textures are dropped before the fine tier — cosmetic-first degradation.
- Missing files degrade gracefully everywhere (`build_tile_index` skips absent paths; empty index → no workers spawned).

---

## Launcher UI — Development Rules
Expand Down
128 changes: 128 additions & 0 deletions crates/dem_io/examples/inspect_color.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Inspect a land-cover (and optionally orthophoto) mosaic: dump the mask-aware
//! overview levels and class-value histograms at known Tirol reference spots
//! (reservoir / forest slope / town / glacier). Used to pin the BEV land-cover
//! class → material mapping in `dem_io::color::lc_code_to_material` — the class
//! codes are not documented in the GeoTIFF itself.
//!
//! Usage:
//! cargo run --release -p dem_io --example inspect_color -- tiles/color/2022470_Mosaik_LC.tif [tiles/color/2019470_Mosaik_RGB.tif]

use std::env;
use std::path::Path;

fn main() {
let lc_arg = env::args()
.nth(1)
.expect("usage: inspect_color <landcover.tif> [ortho_rgb.tif]");
let lc_path = Path::new(&lc_arg).to_path_buf();
let rgb_arg = env::args().nth(2);

let levels = dem_io::ifd_overview_levels(&lc_path).expect("walk IFDs");
println!("land cover overview levels (ifd, scale):");
for (ifd, scale) in &levels {
println!(" ifd {ifd:>2} {scale:.2} m/px");
}

let proj4 = dem_io::crs::tile_proj4(&lc_path).expect("resolve CRS");
println!("proj4: {proj4}");

// (label, lat, lon, radius_m) — picked so each window is dominated by one class.
let spots: &[(&str, f64, f64, f64)] = &[
("Achensee centre (deep lake)", 47.4450, 11.7080, 250.0),
("Speicher Schlegeis (reservoir)", 47.0285, 11.7090, 250.0),
("forest slope near Mayrhofen", 47.1500, 11.8400, 150.0),
("valley meadow near Uderns", 47.3120, 11.8740, 100.0),
("Mayrhofen town centre", 47.1660, 11.8630, 150.0),
("Hintertux glacier / rock", 47.0680, 11.6730, 250.0),
];

// Use a mid overview (~1.6 m) so each window is a few hundred px — enough for
// a stable histogram without decoding thousands of full-res chunks.
let (ifd, scale) = levels
.iter()
.copied()
.find(|&(_, s)| s >= 1.5)
.unwrap_or(levels[0]);
println!("\nhistograms at ifd {ifd} ({scale:.2} m/px):");

for &(label, lat, lon, radius) in spots {
let Ok(centre) = dem_io::crs::from_wgs84(lat, lon, &proj4) else {
println!(" {label}: from_wgs84 failed");
continue;
};
match dem_io::landcover_histogram(&lc_path, centre, radius, ifd) {
Ok(hist) => {
let total: u64 = hist.iter().sum();
print!(" {label}: ");
for (v, &n) in hist.iter().enumerate() {
if n > 0 {
print!("{v}:{:.0}% ", n as f64 / total as f64 * 100.0);
}
}
println!();
}
Err(e) => println!(" {label}: {e}"),
}
}

if let Some(rgb_arg) = rgb_arg {
let rgb_path = Path::new(&rgb_arg).to_path_buf();
let rgb_levels = dem_io::ifd_overview_levels(&rgb_path).expect("walk RGB IFDs");
println!("\northo overview levels (ifd, scale):");
for (ifd, scale) in &rgb_levels {
println!(" ifd {ifd:>2} {scale:.2} m/px");
}
let (rgb_ifd, rgb_scale) = rgb_levels
.iter()
.copied()
.find(|&(_, s)| s >= 1.5)
.unwrap_or(rgb_levels[0]);
let lc_ifd = levels
.iter()
.copied()
.find(|&(_, s)| (s - rgb_scale).abs() < 0.01)
.map(|(i, _)| i);
println!("sampling ortho at ifd {rgb_ifd} ({rgb_scale:.2} m/px), lc ifd {lc_ifd:?}");

for &(label, lat, lon, radius) in spots {
let proj4_rgb = dem_io::crs::tile_proj4(&rgb_path).expect("resolve RGB CRS");
let Ok(centre) = dem_io::crs::from_wgs84(lat, lon, &proj4_rgb) else {
continue;
};
match dem_io::extract_color_window(
&rgb_path,
Some(&lc_path),
centre,
radius,
rgb_ifd,
lc_ifd,
) {
Ok(win) => {
let n = win.georef.rows * win.georef.cols;
let c = n / 2; // centre-ish pixel
let px = &win.rgba[c * 4..c * 4 + 4];
let avg: [u64; 4] = win.rgba.chunks_exact(4).fold([0; 4], |mut a, p| {
for i in 0..4 {
a[i] += p[i] as u64;
}
a
});
println!(
" {label}: {}×{} centre rgba=({},{},{},{}) avg rgba=({},{},{},{})",
win.georef.cols,
win.georef.rows,
px[0],
px[1],
px[2],
px[3],
avg[0] / n as u64,
avg[1] / n as u64,
avg[2] / n as u64,
avg[3] / n as u64,
);
}
Err(e) => println!(" {label}: {e}"),
}
}
}
}
Loading
Loading