tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}