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