//! **Mundo desde mapa real**: convierte un GeoJSON del visor de mapas //! (`nahual-geo-core`) —opcionalmente con un DEM `.pgm` para el relieve— en un //! [`VoxelGrid`] del **mismo `dim`** que el preview del studio, vía el puente //! [`nahual_geo_voxel`]. Así el mundo del mapa entra por la misma maquinaria de //! preview (cámara/órbita/posado) que el procedural, sin refactor. //! //! Dos pasos separados para poder **persistir**: [`leer_fuente`] captura el //! GeoJSON (+ PGM hermano) en una [`MapaFuente`] serializable que se guarda en el //! [`Mundo`](llimphi_voxel::Mundo); [`generar`] materializa el grid desde esa //! fuente (al importar y al **abrir** un proyecto). Encaje: se elige //! `metros_por_voxel` para que la caja del mapa **llene** el `dim` (el eje más //! largo manda) y se genera la ventana en `[0,0]`. use std::path::Path; use llimphi_3d::VoxelGrid; use llimphi_voxel::MapaFuente; use nahual_geo_core::{parse_into, BBox, MapData, MAX_VERTICES}; use nahual_geo_voxel::{Heightfield, MapaVoxel, Opciones}; /// Metros por grado de latitud (igual constante que usa la proyección del puente). const M_POR_GRADO_LAT: f64 = 111_320.0; /// Lee `path` (GeoJSON) y su `.pgm` hermano (si existe) a una [`MapaFuente`] /// **persistible** — se embebe el contenido para que el documento sea /// autocontenido (no dependa de que el archivo siga ahí). No genera el grid. pub fn leer_fuente(path: &str, rango_dem_m: f32) -> Result { let bytes = std::fs::read(path).map_err(|e| format!("no se pudo leer {path}: {e}"))?; let geojson = String::from_utf8(bytes).map_err(|_| "el archivo no es texto UTF-8 (¿GeoJSON?)".to_string())?; // Validación temprana: que parsee y traiga geometría. let (md, _) = parse_into(&geojson, MAX_VERTICES).map_err(|e| format!("GeoJSON inválido: {e}"))?; if md.polygons.is_empty() && md.lines.is_empty() { return Err("el mapa no trae polígonos ni líneas".into()); } // Relieve hermano: un GeoTIFF `.tif`/`.tiff` (georreferenciado, preferido) o un // `.pgm`. Se embeben para que el documento sea autocontenido. let dem_tif = hermano(path, &["tif", "tiff"]); let pgm = if dem_tif.is_none() { hermano(path, &["pgm"]) } else { None }; Ok(MapaFuente { geojson, pgm, dem_tif, rango_dem_m }) } /// Materializa una [`MapaFuente`] a `VoxelGrid` de dimensión `dim`. Devuelve además /// un resumen para la barra de estado. Puro (sin IO ni GPU): sirve tanto al importar /// como al **regenerar** un mundo-mapa recién abierto de un proyecto. pub fn generar(fuente: &MapaFuente, dim: [u32; 3]) -> Result<(VoxelGrid, String), String> { let (md, _trunc) = parse_into(&fuente.geojson, MAX_VERTICES).map_err(|e| format!("GeoJSON inválido: {e}"))?; let bbox = bbox_de(&md); if bbox.is_empty() { return Err("el mapa no tiene coordenadas".into()); } // Encaje: metros_por_voxel para que el eje más largo llene el dim del preview. let lat0 = (bbox.min_lat + bbox.max_lat) * 0.5; let m_lon = M_POR_GRADO_LAT * lat0.to_radians().cos().abs().max(1e-6); let span_x = (bbox.max_lon - bbox.min_lon) * m_lon; let span_z = (bbox.max_lat - bbox.min_lat) * M_POR_GRADO_LAT; let mpv = (span_x / (dim[0].max(2) - 1) as f64) .max(span_z / (dim[2].max(2) - 1) as f64) .max(0.1); // Relieve: GeoTIFF (georreferenciado por sí mismo) tiene prioridad; si no, PGM // estirado a la caja del GeoJSON; si no, plano al nivel del mar. let (height, relieve) = if let Some(tif) = &fuente.dem_tif { match Heightfield::desde_geotiff(tif) { Ok(h) => (h, "GeoTIFF"), Err(_) => (Heightfield::plano(bbox, 0.0), "plano"), } } else if let Some(pgm) = &fuente.pgm { match Heightfield::desde_pgm(pgm, bbox, 0.0, fuente.rango_dem_m) { Ok(h) => (h, "PGM"), Err(_) => (Heightfield::plano(bbox, 0.0), "plano"), } } else { (Heightfield::plano(bbox, 0.0), "plano") }; // Exageración vertical: al encajar una ciudad entera en 128 voxels, `mpv` es // grande (~5 m/voxel) y los edificios quedarían de 1-4 voxels. La subimos para // que el skyline y el relieve lean bien en el preview. let opts = Opciones { metros_por_voxel: mpv, escala_v: 3.0, nivel_mar_m: 0.0, ..Opciones::default() }; let mapa = MapaVoxel::desde_mapdata(bbox, height, opts, &md); let resumen = format!( "mundo desde mapa: {} edificios · {} calles · {} agua · relieve={} · {:.1} m/voxel", mapa.edificios.len(), mapa.calles.len(), mapa.agua.len(), relieve, mpv, ); let grid = mapa.generar_ventana(dim, [0, 0]); Ok((grid, resumen)) } /// Caja envolvente de un `MapData` (usa `bbox_override` si viene, si no escanea). fn bbox_de(md: &MapData) -> BBox { if let Some(b) = md.bbox_override { return b; } let mut b = BBox::empty(); for &c in &md.points { b.expand(c); } for line in &md.lines { for &c in line { b.expand(c); } } for poly in &md.polygons { for ring in poly { for &c in ring { b.expand(c); } } } b } /// Bytes de un archivo hermano (mismo stem, alguna de `exts`) si existe. fn hermano(path: &str, exts: &[&str]) -> Option> { for ext in exts { if let Ok(b) = std::fs::read(Path::new(path).with_extension(ext)) { return Some(b); } } None } #[cfg(test)] mod tests { use super::*; use nahual_geo_voxel::Paleta; fn cuenta_color(g: &VoxelGrid, color: [u8; 3]) -> usize { let [dx, dy, dz] = g.dim(); let mut n = 0; for z in 0..dz { for y in 0..dy { for x in 0..dx { if let Some([r, gg, b, a]) = g.get(x, y, z) { if a > 127 && [r, gg, b] == color { n += 1; } } } } } n } #[test] fn import_geojson_produce_ciudad() { // GeoJSON mínimo: un edificio, un lago y una calle, dentro de ~600 m. let gj = r#"{"type":"FeatureCollection","features":[ {"type":"Feature","properties":{"building":"yes","building:levels":8}, "geometry":{"type":"Polygon","coordinates":[[[0.001,0.001],[0.003,0.001],[0.003,0.003],[0.001,0.003],[0.001,0.001]]]}}, {"type":"Feature","properties":{"natural":"water"}, "geometry":{"type":"Polygon","coordinates":[[[0.004,0.004],[0.0055,0.004],[0.0055,0.0055],[0.004,0.0055],[0.004,0.004]]]}}, {"type":"Feature","properties":{"highway":"residential"}, "geometry":{"type":"LineString","coordinates":[[0.0005,0.0035],[0.0055,0.0035]]}} ]}"#; let dir = std::env::temp_dir(); let path = dir.join("studio_mapa_test.geojson"); std::fs::write(&path, gj).unwrap(); // leer_fuente → (persistible) → generar, el mismo camino que al abrir. let fuente = leer_fuente(path.to_str().unwrap(), 300.0).unwrap(); std::fs::remove_file(&path).ok(); assert!(fuente.geojson.contains("building"), "la fuente embebe el GeoJSON"); let dim = [96u32, 40, 96]; let (grid, resumen) = generar(&fuente, dim).unwrap(); assert_eq!(grid.dim(), dim, "el grid debe calzar el dim del preview"); let p = Paleta::default(); let muro = cuenta_color(&grid, p.muro); let agua = cuenta_color(&grid, p.agua); let calle = cuenta_color(&grid, p.calle); eprintln!("import: {resumen} — muro={muro} agua={agua} calle={calle}"); assert!(muro > 0, "el edificio debe extruirse a muros"); assert!(agua > 0, "el lago debe pintarse"); assert!(calle > 0, "la calle debe estamparse"); } }