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:
@@ -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]
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user