tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-voxel/src/foto.rs Rust · 565 lines · 22.3 KB History Who touched what raw

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//! Foto → **relieve voxel** (medallón/placa) — herramienta de PROP/decoración.
2//!
3//! **OJO (lección 2026-07-18):** esto NO es el camino para PERSONAJES. Voxelizar una
4//! foto de una persona en literal (busto fotográfico) cae de lleno en el valle de
5//! inquietud — a tamaño grande **da terror**. La especie tiene un **estilo propio**
6//! (el `CharSpec`/Actor estilizado, con gestos y personalidad); la foto de un personaje
7//! **sólo debe regular RASGOS** (color de piel/ropa), no volverse geometría (ver
8//! `llimphi-voxel-studio::rostro`). Este módulo queda para RELIEVES de props/decoración
9//! (un medallón, un bajorrelieve), donde una foto cuantizada sí funciona como textura
10//! con volumen. Ver [[detalle-sub-voxel-rugosidad]].
11//!
12//! Puro y `std`: recibe un buffer **RGBA ya decodificado** (el decode y la profundidad
13//! monocular por ONNX viven afuera, en el borde). El relieve por defecto es un **domo
14//! por transformada de distancia** al borde del recorte, con modos Luminancia y Plano.
15//!
16//! Puro y `std`: recibe un buffer **RGBA ya decodificado** (el decode del PNG/JPEG y,
17//! a futuro, la profundidad monocular por ONNX viven afuera, en el borde). El relieve
18//! por defecto es un **domo por transformada de distancia** al borde del recorte
19//! (el centro de la masa sobresale, los bordes se afinan → medallón/busto redondeado),
20//! con modos alternativos Luminancia (brillo = cercanía) y Plano (placa recortada).
21
22use llimphi_3d::VoxelGrid;
23use serde::{Deserialize, Serialize};
24
25/// Cómo derivar la profundidad (grosor en Z) de cada píxel del recorte.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27pub enum Relieve {
28    /// Bulto redondeado por transformada de distancia al borde del recorte. Da un
29    /// relieve creíble desde la sola silueta, sin depth real. Es el default.
30    Domo,
31    /// Brillo del píxel = cercanía (heightmap). Útil si la foto ya trae una pista de
32    /// profundidad en su sombreado.
33    Luminancia,
34    /// Grosor uniforme (placa recortada, tipo sticker grueso).
35    Plano,
36    /// **Profundidad monocular real**: el grosor sale de un mapa de profundidad
37    /// externo (blanco = cerca = grueso), estimado por un modelo tipo MiDaS/DPT en
38    /// el borde (`pixel-verbo-onnx`, [`OpPixel::Profundidad`]). Requiere pasar el
39    /// mapa a [`photo_to_grid_depth`]; con [`photo_to_grid`] (sin mapa) cae al domo.
40    Profundidad,
41}
42
43/// Opciones de la voxelización de una foto.
44#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
45pub struct FotoOpts {
46    /// Resolución objetivo en X (se downsamplea; el alto sale del aspecto).
47    pub ancho: u32,
48    /// Grosor máximo del relieve, en voxels.
49    pub profundidad: u32,
50    /// Cómo derivar la profundidad.
51    pub relieve: Relieve,
52    /// Píxeles con `alpha < alpha_min` = fondo (se recortan). `0` = sin recorte.
53    pub alpha_min: u8,
54    /// Jitter de color determinista `[0,1]` para dar grano (0 = color plano).
55    pub grano: f32,
56}
57
58impl Default for FotoOpts {
59    fn default() -> Self {
60        Self { ancho: 56, profundidad: 12, relieve: Relieve::Domo, alpha_min: 128, grano: 0.06 }
61    }
62}
63
64/// Resumen de la voxelización (para certificar sin introspección de la grilla).
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub struct FotoStats {
67    /// Voxels sólidos colocados.
68    pub voxeles: u32,
69    /// Píxeles del recorte (foreground) tras el downsample.
70    pub recorte: u32,
71    /// Grosor mínimo y máximo del relieve (en voxels) sobre el recorte.
72    pub grosor_min: u32,
73    pub grosor_max: u32,
74    /// Color promedio de los voxels colocados (fidelidad al original).
75    pub color_medio: [u8; 3],
76    /// Dimensiones de la grilla resultante.
77    pub dim: [u32; 3],
78}
79
80/// Luminancia Rec.709 `[0,1]`.
81#[inline]
82fn lum(rgb: [u8; 3]) -> f32 {
83    (0.2126 * rgb[0] as f32 + 0.7152 * rgb[1] as f32 + 0.0722 * rgb[2] as f32) / 255.0
84}
85
86/// Hash determinista → `[-1,1]` para el grano de color (sin RNG).
87#[inline]
88fn jitter(x: u32, y: u32, z: u32) -> f32 {
89    let mut h = x.wrapping_mul(374761393).wrapping_add(y.wrapping_mul(668265263));
90    h = h.wrapping_add(z.wrapping_mul(2246822519));
91    h ^= h >> 13;
92    h = h.wrapping_mul(1274126177);
93    ((h & 0xffff) as f32 / 65535.0) * 2.0 - 1.0
94}
95
96/// Transformada de distancia (chamfer 3-4, dos pasadas): a cada píxel del recorte le
97/// asigna la distancia (aprox euclídea) al fondo más cercano. Fondo = 0.
98fn distance_transform(mask: &[bool], w: usize, h: usize) -> Vec<f32> {
99    let big = (w + h) as f32 * 2.0;
100    let mut d = vec![0.0f32; w * h];
101    for i in 0..w * h {
102        d[i] = if mask[i] { big } else { 0.0 };
103    }
104    // Pasada hacia adelante (vecinos arriba/izquierda).
105    for y in 0..h {
106        for x in 0..w {
107            let i = y * w + x;
108            if !mask[i] {
109                continue;
110            }
111            let mut m = d[i];
112            if x > 0 {
113                m = m.min(d[i - 1] + 1.0);
114            }
115            if y > 0 {
116                m = m.min(d[i - w] + 1.0);
117                if x > 0 {
118                    m = m.min(d[i - w - 1] + 1.41421);
119                }
120                if x + 1 < w {
121                    m = m.min(d[i - w + 1] + 1.41421);
122                }
123            }
124            d[i] = m;
125        }
126    }
127    // Pasada hacia atrás (vecinos abajo/derecha).
128    for y in (0..h).rev() {
129        for x in (0..w).rev() {
130            let i = y * w + x;
131            if !mask[i] {
132                continue;
133            }
134            let mut m = d[i];
135            if x + 1 < w {
136                m = m.min(d[i + 1] + 1.0);
137            }
138            if y + 1 < h {
139                m = m.min(d[i + w] + 1.0);
140                if x + 1 < w {
141                    m = m.min(d[i + w + 1] + 1.41421);
142                }
143                if x > 0 {
144                    m = m.min(d[i + w - 1] + 1.41421);
145                }
146            }
147            d[i] = m;
148        }
149    }
150    d
151}
152
153/// **Voxeliza una foto** a un busto/medallón `VoxelGrid`, con el color del original y
154/// un relieve por [`Relieve`]. `rgba` = buffer fila-por-fila `w×h` (4 bytes/píxel,
155/// origen arriba-izquierda). Devuelve la grilla + un [`FotoStats`] para certificar.
156///
157/// Sin mapa de profundidad: [`Relieve::Profundidad`] cae al domo (necesita el mapa).
158/// Usa [`photo_to_grid_depth`] para el relieve por profundidad real.
159pub fn photo_to_grid(rgba: &[u8], w: u32, h: u32, opts: &FotoOpts) -> (VoxelGrid, FotoStats) {
160    photo_to_grid_inner(rgba, w, h, None, opts)
161}
162
163/// Como [`photo_to_grid`] pero con un **mapa de profundidad** real (`depth`, mismo
164/// `w×h` que `rgba`, RGBA; se lee el canal R = cercanía: blanco = cerca = grueso).
165/// Con [`Relieve::Profundidad`] el grosor sale de ese mapa (estimado por un modelo
166/// tipo MiDaS en el borde, `pixel-verbo-onnx`); con otros relieves el mapa se ignora.
167/// El mapa vive AFUERA a propósito: `llimphi-voxel` es `std` puro y no sabe de ONNX.
168pub fn photo_to_grid_depth(
169    rgba: &[u8],
170    w: u32,
171    h: u32,
172    depth: &[u8],
173    opts: &FotoOpts,
174) -> (VoxelGrid, FotoStats) {
175    photo_to_grid_inner(rgba, w, h, Some(depth), opts)
176}
177
178fn photo_to_grid_inner(
179    rgba: &[u8],
180    w: u32,
181    h: u32,
182    depth: Option<&[u8]>,
183    opts: &FotoOpts,
184) -> (VoxelGrid, FotoStats) {
185    let gw = opts.ancho.max(1);
186    let gh = ((h.max(1) * gw) / w.max(1)).max(1);
187    let prof = opts.profundidad.max(1);
188    let gwz = gw as usize;
189    let ghz = gh as usize;
190
191    // Downsample nearest → color + máscara de recorte (+ profundidad si vino mapa).
192    let mut color = vec![[0u8; 3]; gwz * ghz];
193    let mut mask = vec![false; gwz * ghz];
194    let depth_ok = depth.map(|d| d.len() >= (w * h * 4) as usize).unwrap_or(false);
195    let mut depth_ds = vec![0.0f32; gwz * ghz];
196    if w > 0 && h > 0 && rgba.len() >= (w * h * 4) as usize {
197        for gy in 0..ghz {
198            for gx in 0..gwz {
199                let sx = ((gx as u32 * w) / gw).min(w - 1);
200                let sy = ((gy as u32 * h) / gh).min(h - 1);
201                let si = ((sy * w + sx) * 4) as usize;
202                let a = rgba[si + 3];
203                color[gy * gwz + gx] = [rgba[si], rgba[si + 1], rgba[si + 2]];
204                mask[gy * gwz + gx] = a >= opts.alpha_min;
205                if depth_ok {
206                    // Canal R del mapa = cercanía [0,1] (blanco = cerca = grueso).
207                    depth_ds[gy * gwz + gx] = depth.unwrap()[si] as f32 / 255.0;
208                }
209            }
210        }
211    }
212
213    // Campo de profundidad normalizado [0,1] por píxel del recorte.
214    let mut profile = vec![0.0f32; gwz * ghz];
215    // Sin mapa real, Profundidad cae al domo (mejor que placa plana).
216    let relieve = match (opts.relieve, depth_ok) {
217        (Relieve::Profundidad, false) => Relieve::Domo,
218        (r, _) => r,
219    };
220    match relieve {
221        Relieve::Profundidad => {
222            // Grosor = profundidad estimada (MiDaS): blanco=cerca=grueso. Se re-normaliza
223            // min-max SOBRE EL RECORTE para que el sujeto use todo el rango (el fondo ya
224            // se fue por el alpha), y no quede aplastado si el mapa venía comprimido.
225            let (mut mn, mut mx) = (f32::INFINITY, f32::NEG_INFINITY);
226            for i in 0..gwz * ghz {
227                if mask[i] {
228                    mn = mn.min(depth_ds[i]);
229                    mx = mx.max(depth_ds[i]);
230                }
231            }
232            let rango = (mx - mn).max(1e-6);
233            for i in 0..gwz * ghz {
234                if mask[i] {
235                    profile[i] = ((depth_ds[i] - mn) / rango).clamp(0.0, 1.0);
236                }
237            }
238        }
239        Relieve::Domo => {
240            let dt = distance_transform(&mask, gwz, ghz);
241            let maxd = dt.iter().cloned().fold(0.0f32, f32::max).max(1.0);
242            for i in 0..gwz * ghz {
243                if mask[i] {
244                    let nd = (dt[i] / maxd).clamp(0.0, 1.0);
245                    // Perfil hemisférico: bulto redondo (borde→0, centro→1).
246                    profile[i] = (nd * (2.0 - nd)).sqrt();
247                }
248            }
249        }
250        Relieve::Luminancia => {
251            for i in 0..gwz * ghz {
252                if mask[i] {
253                    profile[i] = lum(color[i]).clamp(0.0, 1.0);
254                }
255            }
256        }
257        Relieve::Plano => {
258            for i in 0..gwz * ghz {
259                if mask[i] {
260                    profile[i] = 1.0;
261                }
262            }
263        }
264    }
265
266    let dz = prof + 1;
267    let mut grid = VoxelGrid::new([gw, gh, dz]);
268    let mut voxeles = 0u32;
269    let mut recorte = 0u32;
270    let mut grosor_min = u32::MAX;
271    let mut grosor_max = 0u32;
272    let mut acc = [0u64; 3];
273    for gy in 0..ghz {
274        for gx in 0..gwz {
275            let i = gy * gwz + gx;
276            if !mask[i] {
277                continue;
278            }
279            recorte += 1;
280            // Grosor ≥1: aun el borde deja una lámina fina (no desaparece).
281            let grosor = (1.0 + (prof - 1) as f32 * profile[i]).round() as u32;
282            let grosor = grosor.clamp(1, prof);
283            grosor_min = grosor_min.min(grosor);
284            grosor_max = grosor_max.max(grosor);
285            // Foto arriba-izquierda → voxel Y hacia arriba: flip vertical.
286            let vy = gh - 1 - gy as u32;
287            let base = color[i];
288            for z in 0..grosor {
289                let j = if opts.grano > 0.0 {
290                    opts.grano * jitter(gx as u32, gy as u32, z)
291                } else {
292                    0.0
293                };
294                let c = [
295                    ((base[0] as f32 * (1.0 + j)).clamp(0.0, 255.0)) as u8,
296                    ((base[1] as f32 * (1.0 + j)).clamp(0.0, 255.0)) as u8,
297                    ((base[2] as f32 * (1.0 + j)).clamp(0.0, 255.0)) as u8,
298                ];
299                grid.set(gx as u32, vy, z, c);
300                voxeles += 1;
301                acc[0] += base[0] as u64;
302                acc[1] += base[1] as u64;
303                acc[2] += base[2] as u64;
304            }
305        }
306    }
307    if grosor_min == u32::MAX {
308        grosor_min = 0;
309    }
310    let n = voxeles.max(1) as u64;
311    let color_medio = [(acc[0] / n) as u8, (acc[1] / n) as u8, (acc[2] / n) as u8];
312    let stats = FotoStats {
313        voxeles,
314        recorte,
315        grosor_min,
316        grosor_max,
317        color_medio,
318        dim: [gw, gh, dz],
319    };
320    (grid, stats)
321}
322
323/// Rotación en XZ por `facing` (0=+Z, 1=+X, 2=−Z, 3=−X), misma convención que la fauna.
324#[inline]
325fn rot(dx: i32, dz: i32, facing: u8) -> (i32, i32) {
326    match facing & 3 {
327        0 => (dx, dz),
328        1 => (-dz, dx),
329        2 => (-dx, -dz),
330        _ => (dz, -dx),
331    }
332}
333
334/// **Planta un busto** (una grilla voxel de [`photo_to_grid`]/[`photo_to_grid_depth`])
335/// DENTRO de una grilla destino, con la base en `(x, y, z)` y orientado por `facing`
336/// (0=+Z…). Es el puente que faltaba entre "una foto produce un busto suelto" y "ese
337/// ser vive en la escena junto al terreno y la fauna": aplica el pincel
338/// [`DETALLE_SER_VIVO`](crate::bicho::DETALLE_SER_VIVO) → el busto comparte la gramática
339/// sub-voxel del mundo (redondeo orgánico), como cualquier ser. Rota en XZ alrededor
340/// del centro del busto; los voxels que caen fuera de la grilla destino se descartan.
341pub fn stamp_busto(dst: &mut VoxelGrid, busto: &VoxelGrid, x: u32, y: u32, z: u32, facing: u8) {
342    let [bw, bh, bd] = busto.dim();
343    let (cx, cz) = (bw as i32 / 2, bd as i32 / 2);
344    let (r, b) = crate::bicho::DETALLE_SER_VIVO;
345    dst.set_detail_brush(r, b);
346    for by in 0..bh {
347        for bz in 0..bd {
348            for bx in 0..bw {
349                let c = match busto.get(bx, by, bz) {
350                    Some(c) if c[3] > 127 => c,
351                    _ => continue,
352                };
353                let (rx, rz) = rot(bx as i32 - cx, bz as i32 - cz, facing);
354                let px = x as i32 + rx;
355                let pz = z as i32 + rz;
356                let py = y as i32 + by as i32;
357                if px >= 0 && py >= 0 && pz >= 0 {
358                    // set_rgba estampa también el pincel de ser vivo (detalle por voxel).
359                    dst.set_rgba(px as u32, py as u32, pz as u32, [c[0], c[1], c[2]], c[3]);
360                }
361            }
362        }
363    }
364    dst.clear_detail_brush();
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    /// Construye una imagen RGBA de prueba: un disco opaco (con un degradé de brillo
372    /// horizontal) sobre fondo transparente.
373    fn disco(w: u32, h: u32) -> Vec<u8> {
374        let cx = w as f32 / 2.0;
375        let cy = h as f32 / 2.0;
376        let r = (w.min(h) as f32) * 0.4;
377        let mut px = vec![0u8; (w * h * 4) as usize];
378        for y in 0..h {
379            for x in 0..w {
380                let i = ((y * w + x) * 4) as usize;
381                let d = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
382                if d <= r {
383                    let b = (x as f32 / w as f32 * 255.0) as u8; // degradé horizontal
384                    px[i] = b;
385                    px[i + 1] = 120;
386                    px[i + 2] = 200;
387                    px[i + 3] = 255;
388                } else {
389                    px[i + 3] = 0; // fondo transparente
390                }
391            }
392        }
393        px
394    }
395
396    #[test]
397    fn recorta_y_da_relieve() {
398        let (w, h) = (64, 64);
399        let img = disco(w, h);
400        let opts = FotoOpts { ancho: 48, profundidad: 14, relieve: Relieve::Domo, alpha_min: 128, grano: 0.0 };
401        let (grid, s) = photo_to_grid(&img, w, h, &opts);
402        // Recorte: sólo el disco (~π·0.4²≈50% del área del bbox, pero el disco ocupa
403        // ~π/4 del cuadrado → algo menos de la mitad).
404        assert!(s.recorte > 0, "recortó algo");
405        let area = (s.dim[0] * s.dim[1]) as f32;
406        assert!((s.recorte as f32) < area * 0.85, "recortó el fondo (no llenó todo)");
407        // Relieve: el domo tiene grosor variable (borde fino, centro grueso).
408        assert!(s.grosor_max > s.grosor_min, "el domo varía en grosor: {} → {}", s.grosor_min, s.grosor_max);
409        assert!(s.grosor_max >= 10, "el centro sobresale cerca del máximo ({})", s.grosor_max);
410        assert!(s.voxeles > s.recorte, "hay volumen (más voxels que píxeles de recorte)");
411        assert_eq!(s.dim, grid.dim());
412    }
413
414    #[test]
415    fn fidelidad_de_color() {
416        let (w, h) = (48, 48);
417        let img = disco(w, h);
418        let (_grid, s) = photo_to_grid(&img, w, h, &FotoOpts::default());
419        // El disco es azulado (g=120, b=200) con r variable: el color medio debe tener
420        // b > r y b > g claramente (no se perdió el color del original).
421        assert!(s.color_medio[2] > s.color_medio[1], "conserva el azul dominante: {:?}", s.color_medio);
422        assert!(s.color_medio[2] > 120, "el azul sigue fuerte: {:?}", s.color_medio);
423    }
424
425    #[test]
426    fn plano_es_grosor_uniforme() {
427        let (w, h) = (32, 32);
428        let img = disco(w, h);
429        let opts = FotoOpts { relieve: Relieve::Plano, profundidad: 8, ..FotoOpts::default() };
430        let (_g, s) = photo_to_grid(&img, w, h, &opts);
431        assert_eq!(s.grosor_min, s.grosor_max, "plano = grosor uniforme");
432        assert_eq!(s.grosor_max, 8, "grosor = profundidad");
433    }
434
435    #[test]
436    fn imagen_vacia_no_panica() {
437        let (g, s) = photo_to_grid(&[], 0, 0, &FotoOpts::default());
438        assert_eq!(s.voxeles, 0);
439        assert!(g.dim()[0] >= 1);
440    }
441
442    /// Mapa de profundidad en RAMPA horizontal (izq negro=lejos, der blanco=cerca)
443    /// como el que emite MiDaS por el borde. Con `Relieve::Profundidad` el grosor
444    /// debe SEGUIR la profundidad: la mitad cercana (derecha) más gruesa que la lejana.
445    #[test]
446    fn profundidad_real_manda_el_grosor() {
447        let (w, h) = (64, 64);
448        let img = disco(w, h); // sujeto opaco centrado
449        // Depth RGBA: R = x/(w-1)*255 (rampa izq→der), gris, opaco.
450        let mut depth = vec![0u8; (w * h * 4) as usize];
451        for y in 0..h {
452            for x in 0..w {
453                let i = ((y * w + x) * 4) as usize;
454                let v = (x as f32 / (w - 1) as f32 * 255.0) as u8;
455                depth[i] = v;
456                depth[i + 1] = v;
457                depth[i + 2] = v;
458                depth[i + 3] = 255;
459            }
460        }
461        let opts = FotoOpts { ancho: 48, profundidad: 14, relieve: Relieve::Profundidad, alpha_min: 128, grano: 0.0 };
462        let (grid, s) = photo_to_grid_depth(&img, w, h, &depth, &opts);
463        assert!(s.recorte > 0);
464        assert!(s.grosor_max > s.grosor_min, "la profundidad varía el grosor: {}→{}", s.grosor_min, s.grosor_max);
465
466        // Grosor (voxels sólidos en Z) por mitad izquierda/derecha, sobre el recorte.
467        let [gw, gh, dz] = grid.dim();
468        let (mut sum_izq, mut n_izq, mut sum_der, mut n_der) = (0u64, 0u64, 0u64, 0u64);
469        for gx in 0..gw {
470            for vy in 0..gh {
471                let mut grosor = 0u64;
472                for z in 0..dz {
473                    if grid.get(gx, vy, z).map(|c| c[3] > 127).unwrap_or(false) {
474                        grosor += 1;
475                    }
476                }
477                if grosor == 0 {
478                    continue;
479                }
480                if gx < gw / 2 {
481                    sum_izq += grosor;
482                    n_izq += 1;
483                } else {
484                    sum_der += grosor;
485                    n_der += 1;
486                }
487            }
488        }
489        let prom_izq = sum_izq as f32 / n_izq.max(1) as f32;
490        let prom_der = sum_der as f32 / n_der.max(1) as f32;
491        assert!(
492            prom_der > prom_izq + 2.0,
493            "la mitad cercana (der) debe ser más gruesa que la lejana (izq): {prom_der:.1} vs {prom_izq:.1}"
494        );
495    }
496
497    /// Sin mapa, `Relieve::Profundidad` cae al domo (no plano) → grosor variable.
498    #[test]
499    fn profundidad_sin_mapa_cae_al_domo() {
500        let (w, h) = (48, 48);
501        let img = disco(w, h);
502        let opts = FotoOpts { relieve: Relieve::Profundidad, ..FotoOpts::default() };
503        let (_g, s) = photo_to_grid(&img, w, h, &opts);
504        assert!(s.grosor_max > s.grosor_min, "cae al domo (grosor variable), no a placa plana");
505    }
506
507    /// El busto se planta en una escena junto al terreno, con detalle de ser vivo por
508    /// voxel (comparte la gramática sub-voxel del mundo) y sin pisar el resto.
509    #[test]
510    fn stamp_busto_planta_el_ser_con_detalle() {
511        let (w, h) = (48, 48);
512        let img = disco(w, h);
513        let (busto, s) = photo_to_grid(&img, w, h, &FotoOpts { grano: 0.0, ..FotoOpts::default() });
514        let [bw, bh, bd] = busto.dim();
515
516        // Escena destino más grande, con un "suelo" previo que no debe tocarse.
517        let mut dst = VoxelGrid::new([bw + 20, bh + 8, bd + 20]);
518        for gx in 0..dst.dim()[0] {
519            for gz in 0..dst.dim()[2] {
520                dst.set(gx, 0, gz, [80, 90, 70]); // suelo
521            }
522        }
523        dst.reset_dirty();
524
525        let (ox, oz) = (10u32, 10u32);
526        stamp_busto(&mut dst, &busto, ox, 1, oz, 0);
527
528        // Cuenta voxels del busto plantados (por encima del suelo) y cuántos con detalle.
529        let mut plantados = 0u32;
530        let mut con_detalle = 0u32;
531        for gx in 0..dst.dim()[0] {
532            for gy in 1..dst.dim()[1] {
533                for gz in 0..dst.dim()[2] {
534                    if dst.get(gx, gy, gz).map(|c| c[3] > 127).unwrap_or(false) {
535                        plantados += 1;
536                        if dst.detail_at(gx, gy, gz) != Some([0, 0]) {
537                            con_detalle += 1;
538                        }
539                    }
540                }
541            }
542        }
543        assert!(plantados > s.recorte, "plantó el volumen del busto ({plantados} vs recorte {})", s.recorte);
544        assert_eq!(plantados, con_detalle, "todo el busto lleva el detalle de ser vivo");
545        // El suelo intacto (sin detalle autorado en y=0).
546        assert_eq!(dst.detail_at(0, 0, 0), Some([0, 0]), "el suelo no recibió el pincel");
547        // Facing distinto ⇒ colocación distinta (no es no-op).
548        let mut dst2 = VoxelGrid::new([bw + 20, bh + 8, bd + 20]);
549        stamp_busto(&mut dst2, &busto, ox, 1, oz, 1);
550        let mut difieren = false;
551        'outer: for gx in 0..dst.dim()[0] {
552            for gy in 1..dst.dim()[1] {
553                for gz in 0..dst.dim()[2] {
554                    let a = dst.get(gx, gy, gz).map(|c| c[3] > 127).unwrap_or(false);
555                    let b = dst2.get(gx, gy, gz).map(|c| c[3] > 127).unwrap_or(false);
556                    if a != b {
557                        difieren = true;
558                        break 'outer;
559                    }
560                }
561            }
562        }
563        assert!(difieren, "el facing rota el busto");
564    }
565}