graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(neighbour): one node for Diffuse, Concentrate, Migrate and Bleed
Four of the Developer set's operators are one operation seen from different
angles — a value and the values near it — so they are one node with a Mode,
over a Neighbourhood of connectivity rings, a world radius, or everything.
Diffuse moves each value toward its neighbours' average; Concentrate is the
same quantity negated, which sharpens instead of smoothing; Migrate transports
value along a per-point Direction, the sender debited exactly what the
receivers are credited; Bleed decays toward zero.
Every mode is componentwise over any attribute type, so one node serves a
float, a vector and an integer count — integers round on the way back, because
a counter that quietly became 7.5 is a counter nobody can index with.
Two decisions worth naming:
A Group narrows which points are EDITED, not which are READ. A diffusion that
could only see inside its own group would bend away from the group boundary
instead of across it, which is the opposite of what a mask is for.
A point is never its own neighbour, under any of the three rules. That sounds
like a detail and is not: it is what makes the rules continuous with each
other, so a radius wide enough to cover the geometry behaves like Global
rather than almost like it. The first draft had Global averaging a point with
itself; the test that compares the two hoods is what caught it, and it is
invisible on a spread-out attribute and glaring on a spike, which would refuse
to come down.
Native Rust rather than a kernel, per the proposal's first decision: this walks
topology, and the kernel language's C subset has no way to express that. The
operator is split from the resolver (apply_neighbour) so it can be exercised on
geometry a test controls rather than only on what a graph happens to produce.
Co-Authored-By: Claude Opus 5 <[email protected]>
nodes/neighbour.json | 62 ++++++
shapeshifter.md | 12 +-
src/geometry.rs | 529 +++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 600 insertions(+), 3 deletions(-)
diff --git a/nodes/neighbour.json b/nodes/neighbour.json
new file mode 100644
index 0000000..f80b619
--- /dev/null
+++ b/nodes/neighbour.json
@@ -0,0 +1,62 @@
+{
+ "name": "Neighbour",
+ "type": "neighbour",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ {
+ "name": "Input",
+ "type": "text",
+ "default": ""
+ },
+ {
+ "name": "Attribute",
+ "type": "text",
+ "default": "mass"
+ },
+ {
+ "name": "Mode",
+ "type": "choice:Diffuse,Concentrate,Migrate,Bleed",
+ "default": "Diffuse"
+ },
+ {
+ "name": "Neighbourhood",
+ "type": "choice:Connectivity,Radius,Global",
+ "default": "Connectivity"
+ },
+ {
+ "name": "Rings",
+ "type": "spinbox",
+ "default": "1",
+ "min": 1.0,
+ "max": 8.0,
+ "step": 1.0
+ },
+ {
+ "name": "Radius",
+ "type": "slider",
+ "default": "0.20",
+ "min": 0.0,
+ "max": 2.0,
+ "step": 0.01
+ },
+ {
+ "name": "Amount",
+ "type": "slider",
+ "default": "0.50",
+ "min": 0.0,
+ "max": 1.0,
+ "step": 0.01
+ },
+ {
+ "name": "Direction",
+ "type": "text",
+ "default": ""
+ },
+ {
+ "name": "Group",
+ "type": "text",
+ "default": ""
+ }
+ ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index 55c6440..c2429f9 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -129,9 +129,14 @@ Spreadsheet, the meta overlays, `project.rs`.
> each other by a cross-backend test. A deformer no longer flattens or welds,
> so topology, groups and point identities pass through untouched.
>
-> Outstanding: the generator ABI (still a corner list out), the topology
-> buffers, vector attributes — which need the CPU interpreter to grow vector
-> types — and the attribute vocabulary nodes themselves.
+> The `neighbour` node is in, as a native Rust evaluator: Diffuse,
+> Concentrate, Migrate and Bleed over a Neighbourhood of connectivity rings,
+> radius or global, componentwise on any attribute type. Per decision 1 the
+> neighbourhood walk stays out of the kernel language.
+>
+> Outstanding: the generator ABI (still a corner list out), the vector
+> steering modes (Align, Lead, Charge), and the rest of the attribute
+> vocabulary — Initialize, Remap, Clip, Composite, Promote, Analysis, Time.
Widen the kernel ABI from `(in_pos, in_col, out_pos, out_col, params)` to
**named attribute buffers bound by the node**, plus the topology arrays as
@@ -259,6 +264,7 @@ Vector families already collapsed into one in September 2026.
|---|---|---|
| `attribute` | Attribute Initialize, Constant, Clip, Remap, Combine, Composite, Promote, Select, Normalize, Weight | 10 → 1 |
| `neighbour` | Diffuse, Concentrate, Migrate, Bleed, Align, Lead, Charge — one Mode, one Neighbourhood | 7 → 1 |
+| | *(first four landed; the vector steering modes remain)* | |
| `gradient` | Gradient, Rotate, Direction | 3 → 1 |
| `analysis` | Analysis, Measure, Metamax, Time Analysis, Region Center | 5 → 1 |
| `time` | Time, Time Ramp, Time Switch | 3 → 1 |
diff --git a/src/geometry.rs b/src/geometry.rs
index 051ae1b..8379a5b 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -728,6 +728,8 @@ pub fn generate_single_node_geometry_with_errors(
resolve_collision_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("relax") {
resolve_relax_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("neighbour") {
+ resolve_neighbour_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("attribute") {
resolve_attribute_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("opencl") {
@@ -1272,6 +1274,285 @@ pub fn resolve_relax_geometry_with_errors(
Some(geom)
}
+/// Which points a neighbourhood operator treats as a point's neighbours.
+enum Hood {
+ /// Points reachable within N edge rings. The mesh's own connectivity, and
+ /// the only one of the three that respects a surface: two points a
+ /// hair's breadth apart across a fold are not neighbours.
+ Connectivity(usize),
+ /// Points within a world-space distance, fold or no fold.
+ Radius(f32),
+ /// Every point. Not a neighbourhood so much as the limit of one — the
+ /// "global" reading of Diffuse and Concentrate, where the thing a value
+ /// moves toward is the whole geometry's average.
+ Global,
+}
+
+/// The Neighbour node: one operator family over an attribute and the points
+/// around each point.
+///
+/// Four Modes, all of them "a value and the values near it":
+///
+/// - **Diffuse** moves each value toward the average of its neighbours.
+/// - **Concentrate** moves it away — the same quantity with the sign flipped,
+/// which sharpens a gradient instead of smoothing it.
+/// - **Migrate** pushes value along a per-point Direction vector. Neighbours
+/// in front of a point receive; the point loses exactly what they gain, so
+/// the total is conserved and value is transported rather than created.
+/// - **Bleed** decays toward zero. It has no neighbours in it at all, but it
+/// belongs to the family: it is what the others compose with to keep a
+/// simulation from saturating, and separating it would make the chain
+/// longer without making it clearer. Neighbourhood is ignored.
+///
+/// Every mode works componentwise on any attribute type, so one node covers a
+/// float, an integer count and a vector — integers round on the way back so a
+/// counter stays whole. Amount scales the whole edit and is the natural
+/// per-frame rate inside a simnet.
+///
+/// This is the node the proposal's table collapses seven Houdini operators
+/// into (Diffuse, Concentrate, Migrate, Bleed, plus the Align/Lead/Charge
+/// vector steering still to come), and it is deliberately a native Rust
+/// evaluator rather than a kernel: it walks topology, which the kernel
+/// language's C subset has no way to express.
+pub fn resolve_neighbour_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Detail> {
+ let input_name = node_param_str(target, "Input", "");
+ if input_name.is_empty() {
+ return None;
+ }
+ let input_node = find_node_by_name(root, &input_name)?;
+ let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+ apply_neighbour(&mut geom, target, ocl_error);
+ Some(geom)
+}
+
+/// The Neighbour operator itself, over geometry already in hand.
+///
+/// Split from the resolver so the operator can be exercised on geometry a test
+/// controls, rather than only on whatever a graph happens to produce.
+pub(crate) fn apply_neighbour(geom: &mut Detail, target: &FsNode, ocl_error: &mut Option<String>) {
+ let name = node_param_str(target, "Attribute", "").trim().to_string();
+ if name.is_empty() {
+ return;
+ }
+ let Some(ty) = geom.points().get(&name).map(|a| a.ty()) else {
+ if ocl_error.is_none() {
+ *ocl_error = Some(format!(
+ "Neighbour '{}': no point attribute named '{}'",
+ target.name, name
+ ));
+ }
+ return;
+ };
+
+ let n = geom.num_points();
+ let k = ty.components();
+ let amount = node_param_f32(target, "Amount", 0.5).clamp(0.0, 1.0);
+ let mode = node_param_str(target, "Mode", "Diffuse").to_lowercase();
+
+ // The attribute as a flat n x k matrix of components, so one body serves
+ // every type. Integers ride through as floats and round on the way back.
+ let mut val: Vec<f32> = Vec::with_capacity(n * k);
+ for p in 0..n {
+ let comps = geom
+ .points()
+ .value(&name, p)
+ .map(attrib_components)
+ .unwrap_or_else(|| vec![0.0; k]);
+ for c in 0..k {
+ val.push(comps.get(c).copied().unwrap_or(0.0));
+ }
+ }
+
+ // A Group narrows which points are EDITED. Their neighbours are still read
+ // from the whole geometry — a diffusion that could only see inside its own
+ // group would bend away from the boundary rather than across it.
+ let group = node_param_str(target, "Group", "");
+ let group = group.trim().to_string();
+ let edits: Vec<bool> = (0..n)
+ .map(|p| group.is_empty() || geom.points().in_group(&group, p))
+ .collect();
+
+ let hood = match node_param_str(target, "Neighbourhood", "Connectivity").to_lowercase().as_str() {
+ "radius" => Hood::Radius(node_param_f32(target, "Radius", 0.2).max(0.0)),
+ "global" => Hood::Global,
+ _ => Hood::Connectivity(node_param_f32(target, "Rings", 1.0).max(1.0) as usize),
+ };
+
+ let mut out = val.clone();
+ match mode.as_str() {
+ "bleed" => {
+ for p in (0..n).filter(|&p| edits[p]) {
+ for c in 0..k {
+ out[p * k + c] = val[p * k + c] * (1.0 - amount);
+ }
+ }
+ }
+ "migrate" => {
+ let dir_name = node_param_str(target, "Direction", "");
+ let dir_name = dir_name.trim().to_string();
+ if dir_name.is_empty() || !geom.points().has(&dir_name) {
+ if ocl_error.is_none() {
+ *ocl_error = Some(format!(
+ "Neighbour '{}': Migrate needs a Direction attribute",
+ target.name
+ ));
+ }
+ return;
+ }
+ // Each point hands a fraction of its value to the neighbours that
+ // lie in front of it, split by how squarely they face the
+ // direction. The sender is debited exactly the sum of the credits,
+ // which is what makes this transport rather than growth.
+ for p in (0..n).filter(|&p| edits[p]) {
+ let dir = geom
+ .points()
+ .value(&dir_name, p)
+ .map(|v| v.as_vec3())
+ .unwrap_or(Vec3::ZERO)
+ .normalize_or_zero();
+ if dir == Vec3::ZERO {
+ continue;
+ }
+ let here = geom.pos(p);
+ let nbrs = neighbours_of(geom, p, &hood);
+ let weights: Vec<(usize, f32)> = nbrs
+ .iter()
+ .filter_map(|&q| {
+ let q = q as usize;
+ let to = (geom.pos(q) - here).normalize_or_zero();
+ let w = to.dot(dir);
+ (w > 0.0).then_some((q, w))
+ })
+ .collect();
+ let total: f32 = weights.iter().map(|(_, w)| w).sum();
+ if total <= 0.0 {
+ continue;
+ }
+ for c in 0..k {
+ let moved = val[p * k + c] * amount;
+ out[p * k + c] -= moved;
+ for (q, w) in &weights {
+ out[q * k + c] += moved * (w / total);
+ }
+ }
+ }
+ }
+ // Diffuse and Concentrate are one operation and its negation: the
+ // distance to the neighbourhood's average, travelled toward it or
+ // away from it.
+ other => {
+ let sign = if other == "concentrate" { -1.0 } else { 1.0 };
+ // A point is never its own neighbour, under any of the three
+ // rules — so the global case is the total MINUS this point, over
+ // the other n-1. Getting that wrong is invisible on a spread-out
+ // attribute and glaring on a spike: a lone high point would
+ // average partly with itself and refuse to come down. It also
+ // keeps the rules continuous with each other, so a radius wide
+ // enough to cover the geometry behaves like Global rather than
+ // almost like it.
+ let global_total: Option<Vec<f32>> = matches!(hood, Hood::Global).then(|| {
+ let mut total = vec![0.0; k];
+ for p in 0..n {
+ for c in 0..k {
+ total[c] += val[p * k + c];
+ }
+ }
+ total
+ });
+
+ for p in (0..n).filter(|&p| edits[p]) {
+ let mean: Vec<f32> = match &global_total {
+ Some(total) => {
+ if n < 2 {
+ continue;
+ }
+ (0..k)
+ .map(|c| (total[c] - val[p * k + c]) / (n - 1) as f32)
+ .collect()
+ }
+ None => {
+ let nbrs = neighbours_of(geom, p, &hood);
+ if nbrs.is_empty() {
+ continue;
+ }
+ let mut mean = vec![0.0; k];
+ for &q in &nbrs {
+ for c in 0..k {
+ mean[c] += val[q as usize * k + c];
+ }
+ }
+ for m in mean.iter_mut() {
+ *m /= nbrs.len() as f32;
+ }
+ mean
+ }
+ };
+ for c in 0..k {
+ let v = val[p * k + c];
+ out[p * k + c] = v + sign * amount * (mean[c] - v);
+ }
+ }
+ }
+ }
+
+ for p in 0..n {
+ let comps = &out[p * k..p * k + k];
+ let _ = geom
+ .points_mut()
+ .set_value(&name, p, components_attrib(ty, comps));
+ }
+}
+
+/// The points around `p` under one neighbourhood rule, never including `p`.
+fn neighbours_of(geom: &Detail, p: usize, hood: &Hood) -> Vec<u32> {
+ match *hood {
+ Hood::Connectivity(1) => geom.point_neighbours(p).to_vec(),
+ Hood::Connectivity(rings) => {
+ // Breadth-first over the edge graph. Each ring is the frontier of
+ // the last, so the cost is the size of the neighbourhood rather
+ // than of the geometry.
+ let mut seen = vec![false; geom.num_points()];
+ seen[p] = true;
+ let mut frontier = vec![p as u32];
+ let mut out = Vec::new();
+ for _ in 0..rings {
+ let mut next = Vec::new();
+ for &q in &frontier {
+ for &r in geom.point_neighbours(q as usize) {
+ if !std::mem::replace(&mut seen[r as usize], true) {
+ next.push(r);
+ out.push(r);
+ }
+ }
+ }
+ if next.is_empty() {
+ break;
+ }
+ frontier = next;
+ }
+ out.sort_unstable();
+ out
+ }
+ // Brute force, and knowingly so: a uniform grid is worth building when
+ // a node needs it on geometry this does not comfortably handle, and
+ // Phase 3's collision work will need the same index.
+ Hood::Radius(r) => {
+ let here = geom.pos(p);
+ let r2 = r * r;
+ (0..geom.num_points() as u32)
+ .filter(|&q| q as usize != p && (geom.pos(q as usize) - here).length_squared() <= r2)
+ .collect()
+ }
+ Hood::Global => (0..geom.num_points() as u32).filter(|&q| q as usize != p).collect(),
+ }
+}
+
/// The Attribute node: pass the input geometry through, running one attribute
/// edit over its POINTS. Operation picks the edit —
///
@@ -2463,6 +2744,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
|| nt == "group"
|| nt == "collision"
|| nt == "relax"
+ || nt == "neighbour"
|| nt == "attribute"
|| nt == "simnet"
}
@@ -2592,6 +2874,15 @@ pub fn network_sphere_vertices_with_errors(
out.merge(&geom);
}
}
+ } else if node.node_type.eq_ignore_ascii_case("neighbour") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_neighbour_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(&geom);
+ }
+ }
} else if node.node_type.eq_ignore_ascii_case("collision") {
let _idx = *count;
*count += 1;
@@ -3929,6 +4220,244 @@ mod simnet_tests {
}
}
+ /// A sphere, one point given a spike of `mass`, then a Neighbour node.
+ /// Returns (before, after) so a test can compare the two directly.
+ fn neighbour_chain(extra: &[(&str, &str)]) -> (Detail, Detail) {
+ let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+ let seed = node(
+ "id-seed",
+ "Seed 1",
+ "attribute",
+ vec![
+ param("Input", "Sphere 1"),
+ param("Operation", "Create"),
+ param("Attribute Name", "mass"),
+ param("Type", "Float"),
+ param("Value", "0.00"),
+ ],
+ vec![],
+ );
+ let mut params = vec![
+ param("Input", "Seed 1"),
+ param("Attribute", "mass"),
+ param("Amount", "0.50"),
+ ];
+ for (k, v) in extra {
+ match params.iter_mut().find(|p| p.name == *k) {
+ Some(p) => p.default = v.to_string(),
+ None => params.push(param(k, v)),
+ }
+ }
+ let nbr = node("id-nbr", "Neighbour 1", "neighbour", params, vec![]);
+ let root = node("id-root", "root", "node", vec![], vec![sphere, seed, nbr]);
+
+ let eval = |name: &str| -> Detail {
+ let target = root.children.iter().find(|c| c.name == name).unwrap();
+ let mut visited = Vec::new();
+ let mut err = None;
+ let mut cache = SimCache::default();
+ let mut sim = EvalSim::new(0, 0, &mut cache);
+ generate_single_node_geometry_with_errors(&root, target, &mut visited, &mut err, &mut sim)
+ .expect(name)
+ };
+ // Spike one point by hand: the interesting behaviour is what happens
+ // to a value that is not already uniform.
+ let mut before = eval("Seed 1");
+ before.points_mut().set_value("mass", 0, AttribValue::Float(10.0)).unwrap();
+ (before, eval("Neighbour 1"))
+ }
+
+ /// Evaluate a Neighbour node over geometry whose `mass` is already spiked,
+ /// bypassing the graph so the spike survives.
+ fn run_neighbour(before: &Detail, params: &[(&str, &str)]) -> Detail {
+ let mut geom = before.clone();
+ let mut params_vec = vec![param("Input", "In"), param("Attribute", "mass")];
+ for (k, v) in params {
+ match params_vec.iter_mut().find(|p| p.name == *k) {
+ Some(p) => p.default = v.to_string(),
+ None => params_vec.push(param(k, v)),
+ }
+ }
+ let nbr = node("id-n", "N", "neighbour", params_vec, vec![]);
+ apply_neighbour(&mut geom, &nbr, &mut None);
+ geom
+ }
+
+ #[test]
+ fn test_neighbour_diffuse_spreads_a_spike_and_conserves_nothing_in_particular() {
+ let (before, _) = neighbour_chain(&[]);
+ let spike_nbrs: Vec<u32> = before.point_neighbours(0).to_vec();
+ assert!(!spike_nbrs.is_empty());
+
+ let after = run_neighbour(&before, &[("Mode", "Diffuse"), ("Amount", "0.50")]);
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+
+ // The spike falls toward its neighbours' average (zero) by Amount, and
+ // each neighbour rises toward an average that now includes the spike.
+ assert!((mass(&after, 0) - 5.0).abs() < 1e-4, "spike did not decay: {}", mass(&after, 0));
+ for &q in &spike_nbrs {
+ assert!(mass(&after, q as usize) > 0.0, "neighbour {q} did not receive");
+ }
+ // A point far from the spike is untouched at one ring.
+ let far = (0..before.num_points())
+ .find(|&p| p != 0 && !spike_nbrs.contains(&(p as u32)) && !before.point_neighbours(p).contains(&0))
+ .unwrap();
+ assert_eq!(mass(&after, far), 0.0, "diffusion reached past its ring");
+ }
+
+ #[test]
+ fn test_neighbour_concentrate_is_diffuse_with_the_sign_flipped() {
+ let (before, _) = neighbour_chain(&[]);
+ let diffused = run_neighbour(&before, &[("Mode", "Diffuse"), ("Amount", "0.40")]);
+ let sharpened = run_neighbour(&before, &[("Mode", "Concentrate"), ("Amount", "0.40")]);
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+
+ // Same distance from the starting value, opposite directions.
+ for p in 0..before.num_points() {
+ let base = mass(&before, p);
+ let d = mass(&diffused, p) - base;
+ let c = mass(&sharpened, p) - base;
+ assert!((d + c).abs() < 1e-4, "point {p}: {d} vs {c}");
+ }
+ assert!(mass(&sharpened, 0) > mass(&before, 0), "the spike must sharpen");
+ }
+
+ #[test]
+ fn test_neighbour_migrate_conserves_the_total() {
+ let (mut before, _) = neighbour_chain(&[]);
+ // Everything flows one way.
+ before.points_mut().create("dir", AttribValue::Float3([0.0, 1.0, 0.0]));
+ for p in 0..before.num_points() {
+ before.points_mut().set_value("mass", p, AttribValue::Float(1.0)).unwrap();
+ }
+ let total = |d: &Detail| -> f32 {
+ (0..d.num_points()).map(|p| d.points().value("mass", p).unwrap().as_f32()).sum()
+ };
+ let sum_before = total(&before);
+
+ let after = run_neighbour(
+ &before,
+ &[("Mode", "Migrate"), ("Direction", "dir"), ("Amount", "0.50")],
+ );
+
+ // The sender loses exactly what the receivers gain — that is what makes
+ // this transport rather than growth, and it is the property a tissue
+ // sim leans on when it moves a nutrient around a surface.
+ assert!(
+ (total(&after) - sum_before).abs() < 1e-3,
+ "migrate leaked: {} -> {}",
+ sum_before,
+ total(&after)
+ );
+ // And it actually moved: the topmost point, with nothing above it to
+ // give to, should have gained without giving.
+ let top = (0..before.num_points())
+ .max_by(|&a, &b| before.pos(a).y.partial_cmp(&before.pos(b).y).unwrap())
+ .unwrap();
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+ assert!(mass(&after, top) > mass(&before, top), "nothing accumulated downstream");
+ }
+
+ #[test]
+ fn test_neighbour_bleed_decays_toward_zero_and_ignores_the_hood() {
+ let (before, _) = neighbour_chain(&[]);
+ let after = run_neighbour(&before, &[("Mode", "Bleed"), ("Amount", "0.25")]);
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+ assert!((mass(&after, 0) - 7.5).abs() < 1e-4);
+ // Applying it repeatedly approaches zero without crossing it.
+ let mut g = before.clone();
+ for _ in 0..40 {
+ g = run_neighbour(&g, &[("Mode", "Bleed"), ("Amount", "0.25")]);
+ }
+ assert!(mass(&g, 0) > 0.0 && mass(&g, 0) < 1e-3, "{}", mass(&g, 0));
+ }
+
+ #[test]
+ fn test_neighbour_hoods_differ_and_rings_reach_further() {
+ let (before, _) = neighbour_chain(&[]);
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+ let touched = |d: &Detail| (0..d.num_points()).filter(|&p| mass(d, p) != 0.0).count();
+
+ let one = run_neighbour(&before, &[("Mode", "Diffuse"), ("Neighbourhood", "Connectivity"), ("Rings", "1")]);
+ let two = run_neighbour(&before, &[("Mode", "Diffuse"), ("Neighbourhood", "Connectivity"), ("Rings", "2")]);
+ assert!(touched(&two) > touched(&one), "a second ring must reach further");
+
+ // Global reaches everything: every point but the spike rises off zero.
+ let global = run_neighbour(&before, &[("Mode", "Diffuse"), ("Neighbourhood", "Global"), ("Amount", "1.00")]);
+ assert_eq!(touched(&global), before.num_points() - 1);
+ // The spike lands on exactly zero, because a point is not its own
+ // neighbour and every OTHER point holds zero.
+ assert_eq!(mass(&global, 0), 0.0);
+
+ // Radius ignores connectivity, and the rules are continuous with each
+ // other: a radius wide enough to swallow the sphere IS Global. That
+ // only holds because neither includes the point itself.
+ let wide = run_neighbour(&before, &[("Mode", "Diffuse"), ("Neighbourhood", "Radius"), ("Radius", "5.00"), ("Amount", "1.00")]);
+ for p in 0..before.num_points() {
+ assert!((mass(&wide, p) - mass(&global, p)).abs() < 1e-6, "point {p}");
+ }
+ let none = run_neighbour(&before, &[("Mode", "Diffuse"), ("Neighbourhood", "Radius"), ("Radius", "0.00")]);
+ assert_eq!(touched(&none), 1, "no neighbours means no change");
+ }
+
+ #[test]
+ fn test_neighbour_group_narrows_the_edit_not_the_reading() {
+ let (mut before, _) = neighbour_chain(&[]);
+ // Only the spike's first neighbour may be edited.
+ let q = before.point_neighbours(0)[0] as usize;
+ before.points_mut().create_group("inner");
+ before.points_mut().add_to_group("inner", q);
+
+ let after = run_neighbour(&before, &[("Mode", "Diffuse"), ("Group", "inner"), ("Amount", "1.00")]);
+ let mass = |d: &Detail, p: usize| d.points().value("mass", p).unwrap().as_f32();
+
+ assert_eq!(mass(&after, 0), 10.0, "a point outside the group is not edited");
+ // But the edited point still READ the spike, which is outside the
+ // group — a diffusion that could only see its own group would bend
+ // away from the boundary instead of across it.
+ assert!(mass(&after, q) > 0.0, "the group member saw its neighbour outside the group");
+ }
+
+ #[test]
+ fn test_neighbour_works_componentwise_on_vectors_and_keeps_integers_whole() {
+ let (before, _) = neighbour_chain(&[]);
+ let mut g = before.clone();
+ g.points_mut().create("vel", AttribValue::Float3([0.0; 3]));
+ g.points_mut().set_value("vel", 0, AttribValue::Float3([3.0, 6.0, 9.0])).unwrap();
+ g.points_mut().create("count", AttribValue::Int(0));
+ g.points_mut().set_value("count", 0, AttribValue::Int(10)).unwrap();
+
+ let v = run_neighbour(&g, &[("Attribute", "vel"), ("Mode", "Bleed"), ("Amount", "0.50")]);
+ assert_eq!(
+ v.points().value("vel", 0),
+ Some(AttribValue::Float3([1.5, 3.0, 4.5])),
+ "every component decays alike"
+ );
+
+ let c = run_neighbour(&g, &[("Attribute", "count"), ("Mode", "Bleed"), ("Amount", "0.25")]);
+ // An integer count stays an integer: 10 * 0.75 = 7.5 rounds rather
+ // than silently becoming a float nobody can index with.
+ assert!(matches!(c.points().value("count", 0), Some(AttribValue::Int(_))));
+ assert_eq!(c.points().value("count", 0), Some(AttribValue::Int(7)));
+ }
+
+ #[test]
+ fn test_neighbour_reports_a_missing_attribute_and_passes_geometry_through() {
+ let (before, _) = neighbour_chain(&[]);
+ let mut err = None;
+ let mut geom = before.clone();
+ let nbr = node(
+ "id-n",
+ "N",
+ "neighbour",
+ vec![param("Input", "In"), param("Attribute", "nope")],
+ vec![],
+ );
+ apply_neighbour(&mut geom, &nbr, &mut err);
+ assert!(err.as_deref().unwrap_or("").contains("nope"), "{err:?}");
+ assert_eq!(geom.num_points(), before.num_points(), "geometry passes through");
+ }
+
/// The scene walk draws the level it is STARTED at — the network editor's
/// current directory — while evaluation stays rooted at the tree root.
/// From the root a subnet's internals draw (recursion); started at the