//! `dominium-iso` — proyección pseudo-3D isométrica. //! //! GPUI no maneja matrices de proyección 3D ni mallas: la ilusión de //! relieve se calcula en CPU antes de emitir quads 2D. Matriz iso fija: //! //! ```text //! x_pantalla = (x - y) · cos(30°) //! y_pantalla = (x + y) · sin(30°) − Z //! ``` //! //! La altura `Z` no existe en el motor lógico — se extrae de los campos //! de la grilla como una combinación lineal config'able de las 5 capas //! ([`ZWeights`]). Los `cos`/`sin` van por `libm` para que la proyección //! sea bit-exacta en cualquier plataforma. #![forbid(unsafe_code)] use dominium_core::Grid; use serde::{Deserialize, Serialize}; /// Pesos del Z compuesto — uno por capa de la grilla. El panel expone /// estos 5 sliders; el relieve es `Σ wᵢ · capaᵢ`. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct ZWeights { pub materia: f32, pub psique: f32, pub poder: f32, pub oro: f32, pub degradacion: f32, } impl Default for ZWeights { /// Por defecto el relieve sigue la `materia`. fn default() -> Self { Self { materia: 1.0, psique: 0.0, poder: 0.0, oro: 0.0, degradacion: 0.0 } } } impl ZWeights { /// Z compuesto de la celda `idx`: combinación lineal de las 5 capas. pub fn z_of(&self, grid: &Grid, idx: usize) -> f32 { self.materia * grid.materia[idx] + self.psique * grid.psique[idx] + self.poder * grid.poder[idx] + self.oro * grid.oro[idx] + self.degradacion * grid.degradacion[idx] } } /// Proyector isométrico. `cos`/`sin` de 30° precomputados vía `libm`. #[derive(Debug, Clone, Copy)] pub struct IsoProjector { cos30: f32, sin30: f32, /// Escala de pantalla (pixels por unidad de mundo). pub scale: f32, /// Cuánto eleva el `Z` en pixels de pantalla. pub z_factor: f32, } impl IsoProjector { /// Crea un proyector. `scale` = pixels por celda; `z_factor` = cuánto /// levanta una unidad de Z. pub fn new(scale: f32, z_factor: f32) -> Self { // 30° en radianes. libm da el mismo bit en x86 y ARM. let rad = core::f32::consts::FRAC_PI_6; Self { cos30: libm::cosf(rad), sin30: libm::sinf(rad), scale, z_factor, } } /// Proyecta una coordenada de mundo `(x, y)` con altura `z` a /// coordenadas de pantalla. pub fn project(&self, x: f32, y: f32, z: f32) -> (f32, f32) { let sx = (x - y) * self.cos30 * self.scale; let sy = ((x + y) * self.sin30 - z * self.z_factor) * self.scale; (sx, sy) } /// Proyecta la sombra de un punto sobre el suelo (Lambert plano): la /// sombra cae en `z = 0` desplazada según la dirección de la luz, con /// largo proporcional a la altura del punto. pub fn shadow(&self, x: f32, y: f32, z: f32, light_dir: (f32, f32)) -> (f32, f32) { let foot_x = x + light_dir.0 * z; let foot_y = y + light_dir.1 * z; self.project(foot_x, foot_y, 0.0) } /// Inversa de [`Self::project`] asumiendo `z = 0` (clicks sobre el /// suelo). Dadas coordenadas de pantalla `(sx, sy)`, devuelve el /// `(x, y)` de mundo que las generó si se hubiera proyectado con /// `z = 0`. Para clicks sobre celdas elevadas el resultado se /// desplaza (las cimas proyectan a una `y_pantalla` distinta a la /// de su pie); para una sembrazón de Conceptos es suficiente. /// /// ```text /// sx = (x - y) · cos30 · scale /// sy = (x + y) · sin30 · scale /// ⇒ x = (sx / (cos30·scale) + sy / (sin30·scale)) / 2 /// y = (sy / (sin30·scale) − sx / (cos30·scale)) / 2 /// ``` pub fn unproject_floor(&self, sx: f32, sy: f32) -> (f32, f32) { let s = self.scale.max(f32::EPSILON); let u = sx / (self.cos30 * s); // = x - y let v = sy / (self.sin30 * s); // = x + y ((u + v) * 0.5, (v - u) * 0.5) } } #[cfg(test)] mod tests { use super::*; fn approx(a: f32, b: f32) -> bool { (a - b).abs() < 1e-4 } #[test] fn origin_projects_to_origin() { let iso = IsoProjector::new(1.0, 1.0); let (x, y) = iso.project(0.0, 0.0, 0.0); assert!(approx(x, 0.0) && approx(y, 0.0)); } #[test] fn diamond_axis_collapses_x() { // En iso, (a, a) cae sobre x_pantalla = 0 (la diagonal del rombo). let iso = IsoProjector::new(1.0, 1.0); let (sx, _) = iso.project(5.0, 5.0, 0.0); assert!(approx(sx, 0.0)); } #[test] fn z_raises_the_point_upward() { let iso = IsoProjector::new(1.0, 10.0); let (_, y0) = iso.project(3.0, 3.0, 0.0); let (_, y1) = iso.project(3.0, 3.0, 2.0); // Más Z → menor y de pantalla (sube). assert!(y1 < y0); } #[test] fn composite_z_is_a_linear_combination() { let mut g = Grid::new(4, 4); let idx = g.idx(1, 1); g.materia[idx] = 10.0; g.poder[idx] = 4.0; let w = ZWeights { materia: 0.5, psique: 0.0, poder: 2.0, oro: 0.0, degradacion: 0.0 }; // 0.5*10 + 2*4 = 13 assert!(approx(w.z_of(&g, idx), 13.0)); } #[test] fn projector_is_deterministic() { let a = IsoProjector::new(2.0, 3.0); let b = IsoProjector::new(2.0, 3.0); assert_eq!(a.project(7.0, 11.0, 1.5), b.project(7.0, 11.0, 1.5)); } #[test] fn unproject_floor_is_inverse_of_project_at_z_zero() { let iso = IsoProjector::new(12.0, 5.0); for (x, y) in [(3.0, 7.0), (15.0, 2.0), (8.0, 8.0), (0.0, 0.0)] { let (sx, sy) = iso.project(x, y, 0.0); let (rx, ry) = iso.unproject_floor(sx, sy); assert!(approx(rx, x) && approx(ry, y), "({x},{y}) → ({rx},{ry})"); } } #[test] fn shadow_of_ground_point_equals_its_projection() { let iso = IsoProjector::new(1.0, 5.0); // z = 0 → la sombra coincide con el punto. let p = iso.project(4.0, 2.0, 0.0); let s = iso.shadow(4.0, 2.0, 0.0, (1.0, 0.5)); assert!(approx(p.0, s.0) && approx(p.1, s.1)); } }