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

commit37627adcd6113b06a57e1b2c57aca912d0b68119
parent23e984b8ad
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 12:43
feat: the Remesh is a subnet of nodes you can dive into

A Repeat of Split Edges, Collapse Edges, Flip Edges, a Tangential Relax
and a Project onto the loop's Seed, then a Transfer behind a switch. It
makes the mesh the native remesh makes, bit for bit; a native remesh in
an older save is recomposed on load, a pass it had switched off bypassed.

New nodes: Repeat (a loop over passes, the simnet's feedback stack, Stop
When Unchanged), Seed (what the enclosing loop began from), the three
edge passes and Project; Relax gains a Tangential mode.

With it: a subnet evaluates its Input once per evaluation; an input
resolves its subnet's wire from the subnet's level; find_input_node looks
on each enclosing level before anywhere; a level inside a loop is shown
as the loop's last pass saw it; Mesh::from_detail keeps the detail's id
counter, so a collapsed point's identity is not handed out again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                 | 111 ++++++++++++-
 nodes/collapse_edges.json |  21 +++
 nodes/flip_edges.json     |  21 +++
 nodes/project.json        |  18 +++
 nodes/relax.json          |  11 +-
 nodes/remesh.json         | 280 +++++++++++++++++++++++++++++---
 nodes/repeat.json         |  61 +++++++
 nodes/seed.json           |   7 +
 nodes/split_edges.json    |  21 +++
 src/app.rs                |  42 ++++-
 src/detail.rs             |  25 ++-
 src/geometry.rs           | 396 ++++++++++++++++++++++++++++++++++++++--------
 src/main.rs               | 229 ++++++++++++++++++++++++++-
 src/remesh.rs             | 128 ++++++++++++++-
 14 files changed, 1274 insertions(+), 97 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index adef8f8..00615d8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -833,7 +833,10 @@ 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
+Distance rows shown while it is on — since the Remesh became a subnet
+(below) that is its `transfer1` child, an ordinary Transfer node whose
+rows are expressions on the subnet's, behind a switch on `chi("../Transfer")`;
+the native node's copy is `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
@@ -843,7 +846,8 @@ 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.
+test, the rule on hand-built points and both nodes through their rows —
+the Remesh both ways, native and subnet.
 
 ### Mold tooling
 
@@ -1040,7 +1044,10 @@ the way a Houdini HDA is — the Embryo is the first to be recomposed that
 way — both in `src/geometry.rs`:
 
 - **`find_input_node(root, target, name)` looks for a SIBLING first, then
-  anywhere.** Every resolver used to search the whole tree from the top, so
+  on each level around the node, nearest first (since 2026-09-30), then
+  anywhere.** The middle step is what lets a child of a subnet name a node
+  BESIDE the subnet — the Remesh subnet's Transfer reading its From —
+  and find that one, not the first of the name in the tree. Every resolver used to search the whole tree from the top, so
   inside the second instance of a subnet a child wired to "input1" found the
   first instance's; the opencl and output resolvers had each grown a
   sibling-first lookup of their own to dodge exactly that. Every wire goes
@@ -1171,6 +1178,98 @@ resolve the same way, or the nested sphere's kernel node arrived with only
 the params its override named. Depth-bounded, so a template that contained
 itself would fail rather than recurse forever.
 
+### The Remesh node is a subnet, and Repeat is a loop (2026-09-30)
+
+The Remesh is a template of nodes now, as the Embryo is, so it can be dived
+into and its passes read, bypassed and rewired. `nodes/remesh.json`:
+
+```
+remesh1 (node)  input1 → repeat1 → transfer1 ─┐
+                               └──────────── result1 (switch on Transfer) → output1
+repeat1 (repeat, Iterations = chi("../Iterations"), Stop When Unchanged on)
+                input1 → split1 → collapse1 → flip1 → relax1 → project1 → output1
+                seed1 ───────────────────────────────────────┘ (Surface)
+```
+
+The subnet makes the mesh the native remesh makes, BIT FOR BIT — points,
+identities, the id counter, primitives, attributes, groups, transfer
+included — and `the_remesh_subnet_is_the_remesh` holds it there, twice in
+a row as a solve's steps are. That rests on three things: a pass that
+changes nothing hands its input back as it came; one that changes
+something converts to the remesher's `Mesh` and back, and the conversion
+keeps point and triangle ORDER (compaction is monotone, so the edge list
+every pass sorts by index comes out the same); and `Mesh::from_detail`
+takes the id counter the detail carries, not one past the highest id left
+— otherwise a point a collapse removed had its id handed out again by the
+next pass's split (which the native remesh, holding one `Mesh` throughout,
+never did — and which, across the frames of a solve, it DID do, so the
+change is a fix for the native node too). With no relaxation and nothing to
+do the input comes back untouched, as the native node's does: every pass
+returns its input, and Project hands back a mesh that IS its Surface.
+
+The pieces, all reusable on their own:
+
+- **Repeat** (`repeat`, `nodes/repeat.json`) — a loop: its chain run
+  Iterations times (at most `REPEAT_MAX`), each pass on the last one's
+  result, through the feedback stack the simnet uses — the `input` child
+  reads the pass before. **Stop When Unchanged** ends it at a pass that
+  changes nothing (`Detail`'s `PartialEq`, every value a reader can see).
+  No frames, and nothing kept between evaluations. It is enterable, the
+  scene walk does not recurse into it (one pass drawn beside the result
+  would be wrong, as for a simnet), and dived in its chain is shown as its
+  LAST pass saw it.
+- **Seed** (`seed`) — inside a loop, what the loop BEGAN from: a repeat's
+  Input, a simnet's seed (the rest shape, which Relax's Rest and the
+  remesh's projection want). Elsewhere, the subnet's Input. Plumbing, so it
+  ignores bypass as `input` and `output` do.
+- **Split Edges / Collapse Edges / Flip Edges** (`split_edges`,
+  `collapse_edges`, `flip_edges`) — one remesh pass each toward a Target
+  Length (`remesh::edge_pass`).
+- **Relax's Tangential mode** — the remesh's relaxation: toward the
+  neighbours' centroid by Amount, less the normal part, Iterations times
+  (`remesh::relax_tangential`). Positions only, so polygons and primitive
+  attributes come through.
+- **Project** (`project`) — every point to the nearest place on a Surface
+  (`remesh::project_onto`; the grid is kept per thread by a hash of the
+  surface, since the subnet projects onto one surface every pass).
+
+**The native `remesh` type still evaluates** (`resolve_remesh_geometry_with_errors`):
+it is what an older save holds until the load recomposes it, it is what
+`mold` calls, and it is what the subnet is held to. `merge_template_defs`
+turns every native `remesh` into the subnet on load, keeping id, name,
+position, flags and values (`recompose_native_embryo`, which does both
+now). The native node's **Split / Collapse / Flip / Project** switches are
+not rows of the subnet — the passes are nodes — so one that was off
+BYPASSES its node inside (`REMESH_PASS_SWITCHES`).
+`a_native_remesh_recomposes_on_load` is the test. A saved simnet holding a
+remesh changes its JSON by this, so its solve goes on from the frame in
+hand under a new key ("An edit is in from the next frame").
+
+Four changes elsewhere came with it:
+
+- **A subnet evaluates its Input once per evaluation** (`EvalSim::seeds`,
+  `level_input`): the first child that reads it fills the slot and the
+  rest read the slot. Without it the subnet's transfer, reading `input1`
+  beside the repeat, evaluated everything upstream of a remesh twice — in
+  a simnet, the detangle. A loop fills the slot as it begins.
+- **An `input` resolves its subnet's wire from the subnet's level**, not
+  from inside: a child that shares the wire's name (the Embryo's `sphere1`
+  beside an outer `sphere1`) is not what the wire names.
+- **A level inside a loop is shown as the loop's last pass saw it**
+  (`push_loop_feedback`, outermost loop first): the scene walk dived into
+  a subnet inside a simnet, the spreadsheet and markers
+  (`node_geometry_as_shown`) and the pull arrows. Until then, dived into a
+  subnet inside a simnet, its `input` read the simnet's seed and the level
+  showed one run of the chain from frame 1 while the simnet beside it
+  played. A visible subnet child of a simnet draws in the simnet's
+  interior too, as its output.
+- **A template child that lists children brings them** (`load_fs_tree`),
+  rather than its base template's — `repeat1` is a Repeat holding the
+  passes, not the Repeat template's empty loop.
+
+`a_repeat_loops_its_chain_and_shows_its_last_pass` covers the loop, the
+seed and both interior views.
+
 ### The wrangle node
 
 `src/wrangle.rs` is a script run once per element, on Rhai — Phase 7 step 1
