graphic design tool
git clone https://git.lucas.co/cce-designer.git
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()
}