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

commit8495f3fe2b446bd2a12581e24f839abe516ebba3
parent6303f0c13a
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 06:37
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]