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

commit0b59e18f484ac2415ecf04a01f681dd5302220bc
parent90b457f578
authorLucas Galante <[email protected]>
date2026-09-18 20:57
feat(phase4): transfer, valence and deform

Transfer carries attributes from one geometry onto another by nearest point —
how a field outlives the geometry it was defined on. A remesh keeps values for
the points that survived, but a chain that REBUILDS (a kernel generator, a
Copy, a fresh Scatter) starts from nothing, and this is what samples the old
state onto the new.

Matching is by nearest POINT rather than nearest surface position. A surface
match would interpolate across the triangle a point lands in and read a little
better on a coarse source; nearest-point is predictable, which matters more
when what is being carried is a simulation's state and a wrong value is a wrong
simulation rather than a slightly wrong colour. The transferred attribute keeps
the source's KIND, so a derivative one does not quietly become live on the way
across.

Valence publishes the number the remesher steers toward — six on a regular
triangulation — so a settled mesh can be told from an unsettled one by looking
at it. Ramped through Visualize the irregular vertices light up, which is what
the render in this commit's verification shows at a sphere's pole.

Deform collapses im_twist, im_bend and im_curl into one node, because they are
the same shape: a transform whose strength varies with how far along an axis a
point sits. Only what varies differs. The position along the axis is normalized
against the geometry's own extent, so Amount means the same on a model of any
size and a deform set up on a rough shape survives that shape growing; the
middle is the still point, which is what makes a twist read as a twist rather
than a rotation of the whole thing. Taper clamps at zero so a large Amount
pinches to a point instead of turning the geometry inside out.

Co-Authored-By: Claude Opus 5 <[email protected]>

 nodes/deform.json   |  13 +++
 nodes/transfer.json |  13 +++
 nodes/valence.json  |  11 +++
 shapeshifter.md     |  12 ++-
 src/geometry.rs     | 246 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/main.rs         | 182 ++++++++++++++++++++++++++++++++++++++
 src/spatial.rs      |  35 ++++++++
 7 files changed, 509 insertions(+), 3 deletions(-)

