tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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::{Rot, VoxError, VoxModel, Voxel};
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/// Por qué no se pudo exportar un grid al formato ajeno.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum VoxExportError {
71 /// El formato guarda las coordenadas en un byte: ningún eje puede pasar de 256
72 /// voxels. Un mundo grande se exporta por pedazos.
73 DemasiadoGrande([u32; 3]),
74}
75
76impl fmt::Display for VoxExportError {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 VoxExportError::DemasiadoGrande(d) => {
80 write!(f, "el modelo {d:?} pasa los 256 voxels por eje del formato .vox")
81 }
82 }
83 }
84}
85
86impl std::error::Error for VoxExportError {}
87
88/// Cuántos colores distintos admite la paleta del formato (el índice `0` es vacío).
89const PALETA: usize = 255;
90
91/// **Exporta un grid del motor a un modelo `.vox`** — el camino de vuelta de
92/// [`model_to_grid`]: lo que se compuso acá (un ser, un mueble, una casa, un
93/// interior) sale a que lo edite quien quiera en el editor ajeno.
94///
95/// Los ejes se remapean al revés (`grid (x,y,z) → vox (x,z,y)`) y los colores se
96/// **cuantizan** a la paleta de 255: si el grid trae 255 colores o menos, el viaje es
97/// exacto; si trae más, los 255 **más usados** forman la paleta y el resto va al más
98/// parecido (distancia RGB al cuadrado). Cuantizar por frecuencia y no por orden de
99/// aparición importa: un mundo con un cielo de mil azules y una casa roja conserva la
100/// casa.
101pub fn grid_to_model(grid: &VoxelGrid) -> Result<VoxModel, VoxExportError> {
102 let dim = grid.dim();
103 if dim.iter().any(|&d| d > 256) {
104 return Err(VoxExportError::DemasiadoGrande(dim));
105 }
106 // Censo de colores (cuántas celdas tiene cada uno).
107 let mut censo: std::collections::HashMap<[u8; 3], u32> = std::collections::HashMap::new();
108 for x in 0..dim[0] {
109 for y in 0..dim[1] {
110 for z in 0..dim[2] {
111 if let Some(c) = grid.get(x, y, z) {
112 if c[3] != 0 {
113 *censo.entry([c[0], c[1], c[2]]).or_insert(0) += 1;
114 }
115 }
116 }
117 }
118 }
119 // Los más usados primero; con empate, orden de color (determinista).
120 let mut orden: Vec<([u8; 3], u32)> = censo.into_iter().collect();
121 orden.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
122 let paleta: Vec<[u8; 3]> = orden.iter().take(PALETA).map(|(c, _)| *c).collect();
123
124 let mut m = VoxModel::new([dim[0], dim[2], dim[1]]); // vox = [x, y(prof), z(arriba)]
125 for (k, c) in paleta.iter().enumerate() {
126 m.palette[k + 1] = [c[0], c[1], c[2], 255];
127 }
128 let indice_de = |c: [u8; 3]| -> u8 {
129 // Exacto si está en la paleta; si no, el más parecido.
130 let mut mejor = 0usize;
131 let mut mejor_d = u32::MAX;
132 for (k, p) in paleta.iter().enumerate() {
133 let d = (0..3)
134 .map(|a| {
135 let e = p[a] as i32 - c[a] as i32;
136 (e * e) as u32
137 })
138 .sum::<u32>();
139 if d == 0 {
140 return k as u8 + 1;
141 }
142 if d < mejor_d {
143 mejor_d = d;
144 mejor = k;
145 }
146 }
147 mejor as u8 + 1
148 };
149 for x in 0..dim[0] {
150 for y in 0..dim[1] {
151 for z in 0..dim[2] {
152 let Some(c) = grid.get(x, y, z) else { continue };
153 if c[3] == 0 {
154 continue;
155 }
156 m.voxels.push(Voxel {
157 x: x as u8,
158 y: z as u8, // profundidad del motor → y del .vox
159 z: y as u8, // altura del motor → z (arriba) del .vox
160 i: indice_de([c[0], c[1], c[2]]),
161 });
162 }
163 }
164 }
165 Ok(m)
166}
167
168/// Escribe un grid del motor como archivo `.vox` (ver [`grid_to_model`]).
169pub fn save_grid(path: impl AsRef<Path>, grid: &VoxelGrid) -> Result<(), VoxLoadError> {
170 let m = grid_to_model(grid).map_err(|e| VoxLoadError::Io(std::io::Error::other(e.to_string())))?;
171 std::fs::write(path, foreign_vox::write(&m)).map_err(VoxLoadError::Io)
172}
173
174/// Caja envolvente de un modelo **ya rotado**, en ejes del motor y **relativa al
175/// centro del modelo** (que es el punto que la traslación del grafo ubica).
176/// Devuelve `(mínimo, máximo)` inclusive. Como la rotación de MagicaVoxel es una
177/// permutación con signo, alcanza con mirar las ocho esquinas.
178fn rotated_span(m: &VoxModel, rot: Rot) -> ([i32; 3], [i32; 3]) {
179 let sz = [m.size[0] as i32, m.size[1] as i32, m.size[2] as i32];
180 let c = [sz[0] / 2, sz[1] / 2, sz[2] / 2];
181 let mut lo = [i32::MAX; 3];
182 let mut hi = [i32::MIN; 3];
183 for bx in 0..2 {
184 for by in 0..2 {
185 for bz in 0..2 {
186 let v = [
187 if bx == 0 { 0 } else { (sz[0] - 1).max(0) },
188 if by == 0 { 0 } else { (sz[1] - 1).max(0) },
189 if bz == 0 { 0 } else { (sz[2] - 1).max(0) },
190 ];
191 let w = rot.apply([v[0] - c[0], v[1] - c[1], v[2] - c[2]]);
192 let wg = [w[0], w[2], w[1]]; // vox (z-arriba) → grid (y-arriba)
193 for a in 0..3 {
194 lo[a] = lo[a].min(wg[a]);
195 hi[a] = hi[a].max(wg[a]);
196 }
197 }
198 }
199 }
200 (lo, hi)
201}
202
203/// Estampa un modelo **girado** por la rotación del grafo de escena, con la esquina
204/// mínima de su caja ya rotada en `origin`. Es el mismo trabajo que [`stamp`] más el
205/// giro alrededor del centro del modelo — que es donde MagicaVoxel lo ancla.
206pub fn stamp_rotated(grid: &mut VoxelGrid, m: &VoxModel, rot: Rot, origin: [u32; 3]) {
207 if rot == Rot::IDENTITY {
208 stamp(grid, m, origin);
209 return;
210 }
211 let (lo, _) = rotated_span(m, rot);
212 let c = [m.size[0] as i32 / 2, m.size[1] as i32 / 2, m.size[2] as i32 / 2];
213 for v in &m.voxels {
214 let col = m.color(v);
215 if col[3] == 0 {
216 continue;
217 }
218 let w = rot.apply([v.x as i32 - c[0], v.y as i32 - c[1], v.z as i32 - c[2]]);
219 let wg = [w[0], w[2], w[1]];
220 let cell = [
221 origin[0] as i32 + wg[0] - lo[0],
222 origin[1] as i32 + wg[1] - lo[1],
223 origin[2] as i32 + wg[2] - lo[2],
224 ];
225 if cell.iter().any(|&k| k < 0) {
226 continue;
227 }
228 grid.set(cell[0] as u32, cell[1] as u32, cell[2] as u32, [col[0], col[1], col[2]]);
229 }
230}
231
232/// Carga el **primer** modelo de un archivo `.vox` como `VoxelGrid`.
233pub fn load_grid(path: impl AsRef<Path>) -> Result<VoxelGrid, VoxLoadError> {
234 let bytes = std::fs::read(path).map_err(VoxLoadError::Io)?;
235 let models = foreign_vox::parse(&bytes).map_err(VoxLoadError::Parse)?;
236 // `parse` ya garantiza ≥1 modelo (si no, devuelve NoModel).
237 Ok(model_to_grid(&models[0]))
238}
239
240/// Compone una **escena multi-modelo** (`nTRN/nGRP/nSHP`) en un único `VoxelGrid`,
241/// colocando cada modelo en su traslación de mundo resuelta por el grafo. En
242/// MagicaVoxel la traslación `_t` ubica el **centro** del modelo, así que la esquina
243/// va en `t − size/2`. El grid se dimensiona a la caja envolvente de todas las piezas
244/// y se desplaza para que el mínimo caiga en el origen. Si la escena no trae grafo
245/// (`.vox` viejo de un solo modelo), cae a [`model_to_grid`] del primer modelo.
246pub fn scene_to_grid(scene: &foreign_vox::Scene) -> VoxelGrid {
247 if scene.placements.is_empty() || scene.models.is_empty() {
248 return model_to_grid(&scene.models[0]);
249 }
250 // Esquina (espacio grid: [x, z, y]) y tamaño grid de cada colocación, con la
251 // pieza YA girada por el grafo (la rotación gira alrededor del centro, así que
252 // cambia tanto el tamaño de la caja como dónde cae su esquina).
253 let mut corners: Vec<([i32; 3], [i32; 3])> = Vec::with_capacity(scene.placements.len());
254 for p in &scene.placements {
255 let m = &scene.models[p.model];
256 let (lo, hi) = rotated_span(m, p.rotation);
257 let t = p.translation; // vox (z-arriba)
258 let t_grid = [t[0], t[2], t[1]];
259 let corner_grid = [t_grid[0] + lo[0], t_grid[1] + lo[1], t_grid[2] + lo[2]];
260 let sz_grid = [hi[0] - lo[0] + 1, hi[1] - lo[1] + 1, hi[2] - lo[2] + 1];
261 corners.push((corner_grid, sz_grid));
262 }
263 // Caja envolvente.
264 let mut lo = [i32::MAX; 3];
265 let mut hi = [i32::MIN; 3];
266 for (c, s) in &corners {
267 for a in 0..3 {
268 lo[a] = lo[a].min(c[a]);
269 hi[a] = hi[a].max(c[a] + s[a]);
270 }
271 }
272 let dim = [
273 (hi[0] - lo[0]).max(1) as u32,
274 (hi[1] - lo[1]).max(1) as u32,
275 (hi[2] - lo[2]).max(1) as u32,
276 ];
277 let mut g = VoxelGrid::new(dim);
278 for (p, (c, _)) in scene.placements.iter().zip(&corners) {
279 let origin = [(c[0] - lo[0]) as u32, (c[1] - lo[1]) as u32, (c[2] - lo[2]) as u32];
280 stamp_rotated(&mut g, &scene.models[p.model], p.rotation, origin);
281 }
282 g.reset_dirty();
283 g
284}
285
286/// Carga una **escena** `.vox` completa (multi-modelo con grafo) como un único
287/// `VoxelGrid` compuesto. Para un `.vox` de un solo modelo equivale a [`load_grid`].
288pub fn load_scene_grid(path: impl AsRef<Path>) -> Result<VoxelGrid, VoxLoadError> {
289 let bytes = std::fs::read(path).map_err(VoxLoadError::Io)?;
290 let scene = foreign_vox::parse_scene(&bytes).map_err(VoxLoadError::Parse)?;
291 Ok(scene_to_grid(&scene))
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use foreign_vox::Voxel;
298
299 #[test]
300 fn remapea_ejes_z_arriba_a_y_arriba() {
301 let mut m = VoxModel::new([2, 3, 4]); // x=2, y=3, z=4 (z arriba)
302 m.palette[1] = [10, 20, 30, 255];
303 // Voxel en vox (1, 2, 3) → grid (1, 3, 2).
304 m.voxels = vec![Voxel { x: 1, y: 2, z: 3, i: 1 }];
305 let g = model_to_grid(&m);
306 assert_eq!(g.dim(), [2, 4, 3], "dim = [x, z, y]");
307 assert!(g.is_solid(1, 3, 2), "vox(x,y,z) → grid(x,z,y)");
308 assert_eq!(g.get(1, 3, 2), Some([10, 20, 30, 255]));
309 }
310
311 #[test]
312 fn omite_transparentes() {
313 let mut m = VoxModel::new([2, 2, 2]);
314 m.palette[1] = [0, 0, 0, 0]; // transparente
315 m.voxels = vec![Voxel { x: 0, y: 0, z: 0, i: 1 }];
316 let g = model_to_grid(&m);
317 assert!(!g.is_solid(0, 0, 0));
318 }
319
320 #[test]
321 fn el_grid_sale_al_formato_ajeno_y_vuelve_igual() {
322 // Ida y vuelta completa: grid → modelo → grid. Con pocos colores tiene que ser
323 // exacta, celda por celda y color por color.
324 let mut g = VoxelGrid::new([5, 4, 3]);
325 g.set(0, 0, 0, [200, 30, 40]);
326 g.set(4, 3, 2, [10, 220, 60]);
327 g.set(2, 1, 1, [200, 30, 40]);
328 let m = grid_to_model(&g).expect("exporta");
329 assert_eq!(m.size, [5, 3, 4], "vox = [x, profundidad, altura]");
330 assert_eq!(m.voxels.len(), 3);
331 let vuelta = model_to_grid(&m);
332 assert_eq!(vuelta.dim(), g.dim());
333 for x in 0..5 {
334 for y in 0..4 {
335 for z in 0..3 {
336 assert_eq!(
337 vuelta.get(x, y, z).map(|c| [c[0], c[1], c[2], c[3]]),
338 g.get(x, y, z).map(|c| [c[0], c[1], c[2], c[3]]),
339 "celda ({x},{y},{z})"
340 );
341 }
342 }
343 }
344 }
345
346 #[test]
347 fn con_mas_colores_que_la_paleta_se_conserva_el_que_manda() {
348 // 300 colores: 255 entran exactos y el resto va al más parecido. El color más
349 // USADO (no el primero) tiene que sobrevivir intacto.
350 let mut g = VoxelGrid::new([20, 20, 1]);
351 let mut k = 0u32;
352 for x in 0..20u32 {
353 for y in 0..20u32 {
354 let c = if x == 0 && y < 10 {
355 [255, 0, 0] // el mandón: 10 celdas del mismo rojo
356 } else {
357 k += 1;
358 [(k % 250) as u8, (k / 7 % 250) as u8, (k / 13 % 250) as u8]
359 };
360 g.set(x, y, 0, c);
361 }
362 }
363 let m = grid_to_model(&g).expect("exporta");
364 assert_eq!(m.voxels.len(), 400, "no se pierde ninguna celda");
365 let vuelta = model_to_grid(&m);
366 for y in 0..10 {
367 assert_eq!(
368 vuelta.get(0, y, 0).map(|c| [c[0], c[1], c[2]]),
369 Some([255, 0, 0]),
370 "el color más usado se conserva exacto"
371 );
372 }
373 }
374
375 #[test]
376 fn un_mundo_grande_no_entra_en_el_formato() {
377 let g = VoxelGrid::new([300, 8, 8]);
378 assert_eq!(
379 grid_to_model(&g).err(),
380 Some(VoxExportError::DemasiadoGrande([300, 8, 8])),
381 "el formato guarda la coordenada en un byte"
382 );
383 }
384
385 #[test]
386 fn la_escena_gira_la_pieza_que_el_grafo_giro() {
387 use foreign_vox::{Placement, Scene};
388 // Barra de 3 voxels a lo largo de la x del .vox.
389 let barra = || {
390 let mut m = VoxModel::new([3, 1, 1]);
391 m.palette[1] = [9, 90, 9, 255];
392 m.voxels = (0..3).map(|x| Voxel { x, y: 0, z: 0, i: 1 }).collect();
393 m
394 };
395 let cuenta = |g: &VoxelGrid| {
396 let d = g.dim();
397 (0..d[0])
398 .flat_map(|x| (0..d[1]).flat_map(move |y| (0..d[2]).map(move |z| (x, y, z))))
399 .filter(|&(x, y, z)| g.is_solid(x as i32, y as i32, z as i32))
400 .count()
401 };
402 let escena = |rot| Scene {
403 models: vec![barra()],
404 placements: vec![Placement { model: 0, translation: [0, 0, 0], rotation: rot }],
405 };
406
407 let derecha = scene_to_grid(&escena(Rot::IDENTITY));
408 assert_eq!(derecha.dim(), [3, 1, 1], "sin girar, la barra corre en x");
409 assert_eq!(cuenta(&derecha), 3);
410
411 // Cuarto de vuelta alrededor de la z del .vox: la barra pasa a correr en la
412 // y del .vox, que es la profundidad (z) del motor.
413 let girada = scene_to_grid(&escena(Rot::from_byte(17)));
414 assert_eq!(girada.dim(), [1, 1, 3], "girada, la barra corre en profundidad");
415 assert_eq!(cuenta(&girada), 3, "no se pierde ni se duplica un voxel al girar");
416 assert_eq!(girada.get(0, 0, 0).map(|c| [c[0], c[1], c[2]]), Some([9, 90, 9]));
417 }
418
419 #[test]
420 fn escena_compone_dos_modelos_con_offset() {
421 use foreign_vox::{Placement, Scene};
422 // Dos cubitos 2³ con un voxel en su esquina; el 2º trasladado +10 en x (vox).
423 let cubo = || {
424 let mut m = VoxModel::new([2, 2, 2]);
425 m.palette[1] = [50, 60, 70, 255];
426 m.voxels = vec![Voxel { x: 0, y: 0, z: 0, i: 1 }];
427 m
428 };
429 let scene = Scene {
430 models: vec![cubo(), cubo()],
431 placements: vec![
432 Placement { model: 0, translation: [0, 0, 0], rotation: Rot::IDENTITY },
433 Placement { model: 1, translation: [10, 0, 0], rotation: Rot::IDENTITY },
434 ],
435 };
436 let g = scene_to_grid(&scene);
437 // Ambos modelos presentes y separados por ~10 en x (centro→esquina = −1 cada uno,
438 // misma resta, así que la separación entre voxels se conserva = 10).
439 let (dx, dy, dz) = (g.dim()[0] as i32, g.dim()[1] as i32, g.dim()[2] as i32);
440 let solid: Vec<i32> =
441 (0..dx).filter(|&x| (0..dy).any(|y| (0..dz).any(|z| g.is_solid(x, y, z)))).collect();
442 assert_eq!(solid.len(), 2, "dos columnas sólidas separadas");
443 assert_eq!(solid[1] - solid[0], 10, "separación de mundo preservada");
444 }
445}