graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(phase4): copy, soft transform, selection by attribute, and bare points
Copy puts real geometry at every point of another — the layout operator, where
Points and Scatter only ever drew markers. Orient reads the target's `N`, so a
Normal upstream is what makes copies stand on a surface rather than all facing
one way, and merge reallocates identities so a thousand copies are a thousand
distinct sets of points rather than one set repeated.
Soft Transform moves a region and lets the surface follow. Distance is measured
from the NEAREST MEMBER of the group, not from its centroid: a selection shaped
like a ridge should drag its whole length, and a centroid would make it drag
hardest in the middle of a line it is nowhere near. The falloff is written out
as an attribute too, so the mask that drove the move is available to look at or
reuse.
Group grew Attribute and Expand. Attribute selects by what a point IS rather
than where it is, which is what makes the Phase 4 measuring nodes composable: a
Distance, a Connectivity or a solver's own attribute becomes a named selection
that Cull, Relax's pin, Attribute's group and Soft Transform all already read.
Expand's Rings is signed, so erode and dilate are one node twice rather than
two nodes.
And the finding that came out of rendering Copy rather than testing it: POINTS
AND SCATTER NOW EMIT BARE POINTS. Both drew a marker sphere at every location
they generated, which is right for looking at and wrong for working with — Copy
placed one instance per marker VERTEX rather than one per location, because the
markers were the only points there were. Markers defaults to true, so nothing
existing looks different.
Copy also refuses an explosion with a number rather than attempting it: a
400-point source on a 10,000-point target is four million points, which is not
a render.
Co-Authored-By: Claude Opus 5 <[email protected]>
nodes/copy.json | 14 +++
nodes/group.json | 30 ++++-
nodes/points.json | 5 +
nodes/scatter.json | 11 +-
nodes/soft_transform.json | 15 +++
shapeshifter.md | 12 +-
src/geometry.rs | 272 +++++++++++++++++++++++++++++++++++++++++++-
src/main.rs | 284 ++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 636 insertions(+), 7 deletions(-)
diff --git a/nodes/copy.json b/nodes/copy.json
new file mode 100644
index 0000000..6bdba6a
--- /dev/null
+++ b/nodes/copy.json
@@ -0,0 +1,14 @@
+{
+ "name": "Copy",
+ "type": "copy",
+ "inputs": 2,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" },
+ { "name": "To", "type": "text", "default": "" },
+ { "name": "Orient", "type": "choice:None,Normal", "default": "None" },
+ { "name": "Scale", "type": "text", "default": "1.00" },
+ { "name": "Scale Attribute", "type": "text", "default": "" },
+ { "name": "Group", "type": "text", "default": "" }
+ ]
+}
diff --git a/nodes/group.json b/nodes/group.json
index 0f34b90..99ae535 100644
--- a/nodes/group.json
+++ b/nodes/group.json
@@ -21,7 +21,7 @@
},
{
"name": "Mode",
- "type": "choice:Box,Random",
+ "type": "choice:Box,Random,Attribute,Expand",
"default": "Box"
},
{
@@ -63,6 +63,34 @@
"name": "Highlight",
"type": "toggle",
"default": "true"
+ },
+ {
+ "name": "Attribute",
+ "type": "text",
+ "default": ""
+ },
+ {
+ "name": "Comparison",
+ "type": "choice:Below,Above",
+ "default": "Above"
+ },
+ {
+ "name": "Threshold",
+ "type": "text",
+ "default": "0.50"
+ },
+ {
+ "name": "Source Group",
+ "type": "text",
+ "default": ""
+ },
+ {
+ "name": "Rings",
+ "type": "spinbox",
+ "default": "1",
+ "min": -8.0,
+ "max": 8.0,
+ "step": 1.0
}
]
}
diff --git a/nodes/points.json b/nodes/points.json
index 6f6a0be..78142ca 100644
--- a/nodes/points.json
+++ b/nodes/points.json
@@ -16,6 +16,11 @@
"name": "Shape",
"type": "choice:None,Spiral,Line,Circle,Grid",
"default": "None"
+ },
+ {
+ "name": "Markers",
+ "type": "choice:true,false",
+ "default": "true"
}
]
}
diff --git a/nodes/scatter.json b/nodes/scatter.json
index 269a284..730486d 100644
--- a/nodes/scatter.json
+++ b/nodes/scatter.json
@@ -4,7 +4,11 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "default": "", "type": "text" },
+ {
+ "name": "Input",
+ "default": "",
+ "type": "text"
+ },
{
"name": "Points",
"type": "spinbox",
@@ -20,6 +24,11 @@
"min": 0.005,
"max": 0.1,
"step": 0.005
+ },
+ {
+ "name": "Markers",
+ "type": "choice:true,false",
+ "default": "true"
}
]
}
diff --git a/nodes/soft_transform.json b/nodes/soft_transform.json
new file mode 100644
index 0000000..3bd1712
--- /dev/null
+++ b/nodes/soft_transform.json
@@ -0,0 +1,15 @@
+{
+ "name": "Soft Transform",
+ "type": "soft_transform",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" },
+ { "name": "Translation", "type": "text", "default": "0.00:0.20:0.00" },
+ { "name": "Center", "type": "text", "default": "0.00:0.00:0.00" },
+ { "name": "Group", "type": "text", "default": "" },
+ { "name": "Radius", "type": "slider", "default": "0.50", "min": 0.0, "max": 10.0, "step": 0.01 },
+ { "name": "Falloff", "type": "choice:Smooth,Linear,Constant", "default": "Smooth" },
+ { "name": "Attribute", "type": "text", "default": "" }
+ ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index 76f6d43..c9b126f 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -280,7 +280,17 @@ Touches: a new `remesh.rs`, `geometry.rs`, `nodes/*.json`.
> recovered parameter names from the HDAs they are honoured (`piece_attr`,
> `dir_attr`).
>
-> Remaining: Copy, Soft Transform, Select, and the Create primitives.
+> Copy and Soft Transform are in too, and `group` grew Attribute and Expand —
+> selection by what a point IS rather than where it is, which is what makes
+> the measuring nodes composable, plus grow/shrink across the surface.
+>
+> `points` and `scatter` can now emit BARE POINTS. Both drew marker spheres at
+> every location, which is right for looking at and wrong for working with:
+> 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.
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 a59392a..f3d0d61 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -750,6 +750,10 @@ pub fn generate_single_node_geometry_with_errors(
resolve_connectivity_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("cull") {
resolve_cull_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("copy") {
+ 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("subdivide") {
resolve_subdivide_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("detangle") {
@@ -960,7 +964,57 @@ fn select_elements(
};
let mode = node_param_str(target, "Mode", "Box").to_lowercase();
- if mode == "random" {
+ if mode == "attribute" {
+ // Select by what a point IS rather than where it is. This is what
+ // makes the measuring nodes composable: Distance, Connectivity or a
+ // solver's own attribute becomes a named selection that Cull, Relax's
+ // pin, Attribute's group and Soft Transform all already read.
+ let attr = node_param_str(target, "Attribute", "");
+ let attr = attr.trim().to_string();
+ let below = node_param_str(target, "Comparison", "Above").eq_ignore_ascii_case("below");
+ let threshold = node_param_f32(target, "Threshold", 0.5);
+ if geom.points().has(&attr) {
+ for p in 0..geom.num_points() {
+ let v = geom.points().value(&attr, p).map(|v| v.as_f32()).unwrap_or(0.0);
+ if if below { v < threshold } else { v > threshold } {
+ member[p] = true;
+ }
+ }
+ }
+ } else if mode == "expand" {
+ // Grow or shrink an existing selection across the surface. Rings is
+ // signed: positive walks outward from the members, negative peels the
+ // boundary off, which is how an erode/dilate pair reads without two
+ // nodes.
+ let src = node_param_str(target, "Source Group", "");
+ let src = src.trim().to_string();
+ let rings = node_param_f32(target, "Rings", 1.0).clamp(-8.0, 8.0) as i32;
+ let mut inside: Vec<bool> = (0..geom.num_points())
+ .map(|p| !src.is_empty() && geom.points().in_group(&src, p))
+ .collect();
+ for _ in 0..rings.unsigned_abs() {
+ let before = inside.clone();
+ for p in 0..geom.num_points() {
+ let touches_other = geom
+ .point_neighbours(p)
+ .iter()
+ .any(|&q| before[q as usize] != before[p]);
+ if !touches_other {
+ continue;
+ }
+ // Growing takes the boundary's outside; shrinking gives up the
+ // boundary's inside.
+ if rings > 0 && !before[p] {
+ inside[p] = true;
+ } else if rings < 0 && before[p] {
+ inside[p] = false;
+ }
+ }
+ }
+ for (p, &m) in inside.iter().enumerate() {
+ member[p] = m;
+ }
+ } else if mode == "random" {
let count = node_param_f32(target, "Count", 1.0).max(0.0) as usize;
// Seed offsets the stream, and the element type joins it so switching
// type reshuffles instead of replaying the same index sequence.
@@ -1604,7 +1658,180 @@ pub fn resolve_cull_geometry_with_errors(
Some(geom)
}
-/// The Subdivide node: four triangles where there was one.
+/// 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
+/// Normal node upstream is what makes copies stand on a surface rather than
+/// all facing the same way, and Scale Attribute lets a field drive their size.
+///
+/// Identities are reallocated per copy by `merge`, so a thousand copies are a
+/// thousand distinct sets of points rather than one set repeated — which is
+/// what lets a solver treat them separately.
+pub fn resolve_copy_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Detail> {
+ let source_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+ let source = generate_single_node_geometry_with_errors(root, source_node, visited, ocl_error, sim)?;
+
+ let to_name = node_param_str(target, "To", "");
+ let to_name = to_name.trim().to_string();
+ let Some(onto) = find_node_by_name(root, &to_name)
+ .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
+ else {
+ if ocl_error.is_none() && !to_name.is_empty() {
+ *ocl_error = Some(format!("Copy '{}': cannot resolve '{}'", target.name, to_name));
+ }
+ return Some(source);
+ };
+
+ let group = node_param_str(target, "Group", "");
+ let group = group.trim().to_string();
+ let targets: Vec<usize> = (0..onto.num_points())
+ .filter(|&p| group.is_empty() || onto.points().in_group(&group, p))
+ .collect();
+
+ // A copy of a 400-point sphere onto a 10,000-point surface is four million
+ // points, which is not a render — it is a hang. Refused with a number
+ // rather than attempted.
+ const CEILING: usize = 2_000_000;
+ let total = source.num_points().saturating_mul(targets.len());
+ if total > CEILING {
+ if ocl_error.is_none() {
+ *ocl_error = Some(format!(
+ "Copy '{}': {} copies of {} points is {} — over the {} ceiling",
+ target.name,
+ targets.len(),
+ source.num_points(),
+ total,
+ CEILING
+ ));
+ }
+ return Some(Detail::new());
+ }
+
+ let orient = node_param_str(target, "Orient", "None").eq_ignore_ascii_case("normal");
+ let scale = node_param_f32(target, "Scale", 1.0);
+ let scale_attr = node_param_str(target, "Scale Attribute", "");
+ let scale_attr = scale_attr.trim().to_string();
+ let normals = orient.then(|| point_normals(&onto));
+
+ let mut out = Detail::new();
+ for &t in &targets {
+ let mut inst = source.clone();
+ let mut s = scale;
+ if !scale_attr.is_empty() {
+ if let Some(v) = onto.points().value(&scale_attr, t) {
+ s *= v.as_f32();
+ }
+ }
+ let rot = match &normals {
+ // Whatever rotation takes +Y onto the point's normal. Copies
+ // modelled standing up therefore stand up on the surface.
+ Some(n) if n[t] != Vec3::ZERO => {
+ glam::Quat::from_rotation_arc(Vec3::Y, n[t])
+ }
+ _ => glam::Quat::IDENTITY,
+ };
+ let at = onto.pos(t);
+ for p in 0..inst.num_points() {
+ inst.set_pos(p, at + rot * (inst.pos(p) * s));
+ }
+ out.merge(&inst);
+ }
+ Some(out)
+}
+
+/// The Soft Transform node: move a region and let the surface follow.
+///
+/// A hard translation on a group leaves a step at the group's edge. This falls
+/// off with distance, so the neighbourhood comes along and the surface stays
+/// continuous — the modelling counterpart of what Relax does after a pull.
+///
+/// Distance is measured from the nearest member of Group where one is given,
+/// not from the group's centre: a selection shaped like a ridge should drag
+/// its whole length, and a centroid would make it drag hardest in the middle
+/// of a line it is nowhere near.
+pub fn resolve_soft_transform_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_soft_transform(&mut geom, target);
+ Some(geom)
+}
+
+pub(crate) fn apply_soft_transform(geom: &mut Detail, target: &FsNode) {
+ let n = geom.num_points();
+ if n == 0 {
+ return;
+ }
+ let translation = node_param_vec3(target, "Translation", Vec3::ZERO);
+ let radius = node_param_f32(target, "Radius", 0.5).max(0.0);
+ let falloff = node_param_str(target, "Falloff", "Smooth").to_lowercase();
+ let group = node_param_str(target, "Group", "");
+ let group = group.trim().to_string();
+ let write_attr = node_param_str(target, "Attribute", "");
+ let write_attr = write_attr.trim().to_string();
+
+ let members: Vec<Vec3> = (0..n)
+ .filter(|&p| !group.is_empty() && geom.points().in_group(&group, p))
+ .map(|p| geom.pos(p))
+ .collect();
+ let centre = node_param_vec3(target, "Center", Vec3::ZERO);
+ let grid = (!members.is_empty()).then(|| crate::spatial::PointGrid::build(&members, radius.max(1e-4)));
+
+ let mut weights = vec![0.0f32; n];
+ let mut near = Vec::new();
+ for p in 0..n {
+ let here = geom.pos(p);
+ let d = match &grid {
+ Some(g) => {
+ g.within(here, radius, &mut near);
+ near.iter()
+ .map(|&i| (members[i as usize] - here).length())
+ .fold(f32::INFINITY, f32::min)
+ }
+ None => (here - centre).length(),
+ };
+ // Outside the radius nothing moves, and a zero radius moves only what
+ // is exactly at the centre — a falloff with no width.
+ let t = if radius <= 0.0 {
+ if d <= 0.0 { 0.0 } else { 1.0 }
+ } else {
+ (d / radius).clamp(0.0, 1.0)
+ };
+ weights[p] = match falloff.as_str() {
+ "constant" => if t < 1.0 { 1.0 } else { 0.0 },
+ "linear" => 1.0 - t,
+ // Smoothstep, so the moved region meets the still one with no
+ // crease — which is the entire reason to prefer this to a hard
+ // translate on a group.
+ _ => 1.0 - (3.0 * t * t - 2.0 * t * t * t),
+ };
+ }
+
+ for p in 0..n {
+ let moved = geom.pos(p) + translation * weights[p];
+ geom.set_pos(p, moved);
+ }
+ // The falloff is useful as data too: written out, it is the mask that
+ // drove the move, ready for Visualize or for a second operator to reuse.
+ if !write_attr.is_empty() {
+ geom.points_mut().create(&write_attr, AttribValue::Float(0.0));
+ let _ = geom.points_mut().insert(&write_attr, AttribData::Float(weights));
+ }
+}
+
+/// The Subdivide node: four triangles where there was one./// The Subdivide node: four triangles where there was one.
pub fn resolve_subdivide_geometry_with_errors(
root: &FsNode,
target: &FsNode,
@@ -3292,6 +3519,9 @@ pub fn resolve_scatter_geometry_with_errors(
let num_points = node_param_f32(target, "Points", 100.0) as usize;
let radius = node_param_f32(target, "Radius", 0.02);
+ // See points_detail: markers are for looking at, bare points are for
+ // working with.
+ let markers = node_param_str(target, "Markers", "true") != "false";
let ray_dir = Vec3::new(0.19, 0.98, 0.05).normalize();
let mut triangles = Vec::new();
@@ -3356,7 +3586,11 @@ pub fn resolve_scatter_geometry_with_errors(
}
if intersection_count % 2 == 1 {
- scattered_geom.merge(&sphere_detail(candidate, radius, 6, 8));
+ if markers {
+ scattered_geom.merge(&sphere_detail(candidate, radius, 6, 8));
+ } else {
+ scattered_geom.add_point(candidate);
+ }
found_count += 1;
}
}
@@ -4267,6 +4501,8 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
|| nt == "suture"
|| nt == "detangle"
|| nt == "subdivide"
+ || nt == "soft_transform"
+ || nt == "copy"
|| nt == "cull"
|| nt == "connectivity"
|| nt == "distance"
@@ -4464,6 +4700,24 @@ pub fn network_sphere_vertices_with_errors(
out.merge(&geom);
}
}
+ } else if node.node_type.eq_ignore_ascii_case("copy") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_copy_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(&geom);
+ }
+ }
+ } else if node.node_type.eq_ignore_ascii_case("soft_transform") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_soft_transform_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;
@@ -4607,6 +4861,12 @@ pub fn points_node_geometry(node: &FsNode, center: Vec3) -> Geometry {
pub fn points_detail(node: &FsNode, center: Vec3) -> Detail {
let num_points = node_param_f32(node, "Points", 100.0) as i32;
let shape = node_param_str(node, "Shape", "None");
+ // Markers off emits BARE POINTS — no marker geometry at all. Everything
+ // that generates locations drew little spheres at them, which is right for
+ // looking at and wrong for working with: Copy placed one instance per
+ // marker vertex rather than one per location, because the markers were the
+ // only points there were.
+ let markers = node_param_str(node, "Markers", "true") != "false";
let mut d = Detail::new();
for i in 0..num_points {
let t = i as f32 / num_points.max(1) as f32;
@@ -4629,7 +4889,11 @@ pub fn points_detail(node: &FsNode, center: Vec3) -> Detail {
// "None" and anything unrecognized: every point at the same spot.
_ => Vec3::ZERO,
};
- d.merge(&sphere_detail(center + offset, 0.02, 6, 8));
+ if markers {
+ d.merge(&sphere_detail(center + offset, 0.02, 6, 8));
+ } else {
+ d.add_point(center + offset);
+ }
}
d
}
diff --git a/src/main.rs b/src/main.rs
index e3ac565..23e14c9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3646,6 +3646,290 @@ mod tests {
assert_eq!(g.num_points(), sphere_detail(Vec3::ZERO, 1.0, 16, 24).num_points());
}
+ #[test]
+ fn test_points_and_scatter_can_emit_bare_points() {
+ // Everything that generates locations drew marker spheres at them,
+ // which is right for looking at and wrong for working with: Copy
+ // placed one instance per marker VERTEX rather than one per location,
+ // because the markers were the only points there were.
+ let markers = modelling_root(
+ "1.0",
+ vec![phase3_node("points", &[("Shape", "Line"), ("Points", "5")])],
+ );
+ let (with, _) = eval_node(&markers, "points 1");
+ assert!(with.num_prims() > 0, "markers are geometry");
+ assert!(with.num_points() > 5);
+
+ let bare = modelling_root(
+ "1.0",
+ vec![phase3_node(
+ "points",
+ &[("Shape", "Line"), ("Points", "5"), ("Markers", "false")],
+ )],
+ );
+ let (without, _) = eval_node(&bare, "points 1");
+ assert_eq!(without.num_points(), 5, "one point per location");
+ assert_eq!(without.num_prims(), 0, "bare points are not geometry");
+
+ // Default is unchanged, so no existing project looks different.
+ let defaulted = modelling_root(
+ "1.0",
+ vec![phase3_node("points", &[("Shape", "Line"), ("Points", "5")])],
+ );
+ assert_eq!(eval_node(&defaulted, "points 1").0.num_points(), with.num_points());
+ }
+
+ #[test]
+ fn test_copy_puts_geometry_at_every_target_point() {
+ let root = modelling_root(
+ "0.2",
+ vec![
+ phase3_node("points", &[("Shape", "Line"), ("Points", "5")]),
+ phase3_node("copy", &[("Input", "sphere 1"), ("To", "points 1")]),
+ ],
+ );
+ let (src, _) = eval_node(&root, "sphere 1");
+ let (onto, _) = eval_node(&root, "points 1");
+ let (g, err) = eval_node(&root, "copy 1");
+ assert!(err.is_none(), "{err:?}");
+
+ assert_eq!(g.num_points(), src.num_points() * onto.num_points());
+ assert_eq!(g.num_prims(), src.num_prims() * onto.num_points());
+ // Every copy is its own set of points: merge reallocates identities, so
+ // a solver can treat them separately rather than seeing one set
+ // repeated.
+ let mut ids = g.ids().to_vec();
+ let total = ids.len();
+ ids.sort_unstable();
+ ids.dedup();
+ assert_eq!(ids.len(), total, "identities were repeated across copies");
+
+ // Each copy lands ON its target. Merge appends them in order, so copy
+ // i is the i-th block of points — and its centroid should be the
+ // target's position plus the source's own centroid, since the `sphere`
+ // node places itself by graph index rather than at the origin.
+ let m = src.num_points();
+ let centroid = |d: &Detail, range: std::ops::Range<usize>| {
+ let n = range.len() as f32;
+ range.map(|p| d.pos(p)).sum::<Vec3>() / n
+ };
+ let src_centre = centroid(&src, 0..m);
+ for i in 0..onto.num_points() {
+ let want = onto.pos(i) + src_centre;
+ let got = centroid(&g, i * m..(i + 1) * m);
+ assert!((got - want).length() < 1e-3, "copy {i} at {got:?}, wanted {want:?}");
+ }
+ }
+
+ #[test]
+ fn test_copy_scales_by_an_attribute_and_refuses_an_explosion() {
+ let mut root = modelling_root(
+ "0.2",
+ vec![
+ phase3_node("points", &[("Shape", "Line"), ("Points", "4")]),
+ phase3_node(
+ "normal",
+ &[("Input", "points 1"), ("Attribute", "N")],
+ ),
+ phase3_node(
+ "copy",
+ &[
+ ("Input", "sphere 1"),
+ ("To", "points 1"),
+ ("Scale", "2.00"),
+ ],
+ ),
+ ],
+ );
+ let (plain, _) = eval_node(&root, "copy 1");
+ let plain_size = plain.bounds().map(|(a, b)| (b - a).length()).unwrap();
+
+ // Scale shrinks the copies without moving the targets they sit on.
+ root.children
+ .iter_mut()
+ .find(|c| c.name == "copy 1")
+ .unwrap()
+ .params
+ .iter_mut()
+ .find(|p| p.name == "Scale")
+ .unwrap()
+ .default = "0.50".into();
+ let (small, _) = eval_node(&root, "copy 1");
+ let small_size = small.bounds().map(|(a, b)| (b - a).length()).unwrap();
+ assert!(small_size < plain_size, "{small_size} should be under {plain_size}");
+ assert_eq!(small.num_points(), plain.num_points());
+
+ // A copy big enough to hang the app is refused with a number rather
+ // than attempted.
+ let huge = modelling_root(
+ "1.0",
+ vec![
+ phase3_node("points", &[("Shape", "Grid"), ("Points", "9000")]),
+ phase3_node("copy", &[("Input", "sphere 1"), ("To", "points 1")]),
+ ],
+ );
+ let (g, err) = eval_node(&huge, "copy 1");
+ assert!(err.as_deref().unwrap_or("").contains("ceiling"), "{err:?}");
+ assert_eq!(g.num_points(), 0, "nothing is built when the copy is refused");
+ }
+
+ #[test]
+ fn test_soft_transform_falls_off_instead_of_leaving_a_step() {
+ let mut sphere = sphere_detail(Vec3::ZERO, 1.0, 12, 16);
+ let before: Vec<Vec3> = (0..sphere.num_points()).map(|p| sphere.pos(p)).collect();
+ // Everything above the equator is selected; the falloff should still
+ // reach below it.
+ sphere.points_mut().create_group("top");
+ for p in 0..sphere.num_points() {
+ if sphere.pos(p).y > 0.7 {
+ sphere.points_mut().add_to_group("top", p);
+ }
+ }
+
+ let node = phase3_node(
+ "soft_transform",
+ &[
+ ("Translation", "0.00:1.00:0.00"),
+ ("Group", "top"),
+ ("Radius", "0.80"),
+ ("Falloff", "Smooth"),
+ ("Attribute", "falloff"),
+ ],
+ );
+ crate::geometry::apply_soft_transform(&mut sphere, &node);
+
+ let moved = |p: usize| (sphere.pos(p) - before[p]).length();
+ // Group members move the full amount.
+ let members = sphere.points().group_members("top");
+ assert!(!members.is_empty());
+ for &p in &members {
+ assert!((moved(p as usize) - 1.0).abs() < 1e-4, "member {p} moved {}", moved(p as usize));
+ }
+ // Points beyond the radius do not move at all, and points between do —
+ // which is the difference from a hard translate on the group.
+ let far = (0..before.len()).find(|&p| before[p].y < -0.9).unwrap();
+ assert!(moved(far) < 1e-5, "a point across the sphere moved {}", moved(far));
+ let between = (0..before.len())
+ .filter(|&p| !members.contains(&(p as u32)) && moved(p) > 1e-4)
+ .count();
+ assert!(between > 0, "nothing followed the selection");
+
+ // The falloff is written out as the mask that drove the move.
+ assert!(sphere.points().has("falloff"));
+ for &p in &members {
+ let w = sphere.points().value("falloff", p as usize).unwrap().as_f32();
+ assert!((w - 1.0).abs() < 1e-4);
+ }
+ assert!((sphere.points().value("falloff", far).unwrap().as_f32()).abs() < 1e-5);
+ }
+
+ #[test]
+ fn test_soft_transform_measures_from_the_nearest_member_not_the_centroid() {
+ // A selection shaped like a ring: its centroid is the middle, where no
+ // member is. Measuring from the centroid would drag hardest at a place
+ // the selection is nowhere near.
+ let mut sphere = sphere_detail(Vec3::ZERO, 1.0, 12, 16);
+ sphere.points_mut().create_group("ring");
+ for p in 0..sphere.num_points() {
+ if sphere.pos(p).y.abs() < 0.15 {
+ sphere.points_mut().add_to_group("ring", p);
+ }
+ }
+ let before: Vec<Vec3> = (0..sphere.num_points()).map(|p| sphere.pos(p)).collect();
+ let node = phase3_node(
+ "soft_transform",
+ &[("Translation", "0.00:0.50:0.00"), ("Group", "ring"), ("Radius", "0.30")],
+ );
+ crate::geometry::apply_soft_transform(&mut sphere, &node);
+
+ let moved = |p: usize| (sphere.pos(p) - before[p]).length();
+ // The ring itself moves fully; the poles, far from every member, do
+ // not — even though they are no further from the ring's CENTROID than
+ // the ring is.
+ for &p in &sphere.points().group_members("ring") {
+ assert!((moved(p as usize) - 0.5).abs() < 1e-4);
+ }
+ let pole = (0..before.len())
+ .max_by(|&a, &b| before[a].y.partial_cmp(&before[b].y).unwrap())
+ .unwrap();
+ assert!(moved(pole) < 1e-5, "the pole moved {}", moved(pole));
+ }
+
+ #[test]
+ fn test_group_selects_by_attribute_and_expands_across_the_surface() {
+ let root = modelling_root(
+ "1.0",
+ vec![
+ phase3_node("connectivity", &[("Input", "sphere 1"), ("Attribute", "piece")]),
+ phase3_node(
+ "normal",
+ &[("Input", "connectivity 1"), ("Attribute", "N")],
+ ),
+ phase3_node(
+ "group",
+ &[
+ ("Input", "normal 1"),
+ ("Group Name", "up"),
+ ("Mode", "Attribute"),
+ ("Attribute", "N"),
+ ("Comparison", "Above"),
+ ("Threshold", "0.50"),
+ ("Highlight", "false"),
+ ],
+ ),
+ ],
+ );
+ let (g, err) = eval_node(&root, "group 1");
+ assert!(err.is_none(), "{err:?}");
+
+ // Selecting by what a point IS, not where it is: this is what makes
+ // the measuring nodes composable. `N` reads as its first component, so
+ // the selection is "normal points along +X".
+ let members = g.points().group_members("up");
+ assert!(!members.is_empty() && members.len() < g.num_points());
+ for &p in &members {
+ assert!(g.points().value("N", p as usize).unwrap().as_f32() > 0.5);
+ }
+
+ // Expand grows that selection across the surface.
+ let mut grown_root = root.clone();
+ grown_root.children.push(phase3_node(
+ "group",
+ &[
+ ("Input", "group 1"),
+ ("Group Name", "wider"),
+ ("Mode", "Expand"),
+ ("Source Group", "up"),
+ ("Rings", "2"),
+ ("Highlight", "false"),
+ ],
+ ));
+ grown_root.children.last_mut().unwrap().name = "group 2".into();
+ grown_root.children.last_mut().unwrap().id = "id-group2".into();
+ let (grown, err) = eval_node(&grown_root, "group 2");
+ assert!(err.is_none(), "{err:?}");
+ let wider = grown.points().group_members("wider");
+ assert!(wider.len() > members.len(), "{} did not grow past {}", wider.len(), members.len());
+ for &p in &members {
+ assert!(wider.contains(&p), "expanding dropped an original member");
+ }
+
+ // And shrinks it: negative rings peel the boundary off, so an
+ // erode/dilate pair is one node twice rather than two nodes.
+ let mut shrunk_root = grown_root.clone();
+ shrunk_root
+ .children
+ .last_mut()
+ .unwrap()
+ .params
+ .iter_mut()
+ .find(|p| p.name == "Rings")
+ .unwrap()
+ .default = "-1".into();
+ let (shrunk, _) = eval_node(&shrunk_root, "group 2");
+ assert!(shrunk.points().group_members("wider").len() < members.len());
+ }
+
// ---- Phase 3: surface development ----
use crate::geometry::sphere_detail;