@@ -1622,8 +1721,10 @@ first written are kept under `cfg(test)` (`flip_pass_reference`,
 step after step. The third changes what a remesh makes, where a flip
 would have made an overlong edge; `a_remesh_settles_and_then_leaves_the_mesh_alone`
 is its test, and fails without the rule ("still changing 162 edges after
-20 rounds"). `remesh::last_changes` is the count the test and the profile
-read.
+20 rounds"). `remesh::last_changes` is the count the test reads, and
+`remesh::take_edge_changes` the profile's: every edge changed since it was
+last taken, by a native remesh or a pass node, since the Remesh subnet is
+several passes where `last_changes` sees one remesh.
 
 ### A grouped point survives a remesh
 
diff --git a/nodes/collapse_edges.json b/nodes/collapse_edges.json
new file mode 100644
index 0000000..d8d3d12
--- /dev/null
+++ b/nodes/collapse_edges.json
@@ -0,0 +1,21 @@
+{
+ "name": "Collapse Edges",
+ "type": "collapse_edges",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  {
+   "name": "Input",
+   "type": "node",
+   "default": ""
+  },
+  {
+   "name": "Target Length",
+   "type": "slider",
+   "default": "0.10",
+   "min": 0.001,
+   "max": 1.0,
+   "step": 0.005
+  }
+ ]
+}
diff --git a/nodes/flip_edges.json b/nodes/flip_edges.json
new file mode 100644
index 0000000..3318a65
--- /dev/null
+++ b/nodes/flip_edges.json
@@ -0,0 +1,21 @@
+{
+ "name": "Flip Edges",
+ "type": "flip_edges",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  {
+   "name": "Input",
+   "type": "node",
+   "default": ""
+  },
+  {
+   "name": "Target Length",
+   "type": "slider",
+   "default": "0.10",
+   "min": 0.001,
+   "max": 1.0,
+   "step": 0.005
+  }
+ ]
+}
diff --git a/nodes/project.json b/nodes/project.json
new file mode 100644
index 0000000..fe418a1
--- /dev/null
+++ b/nodes/project.json
@@ -0,0 +1,18 @@
+{
+ "name": "Project",
+ "type": "project",
+ "inputs": 2,
+ "outputs": 1,
+ "params": [
+  {
+   "name": "Input",
+   "type": "node",
+   "default": ""
+  },
+  {
+   "name": "Surface",
+   "type": "node",
+   "default": ""
+  }
+ ]
+}
diff --git a/nodes/relax.json b/nodes/relax.json
index cef9b7c..5702451 100644
--- a/nodes/relax.json
+++ b/nodes/relax.json
@@ -11,7 +11,7 @@
     },
     {
       "name": "Mode",
-      "type": "choice:Springs,Repel",
+      "type": "choice:Springs,Repel,Tangential",
       "default": "Springs"
     },
     {
@@ -57,6 +57,15 @@
       "type": "toggle",
       "default": "false",
       "show_when": "Mode == Repel"
+    },
+    {
+      "name": "Amount",
+      "type": "slider",
+      "default": "0.50",
+      "min": 0.0,
+      "max": 1.0,
+      "step": 0.01,
+      "show_when": "Mode == Tangential"
     }
   ]
 }
diff --git a/nodes/remesh.json b/nodes/remesh.json
index cd4e72a..6bea3ea 100644
--- a/nodes/remesh.json
+++ b/nodes/remesh.json
@@ -1,6 +1,6 @@
 {
  "name": "Remesh",
- "type": "remesh",
+ "type": "node",
  "inputs": 1,
  "outputs": 1,
  "params": [
@@ -33,26 +33,6 @@
    "max": 1.0,
    "step": 0.01
   },
-  {
-   "name": "Split",
-   "type": "choice:true,false",
-   "default": "true"
-  },
-  {
-   "name": "Collapse",
-   "type": "choice:true,false",
-   "default": "true"
-  },
-  {
-   "name": "Flip",
-   "type": "choice:true,false",
-   "default": "true"
-  },
-  {
-   "name": "Project",
-   "type": "choice:true,false",
-   "default": "true"
-  },
   {
    "name": "Transfer",
    "type": "toggle",
@@ -91,5 +71,263 @@
    "step": 0.01,
    "show_when": "Transfer == true"
   }
+ ],
+ "children": [
+  {
+   "name": "input1",
+   "type": "input",
+   "params": [],
+   "position": [
+    0.0,
+    0.0
+   ],
+   "geometry_visible": false
+  },
+  {
+   "name": "repeat1",
+   "type": "repeat",
+   "position": [
+    0.0,
+    1.0
+   ],
+   "params": [
+    {
+     "name": "Input",
+     "default": "input1"
+    },
+    {
+     "name": "Iterations",
+     "default": "chi(\"../Iterations\")",
+     "expr": true
+    },
+    {
+     "name": "Stop When Unchanged",
+     "default": "true"
+    }
+   ],
+   "children": [
+    {
+     "name": "input1",
+     "type": "input",
+     "params": [],
+     "position": [
+      0.0,
+      0.0
+     ],
+     "geometry_visible": false
+    },
+    {
+     "name": "seed1",
+     "type": "seed",
+     "params": [],
+     "position": [
+      2.0,
+      0.0
+     ],
+     "geometry_visible": false
+    },
+    {
+     "name": "split1",
+     "type": "split_edges",
+     "position": [
+      0.0,
+      1.0
+     ],
+     "params": [
+      {
+       "name": "Input",
+       "default": "input1"
+      },
+      {
+       "name": "Target Length",
+       "default": "chf(\"../../Target Length\")",
+       "expr": true
+      }
+     ],
+     "geometry_visible": false
+    },
+    {
+     "name": "collapse1",
+     "type": "collapse_edges",
+     "position": [
+      0.0,
+      2.0
+     ],
+     "params": [
+      {
+       "name": "Input",
+       "default": "split1"
+      },
+      {
+       "name": "Target Length",
+       "default": "chf(\"../../Target Length\")",
+       "expr": true
+      }
+     ],
+     "geometry_visible": false
+    },
+    {
+     "name": "flip1",
+     "type": "flip_edges",
+     "position": [
+      0.0,
+      3.0
+     ],
+     "params": [
+      {
+       "name": "Input",
+       "default": "collapse1"
+      },
+      {
+       "name": "Target Length",
+       "default": "chf(\"../../Target Length\")",
+       "expr": true
+      }
+     ],
+     "geometry_visible": false
+    },
+    {
+     "name": "relax1",
+     "type": "relax",
+     "position": [
+      0.0,
+      4.0
+     ],
+     "params": [
+      {
+       "name": "Input",
+       "default": "flip1"
+      },
+      {
+       "name": "Mode",
+       "default": "Tangential"
+      },
+      {
+       "name": "Iterations",
+       "default": "1"
+      },
+      {
+       "name": "Amount",
+       "default": "chf(\"../../Relax\")",
+       "expr": true
+      }
+     ],
+     "geometry_visible": false
+    },
+    {
+     "name": "project1",
+     "type": "project",
+     "position": [
+      1.0,
+      5.0
+     ],
+     "params": [
+      {
+       "name": "Input",
+       "default": "relax1"
+      },
+      {
+       "name": "Surface",
+       "default": "seed1"
+      }
+     ],
+     "geometry_visible": false
+    },
+    {
+     "name": "output1",
+     "type": "output",
+     "position": [
+      1.0,
+      6.0
+     ],
+     "params": [
+      {
+       "name": "Input",
+       "default": "project1"
+      }
+     ],
+     "geometry_visible": false
+    }
+   ],
+   "geometry_visible": false
+  },
+  {
+   "name": "transfer1",
+   "type": "transfer",
+   "position": [
+    1.0,
+    2.0
+   ],
+   "params": [
+    {
+     "name": "Input",
+     "default": "repeat1"
+    },
+    {
+     "name": "From",
+     "default": "if(chs(\"../From\"), chs(\"../From\"), \"input1\")",
+     "expr": true
+    },
+    {
+     "name": "Attributes",
+     "default": "chs(\"../Attributes\")",
+     "expr": true
+    },
+    {
+     "name": "Transfer Groups",
+     "default": "chb(\"../Transfer Groups\")",
+     "expr": true
+    },
+    {
+     "name": "Groups",
+     "default": "chs(\"../Groups\")",
+     "expr": true
+    },
+    {
+     "name": "Maximum Distance",
+     "default": "chf(\"../Maximum Distance\")",
+     "expr": true
+    }
+   ],
+   "geometry_visible": false
+  },
+  {
+   "name": "result1",
+   "type": "switch",
+   "position": [
+    0.0,
+    3.0
+   ],
+   "params": [
+    {
+     "name": "Input",
+     "default": "repeat1"
+    },
+    {
+     "name": "Input 2",
+     "default": "transfer1"
+    },
+    {
+     "name": "Index",
+     "default": "chi(\"../Transfer\")",
+     "expr": true
+    }
+   ]
+  },
+  {
+   "name": "output1",
+   "type": "output",
+   "position": [
+    0.0,
+    4.0
+   ],
+   "params": [
+    {
+     "name": "Input",
+     "default": "result1"
+    }
+   ],
+   "geometry_visible": false
+  }
  ]
 }
