graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: Transfer carries groups, and Relax has a copy of it
Transfer Groups on the Transfer node lays the source's named groups over
the target by nearest point, joining and leaving alike. Relax's Transfer
From Rest does the same from its Rest, in either mode and whatever the
rest's point count, so a chain whose remesh renumbers points can keep a
group alive.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 27 +++++++++
nodes/relax.json | 93 +++++++++++++++++++++++++++---
nodes/transfer.json | 44 +++++++++++++--
src/detail.rs | 10 ++++
src/geometry.rs | 160 +++++++++++++++++++++++++++++++++++++++-------------
src/main.rs | 101 ++++++++++++++++++++++++++++++++-
6 files changed, 383 insertions(+), 52 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 810d1bc..e6fd269 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -815,6 +815,33 @@ Buttons dispatch through `execute_menu_action` by LABEL, which carries no node
— `run_export` resolves the node from the current selection, which is sound
because the pressed button can only be on the node the pane is showing.
+### Transfer carries groups, and Relax has a copy of it
+
+`geometry::transfer_onto` is the one transfer (2026-09-30): onto each
+point of the target (in the node's Group, when that names one; within
+Maximum Distance of its nearest source point, when that is above zero)
+the nearest source point's attributes and, with **Transfer Groups** on,
+its membership in the named **Groups** — every group the source has when
+none is named. A membership is COPIED, joining and leaving alike: a
+group carried this way is the source's group laid over the target, not a
+union with what the target had, and a group the target lacks is created
+so it exists everywhere the attribute columns do. The switch is off by
+default and a node from before the row is off, so a saved Transfer
+carries what it carried.
+
+**Relax has the same transfer inside it**, `Transfer From Rest` (off by
+default), with its own Attributes, Transfer Groups, Groups and Maximum
+Distance rows shown while it is on: once the points have moved, the Rest
+geometry's attributes and groups laid over them by nearest point, in
+EITHER mode and whatever the rest's point count — the springs need the
+index correspondence, the transfer does not, so the Rest row shows in
+both modes now. What it is for: a chain whose remesh renumbers, splits
+and collapses points keeps a group alive — the pull's — by reading it
+back off a rest shape that still carries it, at every step, with no
+Transfer node wired in beside the relax. On with no Rest is an error on
+the node. `transfer_carries_groups_and_relax_has_a_copy` is the test,
+the rule on hand-built points and both nodes through their rows.
+
### Mold tooling
`src/mold.rs` is the first GEM operator, ported from the plugin's
diff --git a/nodes/relax.json b/nodes/relax.json
index d7db17a..0a9884f 100644
--- a/nodes/relax.json
+++ b/nodes/relax.json
@@ -4,13 +4,90 @@
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "default": "", "type": "node" },
- { "name": "Mode", "type": "choice:Springs,Repel", "default": "Springs" },
- { "name": "Rest", "default": "", "type": "node", "show_when": "Mode == Springs" },
- { "name": "Pin Group", "default": "", "type": "group", "show_when": "Mode == Springs" },
- { "name": "Stiffness", "type": "slider", "default": "0.50", "min": 0.0, "max": 1.0, "step": 0.05, "show_when": "Mode == Springs" },
- { "name": "Iterations", "type": "spinbox", "default": "8", "min": 0.0, "max": 50.0, "step": 1.0 },
- { "name": "Radius", "type": "slider", "default": "0.05", "min": 0.001, "max": 1.0, "step": 0.001, "show_when": "Mode == Repel" },
- { "name": "In 3D Space", "type": "toggle", "default": "false", "show_when": "Mode == Repel" }
+ {
+ "name": "Input",
+ "default": "",
+ "type": "node"
+ },
+ {
+ "name": "Mode",
+ "type": "choice:Springs,Repel",
+ "default": "Springs"
+ },
+ {
+ "name": "Rest",
+ "default": "",
+ "type": "node"
+ },
+ {
+ "name": "Pin Group",
+ "default": "",
+ "type": "group",
+ "show_when": "Mode == Springs"
+ },
+ {
+ "name": "Stiffness",
+ "type": "slider",
+ "default": "0.50",
+ "min": 0.0,
+ "max": 1.0,
+ "step": 0.05,
+ "show_when": "Mode == Springs"
+ },
+ {
+ "name": "Iterations",
+ "type": "spinbox",
+ "default": "8",
+ "min": 0.0,
+ "max": 50.0,
+ "step": 1.0
+ },
+ {
+ "name": "Radius",
+ "type": "slider",
+ "default": "0.05",
+ "min": 0.001,
+ "max": 1.0,
+ "step": 0.001,
+ "show_when": "Mode == Repel"
+ },
+ {
+ "name": "In 3D Space",
+ "type": "toggle",
+ "default": "false",
+ "show_when": "Mode == Repel"
+ },
+ {
+ "name": "Transfer From Rest",
+ "type": "toggle",
+ "default": "false"
+ },
+ {
+ "name": "Attributes",
+ "type": "text",
+ "default": "",
+ "show_when": "Transfer From Rest == true"
+ },
+ {
+ "name": "Transfer Groups",
+ "type": "toggle",
+ "default": "false",
+ "show_when": "Transfer From Rest == true"
+ },
+ {
+ "name": "Groups",
+ "type": "text",
+ "default": "",
+ "show_when": "Transfer From Rest == true && Transfer Groups == true"
+ },
+ {
+ "name": "Maximum Distance",
+ "type": "slider",
+ "default": "0.00",
+ "min": 0.0,
+ "max": 10.0,
+ "step": 0.01,
+ "show_when": "Transfer From Rest == true"
+ }
]
}
diff --git a/nodes/transfer.json b/nodes/transfer.json
index 5ca01b4..63b7d47 100644
--- a/nodes/transfer.json
+++ b/nodes/transfer.json
@@ -4,10 +4,44 @@
"inputs": 2,
"outputs": 1,
"params": [
- { "name": "Input", "type": "node", "default": "" },
- { "name": "From", "type": "node", "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": "group", "default": "" }
+ {
+ "name": "Input",
+ "type": "node",
+ "default": ""
+ },
+ {
+ "name": "From",
+ "type": "node",
+ "default": ""
+ },
+ {
+ "name": "Attributes",
+ "type": "text",
+ "default": ""
+ },
+ {
+ "name": "Transfer Groups",
+ "type": "toggle",
+ "default": "false"
+ },
+ {
+ "name": "Groups",
+ "type": "text",
+ "default": "",
+ "show_when": "Transfer Groups == true"
+ },
+ {
+ "name": "Maximum Distance",
+ "type": "slider",
+ "default": "0.00",
+ "min": 0.0,
+ "max": 10.0,
+ "step": 0.01
+ },
+ {
+ "name": "Group",
+ "type": "group",
+ "default": ""
+ }
]
}
diff --git a/src/detail.rs b/src/detail.rs
index 4b2dd49..874f7c4 100644
--- a/src/detail.rs
+++ b/src/detail.rs
@@ -551,6 +551,16 @@ impl AttribStore {
}
}
+ /// Put one element in a group or take it out, creating the group if
+ /// needed. Out-of-range indices are ignored.
+ pub fn set_in_group(&mut self, name: &str, i: usize, member: bool) {
+ let len = self.len;
+ let members = self.groups.entry(name.to_string()).or_insert_with(|| vec![false; len]);
+ if let Some(slot) = members.get_mut(i) {
+ *slot = member;
+ }
+ }
+
pub fn in_group(&self, name: &str, i: usize) -> bool {
self.groups.get(name).and_then(|m| m.get(i)).copied().unwrap_or(false)
}
diff --git a/src/geometry.rs b/src/geometry.rs
index b8fbcf2..87d0b07 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1927,16 +1927,16 @@ pub fn resolve_relax_geometry_with_errors(
if node_param_str(target, "Mode", "Springs").eq_ignore_ascii_case("repel") {
let iterations = node_param_f32(target, "Iterations", 8.0).max(0.0) as usize;
let radius = node_param_f32(target, "Radius", 0.05);
- if iterations == 0 || radius <= 0.0 || geom.num_points() < 2 {
- return Some(geom);
- }
- let in_3d = node_param_bool(target, "In 3D Space", false);
- let normals = if in_3d { None } else { Some(point_normals(&geom)) };
- let mut pts: Vec<Vec3> = (0..geom.num_points()).map(|p| geom.pos(p)).collect();
- crate::scatter::relax_points(&mut pts, normals.as_deref(), radius, iterations);
- for (i, q) in pts.into_iter().enumerate() {
- geom.set_pos(i, q);
+ if iterations > 0 && radius > 0.0 && geom.num_points() >= 2 {
+ let in_3d = node_param_bool(target, "In 3D Space", false);
+ let normals = if in_3d { None } else { Some(point_normals(&geom)) };
+ let mut pts: Vec<Vec3> = (0..geom.num_points()).map(|p| geom.pos(p)).collect();
+ crate::scatter::relax_points(&mut pts, normals.as_deref(), radius, iterations);
+ for (i, q) in pts.into_iter().enumerate() {
+ geom.set_pos(i, q);
+ }
}
+ relax_transfer(&mut geom, root, target, visited, ocl_error, sim);
return Some(geom);
}
@@ -1947,6 +1947,8 @@ pub fn resolve_relax_geometry_with_errors(
return Some(geom);
};
if rest.num_points() != geom.num_points() || geom.is_empty() {
+ // Springs need the index correspondence; the transfer does not.
+ relax_transfer(&mut geom, root, target, visited, ocl_error, sim);
return Some(geom);
}
@@ -1984,9 +1986,46 @@ pub fn resolve_relax_geometry_with_errors(
for (p, v) in pos.iter().enumerate() {
geom.set_pos(p, *v);
}
+ relax_transfer(&mut geom, root, target, visited, ocl_error, sim);
Some(geom)
}
+/// The Relax node's copy of the Transfer node, `Transfer From Rest`: once
+/// the points have moved, the Rest geometry's attributes and groups laid
+/// over them by nearest point (`transfer_onto`), in either mode. What it
+/// is for: a chain whose remesh renumbers, splits and collapses points
+/// can keep a group alive — the pull's — by reading it back off a rest
+/// shape that still carries it, at every step, without a Transfer node
+/// wired in beside the relax.
+fn relax_transfer(
+ geom: &mut Detail,
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) {
+ if !node_param_bool(target, "Transfer From Rest", false) {
+ return;
+ }
+ let Some(rest_node) = param_node(root, target, "Rest") else {
+ if ocl_error.is_none() {
+ *ocl_error = Some(format!("{}: Transfer From Rest needs a Rest", target.name));
+ }
+ return;
+ };
+ let Some(rest) = generate_single_node_geometry_with_errors(root, rest_node, visited, ocl_error, sim) else { return };
+ let groups = node_param_bool(target, "Transfer Groups", false).then(|| name_list(&node_param_str(target, "Groups", "")));
+ transfer_onto(
+ geom,
+ &rest,
+ &name_list(&node_param_str(target, "Attributes", "")),
+ groups.as_deref(),
+ node_param_f32(target, "Maximum Distance", 0.0),
+ "",
+ );
+}
+
// ---------------------------------------------------------------------------
// The Immutable Methods set: measure and filter.
//
@@ -2700,30 +2739,74 @@ pub fn resolve_transfer_geometry_with_errors(
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() {
+ let groups = node_param_bool(target, "Transfer Groups", false).then(|| name_list(&node_param_str(target, "Groups", "")));
+ transfer_onto(
+ &mut geom,
+ &source,
+ &name_list(&node_param_str(target, "Attributes", "")),
+ groups.as_deref(),
+ node_param_f32(target, "Maximum Distance", 0.0),
+ node_param_str(target, "Group", "").trim(),
+ );
+ Some(geom)
+}
+
+/// A comma list of names, trimmed, the empty ones dropped.
+pub(crate) fn name_list(text: &str) -> Vec<String> {
+ text.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect()
+}
+
+/// The Transfer node's work, and the Relax node's copy of it: onto each
+/// point of `geom` (in `only`, when that names a group; within `limit` of
+/// its nearest source point, when that is above zero), the nearest point
+/// of `source`'s attributes and group memberships.
+///
+/// `attributes` names the point attributes, every one the source has when
+/// empty. `groups` names the point GROUPS — every one the source has when
+/// empty, and none at all when `None`, which is what a node from before
+/// groups could be transferred asks (2026-09-30). A membership is COPIED:
+/// a point whose nearest source point is in the group joins it, and one
+/// whose is not leaves it, so a group carried this way is the source's
+/// group laid over the target and not a union with what the target had.
+/// A group the target lacks is created, so it exists everywhere the
+/// attribute columns do.
+pub(crate) fn transfer_onto(
+ geom: &mut Detail,
+ source: &Detail,
+ attributes: &[String],
+ groups: Option<&[String]>,
+ limit: f32,
+ only: &str,
+) {
+ if source.num_points() == 0 || geom.num_points() == 0 {
+ return;
+ }
+ let names: Vec<String> = if attributes.is_empty() {
source.points().names().iter().map(|s| s.to_string()).collect()
} else {
- wanted
- .into_iter()
- .filter(|n| source.points().has(n))
- .collect()
+ attributes.iter().filter(|n| source.points().has(n)).cloned().collect()
};
- if names.is_empty() {
- return Some(geom);
+ let group_names: Vec<String> = match groups {
+ None => Vec::new(),
+ Some(wanted) if wanted.is_empty() => source.points().group_names().iter().map(|s| s.to_string()).collect(),
+ Some(wanted) => wanted.iter().filter(|g| source.points().has_group(g)).cloned().collect(),
+ };
+ if names.is_empty() && group_names.is_empty() {
+ return;
}
-
- 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 limit = limit.max(0.0);
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));
+ // Each target point's source, found once for every column.
+ let from: Vec<Option<usize>> = (0..geom.num_points())
+ .map(|p| {
+ if !only.is_empty() && !geom.points().in_group(only, p) {
+ return None;
+ }
+ let (q, dist) = grid.nearest(geom.pos(p))?;
+ (limit <= 0.0 || dist <= limit).then_some(q as usize)
+ })
+ .collect();
for name in &names {
let Some(ty) = source.points().get(name).map(|a| a.ty()) else { continue };
@@ -2733,21 +2816,22 @@ pub fn resolve_transfer_geometry_with_errors(
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) {
+ for (p, q) in from.iter().enumerate() {
+ let Some(q) = q else { continue };
+ if let Some(v) = source.points().value(name, *q) {
let _ = geom.points_mut().set_value(name, p, v);
}
}
}
- Some(geom)
+ for g in &group_names {
+ if !geom.points().has_group(g) {
+ geom.points_mut().create_group(g);
+ }
+ for (p, q) in from.iter().enumerate() {
+ let Some(q) = q else { continue };
+ geom.points_mut().set_in_group(g, p, source.points().in_group(g, *q));
+ }
+ }
}
/// The Valence node: how connected each point is, as data.
diff --git a/src/main.rs b/src/main.rs
index f60cc7d..408f1f3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4788,7 +4788,7 @@ mod tests {
assert_eq!(p.kind(), K::Group, "{}'s Group", t.name);
}
}
- for (ty, name) in [("attribute", "Value"), ("transfer", "Attributes"), ("simnet", "Start Frame"), ("bounds", "Prefix")] {
+ for (ty, name) in [("attribute", "Value"), ("transfer", "Attributes"), ("transfer", "Groups"), ("relax", "Attributes"), ("relax", "Groups"), ("simnet", "Start Frame"), ("bounds", "Prefix")] {
assert_eq!(kind(ty, name), K::Text, "{ty}'s {name} stays text on purpose");
}
// Every template's Input is a wire, top level and composed children alike.
@@ -10391,6 +10391,105 @@ mod tests {
assert!(err.as_deref().unwrap_or("").contains("nope"), "{err:?}");
}
+ /// Transfer carries GROUPS as it carries attributes: with Transfer
+ /// Groups on, each target point takes its nearest source point's
+ /// membership in the named groups — every group when none is named —
+ /// joining and leaving alike; off, or on a node from before the row,
+ /// no group moves. The Relax node has the same transfer inside it,
+ /// from its Rest, in either mode and whatever the rest's point count.
+ #[test]
+ fn transfer_carries_groups_and_relax_has_a_copy() {
+ use crate::detail::AttribValue;
+ // The rule itself, on hand-built points: four source points along
+ // x, the far two in `top`; four targets beside them, all put in
+ // `top` beforehand.
+ let mut source = Detail::new();
+ for x in 0..4 {
+ source.add_point(Vec3::new(x as f32, 0.0, 0.0));
+ }
+ source.points_mut().create("mass", AttribValue::Float(0.0));
+ source.points_mut().set_value("mass", 3, AttribValue::Float(7.0)).unwrap();
+ source.points_mut().create_group("top");
+ source.points_mut().add_to_group("top", 2);
+ source.points_mut().add_to_group("top", 3);
+ source.points_mut().create_group("other");
+ source.points_mut().add_to_group("other", 0);
+ let mut target = Detail::new();
+ for x in [0.1, 0.9, 2.1, 2.9] {
+ target.add_point(Vec3::new(x, 0.0, 0.0));
+ }
+ for p in 0..4 {
+ target.points_mut().add_to_group("top", p);
+ }
+ let member = |d: &Detail, g: &str| (0..d.num_points()).map(|p| d.points().in_group(g, p)).collect::<Vec<_>>();
+ let mut all = target.clone();
+ crate::geometry::transfer_onto(&mut all, &source, &[], Some(&[]), 0.0, "");
+ assert_eq!(member(&all, "top"), vec![false, false, true, true], "membership is the nearest source point's, joining and leaving");
+ assert_eq!(member(&all, "other"), vec![true, false, false, false], "every group, none named");
+ assert_eq!(all.points().value("mass", 3), Some(AttribValue::Float(7.0)));
+ let mut named = target.clone();
+ crate::geometry::transfer_onto(&mut named, &source, &[], Some(&["nope".to_string(), "top".to_string()]), 0.0, "");
+ assert_eq!(member(&named, "top"), vec![false, false, true, true]);
+ assert!(!named.points().has_group("other"), "only the named");
+ let mut none = target.clone();
+ crate::geometry::transfer_onto(&mut none, &source, &[], None, 0.0, "");
+ assert_eq!(member(&none, "top"), vec![true, true, true, true], "no groups asked, none touched");
+ assert!(none.points().has("mass"), "the attributes still are");
+ let mut near = target.clone();
+ crate::geometry::transfer_onto(&mut near, &source, &[], Some(&[]), 0.15, "");
+ assert_eq!(member(&near, "top"), vec![false, false, true, true], "within the limit");
+ near.points_mut().add_to_group("top", 1);
+ let mut far = target.clone();
+ crate::geometry::transfer_onto(&mut far, &source, &[], Some(&[]), 0.05, "");
+ assert_eq!(member(&far, "top"), vec![true, true, true, true], "nothing within 0.05: nothing moves");
+
+ // The nodes. A sphere with a group and an attribute is the source.
+ let up = phase3_node("group", &[("Input", "sphere 1"), ("Group Name", "top"), ("Mode", "Box"), ("Center", "-1.875:1.55:0.00"), ("Size", "4.00:2.00:4.00")]);
+ let tagged = phase3_node("attribute", &[("Input", "group 1"), ("Operation", "Create"), ("Attribute Name", "mass"), ("Type", "Float"), ("Value", "3")]);
+ let line = phase3_node("points", &[("Shape", "Line"), ("Points", "6"), ("Markers", "false")]);
+ let mut transfer = phase3_node("transfer", &[("Input", "points 1"), ("From", "attribute 1"), ("Attributes", "mass"), ("Transfer Groups", "true"), ("Groups", ""), ("Maximum Distance", "0.00")]);
+ let with = |t: FsNode| modelling_root("1.0", vec![up.clone(), tagged.clone(), line.clone(), t]);
+ let (src, err) = eval_node(&with(transfer.clone()), "attribute 1");
+ assert!(err.is_none(), "{err:?}");
+ let members = src.points().group_members("top").len();
+ assert!(members > 0 && members < src.num_points(), "a group of part of the sphere: {members}");
+ let (g, err) = eval_node(&with(transfer.clone()), "transfer 1");
+ assert!(err.is_none(), "{err:?}");
+ assert!(g.points().has_group("top") && g.points().has("mass"), "the group and the attribute were carried");
+
+ // Off, no group moves — and a node without the row is off.
+ transfer.params.iter_mut().find(|p| p.name == "Transfer Groups").unwrap().set_text("false");
+ let (g, _) = eval_node(&with(transfer.clone()), "transfer 1");
+ assert!(!g.points().has_group("top") && g.points().has("mass"), "nothing carried with the switch off");
+ transfer.params.retain(|p| p.name != "Transfer Groups" && p.name != "Groups");
+ let (g, _) = eval_node(&with(transfer.clone()), "transfer 1");
+ assert!(!g.points().has_group("top"), "a node from before the row carries none");
+
+ // Relax's copy: Repel mode with a rest of ANOTHER point count, the
+ // group and the attribute read off it by nearest point.
+ let relax = phase3_node("relax", &[("Input", "points 1"), ("Mode", "Repel"), ("Rest", "attribute 1"), ("Iterations", "0"), ("Transfer From Rest", "true"), ("Attributes", ""), ("Transfer Groups", "true"), ("Groups", "")]);
+ let (g, err) = eval_node(&with(relax.clone()), "relax 1");
+ assert!(err.is_none(), "{err:?}");
+ assert!(g.points().has_group("top") && g.points().has("mass"), "the relax carried the group and the attribute");
+ // Springs mode, same rest: the springs need the index correspondence
+ // and are skipped; the transfer is not.
+ let mut springs = relax.clone();
+ springs.params.iter_mut().find(|p| p.name == "Mode").unwrap().set_text("Springs");
+ let (g, err) = eval_node(&with(springs), "relax 1");
+ assert!(err.is_none(), "{err:?}");
+ assert!(g.points().has_group("top"));
+ // Off, the relax carries nothing, as it never did.
+ let mut off = relax.clone();
+ off.params.iter_mut().find(|p| p.name == "Transfer From Rest").unwrap().set_text("false");
+ let (g, _) = eval_node(&with(off), "relax 1");
+ assert!(!g.points().has_group("top") && !g.points().has("mass"));
+ // On with no Rest: said, not silent.
+ let mut bare = relax.clone();
+ bare.params.iter_mut().find(|p| p.name == "Rest").unwrap().set_text("");
+ let (_, err) = eval_node(&with(bare), "relax 1");
+ assert!(err.as_deref().unwrap_or("").contains("Rest"), "{err:?}");
+ }
+
#[test]
fn test_valence_counts_what_the_remesher_steers_toward() {
use crate::remesh::{remesh, Settings};