tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Sutichaykuna

⎇ main llimphi-3d/src/voxel.rs Rust · 501 siq'ikuna · 21.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `VoxelGrid` — grid de voxels denso y acotado (CPU side). Cada voxel es
2//! RGBA8: `rgb` = color, `a` = ocupación (`0` vacío, `>0` sólido). Se sube a
3//! una textura 3D de GPU que el shader ray-march recorre por DDA.
4//!
5//! M1 es **denso** a propósito (lo más simple que funciona). El salto a sparse
6//! (SVO/brickmap, saltar el aire) es M2 — ver `MOTOR-VOXEL.md` §11.2.
7//!
8//! M3 agrega **dirty tracking**: cada `set`/`clear` expande una caja AABB de la
9//! región cambiada. `VoxelRenderer::sync` sube sólo esa sub-caja (fina + bricks
10//! gruesos afectados) — la actualización incremental que reemplaza al re-mesh.
11
12/// Caja AABB de voxels cambiados desde el último `take_dirty`: `[xmin, ymin,
13/// zmin, xmax, ymax, zmax]` inclusiva.
14pub type DirtyBox = [u32; 6];
15
16/// Grid denso de voxels RGBA8. Índice lineal `x + y*dx + z*dx*dy` (x contiguo),
17/// que es justo el layout que espera `queue.write_texture` (filas en x, luego y,
18/// luego capas en z).
19#[derive(Clone)]
20pub struct VoxelGrid {
21    dim: [u32; 3],
22    data: Vec<[u8; 4]>,
23    /// **Detalle sub-voxel por voxel** (Rg8): `[rugosidad, bisel]` codificados con
24    /// **offset +1** para distinguir "sin autor" (`[0,0]`) de "autor puso 0"
25    /// (`[1,1]`). `rug`/`bisel` autorados en `[0,1]` (rug re-escalado a amplitud
26    /// `0..1.5` en el shader). Todo `0` = el voxel usa el **look global** del
27    /// ray-march (`VoxelRenderer::{rugosidad_amp, bisel}`) → retrocompat byte a
28    /// byte cuando nadie lo toca. Lo llena [`set_detail`](Self::set_detail); el
29    /// renderer lo sube a un atlas paralelo (`dpool`) que espeja los slots del
30    /// pool de color. Es una propiedad de **construcción** del material, no un
31    /// canal editado en vivo (`sync`/`scroll_to` no lo re-suben).
32    detail: Vec<[u8; 2]>,
33    /// **Pincel de detalle** activo (bytes ya codificados con offset +1, o `None`).
34    /// Cuando está puesto, cada [`set`](Self::set)/[`set_rgba`](Self::set_rgba)
35    /// estampa también ESTE detalle en el voxel — así los estampadores profundos
36    /// (`place_objeto`, `stamp_bicho`…) heredan el detalle del material/ser sin
37    /// tener que threadearlo por cada `put`. Se prende con
38    /// [`set_detail_brush`](Self::set_detail_brush) y se apaga con
39    /// [`clear_detail_brush`](Self::clear_detail_brush). `None` = comportamiento
40    /// clásico (el voxel queda "sin autor" salvo `set_detail` explícito).
41    detail_brush: Option<[u8; 2]>,
42    /// AABB de voxels mutados desde el último `take_dirty`. `None` = sin cambios.
43    dirty: Option<DirtyBox>,
44}
45
46impl VoxelGrid {
47    /// Grid vacío de `dim = [dx, dy, dz]` voxels.
48    pub fn new(dim: [u32; 3]) -> Self {
49        let n = (dim[0] * dim[1] * dim[2]) as usize;
50        Self {
51            dim,
52            data: vec![[0, 0, 0, 0]; n],
53            detail: vec![[0, 0]; n],
54            detail_brush: None,
55            dirty: None,
56        }
57    }
58
59    /// Dimensiones `[dx, dy, dz]`.
60    pub fn dim(&self) -> [u32; 3] {
61        self.dim
62    }
63
64    #[inline]
65    fn idx(&self, x: u32, y: u32, z: u32) -> usize {
66        (x + y * self.dim[0] + z * self.dim[0] * self.dim[1]) as usize
67    }
68
69    #[inline]
70    fn mark_dirty(&mut self, x: u32, y: u32, z: u32) {
71        match &mut self.dirty {
72            None => self.dirty = Some([x, y, z, x, y, z]),
73            Some(d) => {
74                d[0] = d[0].min(x);
75                d[1] = d[1].min(y);
76                d[2] = d[2].min(z);
77                d[3] = d[3].max(x);
78                d[4] = d[4].max(y);
79                d[5] = d[5].max(z);
80            }
81        }
82    }
83
84    /// Toma y limpia la caja de cambios pendientes. `VoxelRenderer::sync` la usa
85    /// para subir sólo lo mutado. `None` si no hubo cambios desde la última toma.
86    pub fn take_dirty(&mut self) -> Option<DirtyBox> {
87        self.dirty.take()
88    }
89
90    /// Descarta los cambios pendientes sin subirlos (tras un upload completo, el
91    /// estado inicial ya está en GPU).
92    pub fn reset_dirty(&mut self) {
93        self.dirty = None;
94    }
95
96    /// Marca **todo** el grid como sucio (caja = grid entero) → el próximo
97    /// [`VoxelRenderer::sync`](crate::VoxelRenderer::sync) re-sube la ventana completa.
98    /// Útil al **regenerar** el grid por streaming (la ventana cambió de origen).
99    pub fn mark_all_dirty(&mut self) {
100        self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]);
101    }
102
103    /// Marca un voxel sólido con color `rgb` (alpha = 255). Fuera de rango: no-op.
104    pub fn set(&mut self, x: u32, y: u32, z: u32, rgb: [u8; 3]) {
105        if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
106            let i = self.idx(x, y, z);
107            self.data[i] = [rgb[0], rgb[1], rgb[2], 255];
108            if let Some(d) = self.detail_brush {
109                self.detail[i] = d;
110            }
111            self.mark_dirty(x, y, z);
112        }
113    }
114
115    /// Marca un voxel con color `rgb` y **alpha** explícito. El alpha codifica
116    /// ocupación **y** brillo: `>127` = sólido; el shader lee el brillo como
117    /// `(1 - a/255)·2` (matte `255` → 0 brillo, `130` → lustre pleno). Pasar
118    /// alpha `≤127` lo dejaría "vacío" para el shader — usa `[130, 255]` para
119    /// voxels lustrosos. Fuera de rango: no-op.
120    pub fn set_rgba(&mut self, x: u32, y: u32, z: u32, rgb: [u8; 3], a: u8) {
121        if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
122            let i = self.idx(x, y, z);
123            self.data[i] = [rgb[0], rgb[1], rgb[2], a];
124            if let Some(d) = self.detail_brush {
125                self.detail[i] = d;
126            }
127            self.mark_dirty(x, y, z);
128        }
129    }
130
131    /// Convierte un factor de **brillo** `[0,1]` al alpha de voxel correspondiente
132    /// (`0` → `255` matte, `1` → `130` lustre pleno), manteniéndolo siempre sólido.
133    #[inline]
134    pub fn shine_to_alpha(brillo: f32) -> u8 {
135        255 - (brillo.clamp(0.0, 1.0) * 125.0) as u8
136    }
137
138    /// Amplitud de rugosidad `[0,1.5]` que el shader mapea desde el byte de detalle
139    /// (espeja `(byte-1)/254·1.5`). Expuesto para que el caller razone en la misma
140    /// escala que `VoxelRenderer::rugosidad_amp`.
141    pub const DETALLE_RUG_MAX: f32 = 1.5;
142
143    /// Codifica un factor `[0,1]` al byte de detalle con **offset +1** (`0` autorado
144    /// → `1`, distinguible del `0` "sin autor"). `norm` ya normalizado a `[0,1]`.
145    #[inline]
146    fn detail_byte(norm: f32) -> u8 {
147        1 + (norm.clamp(0.0, 1.0) * 254.0).round() as u8
148    }
149
150    /// **Detalle sub-voxel de este voxel** (capa 3 del plan): `rugosidad` en
151    /// amplitud `[0, DETALLE_RUG_MAX]` y `bisel` en `[0,1]`, autorados por voxel.
152    /// Sobrescribe el look **global** del ray-march sólo para este voxel (dos
153    /// materiales en la misma escena pueden lucir distinto a la vez — lo que el
154    /// escalar global no podía). No cambia la ocupación; pon el voxel sólido con
155    /// `set`/`set_rgba` aparte. Fuera de rango: no-op. No marca dirty: el detalle
156    /// es propiedad de construcción (el renderer lo hornea al armar/rearmar).
157    pub fn set_detail(&mut self, x: u32, y: u32, z: u32, rugosidad: f32, bisel: f32) {
158        if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
159            let i = self.idx(x, y, z);
160            self.detail[i] = [
161                Self::detail_byte(rugosidad / Self::DETALLE_RUG_MAX),
162                Self::detail_byte(bisel),
163            ];
164        }
165    }
166
167    /// **Prende el pincel de detalle**: a partir de aquí cada `set`/`set_rgba`
168    /// estampa también este `(rugosidad, bisel)` en el voxel escrito (misma escala
169    /// que [`set_detail`](Self::set_detail)). Pensado para que los estampadores
170    /// profundos (flora en `place_objeto`, seres en `stamp_bicho`, muebles…) hereden
171    /// el detalle del material/ser sin threadearlo por cada `put`. Acuérdate de
172    /// apagarlo con [`clear_detail_brush`](Self::clear_detail_brush) al terminar el
173    /// objeto, o teñirás de más. No toca voxels ya escritos.
174    pub fn set_detail_brush(&mut self, rugosidad: f32, bisel: f32) {
175        self.detail_brush = Some([
176            Self::detail_byte(rugosidad / Self::DETALLE_RUG_MAX),
177            Self::detail_byte(bisel),
178        ]);
179    }
180
181    /// Apaga el pincel de detalle (vuelve al comportamiento clásico: los `set`
182    /// posteriores no estampan detalle salvo `set_detail` explícito).
183    pub fn clear_detail_brush(&mut self) {
184        self.detail_brush = None;
185    }
186
187    /// Detalle codificado `[rug, bisel]` (bytes con offset +1; `[0,0]` = sin autor)
188    /// del voxel `(x,y,z)`, o `None` fuera de rango.
189    pub fn detail_at(&self, x: u32, y: u32, z: u32) -> Option<[u8; 2]> {
190        (x < self.dim[0] && y < self.dim[1] && z < self.dim[2])
191            .then(|| self.detail[self.idx(x, y, z)])
192    }
193
194    /// Vacía **todos** los voxels y marca el grid entero como dirty (la próxima
195    /// `VoxelRenderer::sync` re-sube todo). Para regenerar el contenido de una
196    /// ventana de *streaming* in-place sin reconstruir el renderer.
197    pub fn clear_all(&mut self) {
198        for px in &mut self.data {
199            *px = [0, 0, 0, 0];
200        }
201        for d in &mut self.detail {
202            *d = [0, 0];
203        }
204        self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]);
205    }
206
207    /// Vacía un voxel.
208    pub fn clear(&mut self, x: u32, y: u32, z: u32) {
209        if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
210            let i = self.idx(x, y, z);
211            self.data[i] = [0, 0, 0, 0];
212            self.detail[i] = [0, 0];
213            self.mark_dirty(x, y, z);
214        }
215    }
216
217    #[inline]
218    fn solid(&self, x: u32, y: u32, z: u32) -> bool {
219        self.data[self.idx(x, y, z)][3] > 0
220    }
221
222    /// `true` si el voxel `(x,y,z)` es sólido. Fuera de rango → `false` (el
223    /// "afuera" del mundo es aire). Lo usa el raycast de `llimphi-voxel` para
224    /// picking/edición (mirar → bloque).
225    #[inline]
226    pub fn is_solid(&self, x: i32, y: i32, z: i32) -> bool {
227        if x < 0 || y < 0 || z < 0 {
228            return false;
229        }
230        let (x, y, z) = (x as u32, y as u32, z as u32);
231        x < self.dim[0] && y < self.dim[1] && z < self.dim[2] && self.solid(x, y, z)
232    }
233
234    /// Color RGBA del voxel `(x,y,z)`, o `None` fuera de rango. `a = 0` = aire.
235    pub fn get(&self, x: u32, y: u32, z: u32) -> Option<[u8; 4]> {
236        (x < self.dim[0] && y < self.dim[1] && z < self.dim[2]).then(|| self.data[self.idx(x, y, z)])
237    }
238
239    /// Altura del voxel sólido más alto en la columna `(x, z)` (escaneando de
240    /// arriba hacia abajo), o `None` si la columna está vacía. Útil para posar
241    /// una cámara/entidad sobre el terreno sin meterla dentro de la roca.
242    pub fn height_at(&self, x: u32, z: u32) -> Option<u32> {
243        if x >= self.dim[0] || z >= self.dim[2] {
244            return None;
245        }
246        (0..self.dim[1]).rev().find(|&y| self.solid(x, y, z))
247    }
248
249    /// Mapa de ocupación grueso por *bricks* de `brick³` voxels (M2): un texel
250    /// por brick, `255` si el brick contiene algún voxel sólido, `0` si está
251    /// todo vacío. El shader marcha primero esta grilla gruesa y se salta los
252    /// bricks vacíos enteros en un paso (empty-space skipping). Devuelve
253    /// `(dim_grueso, bytes R8)` con índice `cx + cy*cdx + cz*cdx*cdy`.
254    pub fn coarse_occupancy(&self, brick: u32) -> ([u32; 3], Vec<u8>) {
255        let b = brick.max(1);
256        let cdim = [
257            self.dim[0].div_ceil(b),
258            self.dim[1].div_ceil(b),
259            self.dim[2].div_ceil(b),
260        ];
261        let mut out = vec![0u8; (cdim[0] * cdim[1] * cdim[2]) as usize];
262        for z in 0..self.dim[2] {
263            for y in 0..self.dim[1] {
264                for x in 0..self.dim[0] {
265                    if self.solid(x, y, z) {
266                        let (cx, cy, cz) = (x / b, y / b, z / b);
267                        out[(cx + cy * cdim[0] + cz * cdim[0] * cdim[1]) as usize] = 255;
268                    }
269                }
270            }
271        }
272        (cdim, out)
273    }
274
275    /// `255` si el brick `(cx,cy,cz)` (tamaño `b`) tiene algún voxel sólido,
276    /// `0` si está todo vacío. Lo usa el brick pool para decidir si un brick
277    /// necesita slot.
278    pub fn brick_occupied(&self, b: u32, cx: u32, cy: u32, cz: u32) -> u8 {
279        let (x0, y0, z0) = (cx * b, cy * b, cz * b);
280        for z in z0..(z0 + b).min(self.dim[2]) {
281            for y in y0..(y0 + b).min(self.dim[1]) {
282                for x in x0..(x0 + b).min(self.dim[0]) {
283                    if self.solid(x, y, z) {
284                        return 255;
285                    }
286                }
287            }
288        }
289        0
290    }
291
292    /// Extrae los voxels de un brick `(cx,cy,cz)` de lado `brick` como RGBA
293    /// plano (`brick³` voxels, x contiguo), padeando con vacío los voxels fuera
294    /// del grid (bricks de borde cuando `dim` no es múltiplo de `brick`). Es la
295    /// unidad de subida al *pool* sparse (un slot del atlas = un brick).
296    pub fn extract_brick(&self, brick: u32, cx: u32, cy: u32, cz: u32) -> Vec<u8> {
297        let b = brick;
298        let mut out = vec![0u8; (b * b * b * 4) as usize];
299        for lz in 0..b {
300            for ly in 0..b {
301                for lx in 0..b {
302                    let (x, y, z) = (cx * b + lx, cy * b + ly, cz * b + lz);
303                    if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
304                        let px = self.data[self.idx(x, y, z)];
305                        let o = ((lx + ly * b + lz * b * b) * 4) as usize;
306                        out[o..o + 4].copy_from_slice(&px);
307                    }
308                }
309            }
310        }
311        out
312    }
313
314    /// Extrae el **detalle sub-voxel** de un brick `(cx,cy,cz)` de lado `brick`
315    /// como Rg8 plano (`brick³` voxels × 2 bytes, x contiguo), padeando con `[0,0]`
316    /// (sin autor → look global) los voxels fuera del grid. Espeja `extract_brick`
317    /// para el atlas paralelo `dpool` del renderer.
318    pub fn extract_brick_detail(&self, brick: u32, cx: u32, cy: u32, cz: u32) -> Vec<u8> {
319        let b = brick;
320        let mut out = vec![0u8; (b * b * b * 2) as usize];
321        for lz in 0..b {
322            for ly in 0..b {
323                for lx in 0..b {
324                    let (x, y, z) = (cx * b + lx, cy * b + ly, cz * b + lz);
325                    if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
326                        let d = self.detail[self.idx(x, y, z)];
327                        let o = ((lx + ly * b + lz * b * b) * 2) as usize;
328                        out[o..o + 2].copy_from_slice(&d);
329                    }
330                }
331            }
332        }
333        out
334    }
335
336    /// Extrae una sub-caja RGBA contigua `[origin, origin+ext)` para subirla con
337    /// `queue.write_texture` (M3: upload incremental de la región fina mutada).
338    pub fn extract_fine(&self, origin: [u32; 3], ext: [u32; 3]) -> Vec<u8> {
339        let mut out = Vec::with_capacity((ext[0] * ext[1] * ext[2] * 4) as usize);
340        for z in origin[2]..origin[2] + ext[2] {
341            for y in origin[1]..origin[1] + ext[1] {
342                let row = self.idx(origin[0], y, z);
343                for i in 0..ext[0] as usize {
344                    out.extend_from_slice(&self.data[row + i]);
345                }
346            }
347        }
348        out
349    }
350
351    /// Recalcula la ocupación gruesa de la caja de bricks `[cmin, cmin+cext)` y
352    /// la devuelve contigua (R8) para subir sólo esos bricks (M3).
353    pub fn coarse_region(&self, brick: u32, cmin: [u32; 3], cext: [u32; 3]) -> Vec<u8> {
354        let b = brick.max(1);
355        let mut out = Vec::with_capacity((cext[0] * cext[1] * cext[2]) as usize);
356        for cz in cmin[2]..cmin[2] + cext[2] {
357            for cy in cmin[1]..cmin[1] + cext[1] {
358                for cx in cmin[0]..cmin[0] + cext[0] {
359                    out.push(self.brick_occupied(b, cx, cy, cz));
360                }
361            }
362        }
363        out
364    }
365
366    /// Bytes RGBA planos listos para `queue.write_texture`.
367    pub fn bytes(&self) -> &[u8] {
368        // `[u8;4]` es contiguo: reinterpretamos el Vec como bytes planos.
369        // SAFETY: `[u8;4]` no tiene padding; len*4 bytes válidos.
370        unsafe {
371            std::slice::from_raw_parts(self.data.as_ptr() as *const u8, self.data.len() * 4)
372        }
373    }
374
375    /// Escena de prueba para M1: un piso de 2 capas + una esfera coloreada por
376    /// posición flotando en el centro. Pone a prueba el DDA (atraviesa aire,
377    /// pega en piso y en esfera) y el sombreado por normal de cara.
378    pub fn demo_scene(dim: [u32; 3]) -> Self {
379        let mut g = Self::new(dim);
380        let [dx, dy, dz] = dim;
381
382        // Piso: 2 capas grises abajo, con un leve damero para leer la perspectiva.
383        for z in 0..dz {
384            for x in 0..dx {
385                let chk = ((x / 4 + z / 4) % 2) == 0;
386                let base = if chk { 70 } else { 95 };
387                for y in 0..2 {
388                    g.set(x, y, z, [base, base + 8, base + 16]);
389                }
390            }
391        }
392
393        // Esfera centrada, color por posición normalizada.
394        let cx = dx as f32 / 2.0;
395        let cy = dy as f32 * 0.55;
396        let cz = dz as f32 / 2.0;
397        let r = (dx.min(dy).min(dz) as f32) * 0.3;
398        for z in 0..dz {
399            for y in 0..dy {
400                for x in 0..dx {
401                    let (fx, fy, fz) = (x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5);
402                    let d = ((fx - cx).powi(2) + (fy - cy).powi(2) + (fz - cz).powi(2)).sqrt();
403                    if d <= r {
404                        let rr = (fx / dx as f32 * 255.0) as u8;
405                        let gg = (fy / dy as f32 * 255.0) as u8;
406                        let bb = (fz / dz as f32 * 255.0) as u8;
407                        g.set(x, y, z, [rr, gg, bb]);
408                    }
409                }
410            }
411        }
412
413        // Pilares: dan rincones para el AO y proyectan/reciben sombras.
414        let pillars: [(u32, u32, u32, [u8; 3]); 3] = [
415            (dx / 5, dz / 4, dy * 7 / 10, [200, 120, 90]),
416            (dx * 4 / 5, dz / 3, dy / 2, [110, 170, 120]),
417            (dx / 3, dz * 4 / 5, dy * 3 / 5, [120, 130, 210]),
418        ];
419        for (px, pz, ph, col) in pillars {
420            for y in 2..(2 + ph).min(dy) {
421                for dxx in 0..3u32 {
422                    for dzz in 0..3u32 {
423                        g.set(px + dxx, y, pz + dzz, col);
424                    }
425                }
426            }
427        }
428        // Estado inicial: el upload completo lo cubre, no es "mutación".
429        g.reset_dirty();
430        g
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn detalle_sin_autor_es_cero() {
440        // Un grid recién nacido no tiene detalle → todo `[0,0]` (look global).
441        let g = VoxelGrid::new([4, 4, 4]);
442        assert_eq!(g.detail_at(1, 1, 1), Some([0, 0]));
443        assert_eq!(g.detail_at(9, 0, 0), None, "fuera de rango");
444    }
445
446    #[test]
447    fn detalle_offset_distingue_cero_autorado() {
448        let mut g = VoxelGrid::new([4, 4, 4]);
449        // Autor pone 0 en ambos → bytes `[1,1]`, DISTINTOS del `[0,0]` sin autor.
450        g.set_detail(0, 0, 0, 0.0, 0.0);
451        assert_eq!(g.detail_at(0, 0, 0), Some([1, 1]), "0 autorado ≠ sin autor");
452        // Máximos → 255 (1 + 254).
453        g.set_detail(1, 0, 0, VoxelGrid::DETALLE_RUG_MAX, 1.0);
454        assert_eq!(g.detail_at(1, 0, 0), Some([255, 255]));
455        // Un valor intermedio queda por encima del piso.
456        g.set_detail(2, 0, 0, VoxelGrid::DETALLE_RUG_MAX * 0.5, 0.5);
457        let d = g.detail_at(2, 0, 0).unwrap();
458        assert!(d[0] > 120 && d[0] < 135, "rug medio ≈ 128: {}", d[0]);
459        assert!(d[1] > 120 && d[1] < 135, "bisel medio ≈ 128: {}", d[1]);
460    }
461
462    #[test]
463    fn pincel_de_detalle_tine_los_set_posteriores() {
464        let mut g = VoxelGrid::new([4, 4, 4]);
465        // Sin pincel: `set` deja el voxel "sin autor".
466        g.set(0, 0, 0, [10, 20, 30]);
467        assert_eq!(g.detail_at(0, 0, 0), Some([0, 0]), "sin pincel = sin autor");
468        // Con pincel: `set` y `set_rgba` heredan el detalle activo.
469        g.set_detail_brush(VoxelGrid::DETALLE_RUG_MAX, 1.0);
470        g.set(1, 0, 0, [10, 20, 30]);
471        g.set_rgba(2, 0, 0, [10, 20, 30], 130);
472        assert_eq!(g.detail_at(1, 0, 0), Some([255, 255]), "set hereda el pincel");
473        assert_eq!(g.detail_at(2, 0, 0), Some([255, 255]), "set_rgba hereda el pincel");
474        // Apagado: vuelve a "sin autor".
475        g.clear_detail_brush();
476        g.set(3, 0, 0, [10, 20, 30]);
477        assert_eq!(g.detail_at(3, 0, 0), Some([0, 0]), "pincel apagado = sin autor");
478    }
479
480    #[test]
481    fn extract_brick_detail_ubica_y_padea() {
482        let mut g = VoxelGrid::new([8, 8, 8]);
483        g.set_detail(1, 2, 3, VoxelGrid::DETALLE_RUG_MAX, 1.0);
484        let d = g.extract_brick_detail(8, 0, 0, 0);
485        assert_eq!(d.len(), 8 * 8 * 8 * 2);
486        // Índice del voxel (1,2,3) en el brick, ×2 bytes.
487        let o = (1 + 2 * 8 + 3 * 8 * 8) * 2;
488        assert_eq!(&d[o..o + 2], &[255, 255], "detalle en su lugar");
489        // El resto padeado en 0 (sin autor).
490        assert_eq!(&d[0..2], &[0, 0]);
491    }
492
493    #[test]
494    fn clear_borra_detalle() {
495        let mut g = VoxelGrid::new([4, 4, 4]);
496        g.set(0, 0, 0, [200, 100, 50]);
497        g.set_detail(0, 0, 0, 1.0, 0.8);
498        g.clear(0, 0, 0);
499        assert_eq!(g.detail_at(0, 0, 0), Some([0, 0]), "clear también zera el detalle");
500    }
501}