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//! # 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}