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

commit4982f0dace782d8bb3d4020255fa108d7e7f65de
parente6eb972831
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 02:17
feat: detangle knows a point's side inside a simnet, and can hold a step to a length

The Surface method is told where the substep began. A point that went
through a triangle is put back on the side it came from; what the passes
leave through is returned, with its triangle, to where the step began; and
Step Limit, off by default, cuts a move to so many thicknesses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md           |  57 +++++++++++--
 nodes/detangle.json |   1 +
 src/detangle.rs     | 229 +++++++++++++++++++++++++++++++++++++++++++++++-----
 src/geometry.rs     |  96 +++++++++++++++++++++-
 src/main.rs         | 167 ++++++++++++++++++++++++++++++++------
 5 files changed, 498 insertions(+), 52 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index f63326e..195771c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1172,11 +1172,52 @@ taking none and the rest all of it, so a contact with a fixed triangle is
 resolved whole where Points resolves half. What a point receives from
 several contacts is their average weighted by depth, not their sum: a point
 over a shared edge touches both triangles and must move once. A triangle
-with a corner inside the point's excluded rings is not a contact. It does
-NOT know which side a point belongs on — one already through is pushed
-further through; that needs the positions the step began from and is the
-next piece of work, as are a cap on movement per substep and edge-edge
-contact.
+with a corner inside the point's excluded rings is not a contact.
+
+**Inside a simnet a point has a SIDE** (`detangle::apply_from`, the same
+day). `resolve_detangle_geometry_with_errors` hands the solve the state the
+substep consumed — the nearest simnet above the node that has pushed one,
+so a detangle in a subnet inside a simnet gets it too — and a `before` that
+is not this mesh (another point count, other primitives) is not used.
+Outside a simnet there is none, and Surface is the distance test alone.
+Three things read it:
+
+- **The passes put back what went through.** `went_through` asks in the
+  TRIANGLE'S terms, since both move: the point's height over it and the
+  place of its foot in it, then and now, a straight line between. A sign
+  change with the foot inside is a passage, and the contact is resolved
+  along the triangle's normal to a thickness clear on the side the point
+  came from. A point with such a contact takes no other that pass — the
+  triangles beside the one it went through see it near, on the wrong side,
+  and would push it on. The passes take NO margin on "inside": a tenth of
+  one pushed points off triangles they had gone around and left more
+  crossed than no memory at all (642 points, Thickness 0.5: 40 beyond the
+  rings against 0).
+- **The hold.** Whatever is still through a triangle when the passes are
+  done goes back to where the step began, its triangle's corners with it
+  (`HOLD_ROUNDS` looks, a margin of `HOLD_MARGIN`, since holding a point
+  that did not quite go through costs it a step's movement and nothing
+  else). The memory is one step long — a point left through is, to the
+  next step, a point that began there — so this is what keeps a miss from
+  becoming permanent.
+- **Step Limit** (a row, shown for Surface, in thicknesses, 0 = off and
+  the default) cuts each movable point's move since the step began to that
+  length before anything is resolved. Off by default because it changes
+  how far a pull pulls, and because the measurements did not earn it a
+  default: it is for use WITH Substeps.
+
+`the_surface_method_puts_back_what_went_through` carries a patch through a
+fixed sheet in one step, by less than a thickness and by several, and
+around its edge; `the_step_limit_holds_a_step_to_a_length` and
+`a_detangle_in_a_simnet_knows_where_the_step_began` (in `geometry.rs`'s
+tests, where the feedback stack can be reached) are the other two.
+
+What is left is **edge-edge**. On the sphere test at Thickness 1 the solve
+reports nothing put back and nothing held, and still leaves crossings
+beyond the rings at a pace of half an edge a step and more (48 at 0.5, 98
+at 0.8, 2562 points): no point went through any triangle, an edge went
+through an edge. That, and folds inside the excluded rings, are what the
+measure still counts.
 
 `detangle::self_intersections` is the MEASURE: every edge passing through a
 triangle (`spatial::segment_crosses_triangle`, tolerance relative to the
@@ -1190,7 +1231,11 @@ by default. `detangle_methods_compared` (ignored; release, `--ignored
 --nocapture`) pushes an icosphere's cap down into its own bowl a fifth of
 an edge a step. At 2562 points, Thickness 1, Rings 2: no detangle 1117
 crossings, all beyond the rings; Points 1699 (892 beyond), 5.1 ms a step;
-Surface 408, NONE beyond the rings, and none left at the end, 17 ms a step.
+Surface 408, NONE beyond the rings, and none left at the end, 17 ms a step
+(24 told where the step began: the wider search and the hold's look).
+At four fifths of an edge a step and Thickness 0.5, where a step outruns
+the thickness, Surface alone let 804 through beyond the rings and the side
+brought that to 140 (642 points: 294 to 0).
 At Thickness 0.5 Surface let 72 through beyond the rings at that size and
 none at 162 and 642 points. What Surface leaves is the fold at the cap's
 rim, inside the rings. Do not measure by pressing a sphere flat by the sign
diff --git a/nodes/detangle.json b/nodes/detangle.json
index 57ea4ae..019de0a 100644
--- a/nodes/detangle.json
+++ b/nodes/detangle.json
@@ -9,6 +9,7 @@
   { "name": "Thickness", "type": "slider", "default": "1.00", "min": 0.0, "max": 8.0, "step": 0.05 },
   { "name": "Rings", "type": "spinbox", "default": "2", "min": 0.0, "max": 6.0, "step": 1.0 },
   { "name": "Iterations", "type": "spinbox", "default": "4", "min": 1.0, "max": 32.0, "step": 1.0 },
+  { "name": "Step Limit", "type": "slider", "default": "0.00", "min": 0.0, "max": 2.0, "step": 0.05, "show_when": "Method == Surface" },
   { "name": "Group", "type": "group", "default": "" },
   { "name": "Tangled Group", "type": "group", "default": "" }
  ]
diff --git a/src/detangle.rs b/src/detangle.rs
index c4dbec9..868210a 100644
--- a/src/detangle.rs
+++ b/src/detangle.rs
@@ -32,7 +32,9 @@
 //!   the TRIANGLES near it, where Points tests it against points. A point
 //!   over the middle of a triangle is near no corner of it, so on a mesh
 //!   whose triangles are larger than the thickness the point test sees
-//!   nothing at all.
+//!   nothing at all. Told where the points were when the step began
+//!   ([`apply_from`]), it also knows which side of a triangle a point
+//!   belongs on, and can hold a step to a length.
 //! - **The measure** ([`self_intersections`]): every edge that passes
 //!   through a triangle. It is what says whether a change to the solve
 //!   helped, and what the node's `Tangled Group` is written from.
@@ -271,12 +273,29 @@ pub struct Work {
     pub searched: usize,
     /// Point-triangle contacts the Surface method resolved, over every pass.
     pub contacts: usize,
+    /// Of those, the ones resolved as a point that had gone THROUGH a
+    /// triangle since the step began, and was put back on its own side.
+    pub crossed: usize,
+    /// Points put back where the step began, being still through a
+    /// triangle when the passes were done, or a corner of one.
+    pub held: usize,
+    /// Points whose move since the step began was cut to the Step Limit.
+    pub limited: usize,
     /// Edges passing through a triangle when the solve was done — counted
     /// only when the node names a Tangled Group to write them to.
     pub crossings: usize,
 }
 
 pub fn apply(geom: &mut Detail, target: &FsNode) -> Work {
+    apply_from(geom, None, target)
+}
+
+/// [`apply`], told where the points were when the step began: inside a
+/// simnet, the state the substep consumed. It is what the Surface method
+/// knows a point's SIDE from, and what the Step Limit is measured against.
+/// A `before` that is not this mesh — another point count, other
+/// primitives — is no memory of it and is not used.
+pub fn apply_from(geom: &mut Detail, before: Option<&Detail>, target: &FsNode) -> Work {
     let mut work = Work::default();
     let n = geom.num_points();
     if n == 0 || geom.num_prims() == 0 {
@@ -289,7 +308,10 @@ pub fn apply(geom: &mut Detail, target: &FsNode) -> Work {
     }
     // A node without the row is one from before it, and solves as it did.
     if node_param_str(target, "Method", "Points").trim().eq_ignore_ascii_case("Surface") {
-        solve_surface(geom, target, &topo, &mut work);
+        let before: Option<Vec<Vec3>> = before
+            .filter(|b| b.num_points() == n && b.num_prims() == geom.num_prims() && topology_key(b) == topo.key)
+            .map(|b| (0..n).map(|p| b.pos(p)).collect());
+        solve_surface(geom, before.as_deref(), target, &topo, &mut work);
     } else {
         solve_points(geom, target, &topo, &mut work);
     }
@@ -529,10 +551,23 @@ impl TriCells {
 /// contact with both triangles and must move once, not twice, and a sum is
 /// what makes a dense contact overshoot and ring.
 ///
-/// What it does not know is which SIDE a point belongs on. A point already
-/// through a triangle is pushed further through; that takes the positions
-/// the step started from, and is not here.
-fn solve_surface(geom: &mut Detail, target: &FsNode, topo: &Topo, work: &mut Work) {
+/// **With `before`, a point has a side.** Distance alone cannot tell a
+/// point that is near a triangle from one that has gone through it, and
+/// pushes the second further through. Where the point was on one side of a
+/// triangle when the step began and is on the other now, having passed
+/// through the triangle's own extent ([`went_through`]), the contact is
+/// resolved along the triangle's normal back to the side it came from, to a
+/// thickness clear of it. A point with such a contact takes no other in
+/// that pass: the triangles beside the one it went through see it near and
+/// on the wrong side, and would push it on.
+///
+/// The memory is one step long. A point the passes did not bring back is,
+/// to the next step, a point that began on that side.
+///
+/// **The Step Limit** cuts each point's move since the step began to that
+/// many thicknesses before anything is resolved, so what arrives here is
+/// close to what left and a contact is met while it is still a contact.
+fn solve_surface(geom: &mut Detail, before: Option<&[Vec3]>, target: &FsNode, topo: &Topo, work: &mut Work) {
     let n = geom.num_points();
     let thickness = thickness_of(geom, target, topo);
     if thickness <= 0.0 || topo.tris.is_empty() {
@@ -545,6 +580,24 @@ fn solve_surface(geom: &mut Detail, target: &FsNode, topo: &Topo, work: &mut Wor
     let free = |p: usize| if movable[p] { 1.0f32 } else { 0.0 };
 
     let mut pos: Vec<Vec3> = (0..n).map(|p| geom.pos(p)).collect();
+    let mut moved_at_all = false;
+    // A node without the row is one from before it, and is not limited.
+    let limit = node_param_f32(target, "Step Limit", 0.0).max(0.0) * thickness;
+    if let (Some(before), true) = (before, limit > 0.0) {
+        for p in (0..n).filter(|&p| movable[p]) {
+            let d = pos[p] - before[p];
+            let len = d.length();
+            if len > limit {
+                pos[p] = before[p] + d * (limit / len);
+                work.limited += 1;
+                moved_at_all = true;
+            }
+        }
+    }
+    // How far anything has come since the step began: a triangle a point
+    // went through may be that far from where either is now.
+    let travelled = before.map_or(0.0, |b| pos.iter().zip(b).map(|(p, q)| (*p - *q).length_squared()).fold(0.0f32, f32::max).sqrt());
+
     // Cells no smaller than a triangle, or each is filed in dozens.
     let cell = thickness.max(mean_edge(geom, topo));
     let mut grid = TriCells::build(&pos, &topo.tris, cell);
@@ -555,7 +608,7 @@ fn solve_surface(geom: &mut Detail, target: &FsNode, topo: &Topo, work: &mut Wor
     let mut stamp = 0u32;
     let mut push = vec![Vec3::ZERO; n];
     let mut weight = vec![0.0f32; n];
-    let mut moved_at_all = false;
+    let mut found: Vec<Contact> = Vec::new();
     for _ in 0..iterations {
         work.passes += 1;
         if drift > DRIFT_CELLS * grid.cell {
@@ -569,14 +622,20 @@ fn solve_surface(geom: &mut Detail, target: &FsNode, topo: &Topo, work: &mut Wor
         for p in 0..n {
             work.searched += 1;
             // A triangle within a thickness of here now had its box within
-            // a thickness and a drift of here when it was filed.
-            let reach = Vec3::splat(thickness + drift);
+            // a thickness and a drift of here when it was filed; one the
+            // point went through lies along the way it came.
+            let reach = thickness + drift;
+            let from = before.map_or(pos[p], |b| b[p]);
+            let (lo, hi) = (pos[p].min(from), pos[p].max(from));
             stamp = stamp.wrapping_add(1);
             if stamp == u32::MAX {
                 seen.iter_mut().for_each(|m| *m = u32::MAX);
                 stamp = 0;
             }
-            grid.gather(pos[p] - reach, pos[p] + reach, &mut seen, stamp, &mut near);
+            let wide = if before.is_some() { reach + travelled } else { reach };
+            grid.gather(lo - wide, hi + wide, &mut seen, stamp, &mut near);
+            found.clear();
+            let mut through = false;
             for &t in &near {
                 let corners = topo.tris[t as usize];
                 let [ia, ib, ic] = corners.map(|c| c as usize);
@@ -585,25 +644,28 @@ fn solve_surface(geom: &mut Detail, target: &FsNode, topo: &Topo, work: &mut Wor
                 // whose box is a thickness away on any axis is further
                 // than that, and is turned away before it costs a search
                 // of the rings or a closest point.
-                let (lo, hi) = (a.min(b).min(c) - thickness, a.max(b).max(c) + thickness);
-                if pos[p].cmplt(lo).any() || pos[p].cmpgt(hi).any() {
+                let (tlo, thi) = (a.min(b).min(c) - thickness, a.max(b).max(c) + thickness);
+                if hi.cmplt(tlo).any() || lo.cmpgt(thi).any() {
                     continue;
                 }
                 if corners.iter().any(|&c| topo.excludes(p, c)) {
                     continue;
                 }
                 let w = crate::spatial::closest_weights_on_triangle(pos[p], a, b, c);
+                if let Some(side) = before.and_then(|was| went_through(was[p], [was[ia], was[ib], was[ic]], pos[p], [a, b, c], 0.0)) {
+                    // `side` is the triangle's normal, turned to the side
+                    // the point came from; it is below the plane by that
+                    // much and belongs a thickness above it.
+                    let below = (pos[p] - a).dot(side);
+                    found.push(Contact { corners: [ia, ib, ic], w, dir: side, deep: thickness - below, through: true });
+                    through = true;
+                    continue;
+                }
                 let d = pos[p] - (a * w[0] + b * w[1] + c * w[2]);
                 let len = d.length();
                 if len >= thickness {
                     continue;
                 }
-                // Inverse masses of one or none: the point's, and each
-                // corner's by the square of its share.
-                let give = free(p) + free(ia) * w[0] * w[0] + free(ib) * w[1] * w[1] + free(ic) * w[2] * w[2];
-                if give <= 0.0 {
-                    continue;
-                }
                 let dir = if len < 1e-9 {
                     let normal = (b - a).cross(c - a).normalize_or_zero();
                     if normal == Vec3::ZERO {
@@ -613,7 +675,16 @@ fn solve_surface(geom: &mut Detail, target: &FsNode, topo: &Topo, work: &mut Wor
                 } else {
                     d / len
                 };
-                let deep = thickness - len;
+                found.push(Contact { corners: [ia, ib, ic], w, dir, deep: thickness - len, through: false });
+            }
+            for contact in found.iter().filter(|c| c.through == through) {
+                let Contact { corners: [ia, ib, ic], w, dir, deep, .. } = *contact;
+                // Inverse masses of one or none: the point's, and each
+                // corner's by the square of its share.
+                let give = free(p) + free(ia) * w[0] * w[0] + free(ib) * w[1] * w[1] + free(ic) * w[2] * w[2];
+                if give <= 0.0 {
+                    continue;
+                }
                 let step = dir * (deep / give);
                 push[p] += step * (free(p) * deep);
                 weight[p] += free(p) * deep;
@@ -625,6 +696,7 @@ fn solve_surface(geom: &mut Detail, target: &FsNode, topo: &Topo, work: &mut Wor
                     weight[i] += free(i) * share * deep;
                 }
                 work.contacts += 1;
+                work.crossed += through as usize;
                 any = true;
             }
         }
@@ -639,6 +711,62 @@ fn solve_surface(geom: &mut Detail, target: &FsNode, topo: &Topo, work: &mut Wor
         moved_at_all = true;
         drift = grid.drift(&pos);
     }
+    // The hold. The passes share a move out and average what they are
+    // given, and under a push that does not let up they can run out before
+    // a point is back on its side — and a point left through a triangle is,
+    // to the next step, a point that began there. So whatever is still
+    // through a triangle goes back to where the step began, and the
+    // triangle with it: the one arrangement of the four known not to cross.
+    // It costs them the step's movement and nothing else.
+    if let Some(was) = before {
+        let mut back: Vec<usize> = Vec::new();
+        for _ in 0..HOLD_ROUNDS {
+            if drift > DRIFT_CELLS * grid.cell {
+                grid = TriCells::build(&pos, &topo.tris, cell);
+                work.grids += 1;
+                drift = 0.0;
+            }
+            back.clear();
+            for p in 0..n {
+                let (lo, hi) = (pos[p].min(was[p]), pos[p].max(was[p]));
+                stamp = stamp.wrapping_add(1);
+                if stamp == u32::MAX {
+                    seen.iter_mut().for_each(|m| *m = u32::MAX);
+                    stamp = 0;
+                }
+                let wide = Vec3::splat(drift + travelled);
+                grid.gather(lo - wide, hi + wide, &mut seen, stamp, &mut near);
+                for &t in &near {
+                    let corners = topo.tris[t as usize];
+                    let [ia, ib, ic] = corners.map(|c| c as usize);
+                    let (a, b, c) = (pos[ia], pos[ib], pos[ic]);
+                    let (tlo, thi) = (a.min(b).min(c).min(was[ia]).min(was[ib]).min(was[ic]), a.max(b).max(c).max(was[ia]).max(was[ib]).max(was[ic]));
+                    if hi.cmplt(tlo).any() || lo.cmpgt(thi).any() {
+                        continue;
+                    }
+                    if corners.iter().any(|&c| topo.excludes(p, c)) {
+                        continue;
+                    }
+                    if went_through(was[p], [was[ia], was[ib], was[ic]], pos[p], [a, b, c], HOLD_MARGIN).is_some() {
+                        back.extend([p, ia, ib, ic]);
+                    }
+                }
+            }
+            let mut put = 0;
+            for &i in &back {
+                if movable[i] && pos[i] != was[i] {
+                    pos[i] = was[i];
+                    put += 1;
+                }
+            }
+            if put == 0 {
+                break;
+            }
+            work.held += put;
+            moved_at_all = true;
+            drift = grid.drift(&pos);
+        }
+    }
     if moved_at_all {
         for (p, v) in pos.iter().enumerate() {
             geom.set_pos(p, *v);
@@ -646,6 +774,69 @@ fn solve_surface(geom: &mut Detail, target: &FsNode, topo: &Topo, work: &mut Wor
     }
 }
 
+/// One point against one triangle, as a pass found it.
+#[derive(Clone, Copy)]
+struct Contact {
+    corners: [usize; 3],
+    /// How much of the closest point each corner is.
+    w: [f32; 3],
+    /// The way the point is to move.
+    dir: Vec3,
+    /// How far, were it to take the whole move.
+    deep: f32,
+    through: bool,
+}
+
+/// A point's height over a triangle's plane, along the normal its winding
+/// gives, and where its foot is in the triangle's own terms: three weights
+/// summing to one, any of them negative outside it. `None` for a triangle
+/// with no area.
+fn over_triangle(p: Vec3, [a, b, c]: [Vec3; 3]) -> Option<(f32, [f32; 3], Vec3)> {
+    let (e1, e2, v) = (b - a, c - a, p - a);
+    let n = e1.cross(e2);
+    let nn = n.length_squared();
+    if nn < 1e-30 {
+        return None;
+    }
+    let (w1, w2) = (v.cross(e2).dot(n) / nn, e1.cross(v).dot(n) / nn);
+    let normal = n / nn.sqrt();
+    Some((v.dot(normal), [1.0 - w1 - w2, w1, w2], normal))
+}
+
+/// How far outside a triangle, in its own weights, a passage still counts
+/// as through it when the question is whether to HOLD the point. A point
+/// going through the edge two triangles share is, after rounding, a little
+/// outside both, and holding one that did not quite go through costs a
+/// step's movement there and nothing else. The passes take no margin: a
+/// push is along the triangle's normal, and measured on the sphere test a
+/// tenth of a margin pushed points off triangles they had gone around,
+/// leaving more crossed than no memory at all.
+const HOLD_MARGIN: f32 = 0.05;
+
+/// How many times the hold looks again: putting points back can leave
+/// others through what was put back.
+const HOLD_ROUNDS: usize = 8;
+
+/// Whether a point went through a triangle between then and now, and if so
+/// the triangle's normal as it is now, turned to the side the point came
+/// from.
+///
+/// Both move, so the question is asked in the TRIANGLE'S terms: the point's
+/// height over it and the place of its foot in it, then and now, with the
+/// passage taken as a straight line between the two. It went through if
+/// the height changed sign and the foot, where the height was nothing, was
+/// inside the triangle.
+fn went_through(p_then: Vec3, tri_then: [Vec3; 3], p_now: Vec3, tri_now: [Vec3; 3], margin: f32) -> Option<Vec3> {
+    let (h0, w0, _) = over_triangle(p_then, tri_then)?;
+    let (h1, w1, normal) = over_triangle(p_now, tri_now)?;
+    if h0 == 0.0 || h0 * h1 >= 0.0 {
+        return None;
+    }
+    let at = h0 / (h0 - h1);
+    let inside = (0..3).all(|i| w0[i] + (w1[i] - w0[i]) * at >= -margin);
+    inside.then(|| normal * h0.signum())
+}
+
 /// Where a surface passes through itself.
 #[derive(Debug, Default, Clone, PartialEq)]
 pub struct Tangles {
diff --git a/src/geometry.rs b/src/geometry.rs
index 296ee2e..d2b67cb 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -2913,7 +2913,19 @@ pub fn resolve_detangle_geometry_with_errors(
 ) -> Option<Detail> {
     let input_node = param_node(root, target, "Input")?;
     let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
-    apply_detangle(&mut geom, target);
+    // Inside a simnet that is mid-solve, what the substep consumed is where
+    // the points were when the step began: the nearest simnet above this
+    // node that has pushed a state.
+    let mut before = None;
+    let mut at = target;
+    while let Some(parent) = find_parent_node(root, &at.id) {
+        if let Some(state) = sim.feedback_for(&parent.id) {
+            before = Some(state);
+            break;
+        }
+        at = parent;
+    }
+    crate::detangle::apply_from(&mut geom, before, target);
     Some(geom)
 }
 
@@ -8797,6 +8809,88 @@ mod simnet_tests {
         assert_eq!(spread_sample(&same, 12), vec![0]);
     }
 
+    /// A detangle inside a simnet is told where the substep began: the
+    /// state its simnet pushed. The Step Limit is measured against it, so
+    /// a pull of a whole unit a frame arrives as half a thickness a frame —
+    /// and the same node outside a simnet, with nothing to measure against,
+    /// limits nothing. Inside a plain subnet inside the simnet it is still
+    /// the simnet's state: the nearest one above that has pushed.
+    #[test]
+    fn a_detangle_in_a_simnet_knows_where_the_step_began() {
+        let graph = |nested: bool| {
+            let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+            let pull = |input: &str| {
+                node(
+                    "id-pull",
+                    "pull1",
+                    "attribute",
+                    vec![
+                        param("Input", input),
+                        param("Operation", "Modify"),
+                        param("Attribute Name", "Pos"),
+                        param("Value", "1.00:0.00:0.00"),
+                        param("Combine", "Add"),
+                        param("Group", ""),
+                    ],
+                    vec![],
+                )
+            };
+            let detangle = |input: &str| {
+                node(
+                    "id-detangle",
+                    "detangle1",
+                    "detangle",
+                    vec![param("Input", input), param("Method", "Surface"), param("Thickness", "1.00"), param("Step Limit", "0.50")],
+                    vec![],
+                )
+            };
+            let chain = if nested {
+                let inside = node(
+                    "id-sub",
+                    "sub1",
+                    "node",
+                    vec![param("Input", "pull1")],
+                    vec![
+                        node("id-sub-in", "input1", "input", vec![], vec![]),
+                        detangle("input1"),
+                        node("id-sub-out", "output1", "output", vec![param("Input", "detangle1")], vec![]),
+                    ],
+                );
+                vec![
+                    node("id-in", "input1", "input", vec![], vec![]),
+                    pull("input1"),
+                    inside,
+                    node("id-out", "output1", "output", vec![param("Input", "sub1")], vec![]),
+                ]
+            } else {
+                vec![
+                    node("id-in", "input1", "input", vec![], vec![]),
+                    pull("input1"),
+                    detangle("pull1"),
+                    node("id-out", "output1", "output", vec![param("Input", "detangle1")], vec![]),
+                ]
+            };
+            let sim = node("id-sim", "Simnet 1", "simnet", vec![param("Input", "Sphere 1")], chain);
+            let outside = detangle("pull1");
+            node("id-root", "root", "node", vec![], vec![sphere, sim, pull("Sphere 1"), outside])
+        };
+        for nested in [false, true] {
+            let root = graph(nested);
+            let seed = solve_at(&root, 1);
+            let edges = seed.edges();
+            let edge = edges.iter().map(|e| (seed.pos(e[1] as usize) - seed.pos(e[0] as usize)).length()).sum::<f32>() / edges.len() as f32;
+            let after = solve_at(&root, 4);
+            let moved = min_x(&after) - min_x(&seed);
+            assert!((moved - 3.0 * 0.5 * edge).abs() < 1e-4, "nested {nested}: three frames moved {moved}, an edge is {edge}");
+
+            let mut cache = SimCache::default();
+            let mut sim = EvalSim::new(4, 1, &mut cache);
+            let mut err = None;
+            let alone = generate_single_node_geometry_with_errors(&root, &root.children[3], &mut Vec::new(), &mut err, &mut sim).expect("evaluates");
+            assert!((min_x(&alone) - min_x(&seed) - 1.0).abs() < 1e-4, "outside a simnet the pull arrives whole");
+        }
+    }
+
     /// Inside a simnet the arrows start where the points were going into
     /// THIS frame's step — with one substep per frame, the previous frame's
     /// solve — not at the seed, which is what evaluating the node on its own
diff --git a/src/main.rs b/src/main.rs
index 292d8f3..44f105e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10290,6 +10290,96 @@ mod tests {
         assert!(held.points().has_group("tangled"), "the group is written, empty");
     }
 
+    /// A point has a side once the solve is told where the step began. The
+    /// patch is carried through a sheet that does not move, in one step:
+    /// by less than a thickness, where distance alone sees it near the
+    /// sheet and pushes it on the way it was going; and by several, where
+    /// distance alone sees nothing at all.
+    #[test]
+    fn the_surface_method_puts_back_what_went_through() {
+        let node = phase3_node(
+            "detangle",
+            &[("Method", "Surface"), ("Thickness", "0.25"), ("Rings", "2"), ("Iterations", "8"), ("Group", "patch")],
+        );
+        let above = |d: &Detail| (4..d.num_points()).filter(|&p| d.pos(p).y > 0.0).count();
+        for (from, to) in [(0.04, -0.04), (0.3, -0.5)] {
+            let before = sheet_and_patch(from, 0.0);
+            let carried = sheet_and_patch(to, 0.0);
+
+            let mut blind = carried.clone();
+            crate::detangle::apply(&mut blind, &node);
+            assert_eq!(above(&blind), 0, "{from} to {to}: with no memory the patch stays under the sheet");
+            assert!(blind.pos(4).y <= carried.pos(4).y, "and is pushed no nearer to it");
+
+            let mut told = carried.clone();
+            let work = crate::detangle::apply_from(&mut told, Some(&before), &node);
+            assert_eq!(above(&told), 25, "{from} to {to}: {work:?}");
+            assert!(work.crossed > 0 || work.held > 0, "{work:?}");
+            assert!(patch_clearance(&told) > 0.03, "clear of the sheet: {}", patch_clearance(&told));
+            assert_eq!(crate::detangle::self_intersections(&told).crossings, 0);
+            assert_eq!(told.positions()[..4], carried.positions()[..4], "the sheet is outside the group");
+
+            // A `before` that is another mesh is no memory of this one.
+            let mut other = carried.clone();
+            let work = crate::detangle::apply_from(&mut other, Some(&sphere_detail(Vec3::ZERO, 0.5, 6, 8)), &node);
+            assert_eq!(other.positions(), blind.positions());
+            assert_eq!((work.crossed, work.held), (0, 0));
+        }
+
+        // What a point went AROUND it did not go through: carried past the
+        // sheet's edge and under it, the patch is left where it is.
+        let shift = |d: &mut Detail, by: Vec3| {
+            for p in 4..d.num_points() {
+                let v = d.pos(p);
+                d.set_pos(p, v + by);
+            }
+        };
+        let (mut before, mut carried) = (sheet_and_patch(0.5, 0.0), sheet_and_patch(-0.5, 0.0));
+        shift(&mut before, Vec3::new(2.0, 0.0, 0.0));
+        shift(&mut carried, Vec3::new(2.0, 0.0, 0.0));
+        let mut told = carried.clone();
+        let work = crate::detangle::apply_from(&mut told, Some(&before), &node);
+        assert_eq!(told.positions(), carried.positions(), "{work:?}");
+    }
+
+    /// The Step Limit holds a point's move since the step began to that
+    /// many thicknesses, in the direction it was going. It is the Surface
+    /// method's, it needs to know where the step began, and only what may
+    /// move is held.
+    #[test]
+    fn the_step_limit_holds_a_step_to_a_length() {
+        let settings = |method: &'static str, limit: &'static str| {
+            phase3_node(
+                "detangle",
+                &[("Method", method), ("Thickness", "0.25"), ("Rings", "2"), ("Iterations", "4"), ("Step Limit", limit), ("Group", "patch")],
+            )
+        };
+        let before = sheet_and_patch(1.0, 0.0);
+        let mut carried = before.clone();
+        for p in 0..carried.num_points() {
+            let v = carried.pos(p);
+            carried.set_pos(p, v + Vec3::new(0.3, 0.0, 0.4));
+        }
+        let mut d = carried.clone();
+        let work = crate::detangle::apply_from(&mut d, Some(&before), &settings("Surface", "0.50"));
+        assert_eq!(work.limited, 25, "{work:?}");
+        let went = d.pos(10) - before.pos(10);
+        assert!((went.normalize() - Vec3::new(0.6, 0.0, 0.8)).length() < 1e-4, "the way it was going: {went:?}");
+        // Half of a thickness that is a quarter of the mean edge, as the
+        // mesh now stands.
+        let edges = carried.edges();
+        let edge = edges.iter().map(|e| (carried.pos(e[1] as usize) - carried.pos(e[0] as usize)).length()).sum::<f32>() / edges.len() as f32;
+        assert!((went.length() - 0.5 * 0.25 * edge).abs() < 1e-5, "{} of an edge of {edge}", went.length());
+        assert_eq!(d.positions()[..4], carried.positions()[..4], "the sheet is outside the group");
+
+        for (node, told) in [(settings("Surface", "0"), true), (settings("Surface", "0.50"), false), (settings("Points", "0.50"), true)] {
+            let mut d = carried.clone();
+            let work = crate::detangle::apply_from(&mut d, told.then_some(&before), &node);
+            assert_eq!(work.limited, 0);
+            assert_eq!(d.positions(), carried.positions());
+        }
+    }
+
     /// The two methods side by side, by the measure and by the clock: a
     /// sphere's cap pushed down into its own bowl a little each step, until
     /// it would have come out underneath. Run in release with `--ignored
@@ -10299,6 +10389,15 @@ mod tests {
     #[test]
     #[ignore]
     fn detangle_methods_compared() {
+        // The method, whether it is told where the step began, and its
+        // Step Limit.
+        let ways: [(&str, &str, bool, &str); 5] = [
+            ("none", "None", false, "0"),
+            ("points", "Points", false, "0"),
+            ("surface", "Surface", false, "0"),
+            ("sided", "Surface", true, "0"),
+            ("sided, limited", "Surface", true, "0.50"),
+        ];
         for frequency in ["4", "8", "16"] {
             let sphere = crate::shapes::sphere_node_detail(
                 &phase3_node("sphere", &[("Method", "Icosphere"), ("Frequency", frequency), ("Radius", "0.5")]),
@@ -10306,35 +10405,51 @@ mod tests {
             );
             let edges = sphere.edges();
             let edge = edges.iter().map(|e| (sphere.pos(e[1] as usize) - sphere.pos(e[0] as usize)).length()).sum::<f32>() / edges.len() as f32;
-            // A fifth of an edge a step, until the pole has travelled the
-            // diameter and a little more.
-            let rate = edge * 0.2;
-            let steps = (1.1 / rate).ceil() as usize;
             let cap: Vec<usize> = (0..sphere.num_points()).filter(|&p| sphere.pos(p).y > 0.2).collect();
-            for thickness in ["0.50", "1.00"] {
-                for method in ["None", "Points", "Surface"] {
-                    let node = phase3_node("detangle", &[("Method", method), ("Thickness", thickness), ("Rings", "2"), ("Iterations", "4")]);
-                    let mut d = sphere.clone();
-                    let (mut worst, mut far, mut spent) = (0, 0, std::time::Duration::ZERO);
-                    for _ in 0..steps {
-                        for &p in &cap {
-                            let v = d.pos(p);
-                            d.set_pos(p, v - Vec3::new(0.0, rate, 0.0));
+            // In edges a step: a fifth, and then more than a thickness.
+            for pace in [0.2f32, 0.8] {
+                let rate = edge * pace;
+                let steps = (1.1 / rate).ceil() as usize;
+                for thickness in ["0.50", "1.00"] {
+                    for (name, method, told, limit) in ways {
+                        let node = phase3_node(
+                            "detangle",
+                            &[("Method", method), ("Thickness", thickness), ("Rings", "2"), ("Iterations", "4"), ("Step Limit", limit)],
+                        );
+                        let mut d = sphere.clone();
+                        let (mut worst, mut far, mut spent) = (0, 0, std::time::Duration::ZERO);
+                        let mut tally = crate::detangle::Work::default();
+                        // A limited step covers less ground, and is given
+                        // the steps to cover the same: what Substeps is for.
+                        let held = limit.parse::<f32>().unwrap() * thickness.parse::<f32>().unwrap();
+                        let steps = if held > 0.0 && held < pace { (steps as f32 * pace / held).ceil() as usize } else { steps };
+                        for _ in 0..steps {
+                            let before = d.clone();
+                            for &p in &cap {
+                                let v = d.pos(p);
+                                d.set_pos(p, v - Vec3::new(0.0, rate, 0.0));
+                            }
+                            if method != "None" {
+                                let t = std::time::Instant::now();
+                                let w = crate::detangle::apply_from(&mut d, told.then_some(&before), &node);
+                                spent += t.elapsed();
+                                tally.crossed += w.crossed;
+                                tally.held += w.held;
+                                tally.limited += w.limited;
+                            }
+                            worst = worst.max(crate::detangle::self_intersections(&d).crossings);
+                            far = far.max(crate::detangle::crossings_beyond(&d, 2));
                         }
-                        if method != "None" {
-                            let t = std::time::Instant::now();
-                            crate::detangle::apply(&mut d, &node);
-                            spent += t.elapsed();
-                        }
-                        worst = worst.max(crate::detangle::self_intersections(&d).crossings);
-                        far = far.max(crate::detangle::crossings_beyond(&d, 2));
+                        let last = crate::detangle::self_intersections(&d).crossings;
+                        println!(
+                            "{:>5} points, pace {pace}, {steps:>4} steps, thickness {thickness}, {name:>14}: worst {worst:>5} crossings ({far:>5} beyond the rings), last {last:>5}, {:.2} ms a step; {} put back through, {} held, {} limited",
+                            d.num_points(),
+                            spent.as_secs_f64() * 1000.0 / steps as f64,
+                            tally.crossed,
+                            tally.held,
+                            tally.limited
+                        );
                     }
-                    let last = crate::detangle::self_intersections(&d).crossings;
-                    println!(
-                        "{:>5} points, {steps:>3} steps, thickness {thickness}, {method:>7}: worst {worst:>5} crossings ({far:>5} beyond the rings), last {last:>5}, {:.2} ms a step",
-                        d.num_points(),
-                        spent.as_secs_f64() * 1000.0 / steps as f64
-                    );
                 }
             }
         }