diff --git a/nodes/deform.json b/nodes/deform.json
new file mode 100644
index 0000000..879e814
--- /dev/null
+++ b/nodes/deform.json
@@ -0,0 +1,13 @@
+{
+ "name": "Deform",
+ "type": "deform",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  { "name": "Input", "type": "text", "default": "" },
+  { "name": "Mode", "type": "choice:Twist,Bend,Taper", "default": "Twist" },
+  { "name": "Axis", "type": "choice:X,Y,Z", "default": "Y" },
+  { "name": "Amount", "type": "slider", "default": "1.00", "min": -4.0, "max": 4.0, "step": 0.05 },
+  { "name": "Group", "type": "text", "default": "" }
+ ]
+}
diff --git a/nodes/transfer.json b/nodes/transfer.json
new file mode 100644
index 0000000..58b0c06
--- /dev/null
+++ b/nodes/transfer.json
@@ -0,0 +1,13 @@
+{
+ "name": "Transfer",
+ "type": "transfer",
+ "inputs": 2,
+ "outputs": 1,
+ "params": [
+  { "name": "Input", "type": "text", "default": "" },
+  { "name": "From", "type": "text", "default": "" },
+  { "name": "Attributes", "type": "text", "default": "" },
+  { "name": "Maximum Distance", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.01 },
+  { "name": "Group", "type": "text", "default": "" }
+ ]
+}
diff --git a/nodes/valence.json b/nodes/valence.json
new file mode 100644
index 0000000..ed1aa66
--- /dev/null
+++ b/nodes/valence.json
@@ -0,0 +1,11 @@
+{
+ "name": "Valence",
+ "type": "valence",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  { "name": "Input", "type": "text", "default": "" },
+  { "name": "Attribute", "type": "text", "default": "valence" },
+  { "name": "Measure", "type": "choice:Neighbours,Primitives", "default": "Neighbours" }
+ ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index c9b126f..7658b4f 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -289,8 +289,16 @@ Touches: a new `remesh.rs`, `geometry.rs`, `nodes/*.json`.
 > Copy placed one instance per marker vertex rather than one per location,
 > because the markers were the only points there were.
 >
-> Remaining: Select (which is `group` with more criteria, not a new node), and
-> the Create primitives.
+> `transfer` carries attributes from one geometry onto another by nearest
+> point — how a field outlives the geometry it was defined on, which a chain
+> that REBUILDS needs and a remesh cannot provide. `valence` publishes the
+> number the remesher steers toward. `deform` collapses `im_twist`, `im_bend`
+> and `im_curl` into one node: they are the same shape, a transform whose
+> strength varies along an axis.
+>
+> Remaining: Select (which is `group` with more criteria, not a new node), the
+> Create primitives, and the long tail — which the audit prunes hard (version
+> forks, the dead-on-arrival nodes, the Houdini-SOP wrappers).
 
 The `im_*` family, which is the part that looks biggest and is actually the
 easiest — ninety nodes, most of them a screenful once points and prims exist.
diff --git a/src/geometry.rs b/src/geometry.rs
index f3d0d61..e793314 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -754,6 +754,12 @@ pub fn generate_single_node_geometry_with_errors(
         resolve_copy_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("soft_transform") {
         resolve_soft_transform_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("transfer") {
+        resolve_transfer_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("valence") {
+        resolve_valence_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("deform") {
+        resolve_deform_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("subdivide") {
         resolve_subdivide_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("detangle") {
@@ -1658,7 +1664,215 @@ pub fn resolve_cull_geometry_with_errors(
     Some(geom)
 }
 
-/// The Copy node: one piece of geometry at every point of another.
+/// The Transfer node: carry attributes from one geometry onto another.
+///
+/// How a field outlives the geometry it was defined on. A remesh keeps values
+/// for the points that survived, but a chain that REBUILDS — a kernel
+/// generator, a Copy, a fresh Scatter — starts from nothing, and this is what
+/// samples the old state onto the new one.
+///
+/// Matching is by nearest POINT, not nearest surface position. A surface match
+/// would interpolate across the triangle a point lands in and read a little
+/// better on a coarse source; nearest-point is predictable, which matters more
+/// when the thing being transferred is a simulation's state and a wrong value
+/// is a wrong simulation rather than a slightly wrong colour.
+///
+/// Attributes names a comma-separated list, or takes every point attribute on
+/// the source when left empty. Maximum Distance of zero means no limit; above
+/// zero, a target with nothing near enough keeps whatever it had.
+pub fn resolve_transfer_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+    let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+
+    let from_name = node_param_str(target, "From", "");
+    let from_name = from_name.trim().to_string();
+    let Some(source) = find_node_by_name(root, &from_name)
+        .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
+    else {
+        if ocl_error.is_none() && !from_name.is_empty() {
+            *ocl_error = Some(format!("Transfer '{}': cannot resolve '{}'", target.name, from_name));
+        }
+        return Some(geom);
+    };
+    if source.num_points() == 0 {
+        return Some(geom);
+    }
+
+    let wanted = node_param_str(target, "Attributes", "");
+    let wanted: Vec<String> = wanted
+        .split(',')
+        .map(|s| s.trim().to_string())
+        .filter(|s| !s.is_empty())
+        .collect();
+    let names: Vec<String> = if wanted.is_empty() {
+        source.points().names().iter().map(|s| s.to_string()).collect()
+    } else {
+        wanted
+            .into_iter()
+            .filter(|n| source.points().has(n))
+            .collect()
+    };
+    if names.is_empty() {
+        return Some(geom);
+    }
+
+    let limit = node_param_f32(target, "Maximum Distance", 0.0).max(0.0);
+    let group = node_param_str(target, "Group", "");
+    let group = group.trim().to_string();
+
+    let src_pos: Vec<Vec3> = (0..source.num_points()).map(|p| source.pos(p)).collect();
+    let grid = crate::spatial::PointGrid::build(&src_pos, limit.max(1e-3));
+
+    for name in &names {
+        let Some(ty) = source.points().get(name).map(|a| a.ty()) else { continue };
+        // Created with the source's type so the column exists everywhere even
+        // where nothing was near enough to fill it — a reader downstream finds
+        // the attribute present and zero rather than missing.
+        geom.points_mut()
+            .get_or_create(name, components_attrib(ty, &vec![0.0; ty.components()]));
+        geom.points_mut().set_kind(name, source.points().kind(name));
+
+        for p in 0..geom.num_points() {
+            if !group.is_empty() && !geom.points().in_group(&group, p) {
+                continue;
+            }
+            let Some((q, dist)) = grid.nearest(geom.pos(p)) else { continue };
+            if limit > 0.0 && dist > limit {
+                continue;
+            }
+            if let Some(v) = source.points().value(name, q as usize) {
+                let _ = geom.points_mut().set_value(name, p, v);
+            }
+        }
+    }
+    Some(geom)
+}
+
+/// The Valence node: how connected each point is, as data.
+///
+/// Valence is what the remesher steers toward — six is a regular
+/// triangulation — so being able to see it is how you tell a mesh that has
+/// settled from one that has not. Ramp it through Visualize and the irregular
+/// vertices light up.
+pub fn resolve_valence_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+    let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+    let name = node_param_str(target, "Attribute", "valence").trim().to_string();
+    if name.is_empty() {
+        return Some(geom);
+    }
+    let by_prims = node_param_str(target, "Measure", "Neighbours").eq_ignore_ascii_case("primitives");
+    let data: Vec<i32> = (0..geom.num_points())
+        .map(|p| {
+            if by_prims {
+                geom.point_prims(p).len() as i32
+            } else {
+                geom.point_neighbours(p).len() as i32
+            }
+        })
+        .collect();
+    geom.points_mut().create(&name, AttribValue::Int(0));
+    let _ = geom.points_mut().insert(&name, AttribData::Int(data));
+    Some(geom)
+}
+
+/// The Deform node: twist, bend and taper about an axis.
+///
+/// Three operators in hou-control — `im_twist`, `im_bend`, `im_curl` — and one
+/// here, because they are the same shape: a transform whose strength varies
+/// with how far along an axis a point sits. Only what varies differs.
+///
+/// The position along the axis is NORMALIZED against the geometry's own extent,
+/// so Amount means the same thing on a model of any size and a deform set up on
+/// a rough shape survives that shape growing.
+pub fn resolve_deform_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+    let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+    apply_deform(&mut geom, target);
+    Some(geom)
+}
+
+pub(crate) fn apply_deform(geom: &mut Detail, target: &FsNode) {
+    let Some((lo, hi)) = geom.bounds() else { return };
+    let axis = match node_param_str(target, "Axis", "Y").to_uppercase().as_str() {
+        "X" => 0,
+        "Z" => 2,
+        _ => 1,
+    };
+    let (u, v) = match axis {
+        0 => (1, 2),
+        2 => (0, 1),
+        _ => (0, 2),
+    };
+    let span = (hi - lo)[axis];
+    if span.abs() < 1e-9 {
+        return;
+    }
+    let amount = node_param_f32(target, "Amount", 1.0);
+    let mode = node_param_str(target, "Mode", "Twist").to_lowercase();
+    let group = node_param_str(target, "Group", "");
+    let group = group.trim().to_string();
+    let centre = (lo + hi) * 0.5;
+
+    for p in 0..geom.num_points() {
+        if !group.is_empty() && !geom.points().in_group(&group, p) {
+            continue;
+        }
+        let here = geom.pos(p);
+        // Minus a half so the middle of the geometry is the still point and
+        // the two ends deform in opposite directions, which is what makes a
+        // twist read as a twist rather than a rotation.
+        let t = (here[axis] - lo[axis]) / span - 0.5;
+        let (du, dv) = (here[u] - centre[u], here[v] - centre[v]);
+        let mut out = here;
+        match mode.as_str() {
+            "bend" => {
+                // Rotate in the plane of the axis and one perpendicular, by an
+                // angle that grows along the axis.
+                let a = amount * t;
+                let (s, c) = a.sin_cos();
+                let along = here[axis] - centre[axis];
+                out[axis] = centre[axis] + along * c - du * s;
+                out[u] = centre[u] + along * s + du * c;
+            }
+            "taper" => {
+                // Scale the perpendicular components. Clamped at zero so a
+                // large Amount pinches to a point instead of turning the
+                // geometry inside out.
+                let k = (1.0 + amount * t).max(0.0);
+                out[u] = centre[u] + du * k;
+                out[v] = centre[v] + dv * k;
+            }
+            _ => {
+                let a = amount * t;
+                let (s, c) = a.sin_cos();
+                out[u] = centre[u] + du * c - dv * s;
+                out[v] = centre[v] + du * s + dv * c;
+            }
+        }
+        geom.set_pos(p, out);
+    }
+}
+
+/// The Copy node: one piece of geometry at every point of another./// The Copy node: one piece of geometry at every point of another.
 ///
 /// The layout operator — scatter real geometry rather than the marker spheres
 /// the Points and Scatter nodes draw. Orient reads the target's `N`, so a
@@ -4501,6 +4715,9 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
         || nt == "suture"
         || nt == "detangle"
         || nt == "subdivide"
+        || nt == "deform"
+        || nt == "valence"
+        || nt == "transfer"
         || nt == "soft_transform"
         || nt == "copy"
         || nt == "cull"
@@ -4718,6 +4935,33 @@ pub fn network_sphere_vertices_with_errors(
                     out.merge(&geom);
                 }
             }
+        } else if node.node_type.eq_ignore_ascii_case("transfer") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_transfer_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(&geom);
+                }
+            }
+        } else if node.node_type.eq_ignore_ascii_case("valence") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_valence_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(&geom);
+                }
+            }
+        } else if node.node_type.eq_ignore_ascii_case("deform") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_deform_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(&geom);
+                }
+            }
         } else if node.node_type.eq_ignore_ascii_case("subdivide") {
             let _idx = *count;
             *count += 1;
diff --git a/src/main.rs b/src/main.rs
index 23e14c9..59105d3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3646,6 +3646,188 @@ mod tests {
         assert_eq!(g.num_points(), sphere_detail(Vec3::ZERO, 1.0, 16, 24).num_points());
     }
 
+    #[test]
+    fn test_transfer_samples_a_field_onto_new_geometry() {
+        // A field defined on one sphere, read onto a second one that shares
+        // none of its points — which is what happens whenever a chain rebuilds
+        // rather than deforms.
+        let mut source = sphere_detail(Vec3::ZERO, 1.0, 12, 16);
+        source.points_mut().create("mass", AttribValue::Float(0.0));
+        for p in 0..source.num_points() {
+            let y = source.pos(p).y;
+            source.points_mut().set_value("mass", p, AttribValue::Float(y)).unwrap();
+        }
+        source
+            .points_mut()
+            .create_kind("scratch", AttribValue::Float(1.0), AttribKind::Derivative);
+
+        let mut dest = sphere_detail(Vec3::ZERO, 1.0, 7, 9);
+        assert!(dest.num_points() != source.num_points());
+
+        // Exercised through the same nearest-point walk the node does.
+        let src_pos: Vec<Vec3> = (0..source.num_points()).map(|p| source.pos(p)).collect();
+        let grid = crate::spatial::PointGrid::build(&src_pos, 0.1);
+        dest.points_mut().create("mass", AttribValue::Float(0.0));
+        for p in 0..dest.num_points() {
+            let (q, _) = grid.nearest(dest.pos(p)).unwrap();
+            let v = source.points().value("mass", q as usize).unwrap();
+            dest.points_mut().set_value("mass", p, v).unwrap();
+        }
+
+        // The field came across: a point's value matches where it sits, to
+        // within the source's resolution.
+        for p in 0..dest.num_points() {
+            let v = dest.points().value("mass", p).unwrap().as_f32();
+            assert!((v - dest.pos(p).y).abs() < 0.25, "point {p}: {v} vs y {}", dest.pos(p).y);
+        }
+    }
+
+    #[test]
+    fn test_transfer_respects_its_maximum_distance_and_carries_the_kind() {
+        let root = modelling_root(
+            "1.0",
+            vec![
+                phase3_node("points", &[("Shape", "Line"), ("Points", "6"), ("Markers", "false")]),
+                phase3_node(
+                    "transfer",
+                    &[
+                        ("Input", "points 1"),
+                        ("From", "sphere 1"),
+                        ("Attributes", "Norm"),
+                        ("Maximum Distance", "0.00"),
+                    ],
+                ),
+            ],
+        );
+        let (g, err) = eval_node(&root, "transfer 1");
+        assert!(err.is_none(), "{err:?}");
+        // No limit: everything finds a nearest source point however far.
+        assert!(g.points().has("Norm"));
+        assert!(
+            (0..g.num_points()).any(|p| g.points().value("Norm", p).unwrap().as_vec3() != Vec3::ZERO),
+            "nothing was transferred"
+        );
+
+        // With a tight limit the sphere is out of reach, so the column exists
+        // and stays at the type's zero — present and empty, not missing.
+        let mut limited = root.clone();
+        limited
+            .children
+            .iter_mut()
+            .find(|c| c.name == "transfer 1")
+            .unwrap()
+            .params
+            .iter_mut()
+            .find(|p| p.name == "Maximum Distance")
+            .unwrap()
+            .default = "0.01".into();
+        let (g, _) = eval_node(&limited, "transfer 1");
+        assert!(g.points().has("Norm"), "the column exists even where nothing was near");
+        assert!(
+            (0..g.num_points()).all(|p| g.points().value("Norm", p).unwrap().as_vec3() == Vec3::ZERO),
+            "something transferred from out of range"
+        );
+
+        // A source it cannot resolve is reported rather than silently doing
+        // nothing.
+        let broken = modelling_root(
+            "1.0",
+            vec![phase3_node("transfer", &[("Input", "sphere 1"), ("From", "nope")])],
+        );
+        let (_, err) = eval_node(&broken, "transfer 1");
+        assert!(err.as_deref().unwrap_or("").contains("nope"), "{err:?}");
+    }
+
+    #[test]
+    fn test_valence_counts_what_the_remesher_steers_toward() {
+        use crate::remesh::{remesh, Settings};
+        let root = modelling_root(
+            "1.0",
+            vec![phase3_node("valence", &[("Input", "sphere 1"), ("Attribute", "valence")])],
+        );
+        let (g, err) = eval_node(&root, "valence 1");
+        assert!(err.is_none(), "{err:?}");
+
+        for p in 0..g.num_points() {
+            let v = g.points().value("valence", p).unwrap().as_f32() as usize;
+            assert_eq!(v, g.point_neighbours(p).len(), "point {p}");
+        }
+        // A UV sphere's poles are the irregular vertices: everything else on a
+        // quad sphere has four neighbours.
+        let counts: Vec<usize> = (0..g.num_points()).map(|p| g.point_neighbours(p).len()).collect();
+        assert!(counts.iter().any(|&c| c > 4), "the poles should be irregular");
+
+        // After remeshing to triangles, six is the regular valence — which is
+        // the number the flip pass steers toward, and being able to see it is
+        // how a settled mesh is told from an unsettled one.
+        let settled = remesh(&g, Settings { target: 0.25, iterations: 6, ..Default::default() });
+        let sixes = (0..settled.num_points())
+            .filter(|&p| settled.point_neighbours(p).len() == 6)
+            .count();
+        assert!(
+            sixes * 2 > settled.num_points(),
+            "only {sixes} of {} points reached valence 6",
+            settled.num_points()
+        );
+    }
+
+    #[test]
+    fn test_deform_twists_bends_and_tapers_about_an_axis() {
+        let base = sphere_detail(Vec3::ZERO, 1.0, 10, 14);
+        let run = |mode: &str, amount: &str| {
+            let mut g = base.clone();
+            let node = phase3_node("deform", &[("Mode", mode), ("Axis", "Y"), ("Amount", amount)]);
+            crate::geometry::apply_deform(&mut g, &node);
+            g
+        };
+
+        // The middle of the geometry is the still point, and the two ends go
+        // opposite ways — which is what makes a twist read as a twist rather
+        // than a rotation of the whole thing.
+        let twisted = run("Twist", "2.00");
+        let top = (0..base.num_points())
+            .max_by(|&a, &b| base.pos(a).y.partial_cmp(&base.pos(b).y).unwrap())
+            .unwrap();
+        let equator = (0..base.num_points())
+            .min_by(|&a, &b| base.pos(a).y.abs().partial_cmp(&base.pos(b).y.abs()).unwrap())
+            .unwrap();
+        assert!((twisted.pos(equator) - base.pos(equator)).length() < 0.15, "the middle moved");
+        // A twist keeps every point's distance from the axis.
+        for p in 0..base.num_points() {
+            let r0 = Vec3::new(base.pos(p).x, 0.0, base.pos(p).z).length();
+            let r1 = Vec3::new(twisted.pos(p).x, 0.0, twisted.pos(p).z).length();
+            assert!((r0 - r1).abs() < 1e-4, "point {p} changed radius");
+            assert!((twisted.pos(p).y - base.pos(p).y).abs() < 1e-4, "point {p} moved along the axis");
+        }
+        let _ = top;
+
+        // A taper pinches one end and swells the other, and clamps at zero
+        // rather than turning the geometry inside out.
+        let tapered = run("Taper", "3.90");
+        let radius = |d: &Detail, p: usize| Vec3::new(d.pos(p).x, 0.0, d.pos(p).z).length();
+        let lower = (0..base.num_points())
+            .filter(|&p| base.pos(p).y < -0.8)
+            .collect::<Vec<_>>();
+        for &p in &lower {
+            assert!(radius(&tapered, p) <= radius(&base, p) + 1e-4, "point {p} swelled at the pinched end");
+        }
+
+        // A bend moves points along the axis, which neither of the others do.
+        let bent = run("Bend", "1.50");
+        assert!(
+            (0..base.num_points()).any(|p| (bent.pos(p).y - base.pos(p).y).abs() > 0.05),
+            "bend did not move anything along the axis"
+        );
+
+        // Zero amount is the identity, whatever the mode.
+        for mode in ["Twist", "Bend", "Taper"] {
+            let still = run(mode, "0.00");
+            for p in 0..base.num_points() {
+                assert!((still.pos(p) - base.pos(p)).length() < 1e-5, "{mode} moved at zero");
+            }
+        }
+    }
+
     #[test]
     fn test_points_and_scatter_can_emit_bare_points() {
         // Everything that generates locations drew marker spheres at them,
diff --git a/src/spatial.rs b/src/spatial.rs
index cf812ab..04217dc 100644
--- a/src/spatial.rs
+++ b/src/spatial.rs
@@ -240,6 +240,41 @@ impl PointGrid {
         PointGrid { points: points.to_vec(), grid }
     }
 
+    pub fn is_empty(&self) -> bool {
+        self.points.is_empty()
+    }
+
+    /// The nearest point, and its distance.
+    ///
+    /// Expands the search box until the best hit is closer than the box is
+    /// wide, the same argument [`TriGrid::closest`] makes: anything outside a
+    /// box that wide is at least that far away, so nothing out there can beat
+    /// what is already in hand.
+    pub fn nearest(&self, p: Vec3) -> Option<(u32, f32)> {
+        if self.points.is_empty() {
+            return None;
+        }
+        let mut reach = self.grid.cell;
+        let mut scratch = Vec::new();
+        for _ in 0..12 {
+            self.grid
+                .gather(p - Vec3::splat(reach), p + Vec3::splat(reach), &mut scratch);
+            let best = scratch
+                .iter()
+                .map(|&i| (i, (self.points[i as usize] - p).length()))
+                .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+            match best {
+                Some(hit) if hit.1 <= reach => return Some(hit),
+                _ => reach *= 2.0,
+            }
+        }
+        self.points
+            .iter()
+            .enumerate()
+            .map(|(i, q)| (i as u32, (*q - p).length()))
+            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
+    }
+
     /// Indices of points within `radius` of `p`, excluding nothing — the
     /// caller decides what does not count as a neighbour, because "not
     /// itself" and "not topologically adjacent" are different questions.