tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main shared/foreign-vox/src/lib.rs Rust · 990 siq'ikuna · 40.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1//! # foreign-vox — puente al formato **MagicaVoxel `.vox`**
2//!
3//! Lee y escribe modelos voxel en el formato de MagicaVoxel (estructura de
4//! *chunks* tipo-RIFF: `MAIN` → `SIZE`/`XYZI`/`RGBA`) y los expone como un
5//! [`VoxModel`] **neutral**: dimensiones + lista de voxels `(x,y,z,índice)` +
6//! una paleta de 256 colores RGBA indexada por el índice del voxel.
7//!
8//! Sin dependencias (sólo `std`): el formato son bytes planos *little-endian*.
9//! **No** conoce el motor — el conversor a `VoxelGrid` vive en `llimphi-voxel`
10//! (CLAUDE.md regla #4: lo ajeno entra por el puente, el núcleo trabaja nativo).
11//!
12//! Convención de color: el índice `i` de un voxel (1..255; `0` = vacío) indexa
13//! [`VoxModel::palette`]`[i]`. Al leer el chunk `RGBA` (256 entradas crudas) se
14//! aplica el corrimiento documentado por MagicaVoxel (`palette[i] = crudo[i-1]`).
15//! Si el archivo no trae `RGBA`, se usa una paleta por defecto (rampa HSV) — los
16//! exportes reales de MagicaVoxel siempre incluyen `RGBA`, así que es un fallback.
17
18use std::fmt;
19
20/// Un voxel: posición en la grilla del modelo (`0..size`) + índice de color
21/// (`1..255`) en la [`VoxModel::palette`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct Voxel {
24    pub x: u8,
25    pub y: u8,
26    pub z: u8,
27    pub i: u8,
28}
29
30/// Un modelo voxel: dimensiones, voxels y paleta (indexada por `voxel.i`).
31#[derive(Debug, Clone)]
32pub struct VoxModel {
33    /// Dimensiones `[x, y, z]` (en el espacio del `.vox`, donde `z` es arriba).
34    pub size: [u32; 3],
35    /// Voxels ocupados.
36    pub voxels: Vec<Voxel>,
37    /// 256 colores RGBA; `palette[voxel.i]` es el color del voxel (`[0]` vacío).
38    pub palette: [[u8; 4]; 256],
39}
40
41impl VoxModel {
42    /// Modelo vacío de las dimensiones dadas con la paleta por defecto.
43    pub fn new(size: [u32; 3]) -> Self {
44        Self { size, voxels: Vec::new(), palette: default_palette() }
45    }
46
47    /// Color RGBA de un voxel (vía su índice en la paleta).
48    pub fn color(&self, v: &Voxel) -> [u8; 4] {
49        self.palette[v.i as usize]
50    }
51}
52
53/// Error de parseo de un `.vox`.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum VoxError {
56    /// Faltan los 4 bytes mágicos `"VOX "` al inicio.
57    BadMagic,
58    /// El buffer se corta antes de lo que un chunk declara.
59    Truncated,
60    /// No hay ningún par `SIZE`+`XYZI` (ningún modelo).
61    NoModel,
62}
63
64impl fmt::Display for VoxError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            VoxError::BadMagic => write!(f, "no es un .vox (faltan los bytes 'VOX ')"),
68            VoxError::Truncated => write!(f, ".vox truncado (un chunk declara más bytes de los que hay)"),
69            VoxError::NoModel => write!(f, ".vox sin modelos (ningún par SIZE+XYZI)"),
70        }
71    }
72}
73
74impl std::error::Error for VoxError {}
75
76/// Una **rotación** del grafo de escena: la matriz 3×3 de MagicaVoxel, que siempre
77/// es una *permutación con signo* de los ejes (los 24 giros de un cubo, más las
78/// reflexiones que el formato admite). Se lee del byte `"_r"` de un `nTRN`.
79///
80/// Fila `a` = qué eje de origen y con qué signo aporta al eje de destino `a`, así
81/// que `apply` es un producto matriz·vector con enteros exactos (no hay redondeo:
82/// una rotación de MagicaVoxel nunca inventa un voxel intermedio).
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub struct Rot(pub [[i8; 3]; 3]);
85
86impl Rot {
87    /// Sin rotación (el `_r` = 4 de MagicaVoxel, y lo que se asume si falta).
88    pub const IDENTITY: Rot = Rot([[1, 0, 0], [0, 1, 0], [0, 0, 1]]);
89
90    /// Desempaca el byte `"_r"`: bits 0-1 = eje no nulo de la fila 0, bits 2-3 =
91    /// el de la fila 1 (el de la fila 2 es el que sobra), bits 4/5/6 = signo de
92    /// cada fila. Un byte inválido (dos filas sobre el mismo eje) cae a identidad
93    /// en vez de romper el archivo entero.
94    pub fn from_byte(b: u8) -> Rot {
95        let r0 = (b & 0b11) as usize;
96        let r1 = ((b >> 2) & 0b11) as usize;
97        if r0 > 2 || r1 > 2 || r0 == r1 {
98            return Rot::IDENTITY;
99        }
100        let r2 = 3 - r0 - r1;
101        let sign = |bit: u8| if b & (1 << bit) != 0 { -1i8 } else { 1i8 };
102        let mut m = [[0i8; 3]; 3];
103        m[0][r0] = sign(4);
104        m[1][r1] = sign(5);
105        m[2][r2] = sign(6);
106        Rot(m)
107    }
108
109    /// Aplica la rotación a un punto (en el espacio del `.vox`, z-arriba).
110    pub fn apply(&self, p: [i32; 3]) -> [i32; 3] {
111        let m = &self.0;
112        let row = |a: usize| {
113            m[a][0] as i32 * p[0] + m[a][1] as i32 * p[1] + m[a][2] as i32 * p[2]
114        };
115        [row(0), row(1), row(2)]
116    }
117
118    /// Composición `self ∘ hija` — la del padre por la del hijo, que es el orden
119    /// en que el grafo las acumula al bajar.
120    pub fn compose(&self, child: &Rot) -> Rot {
121        let mut m = [[0i8; 3]; 3];
122        for a in 0..3 {
123            for c in 0..3 {
124                let mut acc = 0i32;
125                for k in 0..3 {
126                    acc += self.0[a][k] as i32 * child.0[k][c] as i32;
127                }
128                m[a][c] = acc as i8;
129            }
130        }
131        Rot(m)
132    }
133
134    /// Empaqueta la rotación de vuelta al byte `"_r"` del formato (inverso de
135    /// [`from_byte`](Self::from_byte)) — lo que hace falta para **escribir** una
136    /// escena que MagicaVoxel abra con las piezas giradas.
137    pub fn to_byte(&self) -> u8 {
138        let fila = |a: usize| (0..3).find(|&j| self.0[a][j] != 0).unwrap_or(a);
139        let (r0, r1) = (fila(0), fila(1));
140        let mut b = (r0 as u8) | ((r1 as u8) << 2);
141        for (a, bit) in [(0usize, 4u8), (1, 5), (2, 6)] {
142            if self.0[a][fila(a)] < 0 {
143                b |= 1 << bit;
144            }
145        }
146        b
147    }
148
149    /// El tamaño de una caja tras rotarla (permuta las dimensiones; el signo no
150    /// cambia el tamaño).
151    pub fn apply_size(&self, s: [u32; 3]) -> [u32; 3] {
152        let p = self.apply([s[0] as i32, s[1] as i32, s[2] as i32]);
153        [p[0].unsigned_abs(), p[1].unsigned_abs(), p[2].unsigned_abs()]
154    }
155}
156
157impl Default for Rot {
158    fn default() -> Self {
159        Rot::IDENTITY
160    }
161}
162
163/// Una **colocación** de un modelo en la escena: índice del modelo en
164/// [`Scene::models`] + la traslación y la rotación de mundo acumuladas por el
165/// grafo de escena (`nTRN`→`nGRP`→`nSHP`). El origen del `.vox` queda en `[0,0,0]`.
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub struct Placement {
168    /// Índice del modelo en [`Scene::models`].
169    pub model: usize,
170    /// Traslación de mundo (en voxels, eje `.vox`: z-arriba) acumulada por el grafo.
171    pub translation: [i32; 3],
172    /// Rotación de mundo acumulada. `Rot::IDENTITY` si el `.vox` no la trae.
173    /// Gira alrededor del **centro** del modelo, que es donde `translation` lo ubica.
174    pub rotation: Rot,
175}
176
177/// Una escena `.vox`: los modelos crudos + sus colocaciones según el grafo de
178/// escena. Si el archivo es viejo (sólo `SIZE`/`XYZI`, sin `nTRN/nGRP/nSHP`),
179/// `placements` queda vacío y cada modelo se entiende en el origen.
180#[derive(Clone, Debug)]
181pub struct Scene {
182    /// Modelos crudos, uno por par `SIZE`+`XYZI`, en orden de archivo.
183    pub models: Vec<VoxModel>,
184    /// Colocaciones derivadas del grafo de escena (vacío si no hay grafo).
185    pub placements: Vec<Placement>,
186}
187
188/// Nodo del grafo de escena de MagicaVoxel (lo justo para ubicar modelos).
189enum Node {
190    /// `nTRN`: aplica una traslación y una rotación (frame 0) y baja a `child`.
191    Transform { child: i32, translation: [i32; 3], rotation: Rot },
192    /// `nGRP`: agrupa varios hijos bajo la misma transformación.
193    Group { children: Vec<i32> },
194    /// `nSHP`: hoja — referencia a uno o más modelos por índice.
195    Shape { models: Vec<i32> },
196}
197
198/// Lee un `.vox` y devuelve todos sus modelos (uno por par `SIZE`+`XYZI`). La
199/// paleta `RGBA`, si existe, se aplica a todos. Ignora el grafo de escena — para
200/// escenas multi-modelo con posición usa [`parse_scene`].
201pub fn parse(bytes: &[u8]) -> Result<Vec<VoxModel>, VoxError> {
202    Ok(parse_scene(bytes)?.models)
203}
204
205/// Lee un `.vox` completo: modelos **y** grafo de escena (`nTRN/nGRP/nSHP`),
206/// resolviendo la traslación **y la rotación** de mundo de cada modelo colocado
207/// (una pieza girada en MagicaVoxel entra girada, no derecha).
208pub fn parse_scene(bytes: &[u8]) -> Result<Scene, VoxError> {
209    if bytes.len() < 8 || &bytes[0..4] != b"VOX " {
210        return Err(VoxError::BadMagic);
211    }
212    // Tras el header (8 bytes) viene el chunk MAIN; sus hijos son el resto. No
213    // hace falta interpretar MAIN: barremos los chunks linealmente desde su
214    // contenido en adelante (SIZE/XYZI/RGBA no tienen hijos).
215    let mut pos = 8usize;
216    // Saltar el header del chunk MAIN (id + nContent + nChildren = 12 bytes) y su
217    // contenido (que es 0 en la práctica).
218    let main_n = read_u32(bytes, pos + 4)? as usize;
219    pos += 12 + main_n;
220
221    let mut sizes: Vec<[u32; 3]> = Vec::new();
222    let mut groups: Vec<Vec<Voxel>> = Vec::new();
223    let mut palette: Option<[[u8; 4]; 256]> = None;
224    let mut nodes: std::collections::HashMap<i32, Node> = std::collections::HashMap::new();
225
226    while pos + 12 <= bytes.len() {
227        let id = &bytes[pos..pos + 4];
228        let n = read_u32(bytes, pos + 4)? as usize;
229        let m = read_u32(bytes, pos + 8)? as usize;
230        let content_start = pos + 12;
231        let content_end = content_start.checked_add(n).ok_or(VoxError::Truncated)?;
232        if content_end > bytes.len() {
233            return Err(VoxError::Truncated);
234        }
235        let content = &bytes[content_start..content_end];
236
237        match id {
238            b"SIZE" => {
239                if content.len() < 12 {
240                    return Err(VoxError::Truncated);
241                }
242                sizes.push([
243                    read_u32(content, 0)?,
244                    read_u32(content, 4)?,
245                    read_u32(content, 8)?,
246                ]);
247            }
248            b"XYZI" => {
249                let count = read_u32(content, 0)? as usize;
250                let mut vs = Vec::with_capacity(count);
251                let need = 4 + count * 4;
252                if content.len() < need {
253                    return Err(VoxError::Truncated);
254                }
255                for k in 0..count {
256                    let o = 4 + k * 4;
257                    vs.push(Voxel { x: content[o], y: content[o + 1], z: content[o + 2], i: content[o + 3] });
258                }
259                groups.push(vs);
260            }
261            b"RGBA" => {
262                if content.len() < 256 * 4 {
263                    return Err(VoxError::Truncated);
264                }
265                let mut pal = [[0u8; 4]; 256];
266                // Corrimiento MagicaVoxel: el índice de voxel `c` (1..255) → la
267                // `c-1`-ésima entrada cruda. `palette[0]` queda vacío.
268                for c in 1..256usize {
269                    let o = (c - 1) * 4;
270                    pal[c] = [content[o], content[o + 1], content[o + 2], content[o + 3]];
271                }
272                palette = Some(pal);
273            }
274            b"nTRN" => {
275                if let Ok(node) = parse_ntrn(content) {
276                    nodes.insert(node.0, node.1);
277                }
278            }
279            b"nGRP" => {
280                if let Ok(node) = parse_ngrp(content) {
281                    nodes.insert(node.0, node.1);
282                }
283            }
284            b"nSHP" => {
285                if let Ok(node) = parse_nshp(content) {
286                    nodes.insert(node.0, node.1);
287                }
288            }
289            _ => {} // PACK, MATL, LAYR, rOBJ … no afectan geometría ni colocación.
290        }
291        pos = content_end + m;
292    }
293
294    if sizes.is_empty() || groups.is_empty() {
295        return Err(VoxError::NoModel);
296    }
297    let pal = palette.unwrap_or_else(default_palette);
298    let n_models = sizes.len().min(groups.len());
299    let models: Vec<VoxModel> = (0..n_models)
300        .map(|k| VoxModel { size: sizes[k], voxels: groups[k].clone(), palette: pal })
301        .collect();
302
303    // Recorrer el grafo desde el nodo raíz (id 0, siempre un nTRN) acumulando
304    // traslaciones hasta cada nSHP → una colocación por modelo referido.
305    let mut placements = Vec::new();
306    if nodes.contains_key(&0) {
307        let mut stack = vec![(0i32, [0i32; 3], Rot::IDENTITY)];
308        // Cota dura por si un .vox trae ciclos (no debería): a lo sumo |nodos| visitas.
309        let mut budget = nodes.len() * 4 + 8;
310        while let Some((id, off, rot)) = stack.pop() {
311            if budget == 0 {
312                break;
313            }
314            budget -= 1;
315            match nodes.get(&id) {
316                Some(Node::Transform { child, translation, rotation }) => {
317                    // La traslación del hijo se expresa en el marco YA rotado del padre.
318                    let t = rot.apply(*translation);
319                    let acc = [off[0] + t[0], off[1] + t[1], off[2] + t[2]];
320                    stack.push((*child, acc, rot.compose(rotation)));
321                }
322                Some(Node::Group { children }) => {
323                    for c in children {
324                        stack.push((*c, off, rot));
325                    }
326                }
327                Some(Node::Shape { models: ms }) => {
328                    for &mid in ms {
329                        if (mid as usize) < models.len() {
330                            placements.push(Placement {
331                                model: mid as usize,
332                                translation: off,
333                                rotation: rot,
334                            });
335                        }
336                    }
337                }
338                None => {}
339            }
340        }
341    }
342
343    Ok(Scene { models, placements })
344}
345
346/// Serializa un modelo a bytes `.vox` (header + `MAIN` con `SIZE`+`XYZI`+`RGBA`).
347/// Útil para **exportar** una escena voxel y editarla en MagicaVoxel, y para las
348/// pruebas de ida y vuelta.
349pub fn write(model: &VoxModel) -> Vec<u8> {
350    // SIZE.
351    let mut size = Vec::with_capacity(12);
352    for d in model.size {
353        size.extend_from_slice(&d.to_le_bytes());
354    }
355    // XYZI.
356    let mut xyzi = Vec::with_capacity(4 + model.voxels.len() * 4);
357    xyzi.extend_from_slice(&(model.voxels.len() as u32).to_le_bytes());
358    for v in &model.voxels {
359        xyzi.extend_from_slice(&[v.x, v.y, v.z, v.i]);
360    }
361    // RGBA: crudo[j] = palette[j+1] (inverso del corrimiento de lectura).
362    let mut rgba = Vec::with_capacity(256 * 4);
363    for j in 0..256usize {
364        let c = j + 1;
365        rgba.extend_from_slice(&model.palette.get(c).copied().unwrap_or([0, 0, 0, 0]));
366    }
367
368    let mut children = Vec::new();
369    children.extend_from_slice(&chunk(b"SIZE", &size));
370    children.extend_from_slice(&chunk(b"XYZI", &xyzi));
371    children.extend_from_slice(&chunk(b"RGBA", &rgba));
372
373    let mut out = Vec::new();
374    out.extend_from_slice(b"VOX ");
375    out.extend_from_slice(&150u32.to_le_bytes()); // versión
376    // MAIN: contenido vacío, hijos = los chunks de arriba.
377    out.extend_from_slice(b"MAIN");
378    out.extend_from_slice(&0u32.to_le_bytes());
379    out.extend_from_slice(&(children.len() as u32).to_le_bytes());
380    out.extend_from_slice(&children);
381    out
382}
383
384/// Codifica un chunk **sin hijos**: `id` + `len(content)` + `0` + `content`.
385fn chunk(id: &[u8; 4], content: &[u8]) -> Vec<u8> {
386    let mut c = Vec::with_capacity(12 + content.len());
387    c.extend_from_slice(id);
388    c.extend_from_slice(&(content.len() as u32).to_le_bytes());
389    c.extend_from_slice(&0u32.to_le_bytes());
390    c.extend_from_slice(content);
391    c
392}
393
394/// Lee un `u32` little-endian en `off`, o [`VoxError::Truncated`] si no entra.
395fn read_u32(b: &[u8], off: usize) -> Result<u32, VoxError> {
396    b.get(off..off + 4)
397        .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
398        .ok_or(VoxError::Truncated)
399}
400
401// --- Lectura del grafo de escena (nTRN/nGRP/nSHP) -------------------------------
402// El formato extendido de MagicaVoxel usa, dentro del contenido de cada nodo,
403// primitivas: int32 LE, STRING (int32 len + bytes) y DICT (int32 nPairs + pares de
404// STRING). Los lectores de abajo avanzan un cursor `pos`.
405
406/// Lee un `i32` LE en el cursor y lo avanza 4 bytes.
407fn take_i32(b: &[u8], pos: &mut usize) -> Result<i32, VoxError> {
408    let v = read_u32(b, *pos)? as i32;
409    *pos += 4;
410    Ok(v)
411}
412
413/// Lee una `STRING` del `.vox` (int32 len + bytes) en el cursor y lo avanza.
414fn take_string<'a>(b: &'a [u8], pos: &mut usize) -> Result<&'a [u8], VoxError> {
415    let len = read_u32(b, *pos)? as usize;
416    *pos += 4;
417    let s = b.get(*pos..*pos + len).ok_or(VoxError::Truncated)?;
418    *pos += len;
419    Ok(s)
420}
421
422/// Lee un `DICT` (int32 nPairs + pares STRING/STRING) en el cursor; devuelve los
423/// pares como bytes crudos (clave, valor).
424fn take_dict(b: &[u8], pos: &mut usize) -> Result<Vec<(Vec<u8>, Vec<u8>)>, VoxError> {
425    let n = read_u32(b, *pos)? as usize;
426    *pos += 4;
427    let mut out = Vec::with_capacity(n);
428    for _ in 0..n {
429        let k = take_string(b, pos)?.to_vec();
430        let v = take_string(b, pos)?.to_vec();
431        out.push((k, v));
432    }
433    Ok(out)
434}
435
436/// Extrae la traslación `"_t" = "x y z"` de un dict de frame (default `[0,0,0]`).
437fn translation_from_dict(d: &[(Vec<u8>, Vec<u8>)]) -> [i32; 3] {
438    for (k, v) in d {
439        if k.as_slice() == b"_t" {
440            let s = core::str::from_utf8(v).unwrap_or("");
441            let mut it = s.split_whitespace().map(|x| x.parse::<i32>().unwrap_or(0));
442            return [it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0)];
443        }
444    }
445    [0, 0, 0]
446}
447
448/// Extrae la rotación `"_r" = "<byte>"` de un dict de frame (default: identidad).
449fn rotation_from_dict(d: &[(Vec<u8>, Vec<u8>)]) -> Rot {
450    for (k, v) in d {
451        if k.as_slice() == b"_r" {
452            let s = core::str::from_utf8(v).unwrap_or("");
453            return match s.trim().parse::<u8>() {
454                Ok(b) => Rot::from_byte(b),
455                Err(_) => Rot::IDENTITY,
456            };
457        }
458    }
459    Rot::IDENTITY
460}
461
462/// `nTRN`: nodeId, dict, childId, reservedId, layerId, numFrames, frames[]. Tomamos
463/// la traslación del frame 0.
464fn parse_ntrn(content: &[u8]) -> Result<(i32, Node), VoxError> {
465    let mut pos = 0;
466    let id = take_i32(content, &mut pos)?;
467    let _attrs = take_dict(content, &mut pos)?;
468    let child = take_i32(content, &mut pos)?;
469    let _reserved = take_i32(content, &mut pos)?;
470    let _layer = take_i32(content, &mut pos)?;
471    let n_frames = take_i32(content, &mut pos)?.max(0) as usize;
472    let mut translation = [0; 3];
473    let mut rotation = Rot::IDENTITY;
474    for f in 0..n_frames {
475        let frame = take_dict(content, &mut pos)?;
476        if f == 0 {
477            translation = translation_from_dict(&frame);
478            rotation = rotation_from_dict(&frame);
479        }
480    }
481    Ok((id, Node::Transform { child, translation, rotation }))
482}
483
484/// `nGRP`: nodeId, dict, numChildren, childIds[].
485fn parse_ngrp(content: &[u8]) -> Result<(i32, Node), VoxError> {
486    let mut pos = 0;
487    let id = take_i32(content, &mut pos)?;
488    let _attrs = take_dict(content, &mut pos)?;
489    let n = take_i32(content, &mut pos)?.max(0) as usize;
490    let mut children = Vec::with_capacity(n);
491    for _ in 0..n {
492        children.push(take_i32(content, &mut pos)?);
493    }
494    Ok((id, Node::Group { children }))
495}
496
497/// `nSHP`: nodeId, dict, numModels, (modelId, dict)[].
498fn parse_nshp(content: &[u8]) -> Result<(i32, Node), VoxError> {
499    let mut pos = 0;
500    let id = take_i32(content, &mut pos)?;
501    let _attrs = take_dict(content, &mut pos)?;
502    let n = take_i32(content, &mut pos)?.max(0) as usize;
503    let mut models = Vec::with_capacity(n);
504    for _ in 0..n {
505        models.push(take_i32(content, &mut pos)?);
506        let _model_attrs = take_dict(content, &mut pos)?;
507    }
508    Ok((id, Node::Shape { models }))
509}
510
511/// Paleta por defecto **oficial de MagicaVoxel** (sólo se usa si el `.vox` no trae
512/// chunk `RGBA`). Es la tabla canónica que publica el formato (ephtracy/voxel-model):
513/// los exportes reales casi siempre incluyen `RGBA`, pero los modelos viejos /
514/// generados a mano que omiten la paleta ahora abren con los colores correctos en
515/// vez de una rampa inventada. `[0]` = vacío. Ver [`DEFAULT_PALETTE_ABGR`].
516fn default_palette() -> [[u8; 4]; 256] {
517    let mut p = [[0u8; 4]; 256];
518    for (c, &v) in DEFAULT_PALETTE_ABGR.iter().enumerate() {
519        // La tabla viene en 0xAABBGGRR (igual que el spec); la desempacamos a RGBA.
520        p[c] = [
521            (v & 0xff) as u8,
522            ((v >> 8) & 0xff) as u8,
523            ((v >> 16) & 0xff) as u8,
524            ((v >> 24) & 0xff) as u8,
525        ];
526    }
527    p
528}
529
530/// Paleta default canónica de MagicaVoxel, en `0xAABBGGRR` por entrada (formato del
531/// spec). `[0]` = transparente. Es una rampa de matices ×3 niveles + gradientes de
532/// grises/RGB al final — idéntica a la que muestra MagicaVoxel al abrir un modelo
533/// sin paleta propia.
534#[rustfmt::skip]
535const DEFAULT_PALETTE_ABGR: [u32; 256] = [
536    0x00000000, 0xffffffff, 0xffccffff, 0xff99ffff, 0xff66ffff, 0xff33ffff, 0xff00ffff, 0xffffccff,
537    0xffccccff, 0xff99ccff, 0xff66ccff, 0xff33ccff, 0xff00ccff, 0xffff99ff, 0xffcc99ff, 0xff9999ff,
538    0xff6699ff, 0xff3399ff, 0xff0099ff, 0xffff66ff, 0xffcc66ff, 0xff9966ff, 0xff6666ff, 0xff3366ff,
539    0xff0066ff, 0xffff33ff, 0xffcc33ff, 0xff9933ff, 0xff6633ff, 0xff3333ff, 0xff0033ff, 0xffff00ff,
540    0xffcc00ff, 0xff9900ff, 0xff6600ff, 0xff3300ff, 0xff0000ff, 0xffffffcc, 0xffccffcc, 0xff99ffcc,
541    0xff66ffcc, 0xff33ffcc, 0xff00ffcc, 0xffffcccc, 0xffcccccc, 0xff99cccc, 0xff66cccc, 0xff33cccc,
542    0xff00cccc, 0xffff99cc, 0xffcc99cc, 0xff9999cc, 0xff6699cc, 0xff3399cc, 0xff0099cc, 0xffff66cc,
543    0xffcc66cc, 0xff9966cc, 0xff6666cc, 0xff3366cc, 0xff0066cc, 0xffff33cc, 0xffcc33cc, 0xff9933cc,
544    0xff6633cc, 0xff3333cc, 0xff0033cc, 0xffff00cc, 0xffcc00cc, 0xff9900cc, 0xff6600cc, 0xff3300cc,
545    0xff0000cc, 0xffffff99, 0xffccff99, 0xff99ff99, 0xff66ff99, 0xff33ff99, 0xff00ff99, 0xffffcc99,
546    0xffcccc99, 0xff99cc99, 0xff66cc99, 0xff33cc99, 0xff00cc99, 0xffff9999, 0xffcc9999, 0xff999999,
547    0xff669999, 0xff339999, 0xff009999, 0xffff6699, 0xffcc6699, 0xff996699, 0xff666699, 0xff336699,
548    0xff006699, 0xffff3399, 0xffcc3399, 0xff993399, 0xff663399, 0xff333399, 0xff003399, 0xffff0099,
549    0xffcc0099, 0xff990099, 0xff660099, 0xff330099, 0xff000099, 0xffffff66, 0xffccff66, 0xff99ff66,
550    0xff66ff66, 0xff33ff66, 0xff00ff66, 0xffffcc66, 0xffcccc66, 0xff99cc66, 0xff66cc66, 0xff33cc66,
551    0xff00cc66, 0xffff9966, 0xffcc9966, 0xff999966, 0xff669966, 0xff339966, 0xff009966, 0xffff6666,
552    0xffcc6666, 0xff996666, 0xff666666, 0xff336666, 0xff006666, 0xffff3366, 0xffcc3366, 0xff993366,
553    0xff663366, 0xff333366, 0xff003366, 0xffff0066, 0xffcc0066, 0xff990066, 0xff660066, 0xff330066,
554    0xff000066, 0xffffff33, 0xffccff33, 0xff99ff33, 0xff66ff33, 0xff33ff33, 0xff00ff33, 0xffffcc33,
555    0xffcccc33, 0xff99cc33, 0xff66cc33, 0xff33cc33, 0xff00cc33, 0xffff9933, 0xffcc9933, 0xff999933,
556    0xff669933, 0xff339933, 0xff009933, 0xffff6633, 0xffcc6633, 0xff996633, 0xff666633, 0xff336633,
557    0xff006633, 0xffff3333, 0xffcc3333, 0xff993333, 0xff663333, 0xff333333, 0xff003333, 0xffff0033,
558    0xffcc0033, 0xff990033, 0xff660033, 0xff330033, 0xff000033, 0xffffff00, 0xffccff00, 0xff99ff00,
559    0xff66ff00, 0xff33ff00, 0xff00ff00, 0xffffcc00, 0xffcccc00, 0xff99cc00, 0xff66cc00, 0xff33cc00,
560    0xff00cc00, 0xffff9900, 0xffcc9900, 0xff999900, 0xff669900, 0xff339900, 0xff009900, 0xffff6600,
561    0xffcc6600, 0xff996600, 0xff666600, 0xff336600, 0xff006600, 0xffff3300, 0xffcc3300, 0xff993300,
562    0xff663300, 0xff333300, 0xff003300, 0xffff0000, 0xffcc0000, 0xff990000, 0xff660000, 0xff330000,
563    0xff0000ee, 0xff0000dd, 0xff0000bb, 0xff0000aa, 0xff000088, 0xff000077, 0xff000055, 0xff000044,
564    0xff000022, 0xff000011, 0xff00ee00, 0xff00dd00, 0xff00bb00, 0xff00aa00, 0xff008800, 0xff007700,
565    0xff005500, 0xff004400, 0xff002200, 0xff001100, 0xffee0000, 0xffdd0000, 0xffbb0000, 0xffaa0000,
566    0xff880000, 0xff770000, 0xff550000, 0xff440000, 0xff220000, 0xff110000, 0xffeeeeee, 0xffdddddd,
567    0xffbbbbbb, 0xffaaaaaa, 0xff888888, 0xff777777, 0xff555555, 0xff444444, 0xff222222, 0xff111111,
568];
569
570/// Serializa una **escena multi-modelo**: los modelos + un grafo `nTRN/nGRP/nSHP`
571/// que los ubica (traslación y rotación de cada colocación). Es el inverso de
572/// [`parse_scene`], y lo que hace falta para **devolverle** al editor ajeno una
573/// escena compuesta acá (una casa amueblada, un reparto, un set entero).
574///
575/// La paleta del archivo es la del **primer** modelo — el formato tiene una sola por
576/// archivo, así que quien compone tiene que traer los modelos ya reconciliados (es lo
577/// que hace `grid_to_model` del lado del motor, que arma una paleta común).
578/// Sin colocaciones, cae a [`write`] del primer modelo (un `.vox` clásico).
579pub fn write_scene(models: &[VoxModel], placements: &[Placement]) -> Vec<u8> {
580    if models.is_empty() {
581        return Vec::new();
582    }
583    if placements.is_empty() {
584        return write(&models[0]);
585    }
586    let mut children = Vec::new();
587    for m in models {
588        let mut size = Vec::with_capacity(12);
589        for d in m.size {
590            size.extend_from_slice(&d.to_le_bytes());
591        }
592        let mut xyzi = Vec::with_capacity(4 + m.voxels.len() * 4);
593        xyzi.extend_from_slice(&(m.voxels.len() as u32).to_le_bytes());
594        for v in &m.voxels {
595            xyzi.extend_from_slice(&[v.x, v.y, v.z, v.i]);
596        }
597        children.extend_from_slice(&chunk(b"SIZE", &size));
598        children.extend_from_slice(&chunk(b"XYZI", &xyzi));
599    }
600    let mut rgba = Vec::with_capacity(256 * 4);
601    for j in 0..256usize {
602        rgba.extend_from_slice(&models[0].palette.get(j + 1).copied().unwrap_or([0, 0, 0, 0]));
603    }
604    children.extend_from_slice(&chunk(b"RGBA", &rgba));
605
606    // Grafo: nTRN(0) → nGRP(1) → { nTRN(2+2k) → nSHP(3+2k) } por colocación.
607    let i32b = |v: i32| v.to_le_bytes().to_vec();
608    let string = |txt: &str| {
609        let mut o = (txt.len() as u32).to_le_bytes().to_vec();
610        o.extend_from_slice(txt.as_bytes());
611        o
612    };
613    let dict = |pares: &[(&str, String)]| {
614        let mut o = (pares.len() as u32).to_le_bytes().to_vec();
615        for (k, v) in pares {
616            o.extend_from_slice(&string(k));
617            o.extend_from_slice(&string(v));
618        }
619        o
620    };
621    let ntrn = |id: i32, child: i32, frame: Vec<u8>| {
622        let mut c = Vec::new();
623        c.extend_from_slice(&i32b(id));
624        c.extend_from_slice(&dict(&[]));
625        c.extend_from_slice(&i32b(child));
626        c.extend_from_slice(&i32b(-1));
627        c.extend_from_slice(&i32b(0));
628        c.extend_from_slice(&i32b(1));
629        c.extend_from_slice(&frame);
630        c
631    };
632    children.extend_from_slice(&chunk(b"nTRN", &ntrn(0, 1, dict(&[]))));
633    let mut grp = Vec::new();
634    grp.extend_from_slice(&i32b(1));
635    grp.extend_from_slice(&dict(&[]));
636    grp.extend_from_slice(&i32b(placements.len() as i32));
637    for k in 0..placements.len() {
638        grp.extend_from_slice(&i32b(2 + 2 * k as i32));
639    }
640    children.extend_from_slice(&chunk(b"nGRP", &grp));
641    for (k, p) in placements.iter().enumerate() {
642        let (id_t, id_s) = (2 + 2 * k as i32, 3 + 2 * k as i32);
643        let t = format!("{} {} {}", p.translation[0], p.translation[1], p.translation[2]);
644        let mut pares: Vec<(&str, String)> = vec![("_t", t)];
645        if p.rotation != Rot::IDENTITY {
646            pares.push(("_r", p.rotation.to_byte().to_string()));
647        }
648        children.extend_from_slice(&chunk(b"nTRN", &ntrn(id_t, id_s, dict(&pares))));
649        let mut shp = Vec::new();
650        shp.extend_from_slice(&i32b(id_s));
651        shp.extend_from_slice(&dict(&[]));
652        shp.extend_from_slice(&i32b(1));
653        shp.extend_from_slice(&i32b(p.model as i32));
654        shp.extend_from_slice(&dict(&[]));
655        children.extend_from_slice(&chunk(b"nSHP", &shp));
656    }
657
658    let mut out = Vec::new();
659    out.extend_from_slice(b"VOX ");
660    out.extend_from_slice(&150u32.to_le_bytes());
661    out.extend_from_slice(b"MAIN");
662    out.extend_from_slice(&0u32.to_le_bytes());
663    out.extend_from_slice(&(children.len() as u32).to_le_bytes());
664    out.extend_from_slice(&children);
665    out
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    fn modelo_demo() -> VoxModel {
673        let mut m = VoxModel::new([4, 5, 6]);
674        m.palette[1] = [200, 30, 30, 255];
675        m.palette[2] = [30, 180, 60, 255];
676        m.voxels = vec![
677            Voxel { x: 0, y: 0, z: 0, i: 1 },
678            Voxel { x: 3, y: 4, z: 5, i: 2 },
679            Voxel { x: 1, y: 2, z: 3, i: 1 },
680        ];
681        m
682    }
683
684    #[test]
685    fn ida_y_vuelta() {
686        let m = modelo_demo();
687        let bytes = write(&m);
688        let back = parse(&bytes).expect("parse");
689        assert_eq!(back.len(), 1);
690        let r = &back[0];
691        assert_eq!(r.size, [4, 5, 6]);
692        assert_eq!(r.voxels, m.voxels);
693        // Los colores de los índices usados sobreviven el corrimiento RGBA.
694        assert_eq!(r.palette[1], [200, 30, 30, 255]);
695        assert_eq!(r.palette[2], [30, 180, 60, 255]);
696    }
697
698    #[test]
699    fn rechaza_no_vox() {
700        assert_eq!(parse(b"NOPE....").unwrap_err(), VoxError::BadMagic);
701    }
702
703    #[test]
704    fn parsea_bytes_a_mano_sin_rgba() {
705        // .vox mínimo escrito a mano: header + MAIN(vacío) + SIZE(1,1,1) +
706        // XYZI(1 voxel en 0,0,0 índice 1). Sin RGBA → paleta por defecto.
707        let mut b = Vec::new();
708        b.extend_from_slice(b"VOX ");
709        b.extend_from_slice(&150u32.to_le_bytes());
710        // MAIN: n=0, m = bytes de los dos chunks hijos (24+16=... lo calculamos).
711        let size_chunk = {
712            let mut c = Vec::new();
713            c.extend_from_slice(b"SIZE");
714            c.extend_from_slice(&12u32.to_le_bytes());
715            c.extend_from_slice(&0u32.to_le_bytes());
716            c.extend_from_slice(&1u32.to_le_bytes());
717            c.extend_from_slice(&1u32.to_le_bytes());
718            c.extend_from_slice(&1u32.to_le_bytes());
719            c
720        };
721        let xyzi_chunk = {
722            let mut c = Vec::new();
723            c.extend_from_slice(b"XYZI");
724            c.extend_from_slice(&8u32.to_le_bytes()); // 4 (count) + 4 (un voxel)
725            c.extend_from_slice(&0u32.to_le_bytes());
726            c.extend_from_slice(&1u32.to_le_bytes()); // count
727            c.extend_from_slice(&[0, 0, 0, 1]); // voxel
728            c
729        };
730        let children_len = size_chunk.len() + xyzi_chunk.len();
731        b.extend_from_slice(b"MAIN");
732        b.extend_from_slice(&0u32.to_le_bytes());
733        b.extend_from_slice(&(children_len as u32).to_le_bytes());
734        b.extend_from_slice(&size_chunk);
735        b.extend_from_slice(&xyzi_chunk);
736
737        let models = parse(&b).expect("parse");
738        assert_eq!(models.len(), 1);
739        assert_eq!(models[0].size, [1, 1, 1]);
740        assert_eq!(models[0].voxels, vec![Voxel { x: 0, y: 0, z: 0, i: 1 }]);
741        assert_ne!(models[0].palette[1], [0, 0, 0, 0]); // default no vacío
742    }
743
744    #[test]
745    fn escena_multi_modelo_con_traslacion() {
746        // .vox extendido a mano: dos modelos + grafo
747        // nTRN(0)→nGRP(1)→{ nTRN(2)→nSHP(3:model0), nTRN(4,_t="5 0 0")→nSHP(5:model1) }.
748        // Esperado: model0 en [0,0,0], model1 en [5,0,0].
749        let i32b = |v: i32| v.to_le_bytes().to_vec();
750        let s = |txt: &str| {
751            let mut o = (txt.len() as u32).to_le_bytes().to_vec();
752            o.extend_from_slice(txt.as_bytes());
753            o
754        };
755        let dict0 = || 0u32.to_le_bytes().to_vec(); // dict vacío
756        let dict_t = |t: &str| {
757            let mut o = 1u32.to_le_bytes().to_vec();
758            o.extend_from_slice(&s("_t"));
759            o.extend_from_slice(&s(t));
760            o
761        };
762        let cat = |parts: &[Vec<u8>]| parts.concat();
763
764        let size = {
765            let mut c = Vec::new();
766            c.extend_from_slice(&i32b(2));
767            c.extend_from_slice(&i32b(2));
768            c.extend_from_slice(&i32b(2));
769            c
770        };
771        let xyzi = {
772            let mut c = 1u32.to_le_bytes().to_vec();
773            c.extend_from_slice(&[0, 0, 0, 1]);
774            c
775        };
776        // nTRN(id, child, frame): id, dict0, child, -1, -1, 1, frame.
777        let ntrn = |id: i32, child: i32, frame: Vec<u8>| {
778            cat(&[i32b(id), dict0(), i32b(child), i32b(-1), i32b(-1), i32b(1), frame])
779        };
780        let nshp = |id: i32, model: i32| {
781            // id, dict0, numModels=1, (modelId, dict0)
782            cat(&[i32b(id), dict0(), i32b(1), i32b(model), dict0()])
783        };
784        let ngrp = |id: i32, kids: &[i32]| {
785            let mut c = cat(&[i32b(id), dict0(), i32b(kids.len() as i32)]);
786            for &k in kids {
787                c.extend_from_slice(&i32b(k));
788            }
789            c
790        };
791
792        let children = cat(&[
793            chunk(b"SIZE", &size),
794            chunk(b"XYZI", &xyzi),
795            chunk(b"SIZE", &size),
796            chunk(b"XYZI", &xyzi),
797            chunk(b"nTRN", &ntrn(0, 1, dict0())),
798            chunk(b"nGRP", &ngrp(1, &[2, 4])),
799            chunk(b"nTRN", &ntrn(2, 3, dict0())),
800            chunk(b"nSHP", &nshp(3, 0)),
801            chunk(b"nTRN", &ntrn(4, 5, dict_t("5 0 0"))),
802            chunk(b"nSHP", &nshp(5, 1)),
803        ]);
804
805        let mut b = Vec::new();
806        b.extend_from_slice(b"VOX ");
807        b.extend_from_slice(&150u32.to_le_bytes());
808        b.extend_from_slice(b"MAIN");
809        b.extend_from_slice(&0u32.to_le_bytes());
810        b.extend_from_slice(&(children.len() as u32).to_le_bytes());
811        b.extend_from_slice(&children);
812
813        let scene = parse_scene(&b).expect("parse_scene");
814        assert_eq!(scene.models.len(), 2);
815        assert_eq!(scene.placements.len(), 2, "dos colocaciones");
816        let mut by_model: std::collections::HashMap<usize, [i32; 3]> = Default::default();
817        for p in &scene.placements {
818            by_model.insert(p.model, p.translation);
819        }
820        assert_eq!(by_model.get(&0), Some(&[0, 0, 0]));
821        assert_eq!(by_model.get(&1), Some(&[5, 0, 0]));
822    }
823
824    #[test]
825    fn el_byte_de_rotacion_se_desempaca() {
826        // 4 = identidad (el que escribe MagicaVoxel cuando no giraste nada).
827        assert_eq!(Rot::from_byte(4), Rot::IDENTITY);
828        // Un cuarto de vuelta alrededor de z: x→y, y→−x.
829        // fila0 toma el eje 1 con signo −, fila1 toma el eje 0 con +, fila2 el 2 con +.
830        // bits: r0=1, r1=0 (<<2 = 0), signo fila0 negativo (bit 4).
831        let r = Rot::from_byte(0b001_0001);
832        assert_eq!(r.apply([1, 0, 0]), [0, 1, 0], "x va a +y");
833        assert_eq!(r.apply([0, 1, 0]), [-1, 0, 0], "y va a −x");
834        assert_eq!(r.apply([0, 0, 1]), [0, 0, 1], "z queda");
835        // Cuatro cuartos de vuelta = identidad.
836        let cuatro = r.compose(&r).compose(&r).compose(&r);
837        assert_eq!(cuatro, Rot::IDENTITY, "cuatro cuartos vuelven al principio");
838        // Un byte imposible (dos filas sobre el mismo eje) no rompe el archivo.
839        assert_eq!(Rot::from_byte(0b0000_0000), Rot::IDENTITY);
840    }
841
842    #[test]
843    fn la_rotacion_permuta_el_tamano_de_la_caja() {
844        let r = Rot::from_byte(0b001_0001); // cuarto de vuelta en z
845        assert_eq!(r.apply_size([2, 5, 7]), [5, 2, 7]);
846        assert_eq!(Rot::IDENTITY.apply_size([2, 5, 7]), [2, 5, 7]);
847    }
848
849    #[test]
850    fn escena_con_rotacion_del_grafo() {
851        // Mismo esqueleto que el test de traslación, pero el 2º nTRN trae _r.
852        // nTRN(0)→nGRP(1)→{ nTRN(2)→nSHP(3:m0), nTRN(4,_t="5 0 0",_r=cuarto)→nSHP(5:m1) }.
853        let i32b = |v: i32| v.to_le_bytes().to_vec();
854        let s = |txt: &str| {
855            let mut o = (txt.len() as u32).to_le_bytes().to_vec();
856            o.extend_from_slice(txt.as_bytes());
857            o
858        };
859        let dict0 = || 0u32.to_le_bytes().to_vec();
860        let dict_tr = |t: &str, r: &str| {
861            let mut o = 2u32.to_le_bytes().to_vec();
862            o.extend_from_slice(&s("_t"));
863            o.extend_from_slice(&s(t));
864            o.extend_from_slice(&s("_r"));
865            o.extend_from_slice(&s(r));
866            o
867        };
868        let cat = |parts: &[Vec<u8>]| parts.concat();
869        let size = cat(&[i32b(2), i32b(2), i32b(2)]);
870        let xyzi = {
871            let mut c = 1u32.to_le_bytes().to_vec();
872            c.extend_from_slice(&[0, 0, 0, 1]);
873            c
874        };
875        let ntrn = |id: i32, child: i32, frame: Vec<u8>| {
876            cat(&[i32b(id), dict0(), i32b(child), i32b(-1), i32b(-1), i32b(1), frame])
877        };
878        let nshp = |id: i32, model: i32| cat(&[i32b(id), dict0(), i32b(1), i32b(model), dict0()]);
879        let ngrp = |id: i32, kids: &[i32]| {
880            let mut c = cat(&[i32b(id), dict0(), i32b(kids.len() as i32)]);
881            for &k in kids {
882                c.extend_from_slice(&i32b(k));
883            }
884            c
885        };
886        let children = cat(&[
887            chunk(b"SIZE", &size),
888            chunk(b"XYZI", &xyzi),
889            chunk(b"SIZE", &size),
890            chunk(b"XYZI", &xyzi),
891            chunk(b"nTRN", &ntrn(0, 1, dict0())),
892            chunk(b"nGRP", &ngrp(1, &[2, 4])),
893            chunk(b"nTRN", &ntrn(2, 3, dict0())),
894            chunk(b"nSHP", &nshp(3, 0)),
895            chunk(b"nTRN", &ntrn(4, 5, dict_tr("5 0 0", "17"))), // 17 = 0b0010001
896            chunk(b"nSHP", &nshp(5, 1)),
897        ]);
898        let mut b = Vec::new();
899        b.extend_from_slice(b"VOX ");
900        b.extend_from_slice(&150u32.to_le_bytes());
901        b.extend_from_slice(b"MAIN");
902        b.extend_from_slice(&0u32.to_le_bytes());
903        b.extend_from_slice(&(children.len() as u32).to_le_bytes());
904        b.extend_from_slice(&children);
905
906        let scene = parse_scene(&b).expect("parse_scene");
907        let mut by_model: std::collections::HashMap<usize, Placement> = Default::default();
908        for p in &scene.placements {
909            by_model.insert(p.model, *p);
910        }
911        let p0 = by_model[&0];
912        let p1 = by_model[&1];
913        assert_eq!(p0.rotation, Rot::IDENTITY, "el que no giró sigue derecho");
914        assert_eq!(p0.translation, [0, 0, 0]);
915        assert_eq!(p1.rotation, Rot::from_byte(17), "la rotación del nodo llega a la colocación");
916        assert_eq!(p1.translation, [5, 0, 0], "la traslación del propio nodo NO se auto-rota");
917    }
918
919    #[test]
920    fn el_byte_de_rotacion_va_y_vuelve() {
921        // Las 24 rotaciones (y las reflexiones que el formato admite) sobreviven el
922        // viaje byte → matriz → byte.
923        for b in 0u8..128 {
924            let r = Rot::from_byte(b);
925            if r == Rot::IDENTITY && b != 4 {
926                continue; // bytes imposibles: caen a identidad a propósito
927            }
928            assert_eq!(Rot::from_byte(r.to_byte()), r, "byte {b}");
929        }
930    }
931
932    #[test]
933    fn escena_escrita_se_relee_igual() {
934        // Componer acá y devolverle la escena al editor ajeno: los dos modelos, sus
935        // traslaciones y la rotación de uno tienen que volver idénticos.
936        let pieza = |i: u8| {
937            let mut m = VoxModel::new([2, 3, 4]);
938            m.palette[1] = [10, 20, 30, 255];
939            m.palette[2] = [200, 100, 50, 255];
940            m.voxels = vec![Voxel { x: 0, y: 1, z: 2, i }, Voxel { x: 1, y: 0, z: 0, i: 1 }];
941            m
942        };
943        let rot = Rot::from_byte(17);
944        let placements = vec![
945            Placement { model: 0, translation: [3, -4, 5], rotation: Rot::IDENTITY },
946            Placement { model: 1, translation: [-9, 0, 2], rotation: rot },
947        ];
948        let bytes = write_scene(&[pieza(1), pieza(2)], &placements);
949        let vuelta = parse_scene(&bytes).expect("releer la escena escrita");
950        assert_eq!(vuelta.models.len(), 2);
951        assert_eq!(vuelta.models[0].size, [2, 3, 4]);
952        assert_eq!(vuelta.models[1].voxels, pieza(2).voxels);
953        assert_eq!(vuelta.models[0].palette[2], [200, 100, 50, 255], "la paleta viaja");
954        let mut por_modelo: std::collections::HashMap<usize, Placement> = Default::default();
955        for p in &vuelta.placements {
956            por_modelo.insert(p.model, *p);
957        }
958        assert_eq!(por_modelo[&0], placements[0]);
959        assert_eq!(por_modelo[&1], placements[1], "traslación Y rotación sobreviven");
960    }
961
962    #[test]
963    fn escena_sin_colocaciones_cae_al_vox_clasico() {
964        let m = modelo_demo();
965        let bytes = write_scene(std::slice::from_ref(&m), &[]);
966        assert_eq!(bytes, write(&m));
967    }
968
969    #[test]
970    fn vox_viejo_sin_grafo_no_tiene_placements() {
971        // Un .vox de un solo modelo sin nTRN/nGRP/nSHP → placements vacío.
972        let m = modelo_demo();
973        let scene = parse_scene(&write(&m)).expect("parse_scene");
974        assert_eq!(scene.models.len(), 1);
975        assert!(scene.placements.is_empty());
976    }
977
978    #[test]
979    fn paleta_default_es_la_oficial() {
980        let p = default_palette();
981        assert_eq!(p[0], [0, 0, 0, 0]); // índice 0 = vacío/transparente
982        assert_eq!(p[1], [255, 255, 255, 255]); // 0xffffffff = blanco
983        assert_eq!(p[6], [255, 255, 0, 255]); // 0xff00ffff (AABBGGRR) = amarillo
984        assert_eq!(p[255], [17, 17, 17, 255]); // 0xff111111 = gris oscuro
985        // Toda entrada usable (1..256) es opaca y no vacía.
986        for c in 1..256 {
987            assert_eq!(p[c][3], 255, "alpha en índice {c}");
988        }
989    }
990}