tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}