diff --git a/nodes/repeat.json b/nodes/repeat.json
new file mode 100644
index 0000000..2309790
--- /dev/null
+++ b/nodes/repeat.json
@@ -0,0 +1,61 @@
+{
+ "name": "Repeat",
+ "type": "repeat",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  {
+   "name": "Input",
+   "type": "node",
+   "default": ""
+  },
+  {
+   "name": "Iterations",
+   "type": "spinbox",
+   "default": "4",
+   "min": 0.0,
+   "max": 100.0,
+   "step": 1.0
+  },
+  {
+   "name": "Stop When Unchanged",
+   "type": "toggle",
+   "default": "false"
+  }
+ ],
+ "children": [
+  {
+   "name": "input1",
+   "type": "input",
+   "params": [],
+   "position": [
+    0.0,
+    0.0
+   ]
+  },
+  {
+   "name": "seed1",
+   "type": "seed",
+   "params": [],
+   "position": [
+    2.0,
+    0.0
+   ],
+   "geometry_visible": false
+  },
+  {
+   "name": "output1",
+   "type": "output",
+   "params": [
+    {
+     "name": "Input",
+     "default": "input1"
+    }
+   ],
+   "position": [
+    0.0,
+    2.0
+   ]
+  }
+ ]
+}
diff --git a/nodes/seed.json b/nodes/seed.json
new file mode 100644
index 0000000..0583c4a
--- /dev/null
+++ b/nodes/seed.json
@@ -0,0 +1,7 @@
+{
+ "name": "Seed",
+ "type": "seed",
+ "inputs": 0,
+ "outputs": 1,
+ "params": []
+}
diff --git a/nodes/split_edges.json b/nodes/split_edges.json
new file mode 100644
index 0000000..2db7322
--- /dev/null
+++ b/nodes/split_edges.json
@@ -0,0 +1,21 @@
+{
+ "name": "Split Edges",
+ "type": "split_edges",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  {
+   "name": "Input",
+   "type": "node",
+   "default": ""
+  },
+  {
+   "name": "Target Length",
+   "type": "slider",
+   "default": "0.10",
+   "min": 0.001,
+   "max": 1.0,
+   "step": 0.005
+  }
+ ]
+}
diff --git a/src/app.rs b/src/app.rs
index b638717..df63b89 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -169,7 +169,7 @@ impl FsNode {
     /// of them learned about new container types: subnet-like types by name,
     /// otherwise anything that actually has children.
     pub fn is_enterable(&self) -> bool {
-        matches!(self.node_type.as_str(), "node" | "simnet")
+        matches!(self.node_type.as_str(), "node" | "simnet" | "repeat")
             || !self.children.is_empty()
     }
 
@@ -1025,8 +1025,41 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
     // network: id, name, position, display flag and every parameter value
     // carry over by name, and the template's children arrive with fresh ids. Wholesale rather than through the
     // merge below, which never injects children.
+    //
+    // The Remesh the same way (since 2026-09-30): a native `remesh` becomes
+    // the Remesh subnet. Its Split, Collapse, Flip and Project switches are
+    // not rows of the subnet — the passes are nodes inside it — so one that
+    // was off BYPASSES its node there, which is what it meant.
     fn recompose_native_embryo(node: &mut FsNode, templates: &[NodeTemplate]) {
         for c in &mut node.children {
+            if c.node_type.eq_ignore_ascii_case("remesh") {
+                if let Some(t) = templates.iter().find(|t| t.node.name == "Remesh" && t.node.node_type == "node") {
+                    let mut fresh = t.node.clone();
+                    regenerate_node_ids(&mut fresh);
+                    fresh.id = c.id.clone();
+                    fresh.name = c.name.clone();
+                    fresh.position = c.position;
+                    fresh.geometry_visible = c.geometry_visible;
+                    fresh.bypassed = c.bypassed;
+                    for p in &c.params {
+                        if let Some(fp) = fresh.params.iter_mut().find(|fp| fp.name == p.name) {
+                            fp.set_text(p.text().to_string());
+                            fp.set_expr(p.is_expr());
+                        }
+                    }
+                    for (switch, pass) in crate::geometry::REMESH_PASS_SWITCHES {
+                        let on = c.params.iter().find(|p| p.name == *switch).map_or(true, |p| {
+                            !["false", "0", "off"].contains(&p.text().trim().to_ascii_lowercase().as_str())
+                        });
+                        if !on {
+                            if let Some(n) = fresh.children.iter_mut().find(|k| k.node_type == "repeat").and_then(|r| r.children.iter_mut().find(|k| k.name == *pass)) {
+                                n.bypassed = true;
+                            }
+                        }
+                    }
+                    *c = fresh;
+                }
+            }
             if c.node_type.eq_ignore_ascii_case("embryo") {
                 if let Some(t) = templates.iter().find(|t| t.node.name == "Embryo") {
                     let mut fresh = t.node.clone();
@@ -1231,6 +1264,13 @@ pub fn load_fs_tree() -> FsNode {
                             base_p.set_expr(override_p.is_expr());
                         }
                     }
+                    // A child that lists children of its own brings THOSE
+                    // rather than its base's: the Remesh subnet's repeat1
+                    // is a Repeat holding the remesh's passes, not the
+                    // empty input-to-output loop the Repeat template ships.
+                    if !child.children.is_empty() {
+                        resolved_child.children = child.children.clone();
+                    }
                     if depth < 8 {
                         resolve_children(&mut resolved_child, raw_nodes, depth + 1, owner);
                     }
diff --git a/src/detail.rs b/src/detail.rs
index 874f7c4..b3deaa4 100644
--- a/src/detail.rs
+++ b/src/detail.rs
@@ -356,7 +356,7 @@ impl AttribData {
 
 /// Every attribute and group belonging to one element class, plus the element
 /// count they are all kept in step with.
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, PartialEq)]
 pub struct AttribStore {
     len: usize,
     attribs: HashMap<String, AttribData>,
@@ -1017,6 +1017,24 @@ impl Default for Detail {
     }
 }
 
+/// Two details are equal when everything a reader can see of them is: the
+/// points and their identities, the counter new points draw from, the
+/// primitives, and every attribute and group. The derived topology is not
+/// compared, being derived.
+impl PartialEq for Detail {
+    fn eq(&self, other: &Self) -> bool {
+        self.pos == other.pos
+            && self.ids == other.ids
+            && self.next_id == other.next_id
+            && self.vert_point == other.vert_point
+            && self.prim_start == other.prim_start
+            && self.points == other.points
+            && self.verts == other.verts
+            && self.prims == other.prims
+            && self.detail == other.detail
+    }
+}
+
 impl Clone for Detail {
     /// The topology cache is deliberately *not* cloned. It is derived, the
     /// clone exists to be modified, and rebuilding is cheaper than reasoning
@@ -1157,6 +1175,11 @@ impl Detail {
         &self.ids
     }
 
+    /// The identity the next new point will get.
+    pub fn next_id(&self) -> PointId {
+        self.next_id
+    }
+
     /// Where the point carrying `id` currently sits, or `None` if it is gone.
     /// Linear; a solver resolving many ids at once should build a map with
     /// [`Detail::id_map`] instead.
diff --git a/src/geometry.rs b/src/geometry.rs
index f79339f..30cb2d8 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -641,8 +641,8 @@ pub fn find_parent_node<'a>(root: &'a FsNode, child_id: &str) -> Option<&'a FsNo
     visit(root, child_id)
 }
 
-/// The node `name` refers to from `target`: a SIBLING first, then anywhere
-/// under `root`.
+/// The node `name` refers to from `target`: a SIBLING first, then a node on
+/// each level around it, nearest first, then anywhere under `root`.
 ///
 /// Every resolver used to search the whole tree from the top, so inside the
 /// second instance of a subnet a child wired to "input1" found the FIRST
@@ -656,9 +656,19 @@ pub fn find_input_node<'a>(root: &'a FsNode, target: &FsNode, name: &str) -> Opt
     if name.is_empty() {
         return None;
     }
-    find_parent_node(root, &target.id)
-        .and_then(|p| p.children.iter().find(|c| c.name == name || c.id == name))
-        .or_else(|| find_node_by_name(root, name))
+    // The level the node stands on, then each level around it, then
+    // anywhere: a wire out of a subnet's child to a node beside the subnet
+    // (the Remesh subnet's Transfer reading its From) finds THAT node, not
+    // the first of the name in the tree, which in a second copy of the
+    // enclosing level is the first copy's.
+    let mut at = target.id.as_str();
+    while let Some(level) = find_parent_node(root, at) {
+        if let Some(found) = level.children.iter().find(|c| c.name == name || c.id == name) {
+            return Some(found);
+        }
+        at = level.id.as_str();
+    }
+    find_node_by_name(root, name)
 }
 
 pub use crate::expr::{ChKind, Value};
@@ -1205,11 +1215,41 @@ pub struct EvalSim<'a> {
     pub start_frame: i32,
     pub cache: &'a mut SimCache,
     feedback: Vec<(String, Detail)>,
