gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-voxel-studio/src/mapa_import.rs Rust · 188 líneas · 7.8 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! **Mundo desde mapa real**: convierte un GeoJSON del visor de mapas
2//! (`nahual-geo-core`) —opcionalmente con un DEM `.pgm` para el relieve— en un
3//! [`VoxelGrid`] del **mismo `dim`** que el preview del studio, vía el puente
4//! [`nahual_geo_voxel`]. Así el mundo del mapa entra por la misma maquinaria de
5//! preview (cámara/órbita/posado) que el procedural, sin refactor.
6//!
7//! Dos pasos separados para poder **persistir**: [`leer_fuente`] captura el
8//! GeoJSON (+ PGM hermano) en una [`MapaFuente`] serializable que se guarda en el
9//! [`Mundo`](llimphi_voxel::Mundo); [`generar`] materializa el grid desde esa
10//! fuente (al importar y al **abrir** un proyecto). Encaje: se elige
11//! `metros_por_voxel` para que la caja del mapa **llene** el `dim` (el eje más
12//! largo manda) y se genera la ventana en `[0,0]`.
13
14use std::path::Path;
15
16use llimphi_3d::VoxelGrid;
17use llimphi_voxel::MapaFuente;
18use nahual_geo_core::{parse_into, BBox, MapData, MAX_VERTICES};
19use nahual_geo_voxel::{Heightfield, MapaVoxel, Opciones};
20
21/// Metros por grado de latitud (igual constante que usa la proyección del puente).
22const M_POR_GRADO_LAT: f64 = 111_320.0;
23
24/// Lee `path` (GeoJSON) y su `.pgm` hermano (si existe) a una [`MapaFuente`]
25/// **persistible** — se embebe el contenido para que el documento sea
26/// autocontenido (no dependa de que el archivo siga ahí). No genera el grid.
27pub fn leer_fuente(path: &str, rango_dem_m: f32) -> Result<MapaFuente, String> {
28    let bytes = std::fs::read(path).map_err(|e| format!("no se pudo leer {path}: {e}"))?;
29    let geojson = String::from_utf8(bytes).map_err(|_| "el archivo no es texto UTF-8 (¿GeoJSON?)".to_string())?;
30    // Validación temprana: que parsee y traiga geometría.
31    let (md, _) = parse_into(&geojson, MAX_VERTICES).map_err(|e| format!("GeoJSON inválido: {e}"))?;
32    if md.polygons.is_empty() && md.lines.is_empty() {
33        return Err("el mapa no trae polígonos ni líneas".into());
34    }
35    // Relieve hermano: un GeoTIFF `.tif`/`.tiff` (georreferenciado, preferido) o un
36    // `.pgm`. Se embeben para que el documento sea autocontenido.
37    let dem_tif = hermano(path, &["tif", "tiff"]);
38    let pgm = if dem_tif.is_none() { hermano(path, &["pgm"]) } else { None };
39    Ok(MapaFuente { geojson, pgm, dem_tif, rango_dem_m })
40}
41
42/// Materializa una [`MapaFuente`] a `VoxelGrid` de dimensión `dim`. Devuelve además
43/// un resumen para la barra de estado. Puro (sin IO ni GPU): sirve tanto al importar
44/// como al **regenerar** un mundo-mapa recién abierto de un proyecto.
45pub fn generar(fuente: &MapaFuente, dim: [u32; 3]) -> Result<(VoxelGrid, String), String> {
46    let (md, _trunc) = parse_into(&fuente.geojson, MAX_VERTICES).map_err(|e| format!("GeoJSON inválido: {e}"))?;
47    let bbox = bbox_de(&md);
48    if bbox.is_empty() {
49        return Err("el mapa no tiene coordenadas".into());
50    }
51
52    // Encaje: metros_por_voxel para que el eje más largo llene el dim del preview.
53    let lat0 = (bbox.min_lat + bbox.max_lat) * 0.5;
54    let m_lon = M_POR_GRADO_LAT * lat0.to_radians().cos().abs().max(1e-6);
55    let span_x = (bbox.max_lon - bbox.min_lon) * m_lon;
56    let span_z = (bbox.max_lat - bbox.min_lat) * M_POR_GRADO_LAT;
57    let mpv = (span_x / (dim[0].max(2) - 1) as f64)
58        .max(span_z / (dim[2].max(2) - 1) as f64)
59        .max(0.1);
60
61    // Relieve: GeoTIFF (georreferenciado por sí mismo) tiene prioridad; si no, PGM
62    // estirado a la caja del GeoJSON; si no, plano al nivel del mar.
63    let (height, relieve) = if let Some(tif) = &fuente.dem_tif {
64        match Heightfield::desde_geotiff(tif) {
65            Ok(h) => (h, "GeoTIFF"),
66            Err(_) => (Heightfield::plano(bbox, 0.0), "plano"),
67        }
68    } else if let Some(pgm) = &fuente.pgm {
69        match Heightfield::desde_pgm(pgm, bbox, 0.0, fuente.rango_dem_m) {
70            Ok(h) => (h, "PGM"),
71            Err(_) => (Heightfield::plano(bbox, 0.0), "plano"),
72        }
73    } else {
74        (Heightfield::plano(bbox, 0.0), "plano")
75    };
76
77    // Exageración vertical: al encajar una ciudad entera en 128 voxels, `mpv` es
78    // grande (~5 m/voxel) y los edificios quedarían de 1-4 voxels. La subimos para
79    // que el skyline y el relieve lean bien en el preview.
80    let opts = Opciones {
81        metros_por_voxel: mpv,
82        escala_v: 3.0,
83        nivel_mar_m: 0.0,
84        ..Opciones::default()
85    };
86    let mapa = MapaVoxel::desde_mapdata(bbox, height, opts, &md);
87    let resumen = format!(
88        "mundo desde mapa: {} edificios · {} calles · {} agua · relieve={} · {:.1} m/voxel",
89        mapa.edificios.len(),
90        mapa.calles.len(),
91        mapa.agua.len(),
92        relieve,
93        mpv,
94    );
95    let grid = mapa.generar_ventana(dim, [0, 0]);
96    Ok((grid, resumen))
97}
98
99/// Caja envolvente de un `MapData` (usa `bbox_override` si viene, si no escanea).
100fn bbox_de(md: &MapData) -> BBox {
101    if let Some(b) = md.bbox_override {
102        return b;
103    }
104    let mut b = BBox::empty();
105    for &c in &md.points {
106        b.expand(c);
107    }
108    for line in &md.lines {
109        for &c in line {
110            b.expand(c);
111        }
112    }
113    for poly in &md.polygons {
114        for ring in poly {
115            for &c in ring {
116                b.expand(c);
117            }
118        }
119    }
120    b
121}
122
123/// Bytes de un archivo hermano (mismo stem, alguna de `exts`) si existe.
124fn hermano(path: &str, exts: &[&str]) -> Option<Vec<u8>> {
125    for ext in exts {
126        if let Ok(b) = std::fs::read(Path::new(path).with_extension(ext)) {
127            return Some(b);
128        }
129    }
130    None
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use nahual_geo_voxel::Paleta;
137
138    fn cuenta_color(g: &VoxelGrid, color: [u8; 3]) -> usize {
139        let [dx, dy, dz] = g.dim();
140        let mut n = 0;
141        for z in 0..dz {
142            for y in 0..dy {
143                for x in 0..dx {
144                    if let Some([r, gg, b, a]) = g.get(x, y, z) {
145                        if a > 127 && [r, gg, b] == color {
146                            n += 1;
147                        }
148                    }
149                }
150            }
151        }
152        n
153    }
154
155    #[test]
156    fn import_geojson_produce_ciudad() {
157        // GeoJSON mínimo: un edificio, un lago y una calle, dentro de ~600 m.
158        let gj = r#"{"type":"FeatureCollection","features":[
159            {"type":"Feature","properties":{"building":"yes","building:levels":8},
160             "geometry":{"type":"Polygon","coordinates":[[[0.001,0.001],[0.003,0.001],[0.003,0.003],[0.001,0.003],[0.001,0.001]]]}},
161            {"type":"Feature","properties":{"natural":"water"},
162             "geometry":{"type":"Polygon","coordinates":[[[0.004,0.004],[0.0055,0.004],[0.0055,0.0055],[0.004,0.0055],[0.004,0.004]]]}},
163            {"type":"Feature","properties":{"highway":"residential"},
164             "geometry":{"type":"LineString","coordinates":[[0.0005,0.0035],[0.0055,0.0035]]}}
165        ]}"#;
166        let dir = std::env::temp_dir();
167        let path = dir.join("studio_mapa_test.geojson");
168        std::fs::write(&path, gj).unwrap();
169
170        // leer_fuente → (persistible) → generar, el mismo camino que al abrir.
171        let fuente = leer_fuente(path.to_str().unwrap(), 300.0).unwrap();
172        std::fs::remove_file(&path).ok();
173        assert!(fuente.geojson.contains("building"), "la fuente embebe el GeoJSON");
174
175        let dim = [96u32, 40, 96];
176        let (grid, resumen) = generar(&fuente, dim).unwrap();
177
178        assert_eq!(grid.dim(), dim, "el grid debe calzar el dim del preview");
179        let p = Paleta::default();
180        let muro = cuenta_color(&grid, p.muro);
181        let agua = cuenta_color(&grid, p.agua);
182        let calle = cuenta_color(&grid, p.calle);
183        eprintln!("import: {resumen} — muro={muro} agua={agua} calle={calle}");
184        assert!(muro > 0, "el edificio debe extruirse a muros");
185        assert!(agua > 0, "el lago debe pintarse");
186        assert!(calle > 0, "la calle debe estamparse");
187    }
188}