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

commit026ae50ca2f79f1eceb0dc7fbf3b6a4cf1e329bf
parent3e6594786a
authorLucas Galante <[email protected]>
date2026-09-18 15:09
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