refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel

Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+24
View File
@@ -0,0 +1,24 @@
# =============================================================================
# tawasuyu :: foreign-vox — puente al formato MagicaVoxel (.vox)
# -----------------------------------------------------------------------------
# CLAUDE.md regla #4: los formatos ajenos entran por puentes `shared/foreign-*`,
# nunca al núcleo de las apps. Este crate lee/escribe el formato `.vox` de
# MagicaVoxel (chunks RIFF-like MAIN/SIZE/XYZI/RGBA) y lo expone como un
# `VoxModel` **neutral** (dimensiones + voxels + paleta) — sin depender del
# motor: el conversor a `VoxelGrid` vive en `llimphi-voxel` (capa de juego).
#
# Espejo conceptual de `shared/foreign-psd` / `shared/foreign-xlsx`: ingestar lo
# ajeno, dejar el formato nativo al resto. Sin dependencias (sólo `std`): el
# parseo es bytes planos little-endian.
# =============================================================================
[package]
name = "foreign-vox"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "shared/foreign-vox — puente al formato MagicaVoxel (.vox): lee/escribe modelos voxel (SIZE/XYZI/RGBA) como un VoxModel neutral, para importar sets y personajes al motor voxel."
[dependencies]
+636
View File
@@ -0,0 +1,636 @@
//! # foreign-vox — puente al formato **MagicaVoxel `.vox`**
//!
//! Lee y escribe modelos voxel en el formato de MagicaVoxel (estructura de
//! *chunks* tipo-RIFF: `MAIN` → `SIZE`/`XYZI`/`RGBA`) y los expone como un
//! [`VoxModel`] **neutral**: dimensiones + lista de voxels `(x,y,z,índice)` +
//! una paleta de 256 colores RGBA indexada por el índice del voxel.
//!
//! Sin dependencias (sólo `std`): el formato son bytes planos *little-endian*.
//! **No** conoce el motor — el conversor a `VoxelGrid` vive en `llimphi-voxel`
//! (CLAUDE.md regla #4: lo ajeno entra por el puente, el núcleo trabaja nativo).
//!
//! Convención de color: el índice `i` de un voxel (1..255; `0` = vacío) indexa
//! [`VoxModel::palette`]`[i]`. Al leer el chunk `RGBA` (256 entradas crudas) se
//! aplica el corrimiento documentado por MagicaVoxel (`palette[i] = crudo[i-1]`).
//! Si el archivo no trae `RGBA`, se usa una paleta por defecto (rampa HSV) — los
//! exportes reales de MagicaVoxel siempre incluyen `RGBA`, así que es un fallback.
use std::fmt;
/// Un voxel: posición en la grilla del modelo (`0..size`) + índice de color
/// (`1..255`) en la [`VoxModel::palette`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Voxel {
pub x: u8,
pub y: u8,
pub z: u8,
pub i: u8,
}
/// Un modelo voxel: dimensiones, voxels y paleta (indexada por `voxel.i`).
#[derive(Debug, Clone)]
pub struct VoxModel {
/// Dimensiones `[x, y, z]` (en el espacio del `.vox`, donde `z` es arriba).
pub size: [u32; 3],
/// Voxels ocupados.
pub voxels: Vec<Voxel>,
/// 256 colores RGBA; `palette[voxel.i]` es el color del voxel (`[0]` vacío).
pub palette: [[u8; 4]; 256],
}
impl VoxModel {
/// Modelo vacío de las dimensiones dadas con la paleta por defecto.
pub fn new(size: [u32; 3]) -> Self {
Self { size, voxels: Vec::new(), palette: default_palette() }
}
/// Color RGBA de un voxel (vía su índice en la paleta).
pub fn color(&self, v: &Voxel) -> [u8; 4] {
self.palette[v.i as usize]
}
}
/// Error de parseo de un `.vox`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VoxError {
/// Faltan los 4 bytes mágicos `"VOX "` al inicio.
BadMagic,
/// El buffer se corta antes de lo que un chunk declara.
Truncated,
/// No hay ningún par `SIZE`+`XYZI` (ningún modelo).
NoModel,
}
impl fmt::Display for VoxError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
VoxError::BadMagic => write!(f, "no es un .vox (faltan los bytes 'VOX ')"),
VoxError::Truncated => write!(f, ".vox truncado (un chunk declara más bytes de los que hay)"),
VoxError::NoModel => write!(f, ".vox sin modelos (ningún par SIZE+XYZI)"),
}
}
}
impl std::error::Error for VoxError {}
/// Una **colocación** de un modelo en la escena: índice del modelo en
/// [`Scene::models`] + la traslación de mundo acumulada por el grafo de escena
/// (`nTRN`→`nGRP`→`nSHP`). El origen del `.vox` queda en `[0,0,0]`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Placement {
/// Índice del modelo en [`Scene::models`].
pub model: usize,
/// Traslación de mundo (en voxels, eje `.vox`: z-arriba) acumulada por el grafo.
pub translation: [i32; 3],
}
/// Una escena `.vox`: los modelos crudos + sus colocaciones según el grafo de
/// escena. Si el archivo es viejo (sólo `SIZE`/`XYZI`, sin `nTRN/nGRP/nSHP`),
/// `placements` queda vacío y cada modelo se entiende en el origen.
#[derive(Clone, Debug)]
pub struct Scene {
/// Modelos crudos, uno por par `SIZE`+`XYZI`, en orden de archivo.
pub models: Vec<VoxModel>,
/// Colocaciones derivadas del grafo de escena (vacío si no hay grafo).
pub placements: Vec<Placement>,
}
/// Nodo del grafo de escena de MagicaVoxel (lo justo para ubicar modelos).
enum Node {
/// `nTRN`: aplica una traslación (frame 0) y baja a `child`.
Transform { child: i32, translation: [i32; 3] },
/// `nGRP`: agrupa varios hijos bajo la misma transformación.
Group { children: Vec<i32> },
/// `nSHP`: hoja — referencia a uno o más modelos por índice.
Shape { models: Vec<i32> },
}
/// Lee un `.vox` y devuelve todos sus modelos (uno por par `SIZE`+`XYZI`). La
/// paleta `RGBA`, si existe, se aplica a todos. Ignora el grafo de escena — para
/// escenas multi-modelo con posición usá [`parse_scene`].
pub fn parse(bytes: &[u8]) -> Result<Vec<VoxModel>, VoxError> {
Ok(parse_scene(bytes)?.models)
}
/// Lee un `.vox` completo: modelos **y** grafo de escena (`nTRN/nGRP/nSHP`),
/// resolviendo la traslación de mundo de cada modelo colocado. Las rotaciones del
/// grafo (`_r`) hoy se ignoran (MVP: sólo traslación, el caso común de "varios
/// modelos esparcidos en una escena").
pub fn parse_scene(bytes: &[u8]) -> Result<Scene, VoxError> {
if bytes.len() < 8 || &bytes[0..4] != b"VOX " {
return Err(VoxError::BadMagic);
}
// Tras el header (8 bytes) viene el chunk MAIN; sus hijos son el resto. No
// hace falta interpretar MAIN: barremos los chunks linealmente desde su
// contenido en adelante (SIZE/XYZI/RGBA no tienen hijos).
let mut pos = 8usize;
// Saltar el header del chunk MAIN (id + nContent + nChildren = 12 bytes) y su
// contenido (que es 0 en la práctica).
let main_n = read_u32(bytes, pos + 4)? as usize;
pos += 12 + main_n;
let mut sizes: Vec<[u32; 3]> = Vec::new();
let mut groups: Vec<Vec<Voxel>> = Vec::new();
let mut palette: Option<[[u8; 4]; 256]> = None;
let mut nodes: std::collections::HashMap<i32, Node> = std::collections::HashMap::new();
while pos + 12 <= bytes.len() {
let id = &bytes[pos..pos + 4];
let n = read_u32(bytes, pos + 4)? as usize;
let m = read_u32(bytes, pos + 8)? as usize;
let content_start = pos + 12;
let content_end = content_start.checked_add(n).ok_or(VoxError::Truncated)?;
if content_end > bytes.len() {
return Err(VoxError::Truncated);
}
let content = &bytes[content_start..content_end];
match id {
b"SIZE" => {
if content.len() < 12 {
return Err(VoxError::Truncated);
}
sizes.push([
read_u32(content, 0)?,
read_u32(content, 4)?,
read_u32(content, 8)?,
]);
}
b"XYZI" => {
let count = read_u32(content, 0)? as usize;
let mut vs = Vec::with_capacity(count);
let need = 4 + count * 4;
if content.len() < need {
return Err(VoxError::Truncated);
}
for k in 0..count {
let o = 4 + k * 4;
vs.push(Voxel { x: content[o], y: content[o + 1], z: content[o + 2], i: content[o + 3] });
}
groups.push(vs);
}
b"RGBA" => {
if content.len() < 256 * 4 {
return Err(VoxError::Truncated);
}
let mut pal = [[0u8; 4]; 256];
// Corrimiento MagicaVoxel: el índice de voxel `c` (1..255) → la
// `c-1`-ésima entrada cruda. `palette[0]` queda vacío.
for c in 1..256usize {
let o = (c - 1) * 4;
pal[c] = [content[o], content[o + 1], content[o + 2], content[o + 3]];
}
palette = Some(pal);
}
b"nTRN" => {
if let Ok(node) = parse_ntrn(content) {
nodes.insert(node.0, node.1);
}
}
b"nGRP" => {
if let Ok(node) = parse_ngrp(content) {
nodes.insert(node.0, node.1);
}
}
b"nSHP" => {
if let Ok(node) = parse_nshp(content) {
nodes.insert(node.0, node.1);
}
}
_ => {} // PACK, MATL, LAYR, rOBJ … no afectan geometría ni colocación.
}
pos = content_end + m;
}
if sizes.is_empty() || groups.is_empty() {
return Err(VoxError::NoModel);
}
let pal = palette.unwrap_or_else(default_palette);
let n_models = sizes.len().min(groups.len());
let models: Vec<VoxModel> = (0..n_models)
.map(|k| VoxModel { size: sizes[k], voxels: groups[k].clone(), palette: pal })
.collect();
// Recorrer el grafo desde el nodo raíz (id 0, siempre un nTRN) acumulando
// traslaciones hasta cada nSHP → una colocación por modelo referido.
let mut placements = Vec::new();
if nodes.contains_key(&0) {
let mut stack = vec![(0i32, [0i32; 3])];
// Cota dura por si un .vox trae ciclos (no debería): a lo sumo |nodos| visitas.
let mut budget = nodes.len() * 4 + 8;
while let Some((id, off)) = stack.pop() {
if budget == 0 {
break;
}
budget -= 1;
match nodes.get(&id) {
Some(Node::Transform { child, translation }) => {
let acc = [off[0] + translation[0], off[1] + translation[1], off[2] + translation[2]];
stack.push((*child, acc));
}
Some(Node::Group { children }) => {
for c in children {
stack.push((*c, off));
}
}
Some(Node::Shape { models: ms }) => {
for &mid in ms {
if (mid as usize) < models.len() {
placements.push(Placement { model: mid as usize, translation: off });
}
}
}
None => {}
}
}
}
Ok(Scene { models, placements })
}
/// Serializa un modelo a bytes `.vox` (header + `MAIN` con `SIZE`+`XYZI`+`RGBA`).
/// Útil para **exportar** una escena voxel y editarla en MagicaVoxel, y para las
/// pruebas de ida y vuelta.
pub fn write(model: &VoxModel) -> Vec<u8> {
// SIZE.
let mut size = Vec::with_capacity(12);
for d in model.size {
size.extend_from_slice(&d.to_le_bytes());
}
// XYZI.
let mut xyzi = Vec::with_capacity(4 + model.voxels.len() * 4);
xyzi.extend_from_slice(&(model.voxels.len() as u32).to_le_bytes());
for v in &model.voxels {
xyzi.extend_from_slice(&[v.x, v.y, v.z, v.i]);
}
// RGBA: crudo[j] = palette[j+1] (inverso del corrimiento de lectura).
let mut rgba = Vec::with_capacity(256 * 4);
for j in 0..256usize {
let c = j + 1;
rgba.extend_from_slice(&model.palette.get(c).copied().unwrap_or([0, 0, 0, 0]));
}
let mut children = Vec::new();
children.extend_from_slice(&chunk(b"SIZE", &size));
children.extend_from_slice(&chunk(b"XYZI", &xyzi));
children.extend_from_slice(&chunk(b"RGBA", &rgba));
let mut out = Vec::new();
out.extend_from_slice(b"VOX ");
out.extend_from_slice(&150u32.to_le_bytes()); // versión
// MAIN: contenido vacío, hijos = los chunks de arriba.
out.extend_from_slice(b"MAIN");
out.extend_from_slice(&0u32.to_le_bytes());
out.extend_from_slice(&(children.len() as u32).to_le_bytes());
out.extend_from_slice(&children);
out
}
/// Codifica un chunk **sin hijos**: `id` + `len(content)` + `0` + `content`.
fn chunk(id: &[u8; 4], content: &[u8]) -> Vec<u8> {
let mut c = Vec::with_capacity(12 + content.len());
c.extend_from_slice(id);
c.extend_from_slice(&(content.len() as u32).to_le_bytes());
c.extend_from_slice(&0u32.to_le_bytes());
c.extend_from_slice(content);
c
}
/// Lee un `u32` little-endian en `off`, o [`VoxError::Truncated`] si no entra.
fn read_u32(b: &[u8], off: usize) -> Result<u32, VoxError> {
b.get(off..off + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
.ok_or(VoxError::Truncated)
}
// --- Lectura del grafo de escena (nTRN/nGRP/nSHP) -------------------------------
// El formato extendido de MagicaVoxel usa, dentro del contenido de cada nodo,
// primitivas: int32 LE, STRING (int32 len + bytes) y DICT (int32 nPairs + pares de
// STRING). Los lectores de abajo avanzan un cursor `pos`.
/// Lee un `i32` LE en el cursor y lo avanza 4 bytes.
fn take_i32(b: &[u8], pos: &mut usize) -> Result<i32, VoxError> {
let v = read_u32(b, *pos)? as i32;
*pos += 4;
Ok(v)
}
/// Lee una `STRING` del `.vox` (int32 len + bytes) en el cursor y lo avanza.
fn take_string<'a>(b: &'a [u8], pos: &mut usize) -> Result<&'a [u8], VoxError> {
let len = read_u32(b, *pos)? as usize;
*pos += 4;
let s = b.get(*pos..*pos + len).ok_or(VoxError::Truncated)?;
*pos += len;
Ok(s)
}
/// Lee un `DICT` (int32 nPairs + pares STRING/STRING) en el cursor; devuelve los
/// pares como bytes crudos (clave, valor).
fn take_dict(b: &[u8], pos: &mut usize) -> Result<Vec<(Vec<u8>, Vec<u8>)>, VoxError> {
let n = read_u32(b, *pos)? as usize;
*pos += 4;
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let k = take_string(b, pos)?.to_vec();
let v = take_string(b, pos)?.to_vec();
out.push((k, v));
}
Ok(out)
}
/// Extrae la traslación `"_t" = "x y z"` de un dict de frame (default `[0,0,0]`).
fn translation_from_dict(d: &[(Vec<u8>, Vec<u8>)]) -> [i32; 3] {
for (k, v) in d {
if k.as_slice() == b"_t" {
let s = core::str::from_utf8(v).unwrap_or("");
let mut it = s.split_whitespace().map(|x| x.parse::<i32>().unwrap_or(0));
return [it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0)];
}
}
[0, 0, 0]
}
/// `nTRN`: nodeId, dict, childId, reservedId, layerId, numFrames, frames[]. Tomamos
/// la traslación del frame 0.
fn parse_ntrn(content: &[u8]) -> Result<(i32, Node), VoxError> {
let mut pos = 0;
let id = take_i32(content, &mut pos)?;
let _attrs = take_dict(content, &mut pos)?;
let child = take_i32(content, &mut pos)?;
let _reserved = take_i32(content, &mut pos)?;
let _layer = take_i32(content, &mut pos)?;
let n_frames = take_i32(content, &mut pos)?.max(0) as usize;
let mut translation = [0; 3];
for f in 0..n_frames {
let frame = take_dict(content, &mut pos)?;
if f == 0 {
translation = translation_from_dict(&frame);
}
}
Ok((id, Node::Transform { child, translation }))
}
/// `nGRP`: nodeId, dict, numChildren, childIds[].
fn parse_ngrp(content: &[u8]) -> Result<(i32, Node), VoxError> {
let mut pos = 0;
let id = take_i32(content, &mut pos)?;
let _attrs = take_dict(content, &mut pos)?;
let n = take_i32(content, &mut pos)?.max(0) as usize;
let mut children = Vec::with_capacity(n);
for _ in 0..n {
children.push(take_i32(content, &mut pos)?);
}
Ok((id, Node::Group { children }))
}
/// `nSHP`: nodeId, dict, numModels, (modelId, dict)[].
fn parse_nshp(content: &[u8]) -> Result<(i32, Node), VoxError> {
let mut pos = 0;
let id = take_i32(content, &mut pos)?;
let _attrs = take_dict(content, &mut pos)?;
let n = take_i32(content, &mut pos)?.max(0) as usize;
let mut models = Vec::with_capacity(n);
for _ in 0..n {
models.push(take_i32(content, &mut pos)?);
let _model_attrs = take_dict(content, &mut pos)?;
}
Ok((id, Node::Shape { models }))
}
/// Paleta por defecto **oficial de MagicaVoxel** (sólo se usa si el `.vox` no trae
/// chunk `RGBA`). Es la tabla canónica que publica el formato (ephtracy/voxel-model):
/// los exportes reales casi siempre incluyen `RGBA`, pero los modelos viejos /
/// generados a mano que omiten la paleta ahora abren con los colores correctos en
/// vez de una rampa inventada. `[0]` = vacío. Ver [`DEFAULT_PALETTE_ABGR`].
fn default_palette() -> [[u8; 4]; 256] {
let mut p = [[0u8; 4]; 256];
for (c, &v) in DEFAULT_PALETTE_ABGR.iter().enumerate() {
// La tabla viene en 0xAABBGGRR (igual que el spec); la desempacamos a RGBA.
p[c] = [
(v & 0xff) as u8,
((v >> 8) & 0xff) as u8,
((v >> 16) & 0xff) as u8,
((v >> 24) & 0xff) as u8,
];
}
p
}
/// Paleta default canónica de MagicaVoxel, en `0xAABBGGRR` por entrada (formato del
/// spec). `[0]` = transparente. Es una rampa de matices ×3 niveles + gradientes de
/// grises/RGB al final — idéntica a la que muestra MagicaVoxel al abrir un modelo
/// sin paleta propia.
#[rustfmt::skip]
const DEFAULT_PALETTE_ABGR: [u32; 256] = [
0x00000000, 0xffffffff, 0xffccffff, 0xff99ffff, 0xff66ffff, 0xff33ffff, 0xff00ffff, 0xffffccff,
0xffccccff, 0xff99ccff, 0xff66ccff, 0xff33ccff, 0xff00ccff, 0xffff99ff, 0xffcc99ff, 0xff9999ff,
0xff6699ff, 0xff3399ff, 0xff0099ff, 0xffff66ff, 0xffcc66ff, 0xff9966ff, 0xff6666ff, 0xff3366ff,
0xff0066ff, 0xffff33ff, 0xffcc33ff, 0xff9933ff, 0xff6633ff, 0xff3333ff, 0xff0033ff, 0xffff00ff,
0xffcc00ff, 0xff9900ff, 0xff6600ff, 0xff3300ff, 0xff0000ff, 0xffffffcc, 0xffccffcc, 0xff99ffcc,
0xff66ffcc, 0xff33ffcc, 0xff00ffcc, 0xffffcccc, 0xffcccccc, 0xff99cccc, 0xff66cccc, 0xff33cccc,
0xff00cccc, 0xffff99cc, 0xffcc99cc, 0xff9999cc, 0xff6699cc, 0xff3399cc, 0xff0099cc, 0xffff66cc,
0xffcc66cc, 0xff9966cc, 0xff6666cc, 0xff3366cc, 0xff0066cc, 0xffff33cc, 0xffcc33cc, 0xff9933cc,
0xff6633cc, 0xff3333cc, 0xff0033cc, 0xffff00cc, 0xffcc00cc, 0xff9900cc, 0xff6600cc, 0xff3300cc,
0xff0000cc, 0xffffff99, 0xffccff99, 0xff99ff99, 0xff66ff99, 0xff33ff99, 0xff00ff99, 0xffffcc99,
0xffcccc99, 0xff99cc99, 0xff66cc99, 0xff33cc99, 0xff00cc99, 0xffff9999, 0xffcc9999, 0xff999999,
0xff669999, 0xff339999, 0xff009999, 0xffff6699, 0xffcc6699, 0xff996699, 0xff666699, 0xff336699,
0xff006699, 0xffff3399, 0xffcc3399, 0xff993399, 0xff663399, 0xff333399, 0xff003399, 0xffff0099,
0xffcc0099, 0xff990099, 0xff660099, 0xff330099, 0xff000099, 0xffffff66, 0xffccff66, 0xff99ff66,
0xff66ff66, 0xff33ff66, 0xff00ff66, 0xffffcc66, 0xffcccc66, 0xff99cc66, 0xff66cc66, 0xff33cc66,
0xff00cc66, 0xffff9966, 0xffcc9966, 0xff999966, 0xff669966, 0xff339966, 0xff009966, 0xffff6666,
0xffcc6666, 0xff996666, 0xff666666, 0xff336666, 0xff006666, 0xffff3366, 0xffcc3366, 0xff993366,
0xff663366, 0xff333366, 0xff003366, 0xffff0066, 0xffcc0066, 0xff990066, 0xff660066, 0xff330066,
0xff000066, 0xffffff33, 0xffccff33, 0xff99ff33, 0xff66ff33, 0xff33ff33, 0xff00ff33, 0xffffcc33,
0xffcccc33, 0xff99cc33, 0xff66cc33, 0xff33cc33, 0xff00cc33, 0xffff9933, 0xffcc9933, 0xff999933,
0xff669933, 0xff339933, 0xff009933, 0xffff6633, 0xffcc6633, 0xff996633, 0xff666633, 0xff336633,
0xff006633, 0xffff3333, 0xffcc3333, 0xff993333, 0xff663333, 0xff333333, 0xff003333, 0xffff0033,
0xffcc0033, 0xff990033, 0xff660033, 0xff330033, 0xff000033, 0xffffff00, 0xffccff00, 0xff99ff00,
0xff66ff00, 0xff33ff00, 0xff00ff00, 0xffffcc00, 0xffcccc00, 0xff99cc00, 0xff66cc00, 0xff33cc00,
0xff00cc00, 0xffff9900, 0xffcc9900, 0xff999900, 0xff669900, 0xff339900, 0xff009900, 0xffff6600,
0xffcc6600, 0xff996600, 0xff666600, 0xff336600, 0xff006600, 0xffff3300, 0xffcc3300, 0xff993300,
0xff663300, 0xff333300, 0xff003300, 0xffff0000, 0xffcc0000, 0xff990000, 0xff660000, 0xff330000,
0xff0000ee, 0xff0000dd, 0xff0000bb, 0xff0000aa, 0xff000088, 0xff000077, 0xff000055, 0xff000044,
0xff000022, 0xff000011, 0xff00ee00, 0xff00dd00, 0xff00bb00, 0xff00aa00, 0xff008800, 0xff007700,
0xff005500, 0xff004400, 0xff002200, 0xff001100, 0xffee0000, 0xffdd0000, 0xffbb0000, 0xffaa0000,
0xff880000, 0xff770000, 0xff550000, 0xff440000, 0xff220000, 0xff110000, 0xffeeeeee, 0xffdddddd,
0xffbbbbbb, 0xffaaaaaa, 0xff888888, 0xff777777, 0xff555555, 0xff444444, 0xff222222, 0xff111111,
];
#[cfg(test)]
mod tests {
use super::*;
fn modelo_demo() -> VoxModel {
let mut m = VoxModel::new([4, 5, 6]);
m.palette[1] = [200, 30, 30, 255];
m.palette[2] = [30, 180, 60, 255];
m.voxels = vec![
Voxel { x: 0, y: 0, z: 0, i: 1 },
Voxel { x: 3, y: 4, z: 5, i: 2 },
Voxel { x: 1, y: 2, z: 3, i: 1 },
];
m
}
#[test]
fn ida_y_vuelta() {
let m = modelo_demo();
let bytes = write(&m);
let back = parse(&bytes).expect("parse");
assert_eq!(back.len(), 1);
let r = &back[0];
assert_eq!(r.size, [4, 5, 6]);
assert_eq!(r.voxels, m.voxels);
// Los colores de los índices usados sobreviven el corrimiento RGBA.
assert_eq!(r.palette[1], [200, 30, 30, 255]);
assert_eq!(r.palette[2], [30, 180, 60, 255]);
}
#[test]
fn rechaza_no_vox() {
assert_eq!(parse(b"NOPE....").unwrap_err(), VoxError::BadMagic);
}
#[test]
fn parsea_bytes_a_mano_sin_rgba() {
// .vox mínimo escrito a mano: header + MAIN(vacío) + SIZE(1,1,1) +
// XYZI(1 voxel en 0,0,0 índice 1). Sin RGBA → paleta por defecto.
let mut b = Vec::new();
b.extend_from_slice(b"VOX ");
b.extend_from_slice(&150u32.to_le_bytes());
// MAIN: n=0, m = bytes de los dos chunks hijos (24+16=... lo calculamos).
let size_chunk = {
let mut c = Vec::new();
c.extend_from_slice(b"SIZE");
c.extend_from_slice(&12u32.to_le_bytes());
c.extend_from_slice(&0u32.to_le_bytes());
c.extend_from_slice(&1u32.to_le_bytes());
c.extend_from_slice(&1u32.to_le_bytes());
c.extend_from_slice(&1u32.to_le_bytes());
c
};
let xyzi_chunk = {
let mut c = Vec::new();
c.extend_from_slice(b"XYZI");
c.extend_from_slice(&8u32.to_le_bytes()); // 4 (count) + 4 (un voxel)
c.extend_from_slice(&0u32.to_le_bytes());
c.extend_from_slice(&1u32.to_le_bytes()); // count
c.extend_from_slice(&[0, 0, 0, 1]); // voxel
c
};
let children_len = size_chunk.len() + xyzi_chunk.len();
b.extend_from_slice(b"MAIN");
b.extend_from_slice(&0u32.to_le_bytes());
b.extend_from_slice(&(children_len as u32).to_le_bytes());
b.extend_from_slice(&size_chunk);
b.extend_from_slice(&xyzi_chunk);
let models = parse(&b).expect("parse");
assert_eq!(models.len(), 1);
assert_eq!(models[0].size, [1, 1, 1]);
assert_eq!(models[0].voxels, vec![Voxel { x: 0, y: 0, z: 0, i: 1 }]);
assert_ne!(models[0].palette[1], [0, 0, 0, 0]); // default no vacío
}
#[test]
fn escena_multi_modelo_con_traslacion() {
// .vox extendido a mano: dos modelos + grafo
// nTRN(0)→nGRP(1)→{ nTRN(2)→nSHP(3:model0), nTRN(4,_t="5 0 0")→nSHP(5:model1) }.
// Esperado: model0 en [0,0,0], model1 en [5,0,0].
let i32b = |v: i32| v.to_le_bytes().to_vec();
let s = |txt: &str| {
let mut o = (txt.len() as u32).to_le_bytes().to_vec();
o.extend_from_slice(txt.as_bytes());
o
};
let dict0 = || 0u32.to_le_bytes().to_vec(); // dict vacío
let dict_t = |t: &str| {
let mut o = 1u32.to_le_bytes().to_vec();
o.extend_from_slice(&s("_t"));
o.extend_from_slice(&s(t));
o
};
let cat = |parts: &[Vec<u8>]| parts.concat();
let size = {
let mut c = Vec::new();
c.extend_from_slice(&i32b(2));
c.extend_from_slice(&i32b(2));
c.extend_from_slice(&i32b(2));
c
};
let xyzi = {
let mut c = 1u32.to_le_bytes().to_vec();
c.extend_from_slice(&[0, 0, 0, 1]);
c
};
// nTRN(id, child, frame): id, dict0, child, -1, -1, 1, frame.
let ntrn = |id: i32, child: i32, frame: Vec<u8>| {
cat(&[i32b(id), dict0(), i32b(child), i32b(-1), i32b(-1), i32b(1), frame])
};
let nshp = |id: i32, model: i32| {
// id, dict0, numModels=1, (modelId, dict0)
cat(&[i32b(id), dict0(), i32b(1), i32b(model), dict0()])
};
let ngrp = |id: i32, kids: &[i32]| {
let mut c = cat(&[i32b(id), dict0(), i32b(kids.len() as i32)]);
for &k in kids {
c.extend_from_slice(&i32b(k));
}
c
};
let children = cat(&[
chunk(b"SIZE", &size),
chunk(b"XYZI", &xyzi),
chunk(b"SIZE", &size),
chunk(b"XYZI", &xyzi),
chunk(b"nTRN", &ntrn(0, 1, dict0())),
chunk(b"nGRP", &ngrp(1, &[2, 4])),
chunk(b"nTRN", &ntrn(2, 3, dict0())),
chunk(b"nSHP", &nshp(3, 0)),
chunk(b"nTRN", &ntrn(4, 5, dict_t("5 0 0"))),
chunk(b"nSHP", &nshp(5, 1)),
]);
let mut b = Vec::new();
b.extend_from_slice(b"VOX ");
b.extend_from_slice(&150u32.to_le_bytes());
b.extend_from_slice(b"MAIN");
b.extend_from_slice(&0u32.to_le_bytes());
b.extend_from_slice(&(children.len() as u32).to_le_bytes());
b.extend_from_slice(&children);
let scene = parse_scene(&b).expect("parse_scene");
assert_eq!(scene.models.len(), 2);
assert_eq!(scene.placements.len(), 2, "dos colocaciones");
let mut by_model: std::collections::HashMap<usize, [i32; 3]> = Default::default();
for p in &scene.placements {
by_model.insert(p.model, p.translation);
}
assert_eq!(by_model.get(&0), Some(&[0, 0, 0]));
assert_eq!(by_model.get(&1), Some(&[5, 0, 0]));
}
#[test]
fn vox_viejo_sin_grafo_no_tiene_placements() {
// Un .vox de un solo modelo sin nTRN/nGRP/nSHP → placements vacío.
let m = modelo_demo();
let scene = parse_scene(&write(&m)).expect("parse_scene");
assert_eq!(scene.models.len(), 1);
assert!(scene.placements.is_empty());
}
#[test]
fn paleta_default_es_la_oficial() {
let p = default_palette();
assert_eq!(p[0], [0, 0, 0, 0]); // índice 0 = vacío/transparente
assert_eq!(p[1], [255, 255, 255, 255]); // 0xffffffff = blanco
assert_eq!(p[6], [255, 255, 0, 255]); // 0xff00ffff (AABBGGRR) = amarillo
assert_eq!(p[255], [17, 17, 17, 255]); // 0xff111111 = gris oscuro
// Toda entrada usable (1..256) es opaca y no vacía.
for c in 1..256 {
assert_eq!(p[c][3], 255, "alpha en índice {c}");
}
}
}