+    /// What each container being evaluated now takes in, by its id: a
+    /// simnet's seed and a repeat's Input, filled as the loop begins, and a
+    /// subnet's Input, filled the first time a child asks. An `input` or
+    /// `seed` child reads it here rather than evaluating the Input again —
+    /// every pass of a loop, and every child of a subnet that reads it (the
+    /// Remesh subnet's repeat and its transfer both do, and what is upstream
+    /// of a remesh in a simnet is most of the step).
+    seeds: Vec<(String, Option<Detail>)>,
 }
 
 impl<'a> EvalSim<'a> {
     pub fn new(frame: i32, start_frame: i32, cache: &'a mut SimCache) -> Self {
-        Self { frame, start_frame, cache, feedback: Vec::new() }
+        Self { frame, start_frame, cache, feedback: Vec::new(), seeds: Vec::new() }
+    }
+
+    /// What the container `id` takes in, while it is being evaluated — kept
+    /// once asked for. `None` when `id` is not being evaluated (a node inside
+    /// it evaluated on its own, as the scene walk and the spreadsheet do):
+    /// the caller evaluates the Input itself then.
+    fn level_input(
+        &mut self,
+        id: &str,
+        evaluate: impl FnOnce(&mut Self) -> Option<Detail>,
+    ) -> Option<Option<Detail>> {
+        let at = self.seeds.iter().rposition(|(k, _)| k == id)?;
+        if let Some(kept) = &self.seeds[at].1 {
+            return Some(Some(kept.clone()));
+        }
+        // Pushes made while evaluating are popped again before it returns,
+        // so `at` still names this entry.
+        let got = evaluate(self);
+        if let Some(g) = &got {
+            self.seeds[at].1 = Some(g.clone());
+        }
+        Some(got)
     }
 
     /// The state an `input` node should yield, if its parent simnet is mid-solve.
@@ -1261,7 +1301,7 @@ pub fn generate_single_node_geometry(root: &FsNode, target: &FsNode, visited: &m
 /// `input` and `output` are a subnet's plumbing and a camera is not
 /// geometry: the flag on any of them says nothing.
 pub fn is_bypassed(node: &FsNode) -> bool {
-    node.bypassed && !["input", "output", "camera"].iter().any(|ty| node.node_type.eq_ignore_ascii_case(ty))
+    node.bypassed && !["input", "output", "seed", "camera"].iter().any(|ty| node.node_type.eq_ignore_ascii_case(ty))
 }
 
 pub fn generate_single_node_geometry_with_errors(
@@ -1381,6 +1421,16 @@ pub fn generate_single_node_geometry_with_errors(
         resolve_suture_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("remesh") {
         resolve_remesh_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("split_edges") {
+        resolve_edge_pass_geometry_with_errors(root, target, crate::remesh::Pass::Split, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("collapse_edges") {
+        resolve_edge_pass_geometry_with_errors(root, target, crate::remesh::Pass::Collapse, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("flip_edges") {
+        resolve_edge_pass_geometry_with_errors(root, target, crate::remesh::Pass::Flip, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("project") {
+        resolve_project_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("repeat") {
+        resolve_repeat_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("develop") {
         resolve_develop_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("visualize") {
@@ -1395,7 +1445,10 @@ pub fn generate_single_node_geometry_with_errors(
         resolve_simnet_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("node") {
         if let Some(output_node) = target.children.iter().find(|c| c.node_type.eq_ignore_ascii_case("output")) {
-            generate_single_node_geometry_with_errors(root, output_node, visited, ocl_error, sim)
+            sim.seeds.push((target.id.clone(), None));
+            let res = generate_single_node_geometry_with_errors(root, output_node, visited, ocl_error, sim);
+            sim.seeds.pop();
+            res
         } else {
             None
         }
@@ -1404,6 +1457,11 @@ pub fn generate_single_node_geometry_with_errors(
         // which this arm spelled out by hand before that function existed.
         param_node(root, target, "Input")
             .and_then(|node| generate_single_node_geometry_with_errors(root, node, visited, ocl_error, sim))
+    } else if target.node_type.eq_ignore_ascii_case("seed") {
+        // What the enclosing loop began from, whichever pass it is on: a
+        // repeat's Input, a simnet's seed. Outside a loop, the subnet's
+        // Input, as `input` reads it.
+        find_parent_node(root, &target.id).and_then(|parent| level_input(root, parent, visited, ocl_error, sim))
     } else if target.node_type.eq_ignore_ascii_case("input") {
         if let Some(parent) = find_parent_node(root, &target.id) {
             // Inside a simnet that is mid-solve, the input IS the previous
@@ -1414,13 +1472,7 @@ pub fn generate_single_node_geometry_with_errors(
                 visited.pop();
                 return Some(fed);
             }
-            // Resolved, like the kernel's parent read above: a subnet's
-            // Input may itself be a reference.
-            let resolved_parent = resolve_param_refs(root, parent, sim.frame, ocl_error);
-            let parent = resolved_parent.as_ref().unwrap_or(parent);
-            node_param_node(parent, "Input")
-                .and_then(|name| find_input_node(root, target, &name))
-                .and_then(|input_node| generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim))
+            level_input(root, parent, visited, ocl_error, sim)
         } else {
             None
         }
@@ -1926,6 +1978,15 @@ pub fn resolve_relax_geometry_with_errors(
     let input_node = param_node(root, target, "Input")?;
     let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
 
+    // Tangential mode: the remesh's relaxation — each point toward the
+    // centroid of its neighbours by Amount, less the part along its normal,
+    // so the triangles even out and the shape stays. Zero iterations or a
+    // zero Amount is off.
+    if node_param_str(target, "Mode", "Springs").eq_ignore_ascii_case("tangential") {
+        let iterations = node_param_f32(target, "Iterations", 1.0).max(0.0) as usize;
+        return Some(crate::remesh::relax_tangential(&geom, node_param_f32(target, "Amount", 0.5), iterations));
+    }
+
     // Repel mode: the Relax SOP — spheres of Radius pushed apart until they
     // stop overlapping, each point sliding in its tangent plane unless In 3D
     // Space lets it leave. No rest shape, no springs; zero iterations is off.
@@ -3433,6 +3494,12 @@ fn remesh_transfer(
     );
 }
 
+/// A native remesh's pass switches and the node inside the Remesh subnet's
+/// repeat that each one is: what the load bypasses when it recomposes a
+/// native remesh whose switch was off.
+pub const REMESH_PASS_SWITCHES: [(&str, &str); 4] =
+    [("Split", "split1"), ("Collapse", "collapse1"), ("Flip", "flip1"), ("Project", "project1")];
+
 pub(crate) fn remesh_settings(target: &FsNode) -> crate::remesh::Settings {
     crate::remesh::Settings {
         target: node_param_f32(target, "Target Length", 0.1).max(1e-4),
@@ -3445,6 +3512,199 @@ pub(crate) fn remesh_settings(target: &FsNode) -> crate::remesh::Settings {
     }
 }
 
+/// What the container `parent` takes in: its Input, evaluated once per
+/// evaluation of the container and kept on [`EvalSim`] for the rest of it,
+/// or evaluated here when the container is not being evaluated.
+fn level_input(
+    root: &FsNode,
+    parent: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    let evaluate = |sim: &mut EvalSim, visited: &mut Vec<String>, ocl_error: &mut Option<String>| {
+        // Resolved: a subnet's Input may itself be a reference.
+        let resolved_parent = resolve_param_refs(root, parent, sim.frame, ocl_error);
+        let parent = resolved_parent.as_ref().unwrap_or(parent);
+        // Looked up from the SUBNET's level, not from inside it: the name is
+        // the subnet's wire, and a child that happens to share it (the
+        // Embryo's sphere1 beside an outer sphere1) is not what it names.
+        node_param_node(parent, "Input")
+            .and_then(|name| find_input_node(root, parent, &name))
+            .and_then(|input_node| generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim))
+    };
+    match sim.level_input(&parent.id, |sim| evaluate(sim, visited, ocl_error)) {
+        Some(kept) => kept,
+        None => evaluate(sim, visited, ocl_error),
+    }
+}
+
+/// The Split Edges, Collapse Edges and Flip Edges nodes: one of the
+/// remesh's passes on its own ([`crate::remesh::edge_pass`]), toward the
+/// node's Target Length. They are what the Remesh subnet is built from.
+pub fn resolve_edge_pass_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    pass: crate::remesh::Pass,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> 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::edge_pass(&geom, pass, node_param_f32(target, "Target Length", 0.1).max(1e-4)))
+}
+
+/// The Project node: every point of the Input moved to the nearest place on
+/// the Surface ([`crate::remesh::project_onto`]). No Surface, or one that
+/// does not resolve, passes the input through; the second is an error on
+/// the node.
+pub fn resolve_project_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> 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)?;
+    let Some(surface_name) = node_param_node(target, "Surface") else { return Some(geom) };
+    let Some(surface) = find_input_node(root, target, &surface_name)
+        .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
+    else {
+        if ocl_error.is_none() {
+            *ocl_error = Some(format!("{}: cannot resolve Surface '{}'", target.name, surface_name));
+        }
+        return Some(geom);
+    };
+    Some(crate::remesh::project_onto(&geom, &surface))
+}
+
+/// How many passes a Repeat runs at most, whatever its Iterations says: a
+/// hand-edited file reaches here too, and a loop of a million passes is
+/// indistinguishable from a hang.
+pub const REPEAT_MAX: usize = 1000;
+
+/// The Repeat node: its chain run Iterations times, each pass on what the
+/// last one made — a loop, as a simnet is one over frames, with no frames
+/// and nothing kept between evaluations. The `input` child reads the pass
+/// before (the Input on the first), a `seed` child what the loop began
+/// from. Stop When Unchanged ends it at a pass that changes nothing, since
+/// every later one would find the same. A pass that yields nothing (an
+/// unwired chain) keeps what the pass before made.
+pub fn resolve_repeat_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    run_repeat(root, target, visited, ocl_error, sim).map(|(state, _)| state)
+}
+
+/// A repeat's result and the state its LAST pass began from — what its
+/// chain's nodes are shown as, dived in, as a simnet's are shown as its
+/// last substep saw them. Both are the Input when no pass runs.
+fn run_repeat(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<(Detail, Detail)> {
+    let output_node = target.children.iter().find(|c| c.node_type.eq_ignore_ascii_case("output"))?;
+    let seed = param_node(root, target, "Input")
+        .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
+        .unwrap_or_default();
+    let iterations = (node_param_f32(target, "Iterations", 1.0).round().max(0.0) as usize).min(REPEAT_MAX);
+    let stop = node_param_bool(target, "Stop When Unchanged", false);
+    sim.seeds.push((target.id.clone(), Some(seed.clone())));
+    let mut state = seed.clone();
+    let mut prev = seed;
+    for _ in 0..iterations {
+        sim.feedback.push((target.id.clone(), state));
+        let passed = generate_single_node_geometry_with_errors(root, output_node, visited, ocl_error, sim);
+        let before = sim.feedback.pop().map(|(_, g)| g).unwrap_or_default();
+        let after = passed.unwrap_or_else(|| before.clone());
+        let unchanged = stop && after == before;
+        prev = before;
+        state = after;
+        if unchanged {
+            break;
+        }
+    }
+    sim.seeds.pop();
+    Some((state, prev))
+}
+
+/// Whether `node` runs its chain as a loop — a simnet over frames, a repeat
+/// over passes — so that what is inside is one pass of it, read through the
+/// feedback stack.
+pub fn is_loop(node: &FsNode) -> bool {
+    node.node_type.eq_ignore_ascii_case("simnet") || node.node_type.eq_ignore_ascii_case("repeat")
+}
+
+/// The state a loop's LAST pass began from: a simnet's last substep of the
+/// current frame ([`simnet_step_feedback`]), a repeat's last iteration.
+pub fn loop_step_feedback(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    if target.node_type.eq_ignore_ascii_case("simnet") {
+        simnet_step_feedback(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("repeat") {
+        run_repeat(root, target, visited, ocl_error, sim).map(|(_, prev)| prev)
+    } else {
+        None
+    }
+}
+
+/// Push the feedback of every loop around `target` — and `target` itself
+/// when `include_self` and it is one — OUTERMOST first, each worked out with
+/// the ones outside it already pushed, so a node inside a repeat inside a
+/// simnet reads the repeat's last pass of the simnet's last substep. How
+/// many were pushed is returned, for [`pop_loop_feedback`].
+///
+/// This is how anything that shows a node inside a loop shows it: the scene
+/// walk dived in, the spreadsheet, the markers, the pull arrows.
+pub fn push_loop_feedback(
+    root: &FsNode,
+    target: &FsNode,
+    include_self: bool,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> usize {
+    let mut loops: Vec<&FsNode> = Vec::new();
+    if include_self && is_loop(target) {
+        loops.push(target);
+    }
+    let mut at = target;
+    while let Some(parent) = find_parent_node(root, &at.id) {
+        if is_loop(parent) {
+            loops.push(parent);
+        }
+        at = parent;
+    }
+    let mut pushed = 0;
+    for l in loops.into_iter().rev() {
+        if let Some(fed) = loop_step_feedback(root, l, &mut Vec::new(), ocl_error, sim) {
+            sim.feedback.push((l.id.clone(), fed));
+            pushed += 1;
+        }
+    }
+    pushed
+}
+
+/// Undo a [`push_loop_feedback`].
+pub fn pop_loop_feedback(sim: &mut EvalSim, pushed: usize) {
+    for _ in 0..pushed {
+        sim.feedback.pop();
+    }
+}
+
 /// The Develop node: move the surface along its normals by an attribute.
 ///
 /// The whole of surface development in one operator — everything else in the
@@ -3617,33 +3877,17 @@ pub fn moves_points(node: &FsNode) -> bool {
 /// child read the simnet's seed and the rows showed the first frame at
 /// every frame, while the scene beside them played. The simnet is the
 /// nearest one above the node, so a node in a subnet inside a simnet is
-/// read the same way.
+/// read the same way. Every loop around it is, in fact — a repeat's last
+/// pass as well as a simnet's last substep ([`push_loop_feedback`]).
 pub fn node_geometry_as_shown(
     root: &FsNode,
     target: &FsNode,
     ocl_error: &mut Option<String>,
     sim: &mut EvalSim,
 ) -> Option<Detail> {
-    let mut simnet = None;
-    let mut at = target;
-    while let Some(parent) = find_parent_node(root, &at.id) {
-        if parent.node_type.eq_ignore_ascii_case("simnet") {
-            simnet = Some(parent);
-            break;
-        }
-        at = parent;
-    }
-    let mut pushed = false;
-    if let Some(simnet) = simnet {
-        if let Some(fed) = simnet_step_feedback(root, simnet, &mut Vec::new(), ocl_error, sim) {
-            sim.feedback.push((simnet.id.clone(), fed));
-            pushed = true;
-        }
-    }
+    let pushed = push_loop_feedback(root, target, false, ocl_error, sim);
     let geom = generate_single_node_geometry_with_errors(root, target, &mut Vec::new(), ocl_error, sim);
-    if pushed {
-        sim.feedback.pop();
-    }
+    pop_loop_feedback(sim, pushed);
     geom
 }
 
@@ -3666,23 +3910,10 @@ pub fn point_displacements(root: &FsNode, target: &FsNode, sim: &mut EvalSim) ->
         return Vec::new();
     };
     let mut ocl_error = None;
-    let simnet = find_parent_node(root, &target.id).filter(|p| p.node_type.eq_ignore_ascii_case("simnet"));
-    let mut pushed = false;
-    if let Some(simnet) = simnet {
-        let mut visited = Vec::new();
-        match simnet_step_feedback(root, simnet, &mut visited, &mut ocl_error, sim) {
-            Some(fed) => {
-                sim.feedback.push((simnet.id.clone(), fed));
-                pushed = true;
-            }
-            None => return Vec::new(),
-        }
-    }
+    let pushed = push_loop_feedback(root, target, false, &mut ocl_error, sim);
     let before = generate_single_node_geometry_with_errors(root, input_node, &mut Vec::new(), &mut ocl_error, sim);
     let after = generate_single_node_geometry_with_errors(root, target, &mut Vec::new(), &mut ocl_error, sim);
-    if pushed {
-        sim.feedback.pop();
-    }
+    pop_loop_feedback(sim, pushed);
     let (Some(before), Some(after)) = (before, after) else { return Vec::new() };
     if before.num_points() != after.num_points() {
         return Vec::new();
@@ -5254,6 +5485,12 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
         || nt == "normal"
         || nt == "attribute"
         || nt == "simnet"
+        || nt == "repeat"
+        || nt == "seed"
+        || nt == "split_edges"
+        || nt == "collapse_edges"
+        || nt == "flip_edges"
+        || nt == "project"
 }
 
 /// The scene at the timeline's start frame, with a throwaway sim cache — every
@@ -5294,7 +5531,14 @@ pub fn network_sphere_vertices_with_errors(
     // wired into the chain at all (a seed being built beside it) simply
     // draws. Before 2026-09-21 only the output flag drew anything, and a
     // visible node inside a simnet was a node you could not see.
-    if start.node_type.eq_ignore_ascii_case("simnet") {
+    //
+    // A repeat is shown the same way (since 2026-09-30), its chain as its
+    // last pass saw it. And a level INSIDE a loop — a subnet in a simnet,
+    // the Remesh subnet's repeat — is walked with every loop around it
+    // pushed, outermost first, so what it draws is this frame's and this
+    // pass's rather than one run of each chain from its seed.
+    let around = push_loop_feedback(root, start, false, ocl_error, sim);
+    let out = if is_loop(start) {
         let mut out = Detail::new();
         let display_on = start
             .children
@@ -5304,22 +5548,24 @@ pub fn network_sphere_vertices_with_errors(
             .unwrap_or(true);
         if display_on {
             let mut visited = Vec::new();
-            if let Some(geom) = resolve_simnet_geometry_with_errors(root, start, &mut visited, ocl_error, sim) {
+            if let Some(geom) = generate_single_node_geometry_with_errors(root, start, &mut visited, ocl_error, sim) {
                 out.merge(&geom);
             }
         }
+        // A subnet inside draws too, as its output: the Remesh subnet in a
+        // simnet is a step's node like any other.
         let shown: Vec<&FsNode> = start
             .children
             .iter()
             .filter(|c| {
                 c.geometry_visible
                     && !c.node_type.eq_ignore_ascii_case("output")
-                    && is_geometry_node_type(&c.node_type)
+                    && (is_geometry_node_type(&c.node_type) || c.node_type.eq_ignore_ascii_case("node"))
             })
             .collect();
         if !shown.is_empty() {
             let mut visited = Vec::new();
-            if let Some(fed) = simnet_step_feedback(root, start, &mut visited, ocl_error, sim) {
+            if let Some(fed) = loop_step_feedback(root, start, &mut visited, ocl_error, sim) {
                 sim.feedback.push((start.id.clone(), fed));
                 for child in shown {
                     let mut visited = Vec::new();
@@ -5330,8 +5576,17 @@ pub fn network_sphere_vertices_with_errors(
                 sim.feedback.pop();
             }
         }
-        return out;
-    }
+        out
+    } else {
+        walk_level(root, start, ocl_error, sim)
+    };
+    pop_loop_feedback(sim, around);
+    out
+}
+
+/// The scene walk over a level that is not a loop: each child by its flag,
+/// a subnet's children by theirs.
+fn walk_level(root: &FsNode, start: &FsNode, ocl_error: &mut Option<String>, sim: &mut EvalSim) -> Detail {
     fn visit(root: &FsNode, node: &FsNode, parent_visible: bool, top: bool, count: &mut usize, out: &mut Detail, ocl_error: &mut Option<String>, sim: &mut EvalSim) {
         // The walk hands nodes to their resolvers directly, so it resolves
         // references itself — dived into a composed subnet, its children are
@@ -5714,20 +5969,33 @@ pub fn network_sphere_vertices_with_errors(
                     out.merge(&geom);
                 }
             }
-        } else if node.node_type.eq_ignore_ascii_case("simnet") {
+        } else if is_loop(node) {
             let _idx = *count;
             *count += 1;
             if is_visible {
                 let mut visited = Vec::new();
-                if let Some(geom) = resolve_simnet_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                if let Some(geom) = generate_single_node_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
                     out.merge(&geom);
                 }
             }
-            // The chain inside a simnet is the simulation STEP, not scene
-            // content. Recursing into it the way a subnet is recursed into
-            // would merge one un-iterated pass of the chain alongside the
-            // solved result — the sim would draw itself twice, once wrong.
+            // The chain inside a simnet is the simulation STEP, and inside a
+            // repeat one PASS, not scene content. Recursing into it the way
+            // a subnet is recursed into would merge one un-iterated pass of
+            // the chain alongside the result — the loop would draw itself
+            // twice, once wrong.
             return;
+        } else if ["seed", "split_edges", "collapse_edges", "flip_edges", "project"]
+            .iter()
+            .any(|ty| node.node_type.eq_ignore_ascii_case(ty))
+        {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = generate_single_node_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(&geom);
+                }
+            }
         } else if top
             && (node.node_type.eq_ignore_ascii_case("input")
                 || node.node_type.eq_ignore_ascii_case("output"))
@@ -6821,7 +7089,7 @@ pub fn resolve_simnet_geometry_with_errors(
     let (mut state, mut prev_frame, mut done) = match (cached, disk) {
         (Some(hit), _) => hit,
         (None, Some(hit)) => hit,
-        (None, None) => (seed.clone(), seed, 0),
+        (None, None) => (seed.clone(), seed.clone(), 0),
     };
 
     // Substeps run the chain more than once per frame. A step's size is what
@@ -6859,7 +7127,9 @@ pub fn resolve_simnet_geometry_with_errors(
             let prev = state.clone();
 
             sim.feedback.push((target.id.clone(), state));
+            sim.seeds.push((target.id.clone(), Some(seed.clone())));
             let stepped = generate_single_node_geometry_with_errors(root, &output_node, visited, ocl_error, sim);
+            sim.seeds.pop();
             let fed_back = sim.feedback.pop().map(|(_, g)| g);
             // A step that yields nothing (an unwired chain, a failed kernel)
             // holds the previous state rather than collapsing the sim to empty
diff --git a/src/main.rs b/src/main.rs
index 6c4b88a..9afc815 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4827,7 +4827,8 @@ mod tests {
         use crate::app::ParamKind as K;
         let root = crate::app::load_fs_tree();
         let kind = |ty: &str, name: &str| {
-            root.children.iter().find(|t| t.node_type == ty)
+            // By type, or by name for a subnet (the Remesh is a `node`).
+            root.children.iter().find(|t| t.node_type == ty || (t.node_type == "node" && t.name.eq_ignore_ascii_case(ty)))
                 .and_then(|t| t.params.iter().find(|p| p.name == name))
                 .unwrap_or_else(|| panic!("{ty} has no {name}"))
                 .kind()
@@ -4835,7 +4836,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"), ("remesh", "From"),
+            ("suture", "Against"), ("copy", "To"), ("distance", "To"), ("transfer", "From"), ("remesh", "From"), ("project", "Surface"),
         ] {
             assert_eq!(kind(ty, name), K::Node, "{ty}'s {name}");
         }
@@ -10556,7 +10557,18 @@ mod tests {
 
         // 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", "")]);
+        // Both ways a remesh is: the native node, and the subnet whose
+        // Transfer node reads From through an expression and finds a node
+        // beside the subnet, not inside it.
+        let rows = [("Input", "attribute 1"), ("Target Length", "0.5"), ("Iterations", "3"), ("Relax", "0.0"), ("Transfer", "true"), ("From", ""), ("Attributes", ""), ("Transfer Groups", "true"), ("Groups", "")];
+        let templates = crate::app::load_fs_tree();
+        let mut composed = templates.children.iter().find(|t| t.name == "Remesh").unwrap().clone();
+        crate::app::regenerate_node_ids(&mut composed);
+        composed.name = "remesh 1".into();
+        for (k, v) in rows {
+            composed.params.iter_mut().find(|p| p.name == k).unwrap().set_text(v.to_string());
+        }
+        for remesh in [phase3_node("remesh", &rows), composed] {
         let (g, err) = eval_node(&with(remesh.clone()), "remesh 1");
         assert!(err.is_none(), "{err:?}");
         assert_ne!(g.num_points(), src.num_points(), "the remesh changed the points");
@@ -10589,6 +10601,7 @@ mod tests {
         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]
@@ -11016,6 +11029,213 @@ mod tests {
             && a.points().names().iter().all(|n| (0..a.num_points()).all(|p| a.points().value(n, p) == b.points().value(n, p)))
     }
 
+    /// The Remesh is a subnet of nodes (since 2026-09-30) — a Repeat of
+    /// Split Edges, Collapse Edges, Flip Edges, a Tangential Relax and a
+    /// Project, then a Transfer — and it makes the mesh the native remesh
+    /// makes, bit for bit: points, identities, the counter new points draw
+    /// from, primitives, attributes and groups. Twice over, the second
+    /// remesh eating the first as a simulation's next step does, so an
+    /// identity a collapse took out is not handed back. With no relaxation
+    /// asked and nothing to do, the input comes back as it came.
+    #[test]
+    fn the_remesh_subnet_is_the_remesh() {
+        let templates = crate::app::load_fs_tree();
+        let t = templates.children.iter().find(|t| t.name == "Remesh").expect("the Remesh template");
+        assert_eq!(t.node_type, "node", "the Remesh is a subnet");
+        let names: Vec<&str> = t.children.iter().map(|c| c.name.as_str()).collect();
+        assert_eq!(names, ["input1", "repeat1", "transfer1", "result1", "output1"]);
+        let repeat = &t.children[1];
+        assert_eq!(repeat.node_type, "repeat");
+        let passes: Vec<(&str, &str)> = repeat.children.iter().map(|c| (c.name.as_str(), c.node_type.as_str())).collect();
+        assert_eq!(passes, [("input1", "input"), ("seed1", "seed"), ("split1", "split_edges"), ("collapse1", "collapse_edges"),
+            ("flip1", "flip_edges"), ("relax1", "relax"), ("project1", "project"), ("output1", "output")]);
+        for c in &t.children {
+            assert_eq!(c.geometry_visible, c.name == "result1", "only result1 draws: {}", c.name);
+        }
+
+        let tagged = [
+            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")]),
+            phase3_node("attribute", &[("Input", "group 1"), ("Operation", "Create"), ("Attribute Name", "mass"), ("Type", "Float"), ("Value", "3")]),
+        ];
+        let native = |name: &str, input: &str, s: &[(&str, &str)]| {
+            let mut n = phase3_node("remesh", &[("Input", input)]);
+            n.id = format!("id-{name}");
+            n.name = name.into();
+            for (k, v) in s.iter().chain([("Split", "true"), ("Collapse", "true"), ("Flip", "true"), ("Project", "true")].iter()) {
+                n.params.push(crate::app::ParamDef::new(*k, "text", *v));
+            }
+            n
+        };
+        let subnet = |name: &str, input: &str, s: &[(&str, &str)]| {
+            let mut n = t.clone();
+            crate::app::regenerate_node_ids(&mut n);
+            n.name = name.into();
+            n.params.iter_mut().find(|p| p.name == "Input").unwrap().set_text(input.to_string());
+            for (k, v) in s {
+                n.params.iter_mut().find(|p| p.name == *k).unwrap_or_else(|| panic!("the subnet has {k}")).set_text(v.to_string());
+            }
+            n
+        };
+        let same = |a: &Detail, b: &Detail, what: &str| {
+            assert_eq!((a.num_points(), a.num_prims()), (b.num_points(), b.num_prims()), "{what}: counts");
+            assert_eq!(a.positions(), b.positions(), "{what}: positions");
+            assert_eq!(a.ids(), b.ids(), "{what}: identities");
+            assert!(a == b, "{what}: attributes, groups or the id counter");
+        };
+        for settings in [
+            vec![("Target Length", "0.5"), ("Iterations", "3"), ("Relax", "0.5"), ("Transfer", "false")],
+            vec![("Target Length", "0.25"), ("Iterations", "4"), ("Relax", "0.04"), ("Transfer", "true")],
+            vec![("Target Length", "0.8"), ("Iterations", "2"), ("Relax", "1.0"), ("Transfer", "true"), ("Transfer Groups", "true")],
+        ] {
+            let mut children = tagged.to_vec();
+            children.push(native("native1", "attribute 1", &settings));
+            children.push(native("native2", "native1", &settings));
+            children.push(subnet("remesh1", "attribute 1", &settings));
+            children.push(subnet("remesh2", "remesh1", &settings));
+            let root = modelling_root("1.0", children);
+            for (n, s) in [("native1", "remesh1"), ("native2", "remesh2")] {
+                let (a, err) = eval_node(&root, n);
+                assert!(err.is_none(), "{err:?}");
+                let (b, err) = eval_node(&root, s);
+                assert!(err.is_none(), "{err:?}");
+                assert!(b.num_points() > 0 && b.points().has("mass"), "{settings:?}");
+                same(&a, &b, &format!("{s} {settings:?}"));
+            }
+        }
+
+        // No relaxation, and a mesh already at its length: the input back.
+        let settings = [("Target Length", "0.5"), ("Iterations", "3"), ("Relax", "0"), ("Transfer", "false")];
+        let root = modelling_root("1.0", vec![
+            subnet("remesh1", "sphere 1", &settings),
+            subnet("remesh2", "remesh1", &settings),
+            native("native1", "sphere 1", &settings),
+        ]);
+        let (first, _) = eval_node(&root, "remesh1");
+        let (native_first, _) = eval_node(&root, "native1");
+        same(&native_first, &first, "relax 0");
+        let (again, _) = eval_node(&root, "remesh2");
+        let settled = crate::remesh::remesh(&first, crate::remesh::Settings { target: 0.5, iterations: 3, relax: 0.0, ..Default::default() });
+        assert!(settled == first, "the fixture has settled: a native remesh of it is itself");
+        assert!(again == first, "a remesh with nothing to do hands its input back");
+    }
+
+    /// A native remesh from before 2026-09-30 loads as the Remesh subnet —
+    /// wherever it stands, a simnet's chain included — with its values and
+    /// its identity, and a pass it had switched off BYPASSED inside, since
+    /// the switches are the nodes now. It makes the mesh it made.
+    #[test]
+    fn a_native_remesh_recomposes_on_load() {
+        let templates_root = crate::app::load_fs_tree();
+        let templates = crate::app::flatten_node_templates(&templates_root);
+        let mut old = phase3_node("remesh", &[("Input", "sphere 1"), ("Target Length", "0.3"), ("Iterations", "2"), ("Relax", "0.04"),
+            ("Split", "false"), ("Collapse", "true"), ("Flip", "true"), ("Project", "true"), ("Transfer", "true"), ("From", "")]);
+        old.id = "old-id".into();
+        old.name = "remesh1".into();
+        old.position = (2.0, 5.0);
+        let mut root = modelling_root("1.0", vec![ref_node("sub", "sub1", "node", vec![], vec![]), old.clone()]);
+        root.children[1].children.push({ let mut inner = old.clone(); inner.id = "inner-id".into(); inner });
+        crate::app::merge_template_defs(&mut root, &templates);
+        for r in [&root.children[2], &root.children[1].children[0]] {
+            assert_eq!(r.node_type, "node", "recomposed as a subnet, nested ones too");
+            assert!(r.is_enterable());
+            let get = |n: &str| r.params.iter().find(|p| p.name == n).map(|p| p.text().to_string());
+            assert_eq!(get("Target Length").as_deref(), Some("0.3"));
+            assert_eq!(get("Relax").as_deref(), Some("0.04"));
+            assert_eq!(get("Transfer").as_deref(), Some("true"));
+            assert_eq!(get("Split"), None, "the pass switches are nodes now");
+            let repeat = r.children.iter().find(|c| c.name == "repeat1").unwrap();
+            let bypassed: Vec<&str> = repeat.children.iter().filter(|c| c.bypassed).map(|c| c.name.as_str()).collect();
+            assert_eq!(bypassed, ["split1"], "Split was off");
+        }
+        let r = &root.children[2];
+        assert_eq!((r.id.as_str(), r.name.as_str(), r.position), ("old-id", "remesh1", (2.0, 5.0)));
+        // The native node, beside it after the load (which gives the bare
+        // sphere its template's Center too), is what it has to match.
+        let mut native = old.clone();
+        native.id = "native-id".into();
+        native.name = "native1".into();
+        root.children.push(native);
+        let (made, err) = eval_node(&root, "native1");
+        assert!(err.is_none(), "{err:?}");
+        let (g, err) = eval_node(&root, "remesh1");
+        assert!(err.is_none(), "{err:?}");
+        assert!(g.num_points() > 0 && g == made, "the recomposed remesh makes the mesh the native one made");
+    }
+
+    /// A Repeat runs its chain Iterations times, each pass on the last one's
+    /// result; a Seed inside reads what the loop began from. Dived in, its
+    /// chain is shown as the LAST pass saw it, as a simnet's is shown as its
+    /// last substep saw it — and a level inside a loop (a subnet in a
+    /// simnet) is walked with the loop's feedback pushed, so it shows this
+    /// frame and not the seed.
+    #[test]
+    fn a_repeat_loops_its_chain_and_shows_its_last_pass() {
+        let templates_root = crate::app::load_fs_tree();
+        let t = templates_root.children.iter().find(|t| t.name == "Repeat").expect("the Repeat template");
+        assert!(t.is_enterable());
+        let mut repeat = t.clone();
+        crate::app::regenerate_node_ids(&mut repeat);
+        repeat.name = "repeat1".into();
+        repeat.geometry_visible = true;
+        repeat.params.iter_mut().find(|p| p.name == "Input").unwrap().set_text("sphere 1");
+        repeat.params.iter_mut().find(|p| p.name == "Iterations").unwrap().set_text("3");
+        let mut step = ref_node("step", "step1", "transform", vec![("Input", "node", "input1"), ("Translation", "float3", "1:0:0")], vec![]);
+        step.geometry_visible = false;
+        repeat.children.push(step);
+        repeat.children.iter_mut().find(|c| c.name == "output1").unwrap().params[0].set_text("step1");
+        let root = modelling_root("1.0", vec![repeat]);
+        let (sphere, _) = eval_node(&root, "sphere 1");
+        let min_x = |g: &Detail| g.positions().iter().map(|p| p[0]).fold(f32::INFINITY, f32::min);
+        let x0 = min_x(&sphere);
+        let (g, err) = eval_node(&root, "repeat1");
+        assert!(err.is_none(), "{err:?}");
+        assert!((min_x(&g) - (x0 + 3.0)).abs() < 1e-4, "three passes of +1: {}", min_x(&g) - x0);
+
+        // Dived in: the output draws the result, the step as the last pass
+        // saw it (+2 in, +3 out), and the seed what the loop began from.
+        let mut root = root;
+        let r = root.children.iter_mut().find(|c| c.name == "repeat1").unwrap();
+        r.children.iter_mut().find(|c| c.name == "output1").unwrap().geometry_visible = false;
+        r.children.iter_mut().find(|c| c.name == "input1").unwrap().geometry_visible = false;
+        r.children.iter_mut().find(|c| c.name == "step1").unwrap().geometry_visible = true;
+        r.children.iter_mut().find(|c| c.name == "seed1").unwrap().geometry_visible = true;
+        let level = root.children.iter().find(|c| c.name == "repeat1").unwrap();
+        let mut cache = crate::geometry::SimCache::default();
+        let mut sim = crate::geometry::EvalSim::new(0, 0, &mut cache);
+        let mut err = None;
+        let shown = crate::geometry::network_sphere_vertices_with_errors(&root, level, &mut err, &mut sim);
+        assert!(err.is_none(), "{err:?}");
+        assert_eq!(shown.num_points(), 2 * sphere.num_points(), "the step and the seed");
+        let mut xs: Vec<f32> = shown.positions().iter().map(|p| p[0]).collect();
+        xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
+        assert!((xs[0] - x0).abs() < 1e-4, "the seed is the sphere");
+        assert!((xs[sphere.num_points()] - (x0 + 3.0)).abs() < 1e-4, "the step as its last pass made it: {}", xs[sphere.num_points()] - x0);
+
+        // A subnet inside a simnet, dived into, shows the frame.
+        let sub = ref_node("sub", "sub1", "node", vec![("Input", "node", "input1")], vec![
+            ref_node("sub-in", "input1", "input", vec![], vec![]),
+            { let mut n = ref_node("sub-step", "step1", "transform", vec![("Input", "node", "input1"), ("Translation", "float3", "1:0:0")], vec![]); n.geometry_visible = true; n },
+            { let mut n = ref_node("sub-out", "output1", "output", vec![("Input", "node", "step1")], vec![]); n.geometry_visible = false; n },
+        ]);
+        let simnet = ref_node("sim", "simnet1", "simnet", vec![("Input", "node", "sphere 1"), ("Substeps", "spinbox", "1")], vec![
+            ref_node("sim-in", "input1", "input", vec![], vec![]),
+            sub,
+            ref_node("sim-out", "output1", "output", vec![("Input", "node", "sub1")], vec![]),
+        ]);
+        let root = modelling_root("1.0", vec![simnet]);
+        let level = &root.children[1].children[1];
+        let mut cache = crate::geometry::SimCache::default();
+        let mut sim = crate::geometry::EvalSim::new(3, 0, &mut cache);
+        let shown = crate::geometry::network_sphere_vertices_with_errors(&root, level, &mut err, &mut sim);
+        assert!(err.is_none(), "{err:?}");
+        // Its input shows what the frame's step read (+2), its step what it
+        // made (+3); read off the seed, they were +0 and +1.
+        let max_x = |g: &Detail| g.positions().iter().map(|p| p[0]).fold(f32::NEG_INFINITY, f32::max);
+        assert_eq!(shown.num_points(), 2 * sphere.num_points());
+        assert!((min_x(&shown) - (x0 + 2.0)).abs() < 1e-4, "the input at frame 3: {}", min_x(&shown) - x0);
+        assert!((max_x(&shown) - (max_x(&sphere) + 3.0)).abs() < 1e-4, "the step at frame 3: {}", max_x(&shown) - max_x(&sphere));
+    }
+
     /// The faster flip pass and the faster closest-point search are HOW a
     /// remesh is run and not what it does: against the passes as they were
     /// first written, the same mesh bit for bit, step after step of a
@@ -12508,11 +12728,12 @@ mod tests {
             for frame in 1..=frames {
                 let mut sim = crate::geometry::EvalSim::new(frame, 1, &mut cache);
                 let mut err = None;
+                crate::remesh::take_edge_changes();
                 let t = std::time::Instant::now();
                 let d = crate::geometry::generate_single_node_geometry_with_errors(root, node, &mut Vec::new(), &mut err, &mut sim);
                 times.push(t.elapsed().as_secs_f64() * 1000.0);
                 points.push(d.map_or(0, |d| d.num_points()));
-                remeshed.push(crate::remesh::last_changes());
+                remeshed.push(crate::remesh::take_edge_changes());
             }
             let mean = times.iter().sum::<f64>() / times.len() as f64;
             let worst = times.iter().cloned().fold(0.0, f64::max);
diff --git a/src/remesh.rs b/src/remesh.rs
index 19bb203..bc903d4 100644
--- a/src/remesh.rs
+++ b/src/remesh.rs
@@ -152,7 +152,11 @@ impl Mesh {
         }
 
         let n = d.num_points();
-        let max_id = d.ids().iter().copied().max().map(|m| m + 1).unwrap_or(0);
+        // The counter the detail carries, not one past the highest identity
+        // left: a point a collapse removed is not handed its identity back
+        // by the next split, which a remesh run as separate passes — the
+        // Remesh subnet — would otherwise do between every two of them.
+        let max_id = d.ids().iter().copied().max().map(|m| m + 1).unwrap_or(0).max(d.next_id());
         let mut p2t: Vec<Vec<usize>> = vec![Vec::new(); n];
         for (t, tri) in tris.iter().enumerate() {
             for &q in tri {
@@ -765,6 +769,114 @@ pub fn subdivide(input: &Detail, depth: usize) -> Detail {
     m.into_detail()
 }
 
+/// One pass of a kind, run on its own — what the Remesh subnet's pass
+/// nodes are. A pass that changes nothing hands its input back as it came,
+/// its primitives and every attribute untouched, as a remesh that changes
+/// nothing does; one that changes something gives what a remesh would
+/// after that pass, the same mesh bit for bit (which
+/// `the_remesh_subnet_is_the_remesh` holds it to).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Pass {
+    Split,
+    Collapse,
+    Flip,
+}
+
+/// Run one [`Pass`] toward `target` edge length.
+pub fn edge_pass(input: &Detail, pass: Pass, target: f32) -> Detail {
+    if input.num_prims() == 0 || target <= 0.0 {
+        return input.clone();
+    }
+    let mut m = Mesh::from_detail(input);
+    let done = match pass {
+        Pass::Split => split_pass(&mut m, target),
+        Pass::Collapse => collapse_pass(&mut m, target),
+        Pass::Flip => flip_pass(&mut m, target),
+    };
+    EDGE_CHANGES.with(|c| c.set(c.get() + done));
+    if done == 0 {
+        return input.clone();
+    }
+    m.into_detail()
+}
+
+/// The remesh's relaxation, `iterations` times: each point toward the
+/// centroid of its neighbours by `amount`, less the part along its normal.
+/// Only positions move, so everything else the input carries — polygons,
+/// primitive and vertex attributes — comes through untouched.
+pub fn relax_tangential(input: &Detail, amount: f32, iterations: usize) -> Detail {
+    let amount = amount.clamp(0.0, 1.0);
+    if input.num_prims() == 0 || amount <= 0.0 || iterations == 0 {
+        return input.clone();
+    }
+    let mut m = Mesh::from_detail(input);
+    for _ in 0..iterations {
+        relax_pass(&mut m, amount);
+    }
+    let mut out = input.clone();
+    for (p, q) in m.pos.iter().enumerate() {
+        out.set_pos(p, *q);
+    }
+    out
+}
+
+thread_local! {
+    /// The last surface projected onto, by a hash of it: a Remesh subnet
+    /// projects onto one surface every iteration, and the native remesh
+    /// built the grid once for all of them.
+    static PROJECT_GRID: std::cell::RefCell<Option<(u64, std::rc::Rc<crate::spatial::TriGrid>)>> = const { std::cell::RefCell::new(None) };
+}
+
+fn surface_hash(d: &Detail) -> u64 {
+    use std::hash::{Hash, Hasher};
+    let mut h = std::collections::hash_map::DefaultHasher::new();
+    d.num_points().hash(&mut h);
+    for p in d.positions() {
+        for c in p {
+            c.to_bits().hash(&mut h);
+        }
+    }
+    d.num_prims().hash(&mut h);
+    for prim in 0..d.num_prims() {
+        d.prim_points(prim).hash(&mut h);
+    }
+    h.finish()
+}
+
+/// Every point of `input` moved to the nearest place on `surface`: the
+/// remesh's projection, which keeps a relaxed mesh from creeping off the
+/// shape it started as. A mesh that IS the surface is handed back as it
+/// came, since projecting a surface onto itself moves nothing — which is
+/// what lets a Remesh subnet that finds nothing to do return its input
+/// untouched, as the native remesh does.
+pub fn project_onto(input: &Detail, surface: &Detail) -> Detail {
+    if surface.num_prims() == 0 || input.num_points() == 0 || input == surface {
+        return input.clone();
+    }
+    let key = surface_hash(surface);
+    let grid = PROJECT_GRID.with(|g| {
+        let mut g = g.borrow_mut();
+        match g.as_ref() {
+            Some((k, grid)) if *k == key => grid.clone(),
+            _ => {
+                let grid = std::rc::Rc::new(crate::spatial::TriGrid::build(surface));
+                *g = Some((key, grid.clone()));
+                grid
+            }
+        }
+    });
+    let mut out = input.clone();
+    if grid.is_empty() {
+        return out;
+    }
+    for p in 0..out.num_points() {
+        if let Some(hit) = grid.closest(out.pos(p)) {
+            out.set_pos(p, hit.point);
+        }
+    }
+    out
+}
+
 /// Remesh toward `settings.target` edge length.
 pub fn remesh(input: &Detail, settings: Settings) -> Detail {
     remesh_by(input, settings, false)
@@ -788,6 +900,19 @@ pub fn last_changes() -> usize {
     LAST_CHANGES.with(|c| c.get())
 }
 
+thread_local! {
+    /// Every edge split, collapsed or flipped on this thread since the last
+    /// [`take_edge_changes`], by a remesh or a pass node: what a profile
+    /// reads per frame, the Remesh subnet running as several passes where
+    /// [`last_changes`] sees one remesh.
+    static EDGE_CHANGES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
+}
+
+/// The edges changed on this thread since the last call, and start again.
+pub fn take_edge_changes() -> usize {
+    EDGE_CHANGES.with(|c| c.replace(0))
+}
+
 fn remesh_by(input: &Detail, settings: Settings, reference: bool) -> Detail {
     if input.num_prims() == 0 || settings.target <= 0.0 {
         return input.clone();
@@ -842,5 +967,6 @@ fn remesh_by(input: &Detail, settings: Settings, reference: bool) -> Detail {
         }
     }
     LAST_CHANGES.with(|c| c.set(changed));
+    EDGE_CHANGES.with(|c| c.set(c.get() + changed));
     m.into_detail()
 }