gioser

tawasuyu/llimphi

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

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-voxel/src/vox.rs Rust · 180 siq'ikuna · 7.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

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//! Importación de modelos **MagicaVoxel `.vox`** al motor: convierte un
2//! [`foreign_vox::VoxModel`] (neutral, leído por el puente) en un
3//! [`VoxelGrid`](llimphi_3d::VoxelGrid) del motor, para meter **sets y
4//! personajes diseñados afuera** a una escena/película voxel.
5//!
6//! Ejes: MagicaVoxel usa `z` como **arriba**; el motor usa `y` arriba. La
7//! conversión mapea `vox (x, y, z) → grid (x, z, y)` (la `z` del `.vox` sube a la
8//! `y` del grid, la `y` del `.vox` pasa a la profundidad `z`).
9//!
10//! La capa de juego es la dueña de esto (no el motor ni el puente): el puente
11//! sólo entiende bytes, el motor sólo voxels; aquí se casan (CLAUDE.md regla #4).
12
13use std::fmt;
14use std::path::Path;
15
16use foreign_vox::{VoxError, VoxModel};
17use llimphi_3d::VoxelGrid;
18
19/// Error al cargar un `.vox` desde disco.
20#[derive(Debug)]
21pub enum VoxLoadError {
22    /// Falló la lectura del archivo.
23    Io(std::io::Error),
24    /// El contenido no es un `.vox` válido.
25    Parse(VoxError),
26}
27
28impl fmt::Display for VoxLoadError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            VoxLoadError::Io(e) => write!(f, "leyendo .vox: {e}"),
32            VoxLoadError::Parse(e) => write!(f, "parseando .vox: {e}"),
33        }
34    }
35}
36
37impl std::error::Error for VoxLoadError {}
38
39/// Convierte un `VoxModel` en un `VoxelGrid` ajustado a su tamaño (con los ejes
40/// remapeados a la convención del motor). El estado dirty queda limpio (el grid
41/// recién hecho se sube entero).
42pub fn model_to_grid(m: &VoxModel) -> VoxelGrid {
43    // dim del grid = [x, z(vox→y), y(vox→z)], mínimo 1 por eje.
44    let dim = [m.size[0].max(1), m.size[2].max(1), m.size[1].max(1)];
45    let mut g = VoxelGrid::new(dim);
46    stamp(&mut g, m, [0, 0, 0]);
47    g.reset_dirty();
48    g
49}
50
51/// **Estampa** los voxels de un modelo dentro de un grid existente, con la
52/// esquina del modelo en `origin` (espacio de grilla del motor). Para componer
53/// *sets*: meter varias piezas `.vox` en un mismo mundo. Voxels transparentes
54/// (`alpha 0`) se omiten; los que caen fuera del grid, también (`set` los ignora).
55pub fn stamp(grid: &mut VoxelGrid, m: &VoxModel, origin: [u32; 3]) {
56    for v in &m.voxels {
57        let c = m.color(v);
58        if c[3] == 0 {
59            continue;
60        }
61        let gx = origin[0] + v.x as u32;
62        let gy = origin[1] + v.z as u32; // z-arriba (vox) → y-arriba (grid)
63        let gz = origin[2] + v.y as u32;
64        grid.set(gx, gy, gz, [c[0], c[1], c[2]]);
65    }
66}
67
68/// Carga el **primer** modelo de un archivo `.vox` como `VoxelGrid`.
69pub fn load_grid(path: impl AsRef<Path>) -> Result<VoxelGrid, VoxLoadError> {
70    let bytes = std::fs::read(path).map_err(VoxLoadError::Io)?;
71    let models = foreign_vox::parse(&bytes).map_err(VoxLoadError::Parse)?;
72    // `parse` ya garantiza ≥1 modelo (si no, devuelve NoModel).
73    Ok(model_to_grid(&models[0]))
74}
75
76/// Compone una **escena multi-modelo** (`nTRN/nGRP/nSHP`) en un único `VoxelGrid`,
77/// colocando cada modelo en su traslación de mundo resuelta por el grafo. En
78/// MagicaVoxel la traslación `_t` ubica el **centro** del modelo, así que la esquina
79/// va en `t − size/2`. El grid se dimensiona a la caja envolvente de todas las piezas
80/// y se desplaza para que el mínimo caiga en el origen. Si la escena no trae grafo
81/// (`.vox` viejo de un solo modelo), cae a [`model_to_grid`] del primer modelo.
82pub fn scene_to_grid(scene: &foreign_vox::Scene) -> VoxelGrid {
83    if scene.placements.is_empty() || scene.models.is_empty() {
84        return model_to_grid(&scene.models[0]);
85    }
86    // Esquina (espacio grid: [x, z, y]) y tamaño grid de cada colocación.
87    let mut corners: Vec<([i32; 3], [i32; 3])> = Vec::with_capacity(scene.placements.len());
88    for p in &scene.placements {
89        let m = &scene.models[p.model];
90        let sz_grid = [m.size[0] as i32, m.size[2] as i32, m.size[1] as i32];
91        let t = p.translation; // vox (z-arriba)
92        // Centro → esquina (vox), luego remapeo de ejes a grid.
93        let corner_vox = [t[0] - m.size[0] as i32 / 2, t[1] - m.size[1] as i32 / 2, t[2] - m.size[2] as i32 / 2];
94        let corner_grid = [corner_vox[0], corner_vox[2], corner_vox[1]];
95        corners.push((corner_grid, sz_grid));
96    }
97    // Caja envolvente.
98    let mut lo = [i32::MAX; 3];
99    let mut hi = [i32::MIN; 3];
100    for (c, s) in &corners {
101        for a in 0..3 {
102            lo[a] = lo[a].min(c[a]);
103            hi[a] = hi[a].max(c[a] + s[a]);
104        }
105    }
106    let dim = [
107        (hi[0] - lo[0]).max(1) as u32,
108        (hi[1] - lo[1]).max(1) as u32,
109        (hi[2] - lo[2]).max(1) as u32,
110    ];
111    let mut g = VoxelGrid::new(dim);
112    for (p, (c, _)) in scene.placements.iter().zip(&corners) {
113        let origin = [(c[0] - lo[0]) as u32, (c[1] - lo[1]) as u32, (c[2] - lo[2]) as u32];
114        stamp(&mut g, &scene.models[p.model], origin);
115    }
116    g.reset_dirty();
117    g
118}
119
120/// Carga una **escena** `.vox` completa (multi-modelo con grafo) como un único
121/// `VoxelGrid` compuesto. Para un `.vox` de un solo modelo equivale a [`load_grid`].
122pub fn load_scene_grid(path: impl AsRef<Path>) -> Result<VoxelGrid, VoxLoadError> {
123    let bytes = std::fs::read(path).map_err(VoxLoadError::Io)?;
124    let scene = foreign_vox::parse_scene(&bytes).map_err(VoxLoadError::Parse)?;
125    Ok(scene_to_grid(&scene))
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use foreign_vox::Voxel;
132
133    #[test]
134    fn remapea_ejes_z_arriba_a_y_arriba() {
135        let mut m = VoxModel::new([2, 3, 4]); // x=2, y=3, z=4 (z arriba)
136        m.palette[1] = [10, 20, 30, 255];
137        // Voxel en vox (1, 2, 3) → grid (1, 3, 2).
138        m.voxels = vec![Voxel { x: 1, y: 2, z: 3, i: 1 }];
139        let g = model_to_grid(&m);
140        assert_eq!(g.dim(), [2, 4, 3], "dim = [x, z, y]");
141        assert!(g.is_solid(1, 3, 2), "vox(x,y,z) → grid(x,z,y)");
142        assert_eq!(g.get(1, 3, 2), Some([10, 20, 30, 255]));
143    }
144
145    #[test]
146    fn omite_transparentes() {
147        let mut m = VoxModel::new([2, 2, 2]);
148        m.palette[1] = [0, 0, 0, 0]; // transparente
149        m.voxels = vec![Voxel { x: 0, y: 0, z: 0, i: 1 }];
150        let g = model_to_grid(&m);
151        assert!(!g.is_solid(0, 0, 0));
152    }
153
154    #[test]
155    fn escena_compone_dos_modelos_con_offset() {
156        use foreign_vox::{Placement, Scene};
157        // Dos cubitos 2³ con un voxel en su esquina; el 2º trasladado +10 en x (vox).
158        let cubo = || {
159            let mut m = VoxModel::new([2, 2, 2]);
160            m.palette[1] = [50, 60, 70, 255];
161            m.voxels = vec![Voxel { x: 0, y: 0, z: 0, i: 1 }];
162            m
163        };
164        let scene = Scene {
165            models: vec![cubo(), cubo()],
166            placements: vec![
167                Placement { model: 0, translation: [0, 0, 0] },
168                Placement { model: 1, translation: [10, 0, 0] },
169            ],
170        };
171        let g = scene_to_grid(&scene);
172        // Ambos modelos presentes y separados por ~10 en x (centro→esquina = −1 cada uno,
173        // misma resta, así que la separación entre voxels se conserva = 10).
174        let (dx, dy, dz) = (g.dim()[0] as i32, g.dim()[1] as i32, g.dim()[2] as i32);
175        let solid: Vec<i32> =
176            (0..dx).filter(|&x| (0..dy).any(|y| (0..dz).any(|z| g.is_solid(x, y, z)))).collect();
177        assert_eq!(solid.len(), 2, "dos columnas sólidas separadas");
178        assert_eq!(solid[1] - solid[0], 10, "separación de mundo preservada");
179    }
180}