graphic design tool
git clone https://git.lucas.co/cce-designer.git
fix: the transfer copy is Remesh's, not Relax's
Taken off Relax and put on Remesh, where a split, collapse and
renumbering are what a group has to be read back across: from the
node's own input when From names nothing, else the node named.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 35 +++++++++---------
nodes/relax.json | 35 +-----------------
nodes/remesh.json | 38 +++++++++++++++++++
src/geometry.rs | 109 ++++++++++++++++++++++++++++++------------------------
src/main.rs | 64 +++++++++++++++++++-------------
5 files changed, 156 insertions(+), 125 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index e6fd269..1436262 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -815,7 +815,7 @@ 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
+### Transfer carries groups, and Remesh 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
@@ -825,22 +825,23 @@ 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.
+so it exists everywhere the attribute columns do. A group the SOURCE
+lacks is not touched. The switch is off by default and a node from
+before the row is off, so a saved Transfer carries what it carried.
+
+**Remesh has the same transfer inside it**, its `Transfer` toggle (off
+by default) with From, Attributes, Transfer Groups, Groups and Maximum
+Distance rows shown while it is on (`remesh_transfer`): once the mesh is
+remeshed, a source's attributes and groups laid over the NEW points by
+nearest point — the node's own input when From names nothing, which
+needs no wire, else the node it names. What a point was rides a split by
+interpolation and a collapse by the survivor, but not everything; read
+back off the mesh as it was before, or off a shape that still carries
+it, a group is kept at every step of a solve with no Transfer node wired
+in after. A From it cannot resolve is an error on the node. (It was on
+the Relax node for an hour, from its Rest — the user's slip, taken back
+the same day.) `transfer_carries_groups_and_remesh_has_a_copy` is the
+test, the rule on hand-built points and both nodes through their rows.
### Mold tooling
diff --git a/nodes/relax.json b/nodes/relax.json
index 0a9884f..cef9b7c 100644
--- a/nodes/relax.json
+++ b/nodes/relax.json
@@ -17,7 +17,8 @@
{
"name": "Rest",
"default": "",
- "type": "node"
+ "type": "node",
+ "show_when": "Mode == Springs"
},
{
"name": "Pin Group",
@@ -56,38 +57,6 @@
"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/remesh.json b/nodes/remesh.json
index 5982a93..cd4e72a 100644
--- a/nodes/remesh.json
+++ b/nodes/remesh.json
@@ -52,6 +52,44 @@
"name": "Project",
"type": "choice:true,false",
"default": "true"
+ },
+ {
+ "name": "Transfer",
+ "type": "toggle",
+ "default": "false"
+ },
+ {
+ "name": "From",
+ "type": "node",
+ "default": "",
+ "show_when": "Transfer == true"
+ },
+ {
+ "name": "Attributes",
+ "type": "text",
+ "default": "",
+ "show_when": "Transfer == true"
+ },
+ {
+ "name": "Transfer Groups",
+ "type": "toggle",
+ "default": "false",
+ "show_when": "Transfer == true"
+ },
+ {
+ "name": "Groups",
+ "type": "text",
+ "default": "",
+ "show_when": "Transfer == true && Transfer Groups == true"
+ },
+ {
+ "name": "Maximum Distance",
+ "type": "slider",
+ "default": "0.00",
+ "min": 0.0,
+ "max": 10.0,
+ "step": 0.01,
+ "show_when": "Transfer == true"
}
]
}
diff --git a/src/geometry.rs b/src/geometry.rs
index 87d0b07..23a369e 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 {
- 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 {
+ 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);
}
- relax_transfer(&mut geom, root, target, visited, ocl_error, sim);
return Some(geom);
}
@@ -1947,8 +1947,6 @@ 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);
}
@@ -1986,46 +1984,9 @@ 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.
//
@@ -3414,7 +3375,57 @@ pub fn resolve_remesh_geometry_with_errors(
) -> Option<Detail> {
let input_node = param_node(root, target, "Input")?;
let geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
- Some(crate::remesh::remesh(&geom, remesh_settings(target)))
+ let mut out = crate::remesh::remesh(&geom, remesh_settings(target));
+ remesh_transfer(&mut out, &geom, root, target, visited, ocl_error, sim);
+ Some(out)
+}
+
+/// The Remesh node's copy of the Transfer node, `Transfer` (2026-09-30):
+/// once the mesh is remeshed, a source's attributes and groups laid over
+/// the new points by nearest point (`transfer_onto`) — the node's own
+/// INPUT when `From` names nothing, which needs no wire, else the node
+/// it names. What it is for: a remesh splits, collapses and renumbers
+/// points, and what a point was — a group's member, an attribute's value
+/// — rides through a split by interpolation and through a collapse by
+/// the survivor, but not through everything; read back off the mesh as
+/// it was before, or off a shape that still carries it, a group is kept
+/// at every step of a solve with no Transfer node wired in after.
+fn remesh_transfer(
+ out: &mut Detail,
+ input: &Detail,
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) {
+ if !node_param_bool(target, "Transfer", false) {
+ return;
+ }
+ let from_name = node_param_node(target, "From").unwrap_or_default();
+ let named = if from_name.is_empty() {
+ None
+ } else {
+ match find_input_node(root, target, &from_name).and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim)) {
+ Some(d) => Some(d),
+ None => {
+ if ocl_error.is_none() {
+ *ocl_error = Some(format!("{}: cannot resolve Transfer From '{}'", target.name, from_name));
+ }
+ return;
+ }
+ }
+ };
+ let source = named.as_ref().unwrap_or(input);
+ let groups = node_param_bool(target, "Transfer Groups", false).then(|| name_list(&node_param_str(target, "Groups", "")));
+ transfer_onto(
+ out,
+ source,
+ &name_list(&node_param_str(target, "Attributes", "")),
+ groups.as_deref(),
+ node_param_f32(target, "Maximum Distance", 0.0),
+ "",
+ );
}
pub(crate) fn remesh_settings(target: &FsNode) -> crate::remesh::Settings {
diff --git a/src/main.rs b/src/main.rs
index 408f1f3..7a7aaf1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4746,7 +4746,7 @@ mod tests {
for (ty, name) in [
("switch", "Input 2"), ("switch", "Input 3"), ("switch", "Input 4"),
("boolean", "With"), ("collision", "Collider"), ("relax", "Rest"),
- ("suture", "Against"), ("copy", "To"), ("distance", "To"), ("transfer", "From"),
+ ("suture", "Against"), ("copy", "To"), ("distance", "To"), ("transfer", "From"), ("remesh", "From"),
] {
assert_eq!(kind(ty, name), K::Node, "{ty}'s {name}");
}
@@ -4788,7 +4788,7 @@ mod tests {
assert_eq!(p.kind(), K::Group, "{}'s Group", t.name);
}
}
- for (ty, name) in [("attribute", "Value"), ("transfer", "Attributes"), ("transfer", "Groups"), ("relax", "Attributes"), ("relax", "Groups"), ("simnet", "Start Frame"), ("bounds", "Prefix")] {
+ for (ty, name) in [("attribute", "Value"), ("transfer", "Attributes"), ("transfer", "Groups"), ("remesh", "Attributes"), ("remesh", "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.
@@ -10395,10 +10395,10 @@ mod tests {
/// 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.
+ /// no group moves. The Remesh node has the same transfer inside it:
+ /// from its own input when From names nothing, else the node named.
#[test]
- fn transfer_carries_groups_and_relax_has_a_copy() {
+ fn transfer_carries_groups_and_remesh_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
@@ -10465,29 +10465,41 @@ mod tests {
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");
+ // Remesh's copy: a sphere remeshed coarse, its group read back off
+ // its own input — no From, no wire — so the new points carry it.
+ let remesh = phase3_node("remesh", &[("Input", "attribute 1"), ("Target Length", "0.5"), ("Iterations", "3"), ("Relax", "0.0"), ("Transfer", "true"), ("From", ""), ("Attributes", ""), ("Transfer Groups", "true"), ("Groups", "")]);
+ let (g, err) = eval_node(&with(remesh.clone()), "remesh 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_ne!(g.num_points(), src.num_points(), "the remesh changed the points");
+ assert!(g.points().has_group("top") && g.points().has("mass"), "the remesh carried the group and the attribute");
+ let carried = g.points().group_members("top").len();
+ assert!(carried > 0 && carried < g.num_points(), "the top half, on the new points: {carried} of {}", g.num_points());
+ for p in 0..g.num_points() {
+ // The group is the top half of the sphere by its box; each new
+ // point's membership is its nearest old point's.
+ let nearest = (0..src.num_points()).min_by(|&a, &b| src.pos(a).distance(g.pos(p)).partial_cmp(&src.pos(b).distance(g.pos(p))).unwrap()).unwrap();
+ assert_eq!(g.points().in_group("top", p), src.points().in_group("top", nearest), "point {p}");
+ }
+ // Off, the remesh carries what a remesh carries: the group through
+ // its splits and collapses, as before this row.
+ let mut off = remesh.clone();
+ off.params.iter_mut().find(|p| p.name == "Transfer").unwrap().set_text("false");
+ let (plain, err) = eval_node(&with(off), "remesh 1");
+ assert!(err.is_none(), "{err:?}");
+ assert!(plain.points().has_group("top"));
+ // From another node: the line's points carry no group and no mass,
+ // so there is nothing to lay over the remeshed sphere and it is as
+ // the remesh left it.
+ let mut from_line = remesh.clone();
+ from_line.params.iter_mut().find(|p| p.name == "From").unwrap().set_text("points 1");
+ let (g, err) = eval_node(&with(from_line), "remesh 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:?}");
+ assert_eq!(g.points().group_members("top"), plain.points().group_members("top"), "a source without the group leaves it as it was");
+ // A From it cannot resolve is said.
+ let mut broken = remesh.clone();
+ broken.params.iter_mut().find(|p| p.name == "From").unwrap().set_text("nope");
+ let (_, err) = eval_node(&with(broken), "remesh 1");
+ assert!(err.as_deref().unwrap_or("").contains("nope"), "{err:?}");
}
#[test]