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 }