tawasuyu

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Sutichaykuna

⎇ main llimphi-voxel/src/sim.rs Rust · 608 siq'ikuna · 23.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//! `sim` — **simulación de la ley [`Fluir`](crate::LeyKind::Fluir)**: lo que hace
2//! que el agua sea *líquida*. Un autómata celular sobre la ocupación de un
3//! [`VoxelGrid`]: cada celda de agua **cae con gravedad**, **se esparce a vecinos
4//! horizontales** y **cae por las cornisas** (cascadas). Conserva la masa (sólo
5//! reubica celdas) y **converge** (no slosh perpetuo): la lateral sólo procede si el
6//! agua puede descender o si hay *carga* (agua encima), así una torre se desploma en
7//! charco y un manto de 1 de profundidad se queda quieto.
8//!
9//! Es contenido puro de CPU: no toca GPU. La app aplica los cambios al grid
10//! (`set`/`clear`) y sube lo *dirty* con [`VoxelRenderer::sync`]
11//! (llimphi_3d::VoxelRenderer::sync). El paso es determinista (sin azar): mismas
12//! condiciones → misma evolución.
13
14use crate::ecuacion::{FieldDef, FieldEngine, Program};
15use llimphi_3d::VoxelGrid;
16
17const AIR: u8 = 0;
18const SOLID: u8 = 1;
19const WATER: u8 = 2;
20
21/// Un nuevo estado de una celda producido por [`WaterSim::step`]: `(pos, estado)`
22/// con `estado` ∈ {[`AIR`], [`WATER`]}. La app lo traduce a `clear`/`set` en el grid.
23pub type CellChange = ([u32; 3], u8);
24
25/// Estado de aire de una celda (para que el caller distinga sin exponer constantes).
26pub const CELL_AIR: u8 = AIR;
27/// Estado de agua de una celda.
28pub const CELL_WATER: u8 = WATER;
29
30/// Autómata celular del agua sobre la ocupación de un grid.
31pub struct WaterSim {
32    dim: [u32; 3],
33    /// Ocupación: 0 aire, 1 sólido (terreno), 2 agua.
34    occ: Vec<u8>,
35    /// Lock por celda dentro de un paso (evita mover dos veces / oscilar).
36    moved: Vec<bool>,
37    /// Índices tocados en el paso (para limpiar `moved` barato).
38    touched: Vec<usize>,
39    /// Fila de agua más alta (tope del escaneo; el agua nunca sube).
40    top: u32,
41    /// Alterna el orden de los vecinos cada paso (simetría).
42    parity: u32,
43    /// Parámetros de la ley Fluir. `gravedad` `[0,1]` = ritmo de caída (1 = cae cada
44    /// paso; menor = más viscoso, cae a tirones). `horizontal` `[0,1]` = ritmo de
45    /// esparcido lateral (0 = no se esparce → líquido espeso que se apila; 1 = se
46    /// esparce cada paso → líquido fino que se nivela).
47    gravedad: f32,
48    horizontal: f32,
49    /// Acumuladores deterministas que convierten los ritmos `[0,1]` en "actúa este
50    /// paso sí/no" sin azar (no hay `Math.random`).
51    g_acc: f32,
52    h_acc: f32,
53}
54
55impl WaterSim {
56    /// Clasifica un grid: las celdas cuyo color es exactamente `agua` son agua; el
57    /// resto de las sólidas, terreno. El agua del worldgen se pinta con el color de
58    /// paleta sin grano, así el match exacto la identifica.
59    pub fn from_grid(grid: &VoxelGrid, agua: [u8; 3]) -> Self {
60        Self::with_params(grid, agua, 1.0, 1.0)
61    }
62
63    /// Como [`from_grid`](Self::from_grid) pero con los parámetros de la ley Fluir
64    /// (`gravedad`, `horizontal`) que dicta el material líquido.
65    pub fn with_params(grid: &VoxelGrid, agua: [u8; 3], gravedad: f32, horizontal: f32) -> Self {
66        let dim = grid.dim();
67        let n = (dim[0] * dim[1] * dim[2]) as usize;
68        let mut occ = vec![AIR; n];
69        let mut top = 0u32;
70        for z in 0..dim[2] {
71            for y in 0..dim[1] {
72                for x in 0..dim[0] {
73                    if let Some(c) = grid.get(x, y, z) {
74                        if c[3] > 0 {
75                            let i = (x + y * dim[0] + z * dim[0] * dim[1]) as usize;
76                            if [c[0], c[1], c[2]] == agua {
77                                occ[i] = WATER;
78                                top = top.max(y);
79                            } else {
80                                occ[i] = SOLID;
81                            }
82                        }
83                    }
84                }
85            }
86        }
87        Self {
88            dim,
89            occ,
90            moved: vec![false; n],
91            touched: Vec::new(),
92            top,
93            parity: 0,
94            gravedad: gravedad.clamp(0.05, 1.0),
95            horizontal: horizontal.clamp(0.0, 1.0),
96            g_acc: 0.0,
97            h_acc: 0.0,
98        }
99    }
100
101    /// Cantidad de celdas de agua (conservada por `step`).
102    pub fn water_count(&self) -> usize {
103        self.occ.iter().filter(|&&c| c == WATER).count()
104    }
105
106    #[inline]
107    fn idx(&self, x: u32, y: u32, z: u32) -> usize {
108        (x + y * self.dim[0] + z * self.dim[0] * self.dim[1]) as usize
109    }
110
111    /// Ocupación en `(x,y,z)` con bordes: fuera en X/Z o bajo el piso = sólido
112    /// (muros + suelo); por encima del tope vertical = aire.
113    #[inline]
114    fn at(&self, x: i32, y: i32, z: i32) -> u8 {
115        if x < 0 || z < 0 || x >= self.dim[0] as i32 || z >= self.dim[2] as i32 || y < 0 {
116            return SOLID;
117        }
118        if y >= self.dim[1] as i32 {
119            return AIR;
120        }
121        self.occ[self.idx(x as u32, y as u32, z as u32)]
122    }
123
124    #[inline]
125    fn is_free(&self, x: i32, y: i32, z: i32) -> bool {
126        // Libre = aire dentro del grid (no muro, no agua, no tope).
127        x >= 0
128            && z >= 0
129            && y >= 0
130            && x < self.dim[0] as i32
131            && y < self.dim[1] as i32
132            && z < self.dim[2] as i32
133            && self.occ[self.idx(x as u32, y as u32, z as u32)] == AIR
134            && !self.moved[self.idx(x as u32, y as u32, z as u32)]
135    }
136
137    /// Avanza un paso del autómata. Devuelve las celdas que cambiaron de estado
138    /// (para subir sólo eso al grid). Lista vacía = el agua se asentó.
139    pub fn step(&mut self) -> Vec<CellChange> {
140        // Limpiar el lock del paso anterior.
141        for &i in &self.touched {
142            self.moved[i] = false;
143        }
144        self.touched.clear();
145        let mut changes: Vec<CellChange> = Vec::new();
146
147        // Ritmos → "actúa este paso" deterministas (acumulador, sin azar). Con
148        // gravedad/horizontal = 1 actúan siempre (comportamiento base); más bajos,
149        // a tirones → líquido más viscoso/espeso.
150        self.g_acc += self.gravedad;
151        let fall = self.g_acc >= 1.0;
152        if fall {
153            self.g_acc -= 1.0;
154        }
155        self.h_acc += self.horizontal;
156        let spread = self.h_acc >= 1.0;
157        if spread {
158            self.h_acc -= 1.0;
159        }
160
161        // Orden de vecinos horizontales, rotado por paridad (simetría).
162        let dirs0: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)];
163        let rot = (self.parity % 4) as usize;
164        let mut dirs = [(0, 0); 4];
165        for k in 0..4 {
166            dirs[k] = dirs0[(k + rot) % 4];
167        }
168
169        let top = self.top.min(self.dim[1] - 1);
170        for z in 0..self.dim[2] as i32 {
171            for y in 0..=top as i32 {
172                for x in 0..self.dim[0] as i32 {
173                    let i = self.idx(x as u32, y as u32, z as u32);
174                    if self.occ[i] != WATER || self.moved[i] {
175                        continue;
176                    }
177                    // 1) Caer: si abajo hay aire libre (gatea la gravedad).
178                    if fall && self.is_free(x, y - 1, z) {
179                        self.relocate(i, (x, y - 1, z), &mut changes);
180                        continue;
181                    }
182                    // El componente horizontal (diagonal y lateral) gatea por `spread`.
183                    if !spread {
184                        continue;
185                    }
186                    // 2) Caer en diagonal (flujo / cornisa → cascada): vecino libre
187                    //    cuya celda de abajo también está libre.
188                    let mut done = false;
189                    for (dx, dz) in dirs {
190                        if self.is_free(x + dx, y, z + dz) && self.at(x + dx, y - 1, z + dz) == AIR {
191                            self.relocate(i, (x + dx, y, z + dz), &mut changes);
192                            done = true;
193                            break;
194                        }
195                    }
196                    if done {
197                        continue;
198                    }
199                    // 3) Esparcir lateral SÓLO si hay carga (agua justo debajo): una
200                    //    torre se desploma en charco; un manto de 1 se queda quieto.
201                    if self.at(x, y - 1, z) == WATER {
202                        for (dx, dz) in dirs {
203                            if self.is_free(x + dx, y, z + dz) {
204                                self.relocate(i, (x + dx, y, z + dz), &mut changes);
205                                break;
206                            }
207                        }
208                    }
209                }
210            }
211        }
212        self.parity = self.parity.wrapping_add(1);
213        changes
214    }
215
216    /// Mueve el agua de `from` (índice) a `to` (coord), registrando los cambios y
217    /// bloqueando el destino para el resto del paso.
218    fn relocate(&mut self, from: usize, to: (i32, i32, i32), changes: &mut Vec<CellChange>) {
219        let (tx, ty, tz) = (to.0 as u32, to.1 as u32, to.2 as u32);
220        let ti = self.idx(tx, ty, tz);
221        self.occ[from] = AIR;
222        self.occ[ti] = WATER;
223        self.moved[ti] = true;
224        self.touched.push(ti);
225        // Posición de origen.
226        let fz = from as u32 / (self.dim[0] * self.dim[1]);
227        let rem = from as u32 % (self.dim[0] * self.dim[1]);
228        let fy = rem / self.dim[0];
229        let fx = rem % self.dim[0];
230        changes.push(([fx, fy, fz], AIR));
231        changes.push(([tx, ty, tz], WATER));
232    }
233}
234
235/// Simulación de la ley [`Crecer`](crate::LeyKind::Crecer): una planta (material con
236/// rol objeto) **crece de abajo hacia arriba** a su velocidad. Toma las celdas del
237/// objeto en el grid, las esconde, y las va revelando en orden de altura. La
238/// `velocidad` escala cuántas celdas se revelan por paso (a `1.0`, la planta entera
239/// brota en ~[`GROW_TICKS`] pasos sin importar su tamaño).
240pub struct GrowthSim {
241    /// Celdas de la planta `(pos, color)`, ordenadas por altura ascendente.
242    cells: Vec<([u32; 3], [u8; 3])>,
243    /// Cuántas reveladas hasta ahora.
244    idx: usize,
245    /// Acumulador de la tasa de revelado (determinista).
246    acc: f32,
247    /// Celdas reveladas por paso (derivada de la velocidad y el tamaño).
248    rate: f32,
249}
250
251/// Pasos en los que una planta crece del todo a velocidad `1.0` (~2 s a 30 fps).
252pub const GROW_TICKS: f32 = 60.0;
253
254impl GrowthSim {
255    /// Junta las celdas del color de la planta y las ordena por altura. `velocidad`
256    /// viene de la ley Crecer del material.
257    pub fn from_grid(grid: &VoxelGrid, planta: [u8; 3], velocidad: f32) -> Self {
258        let dim = grid.dim();
259        let mut cells = Vec::new();
260        for z in 0..dim[2] {
261            for y in 0..dim[1] {
262                for x in 0..dim[0] {
263                    if let Some(c) = grid.get(x, y, z) {
264                        if c[3] > 0 && [c[0], c[1], c[2]] == planta {
265                            cells.push(([x, y, z], planta));
266                        }
267                    }
268                }
269            }
270        }
271        // Orden por altura (y), luego x,z para determinismo.
272        cells.sort_by_key(|(p, _)| (p[1], p[0], p[2]));
273        let len = cells.len().max(1) as f32;
274        let rate = (velocidad.max(0.0) * len / GROW_TICKS).max(0.05);
275        Self { cells, idx: 0, acc: 0.0, rate }
276    }
277
278    /// Las celdas de la planta (para esconderlas al arrancar el crecimiento).
279    pub fn cells(&self) -> &[([u32; 3], [u8; 3])] {
280        &self.cells
281    }
282
283    /// `true` si la planta ya terminó de crecer.
284    pub fn done(&self) -> bool {
285        self.idx >= self.cells.len()
286    }
287
288    /// Revela el siguiente lote de celdas (según la velocidad). Devuelve las celdas a
289    /// dibujar este paso `(pos, color)`. Vacío cuando ya creció del todo.
290    pub fn step(&mut self) -> Vec<([u32; 3], [u8; 3])> {
291        if self.done() {
292            return Vec::new();
293        }
294        self.acc += self.rate;
295        let n = self.acc.floor() as usize;
296        self.acc -= n as f32;
297        let mut out = Vec::new();
298        for _ in 0..n {
299            if self.idx < self.cells.len() {
300                out.push(self.cells[self.idx]);
301                self.idx += 1;
302            }
303        }
304        out
305    }
306}
307
308/// Simulación de una ley [`Ecuacion`](crate::LeyKind::Ecuacion) **sobre un material
309/// real del mundo**: corre el sistema de ecuaciones de campo en las celdas del material
310/// y las **recolorea** por el valor del campo visible — el material queda donde está,
311/// "vivo" según su ecuación (a diferencia de [`WaterSim`], que mueve celdas).
312///
313/// Clasifica las celdas por **proximidad de color** al color base (± `tol` por canal),
314/// para alcanzar también materiales con *grano* (el terreno varía su color; el agua no).
315/// Materiales de color parecido pueden mezclarse — es una aproximación de preview.
316pub struct EcuacionSim {
317    engine: FieldEngine,
318    /// Celdas del material (posición fija; se clasifican una vez).
319    cells: Vec<[u32; 3]>,
320    /// Color base del material (para modular el brillo).
321    base: [u8; 3],
322    /// Campo que tiñe el color.
323    vis: usize,
324    /// Rango del campo visible (para normalizar).
325    fmin: f32,
326    fmax: f32,
327    /// Último color emitido por celda (para subir sólo lo que cambió).
328    last: Vec<[u8; 3]>,
329}
330
331impl EcuacionSim {
332    /// Clasifica las celdas cercanas a `material` (± `tol` por canal), arma el motor de
333    /// campo sobre el grid y siembra una perturbación determinista por celda (para que
334    /// la ecuación arranque con estructura). `vis` es el campo que se pinta.
335    pub fn from_grid(
336        grid: &VoxelGrid,
337        material: [u8; 3],
338        tol: i32,
339        campos: Vec<FieldDef>,
340        vis: usize,
341    ) -> Self {
342        let dim = grid.dim();
343        let mut cells = Vec::new();
344        for z in 0..dim[2] {
345            for y in 0..dim[1] {
346                for x in 0..dim[0] {
347                    if let Some(c) = grid.get(x, y, z) {
348                        if c[3] > 0 && cerca([c[0], c[1], c[2]], material, tol) {
349                            cells.push([x, y, z]);
350                        }
351                    }
352                }
353            }
354        }
355        let vis = vis.min(campos.len().saturating_sub(1));
356        let (fmin, fmax) = campos.get(vis).map(|d| (d.min, d.max)).unwrap_or((0.0, 1.0));
357        let mut engine = FieldEngine::new(dim, campos);
358        let nf = engine.fields().len();
359        for &[x, y, z] in &cells {
360            for f in 0..nf {
361                let (mn, mx) = {
362                    let d = &engine.fields()[f];
363                    (d.min, d.max)
364                };
365                let h = hash01(x, y, z, f as u32);
366                engine.set(f as u16, x, y, z, mn + h * 0.6 * (mx - mn));
367            }
368        }
369        let last = vec![[0u8; 3]; cells.len()];
370        Self { engine, cells, base: material, vis, fmin, fmax, last }
371    }
372
373    /// Cantidad de celdas del material clasificadas.
374    pub fn cell_count(&self) -> usize {
375        self.cells.len()
376    }
377
378    /// Avanza un paso del campo y devuelve las celdas cuyo **color cambió**
379    /// `(pos, color)`, para subir sólo eso a la GPU. El color modula el brillo del
380    /// color base por el valor normalizado del campo visible (preserva el tono).
381    pub fn step(&mut self, program: &Program, params: &[f32]) -> Vec<([u32; 3], [u8; 3])> {
382        self.engine.step(program, params, 1.0);
383        let span = (self.fmax - self.fmin).max(1e-6);
384        let mut out = Vec::new();
385        for (i, &[x, y, z]) in self.cells.iter().enumerate() {
386            let t = ((self.engine.get(self.vis as u16, x, y, z) - self.fmin) / span).clamp(0.0, 1.0);
387            let k = 0.35 + 0.65 * t;
388            let col = [
389                (self.base[0] as f32 * k) as u8,
390                (self.base[1] as f32 * k) as u8,
391                (self.base[2] as f32 * k) as u8,
392            ];
393            if col != self.last[i] {
394                self.last[i] = col;
395                out.push(([x, y, z], col));
396            }
397        }
398        out
399    }
400}
401
402/// `true` si `c` está a ≤ `tol` de `base` en los tres canales.
403fn cerca(c: [u8; 3], base: [u8; 3], tol: i32) -> bool {
404    (c[0] as i32 - base[0] as i32).abs() <= tol
405        && (c[1] as i32 - base[1] as i32).abs() <= tol
406        && (c[2] as i32 - base[2] as i32).abs() <= tol
407}
408
409/// Hash determinista → `[0,1)` de una celda + índice de campo (sin azar; para sembrar).
410fn hash01(x: u32, y: u32, z: u32, f: u32) -> f32 {
411    let mut h = x
412        .wrapping_mul(0x9E37_79B1)
413        ^ y.wrapping_mul(0x85EB_CA77)
414        ^ z.wrapping_mul(0xC2B2_AE3D)
415        ^ f.wrapping_mul(0x27D4_EB2F);
416    h ^= h >> 15;
417    h = h.wrapping_mul(0x2C1B_3C6D);
418    h ^= h >> 12;
419    (h & 0x00FF_FFFF) as f32 / (0x0100_0000 as f32)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::Material;
426
427    /// Grid con piso sólido y un poco de agua arriba, en una columna.
428    fn grid_con_columna(dim: [u32; 3], agua: [u8; 3], col_x: u32, col_z: u32, altura: u32) -> VoxelGrid {
429        let mut g = VoxelGrid::new(dim);
430        // Piso en y=0.
431        for z in 0..dim[2] {
432            for x in 0..dim[0] {
433                g.set(x, 0, z, Material::Rock.color());
434            }
435        }
436        // Columna de agua sobre el piso.
437        for k in 0..altura {
438            g.set(col_x, 1 + k, col_z, agua);
439        }
440        g.reset_dirty();
441        g
442    }
443
444    #[test]
445    fn ecuacion_sim_clasifica_y_recolorea() {
446        use crate::ecuacion::{Assign, Expr, FieldDef, Program, Symbols};
447        // Grid con un parche plano de un material (color exacto, como el agua).
448        let dim = [12, 1, 12];
449        let mut g = VoxelGrid::new(dim);
450        let mat = [120, 160, 80];
451        for z in 3..9 {
452            for x in 3..9 {
453                g.set(x, 0, z, mat);
454            }
455        }
456        g.reset_dirty();
457        let sym = Symbols { campos: vec!["t".into()], params: vec!["k".into()] };
458        let e = Expr::parse("k * lap(t)", &sym).unwrap();
459        let prog = Program::compile(&[Assign { campo: 0, expr: e }]);
460        let mut sim = EcuacionSim::from_grid(&g, mat, 8, vec![FieldDef::new("t", 0.0, 0.0, 1.0)], 0);
461        assert_eq!(sim.cell_count(), 36, "clasificó el parche 6×6");
462        let mut changed = 0;
463        for _ in 0..30 {
464            changed += sim.step(&prog, &[0.2]).len();
465        }
466        assert!(changed > 0, "la ecuación recoloreó celdas del material");
467    }
468
469    #[test]
470    fn el_agua_cae_hasta_el_piso() {
471        let agua = Material::Water.color();
472        let dim = [8, 16, 8];
473        let mut g = VoxelGrid::new(dim);
474        for z in 0..dim[2] {
475            for x in 0..dim[0] {
476                g.set(x, 0, z, Material::Rock.color());
477            }
478        }
479        g.set(3, 10, 3, agua); // una gota flotando
480        g.reset_dirty();
481        let mut sim = WaterSim::from_grid(&g, agua);
482        assert_eq!(sim.water_count(), 1);
483        for _ in 0..30 {
484            sim.step();
485        }
486        // La gota terminó descansando sobre el piso (y=1) — no flotando.
487        assert_eq!(sim.water_count(), 1, "no se pierde ni duplica agua");
488        assert_eq!(sim.at(3, 1, 3), CELL_WATER, "el agua quedó sobre el piso");
489        assert_eq!(sim.at(3, 10, 3), CELL_AIR, "ya no flota");
490    }
491
492    #[test]
493    fn la_columna_se_esparce_y_se_asienta() {
494        let agua = Material::Water.color();
495        let dim = [16, 20, 16];
496        let g = grid_con_columna(dim, agua, 8, 8, 10); // torre de 10 de agua
497        let mut sim = WaterSim::from_grid(&g, agua);
498        let n0 = sim.water_count();
499
500        let mut last_changes = 1;
501        let mut steps = 0;
502        while last_changes > 0 && steps < 2000 {
503            last_changes = sim.step().len();
504            steps += 1;
505        }
506        // Conserva masa y converge (deja de moverse) en tiempo finito.
507        assert_eq!(sim.water_count(), n0, "masa conservada");
508        assert!(steps < 2000, "se asentó (sin slosh perpetuo) en {steps} pasos");
509
510        // Se esparció: el charco final es más ancho que la columna inicial (1 celda).
511        let mut ancho = 0;
512        for x in 0..dim[0] as i32 {
513            if sim.at(x, 1, 8) == CELL_WATER {
514                ancho += 1;
515            }
516        }
517        assert!(ancho > 1, "el agua se esparció horizontalmente (ancho={ancho})");
518    }
519
520    #[test]
521    fn horizontal_cero_no_se_esparce_espeso() {
522        // Líquido espeso (horizontal=0): cae pero NO se esparce → se apila en columna.
523        let agua = Material::Water.color();
524        let dim = [16, 20, 16];
525        let g = grid_con_columna(dim, agua, 8, 8, 10);
526        let mut sim = WaterSim::with_params(&g, agua, 1.0, 0.0);
527        for _ in 0..200 {
528            sim.step();
529        }
530        let mut ancho = 0;
531        for x in 0..dim[0] as i32 {
532            if sim.at(x, 1, 8) == CELL_WATER {
533                ancho += 1;
534            }
535        }
536        assert_eq!(ancho, 1, "sin componente horizontal el líquido no se esparce");
537    }
538
539    #[test]
540    fn la_planta_crece_de_abajo_hacia_arriba() {
541        // Una columna de "planta" (color cactus) de 6 de alto sobre un piso.
542        let planta = Material::Cactus.color();
543        let dim = [8, 16, 8];
544        let mut g = VoxelGrid::new(dim);
545        for k in 0..6 {
546            g.set(4, 1 + k, 4, planta);
547        }
548        g.reset_dirty();
549        let mut sim = GrowthSim::from_grid(&g, planta, 1.0);
550        assert_eq!(sim.cells().len(), 6);
551
552        // Revelar paso a paso: cada celda nueva está a una altura >= la anterior.
553        let mut revealed: Vec<u32> = Vec::new();
554        let mut steps = 0;
555        while !sim.done() && steps < 1000 {
556            for (pos, _) in sim.step() {
557                revealed.push(pos[1]);
558            }
559            steps += 1;
560        }
561        assert_eq!(revealed.len(), 6, "se revelaron todas las celdas");
562        // Monótono creciente en altura (crece hacia arriba).
563        assert!(revealed.windows(2).all(|w| w[0] <= w[1]), "crece de abajo hacia arriba: {revealed:?}");
564        assert_eq!(revealed[0], 1, "empieza por la base");
565    }
566
567    #[test]
568    fn cae_por_una_cornisa_cascada() {
569        // Una repisa (escalón) con agua arriba: debe caer por el borde.
570        let agua = Material::Water.color();
571        let dim = [16, 16, 8];
572        let mut g = VoxelGrid::new(dim);
573        // Piso bajo en y=0 en todo; una repisa sólida en y=1..=5 sobre la mitad x<8.
574        for z in 0..dim[2] {
575            for x in 0..dim[0] {
576                g.set(x, 0, z, Material::Rock.color());
577            }
578        }
579        for z in 0..dim[2] {
580            for x in 0..8 {
581                for y in 1..=5 {
582                    g.set(x, y, z, Material::Rock.color());
583                }
584            }
585        }
586        // Agua sobre la repisa, cerca del borde (x=7).
587        for z in 0..dim[2] {
588            g.set(7, 6, z, agua);
589        }
590        g.reset_dirty();
591        let mut sim = WaterSim::from_grid(&g, agua);
592        let n0 = sim.water_count();
593        for _ in 0..60 {
594            sim.step();
595        }
596        assert_eq!(sim.water_count(), n0, "masa conservada al caer");
597        // Algo de agua llegó al piso bajo (y=1) del lado sin repisa (x>=8).
598        let mut llego_abajo = false;
599        for z in 0..dim[2] as i32 {
600            for x in 8..dim[0] as i32 {
601                if sim.at(x, 1, z) == CELL_WATER {
602                    llego_abajo = true;
603                }
604            }
605        }
606        assert!(llego_abajo, "el agua cayó por la cornisa hasta el nivel bajo");
607    }
608}