git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/surface_flow.rs (30K)

  1 //! The Diffuse and Concentrate nodes: a point attribute flowing over the
  2 //! SURFACE of a mesh, for use inside a simulation.
  3 //!
  4 //! The Neighbour node's Diffuse and Concentrate modes move each value a
  5 //! fraction of the way toward (or away from) the plain average of its
  6 //! neighbours. That is a filter, and as a step of a simulation it has three
  7 //! faults: it is a fact about the TESSELLATION (a finer mesh spreads a value
  8 //! fewer world units per step, and a point with seven neighbours is pulled
  9 //! differently from one with five), it conserves nothing (a spike's total
 10 //! changes as it spreads), and its Concentrate grows without bound. These two
 11 //! nodes are the simulation's versions, and both are built on one thing:
 12 //!
 13 //! - **The surface's own Laplacian.** Each point holds the area around it (a
 14 //!   third of each triangle it is a corner of) and each edge carries the
 15 //!   cotangent weight — half the sum of the cotangents of the two angles
 16 //!   facing it. That is the discrete Laplace–Beltrami operator, the one that
 17 //!   converges to the smooth surface's as the mesh is refined, so a Rate in
 18 //!   square world units means the same on a coarse mesh and a fine one. A
 19 //!   negative weight (an edge facing two obtuse angles) is taken as zero:
 20 //!   it costs exactness on badly shaped triangles and buys the maximum
 21 //!   principle — no value diffuses above the highest or below the lowest it
 22 //!   started from — which a simulation needs more.
 23 //! - **Flux, not averaging.** Whatever leaves one point along an edge arrives
 24 //!   at the other, so the total — each value times its point's area — is
 25 //!   conserved exactly, by both nodes. An open boundary lets nothing out.
 26 //!
 27 //! **Diffuse** is the heat equation, taken as one IMPLICIT step (backward
 28 //! Euler, solved by preconditioned conjugate gradients): stable at any Rate
 29 //! and any step, where an explicit step past its limit rings and blows up.
 30 //!
 31 //! **Concentrate** is the reverse — value flows UP a gradient — which as an
 32 //! equation has no stable form at all: anything run backward from smooth
 33 //! sharpens fastest at the finest scale. What makes it usable is a limiter:
 34 //! a point gives along its edges only what it holds above the FLOOR — zero,
 35 //! or the lowest value anywhere when the step began if that is lower — so
 36 //! values gather into peaks, a point can be emptied but never overdrawn, and
 37 //! the total is still conserved. Zero rather than simply the lowest value,
 38 //! because with Follow naming another attribute a UNIFORM density has to be
 39 //! able to move, and with the lowest value as the floor it would hold
 40 //! nothing to give. It is
 41 //! explicit, cut into as many internal steps as its stiffness asks for, so a
 42 //! Rate means the same whatever the simulation's substep count.
 43 //!
 44 //! Points outside the node's Group hold their values: they are read as
 45 //! neighbours and feed or drain the points beside them, as a fixed
 46 //! temperature does at the edge of a plate, so the total is conserved only
 47 //! when the Group is the whole mesh.
 48 
 49 use crate::app::FsNode;
 50 use crate::detail::{AttribType, AttribValue, Detail};
 51 use crate::geometry::{
 52     generate_single_node_geometry_with_errors, node_param_bool, node_param_f32, node_param_str, param_node, EvalSim,
 53 };
 54 
 55 /// A mesh's Laplacian: each point's area and its edges' cotangent weights,
 56 /// in CSR form with every edge listed from both ends.
 57 pub struct Surface {
 58     /// The area each point stands for: a third of every triangle it is a
 59     /// corner of.
 60     pub mass: Vec<f64>,
 61     /// Where each point's edges begin in `nbr` and `w`; `n + 1` long.
 62     pub start: Vec<usize>,
 63     pub nbr: Vec<u32>,
 64     /// The edge's cotangent weight, never negative.
 65     pub w: Vec<f64>,
 66 }
 67 
 68 impl Surface {
 69     /// The Laplacian of `geom`'s primitives, fan-triangulated. A point on no
 70     /// triangle has no area and no edges.
 71     pub fn of(geom: &Detail) -> Surface {
 72         let n = geom.num_points();
 73         let pos: Vec<[f64; 3]> = geom
 74             .positions()
 75             .iter()
 76             .map(|p| [p[0] as f64, p[1] as f64, p[2] as f64])
 77             .collect();
 78         let tris = geom.triangulate_points();
 79         let mut mass = vec![0.0f64; n];
 80         let mut half: Vec<(u32, u32, f64)> = Vec::with_capacity(tris.len());
 81         let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
 82         let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
 83         let cross = |a: [f64; 3], b: [f64; 3]| {
 84             [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
 85         };
 86         for t in tris.chunks_exact(3) {
 87             let idx = [t[0] as usize, t[1] as usize, t[2] as usize];
 88             if idx[0] == idx[1] || idx[1] == idx[2] || idx[0] == idx[2] {
 89                 continue;
 90             }
 91             let [a, b, c] = idx.map(|i| pos[i]);
 92             let twice_area = dot(cross(sub(b, a), sub(c, a)), cross(sub(b, a), sub(c, a))).sqrt();
 93             // A sliver with no area has no angles worth the name: its
 94             // cotangents are a division by nothing.
 95             if !(twice_area > 1e-14) {
 96                 continue;
 97             }
 98             for &i in &idx {
 99                 mass[i] += twice_area / 6.0;
100             }
101             // The corner at k faces the edge between the other two; its
102             // cotangent is cos/sin = dot/|cross| of the two sides leaving it,
103             // and |cross| at any corner is twice the area.
104             for k in 0..3 {
105                 let (o, i, j) = (idx[k], idx[(k + 1) % 3], idx[(k + 2) % 3]);
106                 let cot = dot(sub(pos[i], pos[o]), sub(pos[j], pos[o])) / twice_area;
107                 let (lo, hi) = (i.min(j) as u32, i.max(j) as u32);
108                 half.push((lo, hi, 0.5 * cot));
109             }
110         }
111         half.sort_unstable_by_key(|&(a, b, _)| (a, b));
112         let mut edges: Vec<(u32, u32, f64)> = Vec::new();
113         for (a, b, w) in half {
114             match edges.last_mut() {
115                 Some(e) if e.0 == a && e.1 == b => e.2 += w,
116                 _ => edges.push((a, b, w)),
117             }
118         }
119         // The maximum principle over exactness: see the module comment.
120         let mut count = vec![0usize; n + 1];
121         for &(a, b, _) in &edges {
122             count[a as usize + 1] += 1;
123             count[b as usize + 1] += 1;
124         }
125         for i in 0..n {
126             count[i + 1] += count[i];
127         }
128         let start = count;
129         let mut fill = start.clone();
130         let mut nbr = vec![0u32; start[n]];
131         let mut w = vec![0.0f64; start[n]];
132         for &(a, b, ew) in &edges {
133             let ew = ew.max(0.0);
134             for (from, to) in [(a, b), (b, a)] {
135                 let at = fill[from as usize];
136                 nbr[at] = to;
137                 w[at] = ew;
138                 fill[from as usize] += 1;
139             }
140         }
141         Surface { mass, start, nbr, w }
142     }
143 
144     pub fn len(&self) -> usize {
145         self.mass.len()
146     }
147 
148     /// Every edge of `i` as (neighbour, weight).
149     fn edges(&self, i: usize) -> impl Iterator<Item = (usize, f64)> + '_ {
150         (self.start[i]..self.start[i + 1]).map(move |e| (self.nbr[e] as usize, self.w[e]))
151     }
152 
153     /// The total of a value over the surface: each point's value times its
154     /// area. What both operators conserve.
155     pub fn total(&self, u: &[f64]) -> f64 {
156         self.mass.iter().zip(u).map(|(m, x)| m * x).sum()
157     }
158 }
159 
160 /// An edge's conductance under a per-point rate: the weight times the mean
161 /// of its ends' rates, so the edge is the same seen from either end and what
162 /// it carries is still conserved.
163 fn conductance(rate: &[f64], i: usize, j: usize, w: f64) -> f64 {
164     w * 0.5 * (rate[i] + rate[j])
165 }
166 
167 /// One backward-Euler step of the heat equation, `(M + L) u' = M u`, where
168 /// `rate` already holds each point's Rate times the step. Points that are not
169 /// `free` keep their values and enter the free points' equations as known.
170 /// Returns the conjugate-gradient iterations it took.
171 pub fn diffuse(s: &Surface, rate: &[f64], free: &[bool], u: &mut [f64]) -> usize {
172     let n = s.len();
173     // A point is solved for when it is free and has something to be solved
174     // from — an area or an edge. One with neither (a point on no triangle)
175     // would be a row of zeros.
176     let mut diag = vec![0.0f64; n];
177     let mut solve = vec![false; n];
178     for i in 0..n {
179         let k: f64 = s.edges(i).map(|(j, w)| conductance(rate, i, j, w)).sum();
180         diag[i] = s.mass[i] + k;
181         solve[i] = free[i] && diag[i] > 0.0 && k > 0.0;
182     }
183     let mut b = vec![0.0f64; n];
184     for i in (0..n).filter(|&i| solve[i]) {
185         b[i] = s.mass[i] * u[i]
186             + s.edges(i)
187                 .filter(|&(j, _)| !solve[j])
188                 .map(|(j, w)| conductance(rate, i, j, w) * u[j])
189                 .sum::<f64>();
190     }
191     let apply = |x: &[f64], y: &mut [f64]| {
192         for i in 0..n {
193             y[i] = if solve[i] {
194                 diag[i] * x[i]
195                     - s.edges(i)
196                         .filter(|&(j, _)| solve[j])
197                         .map(|(j, w)| conductance(rate, i, j, w) * x[j])
198                         .sum::<f64>()
199             } else {
200                 0.0
201             };
202         }
203     };
204     let mut x: Vec<f64> = (0..n).map(|i| if solve[i] { u[i] } else { 0.0 }).collect();
205     let mut ax = vec![0.0f64; n];
206     apply(&x, &mut ax);
207     let mut r: Vec<f64> = (0..n).map(|i| b[i] - ax[i]).collect();
208     let norm_b = b.iter().map(|v| v * v).sum::<f64>().sqrt();
209     let tol = 1e-10 * norm_b.max(1e-300);
210     let precond = |r: &[f64]| -> Vec<f64> { (0..n).map(|i| if solve[i] { r[i] / diag[i] } else { 0.0 }).collect() };
211     let mut z = precond(&r);
212     let mut p = z.clone();
213     let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
214     let mut ap = vec![0.0f64; n];
215     let mut iterations = 0;
216     while iterations < 4 * n.max(16) {
217         if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= tol {
218             break;
219         }
220         apply(&p, &mut ap);
221         let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
222         if !(pap > 0.0) {
223             break;
224         }
225         let alpha = rz / pap;
226         for i in 0..n {
227             x[i] += alpha * p[i];
228             r[i] -= alpha * ap[i];
229         }
230         z = precond(&r);
231         let rz_next: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
232         let beta = rz_next / rz;
233         rz = rz_next;
234         for i in 0..n {
235             p[i] = z[i] + beta * p[i];
236         }
237         iterations += 1;
238     }
239     for i in (0..n).filter(|&i| solve[i]) {
240         u[i] = x[i];
241     }
242     iterations
243 }
244 
245 /// How hard a Concentrate draws a point on.
246 #[derive(Clone, Copy, PartialEq, Debug)]
247 pub enum Response {
248     /// By the difference alone: the heat equation run backward.
249     Difference,
250     /// The difference times what the giving point holds above the floor —
251     /// the aggregation term of Keller–Segel. Against a Diffuse at the same
252     /// Rate it gathers where the value stands more than one above the floor
253     /// and spreads where it stands less.
254     Amount,
255 }
256 
257 /// The most internal steps one Concentrate takes, however stiff.
258 pub const CONCENTRATE_STEPS_MAX: usize = 64;
259 
260 /// Value flowing up the gradient of `signal` (of `u` itself when `None`),
261 /// with `rate` already each point's Rate times the step. Conserved, never
262 /// below the floor, and cut into internal steps so that no point is asked to
263 /// change by more than about half what it holds in one. Returns the steps.
264 pub fn concentrate(s: &Surface, rate: &[f64], free: &[bool], u: &mut [f64], signal: Option<&[f64]>, response: Response) -> usize {
265     let n = s.len();
266     if n == 0 {
267         return 0;
268     }
269     let floor = u.iter().copied().fold(0.0f64, f64::min);
270     let top = u.iter().copied().fold(f64::NEG_INFINITY, f64::max);
271     // The stiffness: what one point could be asked to pass on in a step, as
272     // a fraction of what it holds — the sum of its conductances over its
273     // area, times the spread a signal or an Amount puts on top.
274     let spread = |v: &[f64]| {
275         let (lo, hi) = v.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &x| (lo.min(x), hi.max(x)));
276         (hi - lo).max(0.0)
277     };
278     let scale = match (signal, response) {
279         (None, Response::Difference) => 1.0,
280         (None, Response::Amount) => top - floor,
281         (Some(sig), Response::Difference) => spread(sig) / (top - floor).max(1e-12),
282         (Some(sig), Response::Amount) => spread(sig),
283     };
284     let stiffness = (0..n)
285         .filter(|&i| s.mass[i] > 0.0)
286         .map(|i| s.edges(i).map(|(j, w)| conductance(rate, i, j, w)).sum::<f64>() / s.mass[i])
287         .fold(0.0f64, f64::max)
288         * scale;
289     // With every value on the floor no point has anything to give.
290     if !(stiffness > 0.0) || !stiffness.is_finite() || !(top > floor) {
291         return 0;
292     }
293     let steps = ((stiffness / 0.5).ceil() as usize).clamp(1, CONCENTRATE_STEPS_MAX);
294     let part = 1.0 / steps as f64;
295 
296     let mut flows: Vec<(usize, usize, f64)> = Vec::new();
297     let mut out = vec![0.0f64; n];
298     for _ in 0..steps {
299         flows.clear();
300         out.iter_mut().for_each(|o| *o = 0.0);
301         let sig: &[f64] = signal.unwrap_or(u);
302         for i in 0..n {
303             for (j, w) in s.edges(i).filter(|&(j, _)| j > i) {
304                 let ds = sig[j] - sig[i];
305                 // Up the gradient: the lower end gives.
306                 let (give, take) = if ds > 0.0 { (i, j) } else if ds < 0.0 { (j, i) } else { continue };
307                 if !free[give] && !free[take] || s.mass[give] <= 0.0 || s.mass[take] <= 0.0 {
308                     continue;
309                 }
310                 let mut f = conductance(rate, i, j, w) * part * ds.abs();
311                 if response == Response::Amount {
312                     f *= (u[give] - floor).max(0.0);
313                 }
314                 if f > 0.0 {
315                     flows.push((give, take, f));
316                     out[give] += f;
317                 }
318             }
319         }
320         // The limiter: a point gives at most what it holds above the floor,
321         // all its edges cut back by the same fraction. A held point (outside
322         // the Group) is limited the same way, though it loses nothing.
323         let cut: Vec<f64> = (0..n)
324             .map(|i| {
325                 let have = (u[i] - floor).max(0.0) * s.mass[i];
326                 if out[i] > have { have / out[i] } else { 1.0 }
327             })
328             .collect();
329         for &(give, take, f) in &flows {
330             let f = f * cut[give];
331             if free[give] {
332                 u[give] -= f / s.mass[give];
333             }
334             if free[take] {
335                 u[take] += f / s.mass[take];
336             }
337         }
338         // A giver drained exactly to the floor can land a rounding under it.
339         for v in u.iter_mut() {
340             if *v < floor {
341                 *v = floor;
342             }
343         }
344     }
345     steps
346 }
347 
348 /// Which of the two nodes.
349 #[derive(Clone, Copy, PartialEq, Debug)]
350 pub enum Flow {
351     Diffuse,
352     Concentrate,
353 }
354 
355 impl Flow {
356     fn label(self) -> &'static str {
357         match self {
358             Flow::Diffuse => "Diffuse",
359             Flow::Concentrate => "Concentrate",
360         }
361     }
362 }
363 
364 pub fn resolve(
365     root: &FsNode,
366     target: &FsNode,
367     flow: Flow,
368     visited: &mut Vec<String>,
369     ocl_error: &mut Option<String>,
370     sim: &mut EvalSim,
371 ) -> Option<Detail> {
372     let input_node = param_node(root, target, "input")?;
373     let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
374     if let Err(why) = apply(&mut geom, target, flow) {
375         if ocl_error.is_none() {
376             *ocl_error = Some(format!("{} '{}': {}", flow.label(), target.name, why));
377         }
378     }
379     Some(geom)
380 }
381 
382 /// A point attribute as one column per component.
383 fn columns(geom: &Detail, name: &str, k: usize) -> Vec<Vec<f64>> {
384     let mut cols = vec![vec![0.0f64; geom.num_points()]; k];
385     for p in 0..geom.num_points() {
386         let Some(v) = geom.points().value(name, p) else { continue };
387         let c: &[f32] = match &v {
388             AttribValue::Float(x) => std::slice::from_ref(x),
389             AttribValue::Float2(x) => x,
390             AttribValue::Float3(x) => x,
391             AttribValue::Float4(x) => x,
392             AttribValue::Int(_) => &[],
393         };
394         if let AttribValue::Int(i) = v {
395             cols[0][p] = i as f64;
396         }
397         for (col, x) in cols.iter_mut().zip(c) {
398             col[p] = *x as f64;
399         }
400     }
401     cols
402 }
403 
404 /// Either node over geometry in hand. Leaves the geometry untouched on an
405 /// error, which says why.
406 pub fn apply(geom: &mut Detail, target: &FsNode, flow: Flow) -> Result<(), String> {
407     let name = node_param_str(target, "attribute", "").trim().to_string();
408     if name.is_empty() {
409         return Ok(());
410     }
411     let Some(ty) = geom.points().get(&name).map(|a| a.ty()) else {
412         return Err(format!("no point attribute named '{name}'"));
413     };
414     let n = geom.num_points();
415     let k = ty.components();
416 
417     let per_frame = node_param_bool(target, "per_frame", false);
418     let dt = if per_frame { geom.detail().value("dt", 0).map_or(1.0, |v| v.as_f32()) } else { 1.0 };
419     let dt = if dt.is_finite() && dt > 0.0 { dt as f64 } else { 1.0 };
420     let base = (node_param_f32(target, "rate", 0.01) as f64).max(0.0) * dt;
421     if base == 0.0 || n == 0 {
422         return Ok(());
423     }
424     let rate_by = node_param_str(target, "rate_by", "").trim().to_string();
425     let rate: Vec<f64> = if rate_by.is_empty() {
426         vec![base; n]
427     } else if !geom.points().has(&rate_by) {
428         return Err(format!("Rate By '{rate_by}' is not a point attribute"));
429     } else {
430         (0..n)
431             .map(|p| base * geom.points().value(&rate_by, p).map_or(0.0, |v| v.as_f32() as f64).max(0.0))
432             .collect()
433     };
434     let group = node_param_str(target, "group", "").trim().to_string();
435     let free: Vec<bool> = (0..n).map(|p| group.is_empty() || geom.points().in_group(&group, p)).collect();
436 
437     let surface = Surface::of(geom);
438     if surface.w.is_empty() {
439         return Err("no surface to flow over (it needs triangles or polygons)".into());
440     }
441 
442     let mut cols = columns(geom, &name, k);
443     match flow {
444         Flow::Diffuse => {
445             for col in cols.iter_mut() {
446                 diffuse(&surface, &rate, &free, col);
447             }
448         }
449         Flow::Concentrate => {
450             let response = if node_param_str(target, "response", "Difference").eq_ignore_ascii_case("amount") {
451                 Response::Amount
452             } else {
453                 Response::Difference
454             };
455             let follow = node_param_str(target, "follow", "").trim().to_string();
456             let signal: Option<Vec<Vec<f64>>> = if follow.is_empty() || follow == name {
457                 None
458             } else {
459                 let Some(fty) = geom.points().get(&follow).map(|a| a.ty()) else {
460                     return Err(format!("Follow '{follow}' is not a point attribute"));
461                 };
462                 let fk = fty.components();
463                 if fk != 1 && fk != k {
464                     return Err(format!("Follow '{follow}' is {} wide and '{name}' {k}; it must be one number or as wide", fk));
465                 }
466                 Some(columns(geom, &follow, fk))
467             };
468             for (c, col) in cols.iter_mut().enumerate() {
469                 let sig = signal.as_ref().map(|s| s[if s.len() == 1 { 0 } else { c }].as_slice());
470                 concentrate(&surface, &rate, &free, col, sig, response);
471             }
472         }
473     }
474 
475     for p in 0..n {
476         let at = |c: usize| cols[c][p] as f32;
477         let value = match ty {
478             AttribType::Float => AttribValue::Float(at(0)),
479             AttribType::Float2 => AttribValue::Float2([at(0), at(1)]),
480             AttribType::Float3 => AttribValue::Float3([at(0), at(1), at(2)]),
481             AttribType::Float4 => AttribValue::Float4([at(0), at(1), at(2), at(3)]),
482             AttribType::Int => AttribValue::Int(cols[0][p].round() as i32),
483         };
484         let _ = geom.points_mut().set_value(&name, p, value);
485     }
486     Ok(())
487 }
488 
489 #[cfg(test)]
490 mod tests {
491     use super::*;
492     use crate::app::ParamDef;
493     use crate::geometry::{sphere_detail, SimCache};
494     use glam::Vec3;
495 
496     fn node(name: &str, node_type: &str, params: &[(&str, &str)], children: Vec<FsNode>) -> FsNode {
497         FsNode {
498             id: format!("id-{name}"),
499             inputs: 1,
500             outputs: 1,
501             name: name.to_string(),
502             node_type: node_type.to_string(),
503             children,
504             params: params.iter().map(|(k, v)| ParamDef::new(k.to_string(), "text".to_string(), v.to_string())).collect(),
505             geometry_visible: true,
506             bypassed: false,
507             position: (0.0, 0.0),
508         }
509     }
510 
511     /// A unit sphere carrying `mass`, set per point from its position.
512     fn sphere(rows: usize, cols: usize, mass: impl Fn(Vec3) -> f32) -> Detail {
513         let mut g = sphere_detail(Vec3::ZERO, 1.0, rows, cols);
514         g.points_mut().create("mass", AttribValue::Float(0.0));
515         for p in 0..g.num_points() {
516             let m = mass(g.pos(p));
517             g.points_mut().set_value("mass", p, AttribValue::Float(m)).unwrap();
518         }
519         g
520     }
521 
522     fn mass(g: &Detail) -> Vec<f64> {
523         (0..g.num_points()).map(|p| g.points().value("mass", p).unwrap().as_f32() as f64).collect()
524     }
525 
526     fn run(g: &Detail, flow: Flow, params: &[(&str, &str)]) -> Detail {
527         let mut all = vec![("attribute", "mass"), ("per_frame", "true")];
528         all.extend_from_slice(params);
529         let mut out = g.clone();
530         apply(&mut out, &node("flow1", "x", &all, vec![]), flow).expect("applies");
531         out
532     }
533 
534     #[test]
535     fn diffuse_spreads_a_spike_and_conserves_its_total() {
536         let before = sphere(24, 32, |p| if p.y > 0.95 { 1.0 } else { 0.0 });
537         let after = run(&before, Flow::Diffuse, &[("rate", "0.02")]);
538         let s = Surface::of(&before);
539         let (u0, u1) = (mass(&before), mass(&after));
540         let (t0, t1) = (s.total(&u0), s.total(&u1));
541         assert!((t1 - t0).abs() < 1e-5 * t0, "total {t0} became {t1}");
542         let max = u1.iter().copied().fold(f64::MIN, f64::max);
543         let min = u1.iter().copied().fold(f64::MAX, f64::min);
544         assert!(max < 1.0 && min >= -1e-6, "out of the range it started in: {min}..{max}");
545         let reached = (0..u1.len()).filter(|&p| u0[p] == 0.0 && u1[p] > 1e-4).count();
546         assert!(reached > 0, "nothing spread");
547         // Nothing reached the far pole in one short step.
548         let south = (0..u1.len()).find(|&p| before.pos(p).y < -0.99).unwrap();
549         assert!(u1[south] < 1e-6);
550     }
551 
552     /// Height on a unit sphere is an eigenfunction of its Laplacian, with
553     /// eigenvalue -2, so one backward-Euler step of Rate r scales it by
554     /// exactly 1 / (1 + 2r) — on the smooth sphere, and so on any mesh fine
555     /// enough to stand for it, whatever its triangles.
556     #[test]
557     fn diffuse_is_a_rate_over_the_surface_and_not_the_tessellation() {
558         for (rows, cols) in [(12, 16), (24, 32), (48, 64)] {
559             let before = sphere(rows, cols, |p| p.y);
560             let after = run(&before, Flow::Diffuse, &[("rate", "0.10")]);
561             let (u0, u1) = (mass(&before), mass(&after));
562             let top = (0..u0.len()).max_by(|&a, &b| u0[a].total_cmp(&u0[b])).unwrap();
563             let ratio = u1[top] / u0[top];
564             assert!((ratio - 1.0 / 1.2).abs() < 0.01, "{rows}x{cols}: the pole decayed to {ratio}, not 0.833");
565         }
566     }
567 
568     #[test]
569     fn diffuse_holds_what_is_outside_its_group_and_leaves_a_uniform_field_alone() {
570         let mut before = sphere(16, 24, |p| if p.y > 0.0 { 1.0 } else { 0.0 });
571         before.points_mut().create_group("north");
572         for p in 0..before.num_points() {
573             if before.pos(p).y > 0.0 {
574                 before.points_mut().add_to_group("north", p);
575             }
576         }
577         let after = run(&before, Flow::Diffuse, &[("rate", "0.05"), ("group", "north")]);
578         let (u0, u1) = (mass(&before), mass(&after));
579         for p in 0..u0.len() {
580             if before.pos(p).y <= 0.0 {
581                 assert_eq!(u0[p], u1[p], "point {p} is outside the group");
582             }
583         }
584         assert!((0..u0.len()).any(|p| u1[p] < u0[p] - 1e-4), "the cold half drained nothing");
585 
586         let flat = sphere(16, 24, |_| 0.5);
587         let same = run(&flat, Flow::Diffuse, &[("rate", "1.0")]);
588         for (a, b) in mass(&flat).iter().zip(mass(&same)) {
589             assert!((a - b).abs() < 1e-6);
590         }
591     }
592 
593     #[test]
594     fn concentrate_gathers_into_peaks_conserving_the_total_and_never_below_zero() {
595         // A gentle bump: the top gathers from the slope below it.
596         let before = sphere(24, 32, |p| 1.0 + 0.1 * p.y);
597         let after = run(&before, Flow::Concentrate, &[("rate", "0.05")]);
598         let s = Surface::of(&before);
599         let (u0, u1) = (mass(&before), mass(&after));
600         let (t0, t1) = (s.total(&u0), s.total(&u1));
601         assert!((t1 - t0).abs() < 1e-5 * t0, "total {t0} became {t1}");
602         let max0 = u0.iter().copied().fold(f64::MIN, f64::max);
603         let max1 = u1.iter().copied().fold(f64::MIN, f64::max);
604         assert!(max1 > max0 + 1e-3, "the peak did not grow: {max0} -> {max1}");
605 
606         // Hard enough to empty points: they stop at zero, and the total holds.
607         let mut g = before.clone();
608         for _ in 0..20 {
609             g = run(&g, Flow::Concentrate, &[("rate", "1.0")]);
610         }
611         let u = mass(&g);
612         assert!(u.iter().all(|&v| v >= 0.0), "overdrawn: {}", u.iter().copied().fold(f64::MAX, f64::min));
613         assert!(u.iter().any(|&v| v < 1e-3), "nothing emptied");
614         assert!((s.total(&u) - t0).abs() < 1e-4 * t0);
615     }
616 
617     #[test]
618     fn concentrate_follows_another_attribute_up_its_gradient() {
619         let mut before = sphere(16, 24, |_| 1.0);
620         before.points_mut().create("food", AttribValue::Float(0.0));
621         for p in 0..before.num_points() {
622             let y = before.pos(p).y;
623             before.points_mut().set_value("food", p, AttribValue::Float(y)).unwrap();
624         }
625         for response in ["Difference", "Amount"] {
626             let after = run(&before, Flow::Concentrate, &[("rate", "0.05"), ("follow", "food"), ("response", response)]);
627             let u = mass(&after);
628             let pole = |north: bool| {
629                 (0..u.len()).find(|&p| if north { before.pos(p).y > 0.99 } else { before.pos(p).y < -0.99 }).unwrap()
630             };
631             assert!(u[pole(true)] > 1.01 && u[pole(false)] < 0.99, "{response}: {} / {}", u[pole(true)], u[pole(false)]);
632             assert_eq!(mass(&before).len(), u.len());
633         }
634         // A Follow neither one number nor as wide as the attribute is refused.
635         let mut wide = before.clone();
636         wide.points_mut().create("pair", AttribValue::Float2([0.0, 0.0]));
637         let err = apply(&mut wide, &node("c", "concentrate", &[("attribute", "mass"), ("follow", "pair")], vec![]), Flow::Concentrate);
638         assert!(err.is_err());
639     }
640 
641     /// Inside a simnet, Per Frame divides a frame among the substeps: four
642     /// substeps of a quarter spread a frame as far as one substep of the
643     /// whole — not exactly, a backward-Euler step being first order, but
644     /// nowhere near the four times as far they would without it.
645     #[test]
646     fn a_diffuse_in_a_simnet_spreads_a_frame_whatever_the_substeps() {
647         let graph = |substeps: &str, per_frame: &str| {
648             let sphere = node("sphere1", "sphere", &[("radius", "1.0"), ("rows", "24"), ("columns", "32"), ("center_x", "0"), ("center_y", "0"), ("center_z", "0")], vec![]);
649             let seed = node("seed1", "wrangle", &[("input", "sphere1"), ("class", "Points"), ("code", "@mass = @P.y;")], vec![]);
650             let sim = node(
651                 "simnet1",
652                 "simnet",
653                 &[("input", "seed1"), ("substeps", substeps)],
654                 vec![
655                     node("input1", "input", &[], vec![]),
656                     node("diffuse1", "diffuse", &[("input", "input1"), ("attribute", "mass"), ("rate", "0.10"), ("per_frame", per_frame)], vec![]),
657                     node("output1", "output", &[("input", "diffuse1")], vec![]),
658                 ],
659             );
660             node("root", "node", &[], vec![sphere, seed, sim])
661         };
662         let pole_after_a_frame = |root: &FsNode| {
663             let sim_node = root.children.iter().find(|c| c.node_type == "simnet").unwrap();
664             let mut cache = SimCache::default();
665             let mut sim = EvalSim::new(2, 1, &mut cache);
666             let mut err = None;
667             let g = generate_single_node_geometry_with_errors(root, sim_node, &mut Vec::new(), &mut err, &mut sim).expect("solves");
668             assert!(err.is_none(), "{err:?}");
669             let u = mass(&g);
670             let top = (0..u.len()).max_by(|&a, &b| g.pos(a).y.total_cmp(&g.pos(b).y)).unwrap();
671             u[top] / g.pos(top).y as f64
672         };
673         let one = pole_after_a_frame(&graph("1", "true"));
674         let four = pole_after_a_frame(&graph("4", "true"));
675         let unscaled = pole_after_a_frame(&graph("4", "false"));
676         assert!((one - 1.0 / 1.2).abs() < 0.03, "one substep: {one}");
677         assert!((four - one).abs() < 0.02, "four substeps {four} against one {one}");
678         assert!(unscaled < four - 0.1, "without Per Frame four substeps spread four frames: {unscaled}");
679     }
680 
681     #[test]
682     fn integers_stay_whole_and_errors_say_why() {
683         let mut g = sphere(12, 16, |_| 0.0);
684         g.points_mut().create("count", AttribValue::Int(0));
685         g.points_mut().set_value("count", 0, AttribValue::Int(1000)).unwrap();
686         let mut out = g.clone();
687         apply(&mut out, &node("d", "diffuse", &[("attribute", "count"), ("rate", "0.05")], vec![]), Flow::Diffuse).unwrap();
688         assert!(matches!(out.points().value("count", 0), Some(AttribValue::Int(v)) if v < 1000 && v > 0));
689 
690         let mut missing = g.clone();
691         let e = apply(&mut missing, &node("d", "diffuse", &[("attribute", "heat")], vec![]), Flow::Diffuse).unwrap_err();
692         assert!(e.contains("heat"), "{e}");
693         let mut cloud = Detail::new();
694         cloud.add_point(Vec3::ZERO);
695         cloud.add_point(Vec3::X);
696         cloud.points_mut().create("mass", AttribValue::Float(1.0));
697         let e = apply(&mut cloud, &node("d", "diffuse", &[("attribute", "mass")], vec![]), Flow::Diffuse).unwrap_err();
698         assert!(e.contains("surface"), "{e}");
699     }
700 }