graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the Embryo is a template of nodes; hull node, Scatter Surface mode, Relax Repel mode
The native embryo of 2026-09-21 is recomposed as a subnet wired the way the
HDA's network is, its controls reaching the children through parameter
references: sphere1 behind a switch on chi("Source"), scatter1 (Surface
mode) and hull1 behind a switch on chi("Method"), then relax1 (Repel mode),
subdivide1, normal1, output1. Dive in and the pipeline is there to read.
What it needed, all reusable: a `hull` node (src/hull.rs, the convex hull
that lived in embryo.rs), Scatter's Surface mode (points on the surface by
area, seeded, optionally relaxed apart) beside its Volume mode, Relax's
Repel mode (spheres pushed apart, sliding in the tangent plane) beside its
Springs mode (src/scatter.rs), recursive template child resolution so a
nested Sphere brings its kernel node's params, and template children
carrying their display flag — exactly one child, normal1, draws; with all
of them visible the hull drew five times over at 2.4 s per edit.
A native embryo in an older save is recomposed on load with its values,
identity and meta child intact. embryo.rs is gone.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 76 +++++----
nodes/embryo.json | 368 +++++++++++++++++++++++++++++++++++++++--
nodes/hull.json | 9 ++
nodes/relax.json | 25 +--
nodes/scatter.json | 37 ++---
src/app.rs | 56 ++++++-
src/embryo.rs | 467 -----------------------------------------------------
src/geometry.rs | 103 +++++++-----
src/hull.rs | 157 ++++++++++++++++++
src/main.rs | 281 ++++++++++++++++++++------------
src/scatter.rs | 165 +++++++++++++++++++
11 files changed, 1043 insertions(+), 701 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index a23acbc..84713c8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -419,39 +419,59 @@ way — all in `src/geometry.rs`:
clamped; an empty slot passes nothing. Only `Input` draws a wire, the
limit every second operand has (Boolean's With, Copy's target).
-### The Embryo node
-
-`src/embryo.rs` is hou-control's `developer_embryo`, the Developer family's
-first Pre-Simulation operator — "the seed geometry a simulation starts from"
-— ported as a native node (`nodes/embryo.json`, evaluated by
-`resolve_embryo_geometry_with_errors`, the pipeline itself a plain struct so
-tests drive it without a node tree). The HDA is a small network behind two
-switches, and the module is that network in order: **Source** (a polygon
-sphere of Radius with Base Resolution rows and columns, or the Input),
-**Method** (Basic uses it as is; Scatter scatters Scatter Count points over
-it by area, relaxes them apart across the surface, and wraps them in a
-convex hull), then the Relax SOP on the result's points (off by default),
-Subdivision Depth, and normals last as `N`. The defaults are the HDA's, and
-`embryo_node_reads_its_template` pins the template to them.
+### The Embryo node is a template of nodes
+
+`nodes/embryo.json` is hou-control's `developer_embryo`, the Developer
+family's first Pre-Simulation operator — "the seed geometry a simulation
+starts from" — as a SUBNET of ten ordinary nodes wired the way the HDA's
+network is, its controls reaching the children through parameter references
+(above). Dive in and the pipeline is there to read, break and reuse: `input1`
+and a `sphere1` (Radius `chf("Radius")`, Rows and Columns
+`chi("Base Resolution")`) behind `source1`, a `switch` whose Index is
+`chi("Source")`; `scatter1` in Surface mode reading the Scatter folder's
+controls, `hull1` behind it, and `method1`, a switch on `chi("Method")`
+between the source and the hull; then `relax1` in Repel mode, `subdivide1`,
+`normal1`, `output1`. The defaults are the HDA's, and
+`embryo_template_builds_a_sphere_a_hull_or_the_input` drives the template
+end to end.
+
+It was a native node for one day (2026-09-21, `src/embryo.rs`, a pipeline in
+Rust), which is the wrong shape for this app: CLAUDE.md refuses `gem_graph`
+for the same reason, and a node you cannot dive into cannot be learned from.
+Recomposing it needed four reusable pieces, all of which outlive it:
+parameter references and the `switch` node (their own section above), the
+`hull` node (`src/hull.rs` — the incremental convex hull; points that span
+no volume pass through), and two modes on existing nodes (`src/scatter.rs`):
+**Scatter's Surface mode** (points ON the surface by area, seeded, optionally
+pushed apart across it with a radius derived from the area per point — the
+Scatter SOP with Relax Points) beside its original Volume mode, and
+**Relax's Repel mode** (spheres of Radius pushed apart, sliding in the
+tangent plane unless In 3D Space; zero iterations is off) beside its
+original Springs mode. A native `embryo` in an older save is recomposed on
+load (`recompose_native_embryo` in `merge_template_defs`): id, name,
+position, flag, values and meta child carry over, the template's children
+arrive fresh.
Two deliberate differences from the HDA. **Subdivide does not smooth**: it
is this app's `remesh::subdivide` (four triangles per triangle, points
unmoved), where the HDA runs Catmull-Clark — same parameter, one operation
rather than two under one name. **The second input is the first**: the HDA
-read its Source from input 2 (its audit notes that input had been wired to
-the first connector), and this app's nodes name one Input.
-
-The convex hull is the one piece nothing here had, and it is the incremental
-algorithm rather than quickhull: a tetrahedron from the extreme points, then
-each point either lies inside or sees some faces, which are replaced by a fan
-from the horizon to the point. Points within a size-relative tolerance of a
-face count as inside — the HDA's Remove Inline Points — or a hull of a
-thousand coplanar slivers comes back. It is O(points × faces), which for a
-thousand scattered points is nothing; a hull of a million would want the
-conflict lists. The scatter's relaxation derives each point's push radius
-from the surface area per point (spheres of that radius roughly tile the
-surface), scaled by Scale Radii By, and puts every point back on the nearest
-surface point after each push.
+read its Source from input 2, and this app's nodes name one Input.
+
+**Exactly one child of the template draws, `normal1`**, the last real node
+— as the Sphere's kernel node is its one drawing child. A subnet viewed from
+OUTSIDE shows its internals by their own flags (output children draw only
+at the displayed level), so with every chain node visible the hull drew
+five times over, each draw re-evaluating the pipeline: 2.4 s per edit on a
+release build, 0.1 s with one. Template child specs therefore carry
+`geometry_visible` through `load_fs_tree` (absent means on, as before).
+
+Nesting a subnet template inside a template (the Embryo's `sphere1` is the
+Sphere template) is what made `load_fs_tree`'s child resolution recursive:
+a base that is itself a subnet brings raw children of its own, and those
+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 volume representation
diff --git a/nodes/embryo.json b/nodes/embryo.json
index 833384c..dc6722e 100644
--- a/nodes/embryo.json
+++ b/nodes/embryo.json
@@ -1,24 +1,358 @@
{
"name": "Embryo",
- "type": "embryo",
+ "type": "node",
"inputs": 1,
"outputs": 1,
"params": [
- { "name": "Input", "type": "text", "default": "" },
- { "name": "Source", "type": "choice:Internal,Input", "default": "Internal" },
- { "name": "Method", "type": "choice:Basic,Scatter", "default": "Basic" },
- { "name": "Base Resolution", "type": "spinbox", "default": "50", "min": 3, "max": 100, "step": 1, "show_when": "Source == Internal" },
- { "name": "Radius", "type": "slider", "default": "0.5", "min": 0.01, "max": 10.0, "step": 0.01, "show_when": "Source == Internal" },
- { "name": "Scatter Count", "type": "spinbox", "default": "1000", "min": 10, "max": 10000, "step": 10, "show_when": "Method == Scatter" },
- { "name": "Scatter Seed", "type": "slider", "default": "1.1", "min": 0.0, "max": 10.0, "step": 0.1, "show_when": "Method == Scatter" },
- { "name": "Relax Points", "type": "toggle", "default": "true", "show_when": "Method == Scatter" },
- { "name": "Scatter Relax Iterations", "type": "spinbox", "default": "50", "min": 0, "max": 100, "step": 1, "show_when": "Method == Scatter && Relax Points == true" },
- { "name": "Scale Radii By", "type": "slider", "default": "1.248", "min": 0.0, "max": 2.0, "step": 0.001, "show_when": "Method == Scatter && Relax Points == true" },
- { "name": "Use Max Relax Radius", "type": "toggle", "default": "true", "show_when": "Method == Scatter && Relax Points == true" },
- { "name": "Scatter Relax Radius", "type": "slider", "default": "10", "min": 0.0, "max": 100.0, "step": 0.1, "show_when": "Method == Scatter && Relax Points == true && Use Max Relax Radius == true" },
- { "name": "Relax Iterations", "type": "spinbox", "default": "0", "min": 0, "max": 50, "step": 1 },
- { "name": "Relax Radius", "type": "slider", "default": "1", "min": 0.001, "max": 1.0, "step": 0.001 },
- { "name": "Relax in 3D Space", "type": "toggle", "default": "false" },
- { "name": "Subdivision Depth", "type": "spinbox", "default": "0", "min": 0, "max": 3, "step": 1 }
+ {
+ "name": "Input",
+ "type": "text",
+ "default": ""
+ },
+ {
+ "name": "Source",
+ "type": "choice:Internal,Input",
+ "default": "Internal"
+ },
+ {
+ "name": "Method",
+ "type": "choice:Basic,Scatter",
+ "default": "Basic"
+ },
+ {
+ "name": "Base Resolution",
+ "type": "spinbox",
+ "default": "50",
+ "min": 3,
+ "max": 100,
+ "step": 1,
+ "show_when": "Source == Internal"
+ },
+ {
+ "name": "Radius",
+ "type": "slider",
+ "default": "0.5",
+ "min": 0.01,
+ "max": 10.0,
+ "step": 0.01,
+ "show_when": "Source == Internal"
+ },
+ {
+ "name": "Scatter Count",
+ "type": "spinbox",
+ "default": "1000",
+ "min": 10,
+ "max": 10000,
+ "step": 10,
+ "show_when": "Method == Scatter"
+ },
+ {
+ "name": "Scatter Seed",
+ "type": "slider",
+ "default": "1.1",
+ "min": 0.0,
+ "max": 10.0,
+ "step": 0.1,
+ "show_when": "Method == Scatter"
+ },
+ {
+ "name": "Relax Points",
+ "type": "toggle",
+ "default": "true",
+ "show_when": "Method == Scatter"
+ },
+ {
+ "name": "Scatter Relax Iterations",
+ "type": "spinbox",
+ "default": "50",
+ "min": 0,
+ "max": 100,
+ "step": 1,
+ "show_when": "Method == Scatter && Relax Points == true"
+ },
+ {
+ "name": "Scale Radii By",
+ "type": "slider",
+ "default": "1.248",
+ "min": 0.0,
+ "max": 2.0,
+ "step": 0.001,
+ "show_when": "Method == Scatter && Relax Points == true"
+ },
+ {
+ "name": "Use Max Relax Radius",
+ "type": "toggle",
+ "default": "true",
+ "show_when": "Method == Scatter && Relax Points == true"
+ },
+ {
+ "name": "Scatter Relax Radius",
+ "type": "slider",
+ "default": "10",
+ "min": 0.0,
+ "max": 100.0,
+ "step": 0.1,
+ "show_when": "Method == Scatter && Relax Points == true && Use Max Relax Radius == true"
+ },
+ {
+ "name": "Relax Iterations",
+ "type": "spinbox",
+ "default": "0",
+ "min": 0,
+ "max": 50,
+ "step": 1
+ },
+ {
+ "name": "Relax Radius",
+ "type": "slider",
+ "default": "1",
+ "min": 0.001,
+ "max": 1.0,
+ "step": 0.001
+ },
+ {
+ "name": "Relax in 3D Space",
+ "type": "toggle",
+ "default": "false"
+ },
+ {
+ "name": "Subdivision Depth",
+ "type": "spinbox",
+ "default": "0",
+ "min": 0,
+ "max": 3,
+ "step": 1
+ }
+ ],
+ "children": [
+ {
+ "name": "input1",
+ "type": "input",
+ "params": [],
+ "position": [
+ 2.0,
+ 0.0
+ ],
+ "geometry_visible": false
+ },
+ {
+ "name": "sphere1",
+ "type": "sphere",
+ "position": [
+ 0.0,
+ 0.0
+ ],
+ "params": [
+ {
+ "name": "Radius",
+ "default": "chf(\"Radius\")"
+ },
+ {
+ "name": "Rows",
+ "default": "chi(\"Base Resolution\")"
+ },
+ {
+ "name": "Columns",
+ "default": "chi(\"Base Resolution\")"
+ },
+ {
+ "name": "Center Y",
+ "default": "0.0"
+ }
+ ],
+ "geometry_visible": false
+ },
+ {
+ "name": "source1",
+ "type": "switch",
+ "position": [
+ 1.0,
+ 1.0
+ ],
+ "params": [
+ {
+ "name": "Input",
+ "default": "sphere1"
+ },
+ {
+ "name": "Input 2",
+ "default": "input1"
+ },
+ {
+ "name": "Index",
+ "default": "chi(\"Source\")"
+ }
+ ],
+ "geometry_visible": false
+ },
+ {
+ "name": "scatter1",
+ "type": "scatter",
+ "position": [
+ 2.0,
+ 2.0
+ ],
+ "params": [
+ {
+ "name": "Input",
+ "default": "source1"
+ },
+ {
+ "name": "Mode",
+ "default": "Surface"
+ },
+ {
+ "name": "Points",
+ "default": "chi(\"Scatter Count\")"
+ },
+ {
+ "name": "Seed",
+ "default": "chf(\"Scatter Seed\")"
+ },
+ {
+ "name": "Relax Points",
+ "default": "chb(\"Relax Points\")"
+ },
+ {
+ "name": "Relax Iterations",
+ "default": "chi(\"Scatter Relax Iterations\")"
+ },
+ {
+ "name": "Scale Radii By",
+ "default": "chf(\"Scale Radii By\")"
+ },
+ {
+ "name": "Use Max Relax Radius",
+ "default": "chb(\"Use Max Relax Radius\")"
+ },
+ {
+ "name": "Max Relax Radius",
+ "default": "chf(\"Scatter Relax Radius\")"
+ },
+ {
+ "name": "Markers",
+ "default": "false"
+ }
+ ],
+ "geometry_visible": false
+ },
+ {
+ "name": "hull1",
+ "type": "hull",
+ "position": [
+ 2.0,
+ 3.0
+ ],
+ "params": [
+ {
+ "name": "Input",
+ "default": "scatter1"
+ }
+ ],
+ "geometry_visible": false
+ },
+ {
+ "name": "method1",
+ "type": "switch",
+ "position": [
+ 1.0,
+ 4.0
+ ],
+ "params": [
+ {
+ "name": "Input",
+ "default": "source1"
+ },
+ {
+ "name": "Input 2",
+ "default": "hull1"
+ },
+ {
+ "name": "Index",
+ "default": "chi(\"Method\")"
+ }
+ ],
+ "geometry_visible": false
+ },
+ {
+ "name": "relax1",
+ "type": "relax",
+ "position": [
+ 1.0,
+ 5.0
+ ],
+ "params": [
+ {
+ "name": "Input",
+ "default": "method1"
+ },
+ {
+ "name": "Mode",
+ "default": "Repel"
+ },
+ {
+ "name": "Iterations",
+ "default": "chi(\"Relax Iterations\")"
+ },
+ {
+ "name": "Radius",
+ "default": "chf(\"Relax Radius\")"
+ },
+ {
+ "name": "In 3D Space",
+ "default": "chb(\"Relax in 3D Space\")"
+ }
+ ],
+ "geometry_visible": false
+ },
+ {
+ "name": "subdivide1",
+ "type": "subdivide",
+ "position": [
+ 1.0,
+ 6.0
+ ],
+ "params": [
+ {
+ "name": "Input",
+ "default": "relax1"
+ },
+ {
+ "name": "Depth",
+ "default": "chi(\"Subdivision Depth\")"
+ }
+ ],
+ "geometry_visible": false
+ },
+ {
+ "name": "normal1",
+ "type": "normal",
+ "position": [
+ 1.0,
+ 7.0
+ ],
+ "params": [
+ {
+ "name": "Input",
+ "default": "subdivide1"
+ }
+ ],
+ "geometry_visible": true
+ },
+ {
+ "name": "output1",
+ "type": "output",
+ "position": [
+ 1.0,
+ 8.0
+ ],
+ "params": [
+ {
+ "name": "Input",
+ "default": "normal1"
+ }
+ ],
+ "geometry_visible": false
+ }
]
}
diff --git a/nodes/hull.json b/nodes/hull.json
new file mode 100644
index 0000000..59f7743
--- /dev/null
+++ b/nodes/hull.json
@@ -0,0 +1,9 @@
+{
+ "name": "Hull",
+ "type": "hull",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" }
+ ]
+}
diff --git a/nodes/relax.json b/nodes/relax.json
index 173fc91..1c55f01 100644
--- a/nodes/relax.json
+++ b/nodes/relax.json
@@ -5,23 +5,12 @@
"outputs": 1,
"params": [
{ "name": "Input", "default": "", "type": "text" },
- { "name": "Rest", "default": "", "type": "text" },
- { "name": "Pin Group", "default": "", "type": "text" },
- {
- "name": "Stiffness",
- "type": "slider",
- "default": "0.50",
- "min": 0.0,
- "max": 1.0,
- "step": 0.05
- },
- {
- "name": "Iterations",
- "type": "spinbox",
- "default": "8",
- "min": 1.0,
- "max": 50.0,
- "step": 1.0
- }
+ { "name": "Mode", "type": "choice:Springs,Repel", "default": "Springs" },
+ { "name": "Rest", "default": "", "type": "text", "show_when": "Mode == Springs" },
+ { "name": "Pin Group", "default": "", "type": "text", "show_when": "Mode == Springs" },
+ { "name": "Stiffness", "type": "slider", "default": "0.50", "min": 0.0, "max": 1.0, "step": 0.05, "show_when": "Mode == Springs" },
+ { "name": "Iterations", "type": "spinbox", "default": "8", "min": 0.0, "max": 50.0, "step": 1.0 },
+ { "name": "Radius", "type": "slider", "default": "0.05", "min": 0.001, "max": 1.0, "step": 0.001, "show_when": "Mode == Repel" },
+ { "name": "In 3D Space", "type": "toggle", "default": "false", "show_when": "Mode == Repel" }
]
}
diff --git a/nodes/scatter.json b/nodes/scatter.json
index 730486d..8861042 100644
--- a/nodes/scatter.json
+++ b/nodes/scatter.json
@@ -4,31 +4,16 @@
"inputs": 1,
"outputs": 1,
"params": [
- {
- "name": "Input",
- "default": "",
- "type": "text"
- },
- {
- "name": "Points",
- "type": "spinbox",
- "default": "100",
- "min": 10.0,
- "max": 5000.0,
- "step": 10.0
- },
- {
- "name": "Radius",
- "type": "slider",
- "default": "0.02",
- "min": 0.005,
- "max": 0.1,
- "step": 0.005
- },
- {
- "name": "Markers",
- "type": "choice:true,false",
- "default": "true"
- }
+ { "name": "Input", "default": "", "type": "text" },
+ { "name": "Mode", "type": "choice:Volume,Surface", "default": "Volume" },
+ { "name": "Points", "type": "spinbox", "default": "100", "min": 10.0, "max": 10000.0, "step": 10.0 },
+ { "name": "Seed", "type": "slider", "default": "1.1", "min": 0.0, "max": 10.0, "step": 0.1, "show_when": "Mode == Surface" },
+ { "name": "Relax Points", "type": "toggle", "default": "false", "show_when": "Mode == Surface" },
+ { "name": "Relax Iterations", "type": "spinbox", "default": "50", "min": 0, "max": 100, "step": 1, "show_when": "Mode == Surface && Relax Points == true" },
+ { "name": "Scale Radii By", "type": "slider", "default": "1.248", "min": 0.0, "max": 2.0, "step": 0.001, "show_when": "Mode == Surface && Relax Points == true" },
+ { "name": "Use Max Relax Radius", "type": "toggle", "default": "true", "show_when": "Mode == Surface && Relax Points == true" },
+ { "name": "Max Relax Radius", "type": "slider", "default": "10", "min": 0.0, "max": 100.0, "step": 0.1, "show_when": "Mode == Surface && Relax Points == true && Use Max Relax Radius == true" },
+ { "name": "Radius", "type": "slider", "default": "0.02", "min": 0.005, "max": 0.1, "step": 0.005, "show_when": "Markers == true" },
+ { "name": "Markers", "type": "choice:true,false", "default": "true" }
]
}
diff --git a/src/app.rs b/src/app.rs
index 9af28db..8fe9ac5 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -692,6 +692,39 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
}
retype_legacy(root);
+ // A NATIVE embryo (node type "embryo", 2026-09-21 only) is recomposed as
+ // an instance of the Embryo template, which is the same pipeline as a
+ // network: id, name, position, display flag and every parameter value
+ // carry over by name, the meta child is kept, and the template's
+ // children arrive with fresh ids. Wholesale rather than through the
+ // merge below, which never injects children.
+ fn recompose_native_embryo(node: &mut FsNode, templates: &[NodeTemplate]) {
+ for c in &mut node.children {
+ 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();
+ 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;
+ for p in &c.params {
+ if let Some(fp) = fresh.params.iter_mut().find(|fp| fp.name == p.name) {
+ fp.default = p.default.clone();
+ }
+ }
+ if let Some(meta) = c.children.iter().find(|m| m.node_type == "meta") {
+ fresh.children.retain(|m| m.node_type != "meta");
+ fresh.children.push(meta.clone());
+ }
+ *c = fresh;
+ }
+ }
+ recompose_native_embryo(c, templates);
+ }
+ }
+ recompose_native_embryo(root, templates);
+
fn template_for<'a>(node: &FsNode, templates: &'a [NodeTemplate]) -> Option<&'a FsNode> {
if node.node_type.eq_ignore_ascii_case("node") {
let base = node
@@ -786,7 +819,14 @@ pub fn load_fs_tree() -> FsNode {
}
}
- for mut node in raw_nodes.clone() {
+ // A child names its base template by type or by name, and takes
+ // the base's params with its own overrides on top. RECURSIVELY: a
+ // base that is itself a subnet (the Embryo's sphere1 is a Sphere)
+ // brings raw children of its own, and those resolve the same way,
+ // or the nested sphere's kernel node would arrive with only the
+ // params its template override named. Depth-bounded, because a
+ // template that contained itself would otherwise never finish.
+ fn resolve_children(node: &mut FsNode, raw_nodes: &[FsNode], depth: usize, owner: &str) {
let mut resolved_children = Vec::new();
for child in &node.children {
let base_template = raw_nodes.iter().find(|t| {
@@ -800,21 +840,33 @@ pub fn load_fs_tree() -> FsNode {
}
resolved_child.name = child.name.clone();
resolved_child.position = child.position;
+ // The display flag too: a composed subnet's internals
+ // are switched off in the template (only its output
+ // draws), or viewing the subnet from outside draws the
+ // chain's intermediate stages on top of its result.
+ resolved_child.geometry_visible = child.geometry_visible;
// Merge parameters
for override_p in &child.params {
if let Some(base_p) = resolved_child.params.iter_mut().find(|p| p.name == override_p.name) {
base_p.default = override_p.default.clone();
}
}
+ if depth < 8 {
+ resolve_children(&mut resolved_child, raw_nodes, depth + 1, owner);
+ }
resolved_children.push(resolved_child);
} else {
panic!(
"Node template of type '{}' not found for child '{}' in template '{}'",
- child.node_type, child.name, node.name
+ child.node_type, child.name, owner
);
}
}
node.children = resolved_children;
+ }
+ for mut node in raw_nodes.clone() {
+ let owner = node.name.clone();
+ resolve_children(&mut node, &raw_nodes, 0, &owner);
children.push(node);
}
}
diff --git a/src/embryo.rs b/src/embryo.rs
deleted file mode 100644
index 4027e36..0000000
--- a/src/embryo.rs
+++ /dev/null
@@ -1,467 +0,0 @@
-//! The Embryo node: the seed geometry a simulation starts from.
-//!
-//! Ported from hou-control's `developer_embryo`, the first of the Developer
-//! family's Pre-Simulation operators — "the embryo or seed is created". The
-//! HDA is a small network behind two switches, and this is that network in
-//! order:
-//!
-//! 1. **Source** — a polygon sphere of Radius with Base Resolution rows and
-//! columns (`Internal`), or whatever is wired to the node (`Input`).
-//! 2. **Method** — `Basic` uses that geometry as is; `Scatter` scatters
-//! Scatter Count points over it, relaxes them apart across the surface,
-//! and wraps them in a convex hull (the HDA's `shrinkwrap`).
-//! 3. **Relax** — the Relax SOP on the result's points: spheres of Relax
-//! Radius are pushed apart until they stop overlapping, sliding in each
-//! point's tangent plane unless Relax in 3D Space lets them leave it.
-//! Off by default (zero iterations), as in the HDA.
-//! 4. **Subdivide** — Subdivision Depth rounds of this app's `subdivide`,
-//! which splits every triangle into four and does NOT smooth; the HDA
-//! runs OpenSubdiv Catmull-Clark here, which does. Same name, same
-//! parameter, one deliberate difference — see `remesh::subdivide` for
-//! why this app keeps the two operations apart.
-//! 5. **Normals last**, as the attribute `N`, the way the Normal node writes
-//! them.
-//!
-//! The convex hull is the one piece nothing in the app had. It is the
-//! incremental algorithm: a starting tetrahedron from the extreme points,
-//! then each remaining point in turn either lies inside the hull so far or
-//! sees some of its faces — those are removed, and the ring of edges where
-//! visible meets hidden (the horizon) is fanned to the new point. Points
-//! within a tolerance of a face are treated as inside, which is what the
-//! HDA's Remove Inline Points does: a hull with a thousand coplanar slivers
-//! is not a better hull.
-
-use crate::detail::{AttribData, AttribValue, Detail};
-use crate::spatial::{PointGrid, TriGrid};
-use glam::Vec3;
-
-/// Where the seed geometry comes from.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum Source {
- Internal,
- Input,
-}
-
-impl Source {
- pub fn parse(s: &str) -> Source {
- match s.trim().to_ascii_lowercase().as_str() {
- "input" | "second input" | "second_input" => Source::Input,
- _ => Source::Internal,
- }
- }
-}
-
-/// What is done with the seed before the relax and subdivide steps.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum Method {
- Basic,
- Scatter,
-}
-
-impl Method {
- pub fn parse(s: &str) -> Method {
- match s.trim().to_ascii_lowercase().as_str() {
- "scatter" => Method::Scatter,
- _ => Method::Basic,
- }
- }
-}
-
-/// The node's parameters, the HDA's in the HDA's order.
-#[derive(Debug, Clone, PartialEq)]
-pub struct EmbryoParams {
- pub source: Source,
- pub method: Method,
- /// Rows AND columns of the internal sphere.
- pub base_resolution: usize,
- pub radius: f32,
- pub scatter_count: usize,
- pub scatter_seed: f32,
- /// The scatter's own relaxation — the Scatter SOP's Relax Points.
- pub relax_points: bool,
- pub scatter_relax_iterations: usize,
- /// Scales the radius each scattered point pushes with, which is derived
- /// from the area per point.
- pub scale_radii_by: f32,
- pub use_max_radius: bool,
- pub max_radius: f32,
- /// The Relax SOP that follows the method switch; zero is off.
- pub relax_iterations: usize,
- pub relax_radius: f32,
- pub relax_in_3d: bool,
- pub subdivision_depth: usize,
-}
-
-impl Default for EmbryoParams {
- /// The HDA's defaults, which are also the template's.
- fn default() -> Self {
- EmbryoParams {
- source: Source::Internal,
- method: Method::Basic,
- base_resolution: 50,
- radius: 0.5,
- scatter_count: 1000,
- scatter_seed: 1.1,
- relax_points: true,
- scatter_relax_iterations: 50,
- scale_radii_by: 1.248,
- use_max_radius: true,
- max_radius: 10.0,
- relax_iterations: 0,
- relax_radius: 1.0,
- relax_in_3d: false,
- subdivision_depth: 0,
- }
- }
-}
-
-/// A xorshift32, seeded from the float the parameter carries.
-///
-/// The Scatter Seed is a float because Houdini's is; two seeds that differ
-/// in any digit give different bit patterns, and that is all a seed needs.
-struct Rng(u32);
-
-impl Rng {
- fn from_seed(seed: f32) -> Rng {
- let bits = seed.to_bits() ^ 0x9e37_79b9;
- Rng(if bits == 0 { 1 } else { bits })
- }
-
- fn next_u32(&mut self) -> u32 {
- let mut x = self.0;
- x ^= x << 13;
- x ^= x >> 17;
- x ^= x << 5;
- self.0 = x;
- x
- }
-
- /// Uniform in [0, 1).
- fn next_f32(&mut self) -> f32 {
- (self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
- }
-}
-
-/// The seed geometry: the whole pipeline above.
-///
-/// `input` is consulted only for [`Source::Input`]; with that source and no
-/// input there is nothing to seed from, and the node produces nothing rather
-/// than quietly substituting the sphere.
-pub fn embryo(input: Option<&Detail>, p: &EmbryoParams) -> Option<Detail> {
- let seed = match p.source {
- Source::Internal => {
- let res = p.base_resolution.clamp(3, 128);
- crate::geometry::sphere_detail(Vec3::ZERO, p.radius.max(1e-4), res, res)
- }
- Source::Input => input?.clone(),
- };
-
- let mut geom = match p.method {
- Method::Basic => seed,
- Method::Scatter => {
- let mut pts = scatter_on_surface(&seed, p.scatter_count, p.scatter_seed);
- if p.relax_points && p.scatter_relax_iterations > 0 && !pts.is_empty() {
- // The Scatter SOP derives each point's radius from the area it
- // has to itself: spheres of that radius roughly tile the
- // surface, so pushing them apart until they stop overlapping
- // spreads the points evenly. Scale Radii By tunes how hard
- // they push; the HDA's 1.248 is what its author settled on.
- let per_point = surface_area(&seed) / pts.len().max(1) as f32;
- let mut radius = p.scale_radii_by * (per_point / std::f32::consts::PI).sqrt();
- if p.use_max_radius {
- radius = radius.min(p.max_radius);
- }
- let grid = TriGrid::build(&seed);
- relax_on_surface(&mut pts, &grid, radius, p.scatter_relax_iterations);
- }
- // A scatter with nothing to hull — an empty input — is empty,
- // and a hull that cannot be built (every point coplanar) keeps
- // the points as points so what went in is at least visible.
- match convex_hull(&pts) {
- Some(hull) => hull,
- None => {
- let mut d = Detail::new();
- for q in &pts {
- d.add_point(*q);
- }
- d
- }
- }
- }
- };
-
- if p.relax_iterations > 0 && p.relax_radius > 0.0 && geom.num_points() > 1 {
- let normals = if p.relax_in_3d { None } else { Some(crate::geometry::point_normals(&geom)) };
- let mut pts: Vec<Vec3> = (0..geom.num_points()).map(|i| geom.pos(i)).collect();
- relax_points(&mut pts, normals.as_deref(), p.relax_radius, p.relax_iterations);
- for (i, q) in pts.into_iter().enumerate() {
- geom.set_pos(i, q);
- }
- }
-
- if p.subdivision_depth > 0 {
- geom = crate::remesh::subdivide(&geom, p.subdivision_depth);
- }
-
- if geom.num_prims() > 0 {
- let n: Vec<[f32; 3]> = crate::geometry::point_normals(&geom).iter().map(|v| v.to_array()).collect();
- geom.points_mut().create("N", AttribValue::Float3([0.0; 3]));
- let _ = geom.points_mut().insert("N", AttribData::Float3(n));
- }
- Some(geom)
-}
-
-/// The surface's triangles, fanned from its primitives.
-fn triangles(d: &Detail) -> Vec<[Vec3; 3]> {
- d.triangulate(|pos, _| Vec3::from(pos))
- .chunks_exact(3)
- .map(|t| [t[0], t[1], t[2]])
- .collect()
-}
-
-fn tri_area(t: &[Vec3; 3]) -> f32 {
- 0.5 * (t[1] - t[0]).cross(t[2] - t[0]).length()
-}
-
-pub fn surface_area(d: &Detail) -> f32 {
- triangles(d).iter().map(tri_area).sum()
-}
-
-/// `count` points scattered uniformly by area over the surface.
-///
-/// Uniform by AREA, not by primitive: a triangle is chosen with probability
-/// proportional to its area, then a point inside it by the square-root
-/// barycentric draw, so a big face gets its share and a sliver gets almost
-/// none. Deterministic for a seed, so a scrub or a reload gives the same
-/// embryo.
-pub fn scatter_on_surface(surface: &Detail, count: usize, seed: f32) -> Vec<Vec3> {
- let tris = triangles(surface);
- if tris.is_empty() || count == 0 {
- return Vec::new();
- }
- let mut cumulative = Vec::with_capacity(tris.len());
- let mut total = 0.0;
- for t in &tris {
- total += tri_area(t);
- cumulative.push(total);
- }
- if total <= 0.0 {
- return Vec::new();
- }
- let mut rng = Rng::from_seed(seed);
- let mut out = Vec::with_capacity(count);
- for _ in 0..count {
- let r = rng.next_f32() * total;
- let i = cumulative.partition_point(|&c| c < r).min(tris.len() - 1);
- let [a, b, c] = tris[i];
- let u = rng.next_f32().sqrt();
- let v = rng.next_f32();
- out.push(a * (1.0 - u) + b * (u * (1.0 - v)) + c * (u * v));
- }
- out
-}
-
-/// One pass of pushing overlapping spheres apart. Returns the displacements
-/// rather than applying them, so a caller can constrain them first.
-fn repulsion(pts: &[Vec3], radius: f32) -> Vec<Vec3> {
- let mut moves = vec![Vec3::ZERO; pts.len()];
- if radius <= 0.0 || pts.len() < 2 {
- return moves;
- }
- let reach = 2.0 * radius;
- let grid = PointGrid::build(pts, reach.max(1e-6));
- let mut near = Vec::new();
- for (i, &p) in pts.iter().enumerate() {
- grid.within(p, reach, &mut near);
- for &j in &near {
- let j = j as usize;
- if j == i {
- continue;
- }
- let d = p - pts[j];
- let len = d.length();
- if len >= reach {
- continue;
- }
- // Each of the pair moves half the overlap; a coincident pair has
- // no direction to move in, so it is nudged along an axis and the
- // next pass separates it properly.
- let dir = if len > 1e-9 { d / len } else { Vec3::X };
- moves[i] += dir * ((reach - len) * 0.5);
- }
- }
- moves
-}
-
-/// Push points apart across a surface: repel, then put every point back on
-/// the nearest surface point, `iterations` times.
-pub fn relax_on_surface(pts: &mut [Vec3], surface: &TriGrid, radius: f32, iterations: usize) {
- if surface.is_empty() {
- return;
- }
- for _ in 0..iterations {
- let moves = repulsion(pts, radius);
- for (p, m) in pts.iter_mut().zip(moves) {
- let moved = *p + m;
- *p = surface.closest(moved).map_or(moved, |h| h.point);
- }
- }
-}
-
-/// The Relax SOP: push spheres of `radius` apart. With `normals`, each
-/// point's move is flattened into its tangent plane, so a relaxed mesh keeps
-/// its shape and only its points slide; without, points move freely.
-pub fn relax_points(pts: &mut [Vec3], normals: Option<&[Vec3]>, radius: f32, iterations: usize) {
- for _ in 0..iterations {
- let moves = repulsion(pts, radius);
- for (i, (p, mut m)) in pts.iter_mut().zip(moves).enumerate() {
- if let Some(n) = normals.and_then(|ns| ns.get(i)) {
- if n.length_squared() > 0.0 {
- m -= *n * m.dot(*n);
- }
- }
- *p += m;
- }
- }
-}
-
-/// The convex hull of `points`, as a closed triangle mesh over only the
-/// points that lie on it — `None` when the points do not span a volume.
-pub fn convex_hull(points: &[Vec3]) -> Option<Detail> {
- let faces = hull_faces(points)?;
- let mut remap = vec![u32::MAX; points.len()];
- let mut d = Detail::new();
- for f in &faces {
- let mut ids = [0u32; 3];
- for (k, &pi) in f.iter().enumerate() {
- if remap[pi] == u32::MAX {
- remap[pi] = d.add_point(points[pi]);
- }
- ids[k] = remap[pi];
- }
- d.add_prim(&ids);
- }
- Some(d)
-}
-
-/// The hull's faces as index triples into `points`, wound outward.
-fn hull_faces(points: &[Vec3]) -> Option<Vec<[usize; 3]>> {
- if points.len() < 4 {
- return None;
- }
- let (lo, hi) = points
- .iter()
- .fold((Vec3::splat(f32::MAX), Vec3::splat(f32::MIN)), |(lo, hi), &p| (lo.min(p), hi.max(p)));
- let diag = (hi - lo).length();
- if !(diag > 0.0) {
- return None;
- }
- // "On the face" and "no volume" are both judged against the cloud's own
- // size, so the hull of a millimetre embryo and of a metre one build the
- // same way.
- let eps = diag * 1e-5;
-
- // The starting tetrahedron: the two points furthest apart along an axis,
- // the point furthest from that line, the point furthest from that plane.
- let mut ext = [0usize; 6];
- for (i, p) in points.iter().enumerate() {
- for a in 0..3 {
- if p[a] < points[ext[a]][a] {
- ext[a] = i;
- }
- if p[a] > points[ext[a + 3]][a] {
- ext[a + 3] = i;
- }
- }
- }
- let (mut i0, mut i1, mut best) = (0, 0, -1.0);
- for &a in &ext {
- for &b in &ext {
- let d = (points[a] - points[b]).length();
- if d > best {
- best = d;
- i0 = a;
- i1 = b;
- }
- }
- }
- if best <= eps {
- return None;
- }
- let dir = (points[i1] - points[i0]).normalize();
- let (mut i2, mut best) = (0, -1.0);
- for (i, &p) in points.iter().enumerate() {
- let off = p - points[i0];
- let d = (off - dir * off.dot(dir)).length();
- if d > best {
- best = d;
- i2 = i;
- }
- }
- if best <= eps {
- return None;
- }
- let n = (points[i1] - points[i0]).cross(points[i2] - points[i0]).normalize();
- let (mut i3, mut best) = (0, -1.0);
- for (i, &p) in points.iter().enumerate() {
- let d = (p - points[i0]).dot(n).abs();
- if d > best {
- best = d;
- i3 = i;
- }
- }
- if best <= eps {
- return None;
- }
-
- // Wind the four faces so every normal points away from the centroid.
- let centroid = (points[i0] + points[i1] + points[i2] + points[i3]) / 4.0;
- let mut faces: Vec<[usize; 3]> = Vec::new();
- for f in [[i0, i1, i2], [i0, i1, i3], [i0, i2, i3], [i1, i2, i3]] {
- let n = face_normal(points, f);
- if (points[f[0]] - centroid).dot(n) < 0.0 {
- faces.push([f[0], f[2], f[1]]);
- } else {
- faces.push(f);
- }
- }
-
- let mut edges: Vec<(usize, usize)> = Vec::new();
- for (pi, &p) in points.iter().enumerate() {
- if pi == i0 || pi == i1 || pi == i2 || pi == i3 {
- continue;
- }
- // The faces this point looks at from outside.
- let visible: Vec<bool> = faces
- .iter()
- .map(|f| (p - points[f[0]]).dot(face_normal(points, *f)) > eps)
- .collect();
- if !visible.iter().any(|&v| v) {
- continue;
- }
- // The horizon: directed edges of visible faces whose reverse is not
- // an edge of a visible face. The winding of the visible face gives
- // the new face's winding for free.
- edges.clear();
- for (f, &v) in faces.iter().zip(&visible) {
- if v {
- edges.push((f[0], f[1]));
- edges.push((f[1], f[2]));
- edges.push((f[2], f[0]));
- }
- }
- let horizon: Vec<(usize, usize)> =
- edges.iter().copied().filter(|&(a, b)| !edges.contains(&(b, a))).collect();
- let mut kept: Vec<[usize; 3]> =
- faces.iter().zip(&visible).filter(|(_, &v)| !v).map(|(f, _)| *f).collect();
- for (a, b) in horizon {
- kept.push([a, b, pi]);
- }
- faces = kept;
- }
- Some(faces)
-}
-
-fn face_normal(points: &[Vec3], f: [usize; 3]) -> Vec3 {
- (points[f[1]] - points[f[0]]).cross(points[f[2]] - points[f[0]]).normalize_or_zero()
-}
diff --git a/src/geometry.rs b/src/geometry.rs
index 1005dd3..67fd4a5 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -918,8 +918,8 @@ pub fn generate_single_node_geometry_with_errors(
resolve_volume_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("mold_shell") {
resolve_mold_shell_geometry_with_errors(root, target, visited, ocl_error, sim)
- } else if target.node_type.eq_ignore_ascii_case("embryo") {
- resolve_embryo_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("hull") {
+ resolve_hull_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("switch") {
resolve_switch_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("boolean") {
@@ -1470,6 +1470,25 @@ pub fn resolve_relax_geometry_with_errors(
let input_node = find_input_node(root, target, &input_name)?;
let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+ // 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.
+ if node_param_str(target, "Mode", "Springs").eq_ignore_ascii_case("repel") {
+ let iterations = node_param_f32(target, "Iterations", 8.0).max(0.0) as usize;
+ let radius = node_param_f32(target, "Radius", 0.05);
+ if iterations == 0 || radius <= 0.0 || geom.num_points() < 2 {
+ return Some(geom);
+ }
+ let in_3d = node_param_str(target, "In 3D Space", "false") == "true";
+ let normals = if in_3d { None } else { Some(point_normals(&geom)) };
+ let mut pts: Vec<Vec3> = (0..geom.num_points()).map(|p| geom.pos(p)).collect();
+ crate::scatter::relax_points(&mut pts, normals.as_deref(), radius, iterations);
+ for (i, q) in pts.into_iter().enumerate() {
+ geom.set_pos(i, q);
+ }
+ return Some(geom);
+ }
+
let rest_name = node_param_str(target, "Rest", "");
let rest_name = rest_name.trim();
if rest_name.is_empty() {
@@ -1967,49 +1986,21 @@ pub fn resolve_mold_shell_geometry_with_errors(
Some(shell.unwrap_or(input))
}
-/// The Embryo node: the seed geometry a simulation starts from — see
-/// `crate::embryo`. The parameters are read here by the template's names;
-/// the pipeline itself takes a plain struct so a test can drive it without
-/// a node tree.
-pub fn resolve_embryo_geometry_with_errors(
+/// The Hull node: the convex hull of the input's points, as a closed
+/// triangle mesh (`crate::hull`). Points that do not span a volume — fewer
+/// than four, or all coplanar — pass through as they are, so a scatter too
+/// thin to hull is at least still visible.
+pub fn resolve_hull_geometry_with_errors(
root: &FsNode,
target: &FsNode,
visited: &mut Vec<String>,
ocl_error: &mut Option<String>,
sim: &mut EvalSim,
) -> Option<Detail> {
- let params = embryo_params(target);
- let input = if params.source == crate::embryo::Source::Input {
- let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
- Some(generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?)
- } else {
- None
- };
- crate::embryo::embryo(input.as_ref(), ¶ms)
-}
-
-/// An Embryo node's parameters, as the pipeline takes them.
-pub fn embryo_params(target: &FsNode) -> crate::embryo::EmbryoParams {
- use crate::embryo::{EmbryoParams, Method, Source};
- let d = EmbryoParams::default();
- let flag = |name: &str, default: bool| node_param_str(target, name, if default { "true" } else { "false" }) == "true";
- EmbryoParams {
- source: Source::parse(&node_param_str(target, "Source", "Internal")),
- method: Method::parse(&node_param_str(target, "Method", "Basic")),
- base_resolution: node_param_f32(target, "Base Resolution", d.base_resolution as f32).max(3.0) as usize,
- radius: node_param_f32(target, "Radius", d.radius),
- scatter_count: node_param_f32(target, "Scatter Count", d.scatter_count as f32).max(0.0) as usize,
- scatter_seed: node_param_f32(target, "Scatter Seed", d.scatter_seed),
- relax_points: flag("Relax Points", d.relax_points),
- scatter_relax_iterations: node_param_f32(target, "Scatter Relax Iterations", d.scatter_relax_iterations as f32).max(0.0) as usize,
- scale_radii_by: node_param_f32(target, "Scale Radii By", d.scale_radii_by),
- use_max_radius: flag("Use Max Relax Radius", d.use_max_radius),
- max_radius: node_param_f32(target, "Scatter Relax Radius", d.max_radius),
- relax_iterations: node_param_f32(target, "Relax Iterations", d.relax_iterations as f32).max(0.0) as usize,
- relax_radius: node_param_f32(target, "Relax Radius", d.relax_radius),
- relax_in_3d: flag("Relax in 3D Space", d.relax_in_3d),
- subdivision_depth: node_param_f32(target, "Subdivision Depth", d.subdivision_depth as f32).clamp(0.0, 6.0) as usize,
- }
+ let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+ let input = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+ let pts: Vec<Vec3> = (0..input.num_points()).map(|i| input.pos(i)).collect();
+ Some(crate::hull::convex_hull(&pts).unwrap_or(input))
}
/// The Switch node: one of up to four inputs, chosen by Index.
@@ -4226,6 +4217,34 @@ pub fn resolve_scatter_geometry_with_errors(
// working with.
let markers = node_param_str(target, "Markers", "true") != "false";
+ // Surface mode: points ON the surface, by area, optionally pushed apart
+ // across it — the Scatter SOP with Relax Points, which is what a seed
+ // for a hull wants. Volume mode below is what this node did first:
+ // points INSIDE the shape, by parity.
+ if node_param_str(target, "Mode", "Volume").eq_ignore_ascii_case("surface") {
+ let seed = node_param_f32(target, "Seed", 1.1);
+ let mut pts = crate::scatter::scatter_on_surface(&geom, num_points, seed);
+ let relax = node_param_str(target, "Relax Points", "false") == "true";
+ let iterations = node_param_f32(target, "Relax Iterations", 50.0).max(0.0) as usize;
+ if relax && iterations > 0 && !pts.is_empty() {
+ let scale = node_param_f32(target, "Scale Radii By", 1.248);
+ let max = (node_param_str(target, "Use Max Relax Radius", "true") == "true")
+ .then(|| node_param_f32(target, "Max Relax Radius", 10.0));
+ let r = crate::scatter::relax_radius(&geom, pts.len(), scale, max);
+ let grid = crate::spatial::TriGrid::build(&geom);
+ crate::scatter::relax_on_surface(&mut pts, &grid, r, iterations);
+ }
+ let mut out = Detail::new();
+ for p in pts {
+ if markers {
+ out.merge(&sphere_detail(p, radius, 6, 8));
+ } else {
+ out.add_point(p);
+ }
+ }
+ return Some(out);
+ }
+
let ray_dir = Vec3::new(0.19, 0.98, 0.05).normalize();
let mut triangles = Vec::new();
let mut min_pos = Vec3::splat(f32::MAX);
@@ -5226,7 +5245,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
|| nt == "export"
|| nt == "boolean"
|| nt == "mold_shell"
- || nt == "embryo"
+ || nt == "hull"
|| nt == "switch"
|| nt == "volume"
|| nt == "deform"
@@ -5548,12 +5567,12 @@ pub fn network_sphere_vertices_with_errors(
out.merge(&geom);
}
}
- } else if node.node_type.eq_ignore_ascii_case("embryo") {
+ } else if node.node_type.eq_ignore_ascii_case("hull") {
let _idx = *count;
*count += 1;
if is_visible {
let mut visited = Vec::new();
- if let Some(geom) = resolve_embryo_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ if let Some(geom) = resolve_hull_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
out.merge(&geom);
}
}
diff --git a/src/hull.rs b/src/hull.rs
new file mode 100644
index 0000000..09f0d99
--- /dev/null
+++ b/src/hull.rs
@@ -0,0 +1,157 @@
+//! The convex hull of a point cloud, as a closed triangle mesh — the `hull`
+//! node, and what hou-control's `developer_embryo` does with its scattered
+//! points (its `shrinkwrap`).
+//!
+//! The incremental algorithm rather than quickhull: a starting tetrahedron
+//! from the extreme points, then each remaining point in turn either lies
+//! inside the hull so far or sees some of its faces — those are removed, and
+//! the ring of edges where visible meets hidden (the horizon) is fanned to
+//! the new point. Points within a tolerance of a face are treated as inside,
+//! which is what the HDA's Remove Inline Points does: a hull with a thousand
+//! coplanar slivers is not a better hull. It is O(points × faces), which for
+//! a thousand scattered points is nothing; a hull of a million would want
+//! the conflict lists.
+
+use crate::detail::Detail;
+use glam::Vec3;
+
+/// The convex hull of `points`, as a closed triangle mesh over only the
+/// points that lie on it — `None` when the points do not span a volume.
+pub fn convex_hull(points: &[Vec3]) -> Option<Detail> {
+ let faces = hull_faces(points)?;
+ let mut remap = vec![u32::MAX; points.len()];
+ let mut d = Detail::new();
+ for f in &faces {
+ let mut ids = [0u32; 3];
+ for (k, &pi) in f.iter().enumerate() {
+ if remap[pi] == u32::MAX {
+ remap[pi] = d.add_point(points[pi]);
+ }
+ ids[k] = remap[pi];
+ }
+ d.add_prim(&ids);
+ }
+ Some(d)
+}
+
+/// The hull's faces as index triples into `points`, wound outward.
+fn hull_faces(points: &[Vec3]) -> Option<Vec<[usize; 3]>> {
+ if points.len() < 4 {
+ return None;
+ }
+ let (lo, hi) = points
+ .iter()
+ .fold((Vec3::splat(f32::MAX), Vec3::splat(f32::MIN)), |(lo, hi), &p| (lo.min(p), hi.max(p)));
+ let diag = (hi - lo).length();
+ if !(diag > 0.0) {
+ return None;
+ }
+ // "On the face" and "no volume" are both judged against the cloud's own
+ // size, so the hull of a millimetre embryo and of a metre one build the
+ // same way.
+ let eps = diag * 1e-5;
+
+ // The starting tetrahedron: the two points furthest apart along an axis,
+ // the point furthest from that line, the point furthest from that plane.
+ let mut ext = [0usize; 6];
+ for (i, p) in points.iter().enumerate() {
+ for a in 0..3 {
+ if p[a] < points[ext[a]][a] {
+ ext[a] = i;
+ }
+ if p[a] > points[ext[a + 3]][a] {
+ ext[a + 3] = i;
+ }
+ }
+ }
+ let (mut i0, mut i1, mut best) = (0, 0, -1.0);
+ for &a in &ext {
+ for &b in &ext {
+ let d = (points[a] - points[b]).length();
+ if d > best {
+ best = d;
+ i0 = a;
+ i1 = b;
+ }
+ }
+ }
+ if best <= eps {
+ return None;
+ }
+ let dir = (points[i1] - points[i0]).normalize();
+ let (mut i2, mut best) = (0, -1.0);
+ for (i, &p) in points.iter().enumerate() {
+ let off = p - points[i0];
+ let d = (off - dir * off.dot(dir)).length();
+ if d > best {
+ best = d;
+ i2 = i;
+ }
+ }
+ if best <= eps {
+ return None;
+ }
+ let n = (points[i1] - points[i0]).cross(points[i2] - points[i0]).normalize();
+ let (mut i3, mut best) = (0, -1.0);
+ for (i, &p) in points.iter().enumerate() {
+ let d = (p - points[i0]).dot(n).abs();
+ if d > best {
+ best = d;
+ i3 = i;
+ }
+ }
+ if best <= eps {
+ return None;
+ }
+
+ // Wind the four faces so every normal points away from the centroid.
+ let centroid = (points[i0] + points[i1] + points[i2] + points[i3]) / 4.0;
+ let mut faces: Vec<[usize; 3]> = Vec::new();
+ for f in [[i0, i1, i2], [i0, i1, i3], [i0, i2, i3], [i1, i2, i3]] {
+ let n = face_normal(points, f);
+ if (points[f[0]] - centroid).dot(n) < 0.0 {
+ faces.push([f[0], f[2], f[1]]);
+ } else {
+ faces.push(f);
+ }
+ }
+
+ let mut edges: Vec<(usize, usize)> = Vec::new();
+ for (pi, &p) in points.iter().enumerate() {
+ if pi == i0 || pi == i1 || pi == i2 || pi == i3 {
+ continue;
+ }
+ // The faces this point looks at from outside.
+ let visible: Vec<bool> = faces
+ .iter()
+ .map(|f| (p - points[f[0]]).dot(face_normal(points, *f)) > eps)
+ .collect();
+ if !visible.iter().any(|&v| v) {
+ continue;
+ }
+ // The horizon: directed edges of visible faces whose reverse is not
+ // an edge of a visible face. The winding of the visible face gives
+ // the new face's winding for free.
+ edges.clear();
+ for (f, &v) in faces.iter().zip(&visible) {
+ if v {
+ edges.push((f[0], f[1]));
+ edges.push((f[1], f[2]));
+ edges.push((f[2], f[0]));
+ }
+ }
+ let horizon: Vec<(usize, usize)> =
+ edges.iter().copied().filter(|&(a, b)| !edges.contains(&(b, a))).collect();
+ let mut kept: Vec<[usize; 3]> =
+ faces.iter().zip(&visible).filter(|(_, &v)| !v).map(|(f, _)| *f).collect();
+ for (a, b) in horizon {
+ kept.push([a, b, pi]);
+ }
+ faces = kept;
+ }
+ Some(faces)
+}
+
+fn face_normal(points: &[Vec3], f: [usize; 3]) -> Vec3 {
+ (points[f[1]] - points[f[0]]).cross(points[f[2]] - points[f[0]]).normalize_or_zero()
+}
diff --git a/src/main.rs b/src/main.rs
index d335af5..480c42b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -29,7 +29,8 @@ pub mod command;
pub mod dialog;
pub mod layout;
pub mod mold;
-pub mod embryo;
+pub mod hull;
+pub mod scatter;
pub mod page;
pub mod thumbnail;
@@ -5639,13 +5640,13 @@ mod tests {
assert!(rows[1].2.starts_with("spinbox"));
}
- // ----- The Embryo node (src/embryo.rs) -----
+ // ----- Hull, surface scatter, repel relax, and the Embryo template -----
/// The hull of a cube's corners plus points inside it is the cube: eight
/// points, twelve triangles, closed, with nothing left outside it.
#[test]
fn convex_hull_of_a_cube_with_interior_points_is_the_cube() {
- use crate::embryo::convex_hull;
+ use crate::hull::convex_hull;
let mut pts = Vec::new();
for x in [-1.0, 1.0] {
for y in [-1.0, 1.0] {
@@ -5662,8 +5663,6 @@ mod tests {
assert_eq!(hull.num_points(), 8, "only the corners are on the hull");
assert_eq!(hull.num_prims(), 12);
assert!(hull.is_closed(), "a hull is watertight and consistently wound");
- // Every face looks away from the centre, and every input point is on
- // or behind every face.
for prim in 0..hull.num_prims() {
let ids = hull.prim_points(prim);
let (a, b, c) = (hull.pos(ids[0] as usize), hull.pos(ids[1] as usize), hull.pos(ids[2] as usize));
@@ -5673,17 +5672,31 @@ mod tests {
assert!((*q - a).dot(n) <= 1e-4, "point {q:?} is outside face {prim}");
}
}
- // No volume, no hull.
let flat: Vec<Vec3> = (0..20).map(|i| Vec3::new(i as f32, (i * i) as f32 * 0.1, 0.0)).collect();
assert!(convex_hull(&flat).is_none(), "coplanar points span no volume");
assert!(convex_hull(&pts[..3]).is_none());
+
+ // The node: a hull of the input's points; too few to hull passes through.
+ let src = ref_node("s", "src", "points", vec![("Shape", "text", "Spiral"), ("Points", "spinbox", "60"), ("Markers", "text", "false")], vec![]);
+ let hull_node = ref_node("h", "hull1", "hull", vec![("Input", "text", "src")], vec![]);
+ let root = ref_node("root", "root", "node", vec![], vec![src, hull_node]);
+ let (g, err) = eval(&root, &root.children[1]);
+ assert!(err.is_none(), "{err:?}");
+ let g = g.unwrap();
+ assert!(g.num_prims() > 0 && g.is_closed(), "the spiral hulls into a closed mesh");
+ let line = ref_node("l", "line", "points", vec![("Shape", "text", "Line"), ("Points", "spinbox", "5"), ("Markers", "text", "false")], vec![]);
+ let hull2 = ref_node("h2", "hull2", "hull", vec![("Input", "text", "line")], vec![]);
+ let root2 = ref_node("root", "root", "node", vec![], vec![line, hull2]);
+ let g = eval(&root2, &root2.children[1]).0.unwrap();
+ assert_eq!((g.num_points(), g.num_prims()), (5, 0), "a line of points passes through unhulled");
}
/// Scattered points lie on the surface, in the number asked for, and a
- /// seed reproduces its draw.
+ /// seed reproduces its draw; the node's Surface mode emits them, relaxed
+ /// apart when asked.
#[test]
- fn embryo_scatter_lands_on_the_surface_and_is_seeded() {
- use crate::embryo::scatter_on_surface;
+ fn scatter_surface_mode_lands_on_the_surface_and_is_seeded() {
+ use crate::scatter::scatter_on_surface;
let sphere = crate::geometry::sphere_detail(Vec3::ZERO, 0.5, 12, 16);
let a = scatter_on_surface(&sphere, 300, 1.1);
assert_eq!(a.len(), 300);
@@ -5695,112 +5708,178 @@ mod tests {
assert_eq!(a, scatter_on_surface(&sphere, 300, 1.1), "same seed, same points");
assert_ne!(a, scatter_on_surface(&sphere, 300, 2.0), "another seed, another draw");
assert!(scatter_on_surface(&Detail::new(), 10, 1.0).is_empty());
- }
-
- /// The pipeline end to end: Basic is the internal sphere; Scatter is a
- /// closed hull of at most Scatter Count points inside the sphere's
- /// radius; Input reads what it is given; and every mesh carries N.
- #[test]
- fn embryo_builds_a_sphere_a_hull_or_the_input() {
- use crate::embryo::{embryo, EmbryoParams, Method, Source};
- let basic = embryo(None, &EmbryoParams::default()).expect("the internal sphere");
- let sphere = crate::geometry::sphere_detail(Vec3::ZERO, 0.5, 50, 50);
- assert_eq!(basic.num_points(), sphere.num_points());
- assert_eq!(basic.num_prims(), sphere.num_prims());
- assert!(basic.points().value("N", 0).is_some(), "normals are written last");
- let scattered = embryo(None, &EmbryoParams { method: Method::Scatter, scatter_count: 400, ..EmbryoParams::default() })
- .expect("a hull");
- assert!(scattered.num_prims() > 0, "the scatter is hulled into a surface");
- assert!(scattered.is_closed(), "the hull is watertight");
- assert!(scattered.num_points() <= 400);
- for p in 0..scattered.num_points() {
- let r = scattered.pos(p).length();
- assert!(r <= 0.5 + 1e-3, "hull point {p} at {r} lies inside the seed sphere");
+ let scatter = |relax: &str| {
+ let src = ref_node("s", "src", "sphere", vec![("Radius", "slider", "0.5")], vec![]);
+ let sc = ref_node("sc", "scatter1", "scatter", vec![
+ ("Input", "text", "src"), ("Mode", "choice:Volume,Surface", "Surface"), ("Points", "spinbox", "80"),
+ ("Seed", "slider", "1.1"), ("Relax Points", "toggle", relax), ("Relax Iterations", "spinbox", "30"),
+ ("Markers", "choice:true,false", "false"),
+ ], vec![]);
+ let root = ref_node("root", "root", "node", vec![], vec![src, sc]);
+ let (g, err) = eval(&root, &root.children[1]);
+ assert!(err.is_none(), "{err:?}");
+ (g.unwrap(), eval(&root, &root.children[0]).0.unwrap())
+ };
+ let (raw, src) = scatter("false");
+ assert_eq!(raw.num_points(), 80, "bare points, one per location");
+ let grid = crate::spatial::TriGrid::build(&src);
+ for p in 0..raw.num_points() {
+ assert!(grid.closest(raw.pos(p)).unwrap().distance < 1e-3);
+ }
+ let (relaxed, _) = scatter("true");
+ assert_eq!(relaxed.num_points(), 80);
+ let nearest = |d: &Detail| -> f32 {
+ let mut worst = f32::MAX;
+ for i in 0..d.num_points() {
+ let mut best = f32::MAX;
+ for j in 0..d.num_points() {
+ if i != j { best = best.min((d.pos(i) - d.pos(j)).length()); }
+ }
+ worst = worst.min(best);
+ }
+ worst
+ };
+ assert!(nearest(&relaxed) > nearest(&raw), "relaxing spreads the closest pair: {} vs {}", nearest(&relaxed), nearest(&raw));
+ for p in 0..relaxed.num_points() {
+ assert!(grid.closest(relaxed.pos(p)).unwrap().distance < 1e-3, "relaxed points stay on the surface");
}
- assert!(scattered.points().value("N", 0).is_some());
+ }
- // Relaxing spreads the scatter: the hull of relaxed points reaches
- // further round the sphere than the hull of the raw draw.
- let raw = embryo(None, &EmbryoParams { method: Method::Scatter, scatter_count: 60, relax_points: false, ..EmbryoParams::default() }).unwrap();
- let relaxed = embryo(None, &EmbryoParams { method: Method::Scatter, scatter_count: 60, ..EmbryoParams::default() }).unwrap();
- let area = |d: &Detail| crate::embryo::surface_area(d);
- assert!(area(&relaxed) > area(&raw) * 0.99, "relaxed hull area {} vs raw {}", area(&relaxed), area(&raw));
-
- // Source Input: the seed is the input, and nothing without one.
- let cube = crate::geometry::sphere_detail(Vec3::new(2.0, 0.0, 0.0), 0.25, 6, 8);
- let from_input = embryo(Some(&cube), &EmbryoParams { source: Source::Input, ..EmbryoParams::default() }).unwrap();
- assert_eq!(from_input.num_points(), cube.num_points());
- assert!((from_input.pos(0) - cube.pos(0)).length() < 1e-6);
- assert!(embryo(None, &EmbryoParams { source: Source::Input, ..EmbryoParams::default() }).is_none());
-
- // Subdivision Depth multiplies the faces by four per level.
- let sub = embryo(None, &EmbryoParams { base_resolution: 8, subdivision_depth: 1, ..EmbryoParams::default() }).unwrap();
- let coarse = embryo(None, &EmbryoParams { base_resolution: 8, ..EmbryoParams::default() }).unwrap();
- assert_eq!(sub.num_prims(), coarse.triangulate_points().len() / 3 * 4);
- }
-
- /// The Relax step slides points apart in their tangent planes: the
- /// sphere's points end up better spaced but still on the sphere.
- #[test]
- fn embryo_relax_keeps_points_in_their_tangent_planes() {
- use crate::embryo::{embryo, EmbryoParams};
- let p = EmbryoParams { base_resolution: 10, relax_iterations: 5, relax_radius: 0.08, ..EmbryoParams::default() };
- let relaxed = embryo(None, &p).unwrap();
- let plain = embryo(None, &EmbryoParams { base_resolution: 10, ..EmbryoParams::default() }).unwrap();
+ /// Relax in Repel mode slides a mesh's points apart in their tangent
+ /// planes, so they stay on the shape; In 3D Space lets them leave it.
+ #[test]
+ fn relax_repel_mode_keeps_points_in_their_tangent_planes() {
+ let relax = |in_3d: &str, iterations: &str| {
+ let src = ref_node("s", "src", "sphere", vec![("Radius", "slider", "0.5")], vec![]);
+ let rx = ref_node("r", "relax1", "relax", vec![
+ ("Input", "text", "src"), ("Mode", "choice:Springs,Repel", "Repel"), ("Iterations", "spinbox", iterations),
+ ("Radius", "slider", "0.08"), ("In 3D Space", "toggle", in_3d),
+ ], vec![]);
+ let root = ref_node("root", "root", "node", vec![], vec![src, rx]);
+ (eval(&root, &root.children[1]).0.unwrap(), eval(&root, &root.children[0]).0.unwrap())
+ };
+ let (relaxed, plain) = relax("false", "5");
+ let centre = (0..plain.num_points()).map(|i| plain.pos(i)).sum::<Vec3>() / plain.num_points() as f32;
let mut moved = 0;
for i in 0..relaxed.num_points() {
- let (a, b) = (relaxed.pos(i), plain.pos(i));
- if (a - b).length() > 1e-5 {
- moved += 1;
- }
- // A tangent-plane slide changes the radius only to second order.
- assert!((a.length() - 0.5).abs() < 0.05, "point {i} left the sphere: r = {}", a.length());
+ if (relaxed.pos(i) - plain.pos(i)).length() > 1e-5 { moved += 1; }
+ assert!(((relaxed.pos(i) - centre).length() - 0.5).abs() < 0.05, "point {i} left the sphere");
}
assert!(moved > 0, "some point moved");
- let free = embryo(None, &EmbryoParams { relax_in_3d: true, ..p.clone() }).unwrap();
- let mut left = 0;
- for i in 0..free.num_points() {
- if (free.pos(i).length() - 0.5).abs() > 0.01 {
- left += 1;
- }
- }
- assert!(left > 0, "in 3D the points are free to leave the surface");
+ let (free, _) = relax("true", "5");
+ assert!((0..free.num_points()).any(|i| ((free.pos(i) - centre).length() - 0.5).abs() > 0.01), "in 3D the points are free to leave");
+ let (off, _) = relax("false", "0");
+ assert!((0..off.num_points()).all(|i| (off.pos(i) - plain.pos(i)).length() < 1e-6), "zero iterations is off");
}
- /// The node reads the template's parameters into the pipeline, and the
- /// template's defaults are the HDA's.
+ /// The Embryo template, end to end: an instance whose children read
+ /// its controls through references. Basic is the internal sphere;
+ /// Scatter is a closed hull of at most Scatter Count points inside the
+ /// sphere's radius; Input reads what it is given; every mesh carries N.
#[test]
- fn embryo_node_reads_its_template() {
- use crate::embryo::{EmbryoParams, Method, Source};
+ fn embryo_template_builds_a_sphere_a_hull_or_the_input() {
let templates_root = crate::app::load_fs_tree();
let t = templates_root.children.iter().find(|t| t.name == "Embryo").expect("the Embryo template");
- assert_eq!(t.node_type, "embryo");
- assert_eq!(crate::geometry::embryo_params(t), EmbryoParams::default(), "template defaults are the HDA's");
+ assert_eq!(t.node_type, "node", "the Embryo is a subnet of nodes");
+ let names: Vec<&str> = t.children.iter().map(|c| c.name.as_str()).collect();
+ assert_eq!(names, ["input1", "sphere1", "source1", "scatter1", "hull1", "method1", "relax1", "subdivide1", "normal1", "output1"]);
+ for c in &t.children {
+ // The last real node is the one that draws — as the Sphere's
+ // kernel node is — since a subnet viewed from outside shows its
+ // internals by their flags and output children only draw at the
+ // displayed level. Every chain node visible drew the hull five
+ // times over and cost seconds per edit.
+ assert_eq!(c.geometry_visible, c.name == "normal1", "only normal1 draws: {} is {}", c.name, c.geometry_visible);
+ }
+ // Nested template resolution: the sphere inside carries its own
+ // resolved children, kernel node params included.
+ let sphere1 = t.children.iter().find(|c| c.name == "sphere1").unwrap();
+ let opencl1 = sphere1.children.iter().find(|c| c.name == "opencl1").expect("the nested sphere's kernel node");
+ assert!(opencl1.params.iter().any(|p| p.name == "Input"), "the nested kernel node has its template's params");
+ assert!(opencl1.params.iter().find(|p| p.name == "Code").unwrap().default.contains("chf(\"Radius\""));
+
+ let instance = |overrides: &[(&str, &str)], extra: Vec<FsNode>| {
+ let mut inst = t.clone();
+ crate::app::regenerate_node_ids(&mut inst);
+ inst.name = "embryo1".into();
+ for (n, v) in overrides {
+ inst.params.iter_mut().find(|p| p.name == *n).unwrap_or_else(|| panic!("param {n}")).default = v.to_string();
+ }
+ let mut children = extra;
+ children.push(inst);
+ ref_node("root", "root", "node", vec![], children)
+ };
+ let run = |root: &FsNode| {
+ let e = root.children.iter().find(|c| c.name == "embryo1").unwrap();
+ let (g, err) = eval(root, e);
+ assert!(err.is_none(), "{err:?}");
+ g.expect("the embryo evaluates")
+ };
+ let extent = |g: &Detail| g.positions().iter().map(|p| Vec3::from(*p).length()).fold(0.0, f32::max);
- let mut inst = t.clone();
- inst.id = "embryo1".into();
- inst.name = "Embryo 1".into();
- for (name, value) in [("Method", "Scatter"), ("Scatter Count", "200"), ("Source", "Internal")] {
- inst.params.iter_mut().find(|p| p.name == name).unwrap().default = value.to_string();
- }
- let read = crate::geometry::embryo_params(&inst);
- assert_eq!(read.method, Method::Scatter);
- assert_eq!(read.scatter_count, 200);
- assert_eq!(read.source, Source::Internal);
+ let basic = run(&instance(&[], vec![]));
+ assert!(basic.num_prims() > 0);
+ assert!((extent(&basic) - 0.5).abs() < 0.02, "Basic is the internal sphere of Radius 0.5: {}", extent(&basic));
+ assert!(basic.points().value("N", 0).is_some(), "normals are written last");
+ let big = run(&instance(&[("Radius", "1.5"), ("Base Resolution", "8")], vec![]));
+ assert!((extent(&big) - 1.5).abs() < 0.05, "Radius reaches the sphere through chf: {}", extent(&big));
+ assert!(big.num_points() < basic.num_points(), "Base Resolution reaches Rows and Columns through chi");
- let root = FsNode {
- id: "root".into(), name: "root".into(), node_type: "node".into(),
- children: vec![inst], params: vec![], geometry_visible: true, position: (0.0, 0.0), inputs: 0, outputs: 0,
- };
- let mut visited = Vec::new();
- let mut err = None;
- let geom = crate::geometry::generate_single_node_geometry_with_errors(
- &root, &root.children[0], &mut visited, &mut err,
- &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
- ).expect("the node evaluates");
- assert!(err.is_none());
- assert!(geom.is_closed() && geom.num_points() <= 200);
+ let scattered = run(&instance(&[("Method", "Scatter"), ("Scatter Count", "400"), ("Base Resolution", "16")], vec![]));
+ assert!(scattered.num_prims() > 0 && scattered.is_closed(), "Scatter hulls the points into a closed mesh");
+ assert!(scattered.num_points() <= 400);
+ assert!(extent(&scattered) <= 0.5 + 1e-3, "the hull lies inside the seed sphere");
+ assert!(scattered.points().value("N", 0).is_some());
+
+ let seed = ref_node("seed", "seed", "sphere", vec![("Radius", "slider", "0.25")], vec![]);
+ let root = instance(&[("Source", "Input"), ("Input", "seed")], vec![seed]);
+ let from_input = run(&root);
+ let seed_geom = eval(&root, &root.children[0]).0.unwrap();
+ assert_eq!(from_input.num_points(), seed_geom.num_points(), "Source Input is the wired node");
+ assert!((from_input.pos(0) - seed_geom.pos(0)).length() < 1e-6);
+ let e = instance(&[("Source", "Input")], vec![]);
+ assert!(eval(&e, &e.children[0]).0.is_none(), "Source Input with nothing wired seeds nothing");
+
+ let coarse = run(&instance(&[("Base Resolution", "8")], vec![]));
+ let sub = run(&instance(&[("Base Resolution", "8"), ("Subdivision Depth", "1")], vec![]));
+ // Against the real subdivide of the same mesh rather than ×4: the
+ // kernel sphere's pole triangles are degenerate and subdivide drops
+ // them.
+ assert_eq!(sub.num_prims(), crate::remesh::subdivide(&coarse, 1).num_prims(), "Subdivision Depth reaches Depth");
+ }
+
+ /// A native embryo from 2026-09-21 loads as an instance of the template,
+ /// with its values, its identity and its meta child intact.
+ #[test]
+ fn a_native_embryo_recomposes_on_load() {
+ use crate::app::ParamDef;
+ let templates_root = crate::app::load_fs_tree();
+ let templates = crate::app::flatten_node_templates(&templates_root);
+ let param = |n: &str, v: &str| ParamDef { name: n.into(), label: String::new(), param_type: "text".into(), default: v.into(), options: vec![], min: None, max: None, step: None, show_when: String::new() };
+ let meta = ref_node("m", "meta", "meta", vec![("Point Markers", "toggle", "true")], vec![]);
+ let mut native = ref_node("old-id", "embryo1", "embryo", vec![], vec![meta]);
+ native.params = vec![param("Input", ""), param("Method", "Scatter"), param("Scatter Count", "150"), param("Radius", "0.7"), param("Base Resolution", "16")];
+ native.position = (3.0, 4.0);
+ native.geometry_visible = true;
+ let mut root = ref_node("root", "root", "node", vec![], vec![native]);
+ crate::app::merge_template_defs(&mut root, &templates);
+ let e = &root.children[0];
+ assert_eq!(e.node_type, "node", "recomposed as a subnet");
+ assert_eq!((e.id.as_str(), e.name.as_str(), e.position, e.geometry_visible), ("old-id", "embryo1", (3.0, 4.0), true));
+ let get = |n: &str| e.params.iter().find(|p| p.name == n).unwrap().default.clone();
+ assert_eq!(get("Method"), "Scatter");
+ assert_eq!(get("Scatter Count"), "150");
+ assert_eq!(get("Radius"), "0.7");
+ assert_eq!(get("Scatter Seed"), "1.1", "a param the native node lacked takes the template default");
+ assert!(e.children.iter().any(|c| c.name == "hull1"));
+ assert!(e.children.iter().any(|c| c.node_type == "meta" && c.params.iter().any(|p| p.name == "Point Markers")), "the meta child is kept");
+ let (g, err) = eval(&root, e);
+ assert!(err.is_none(), "{err:?}");
+ let g = g.unwrap();
+ assert!(g.is_closed() && g.num_points() <= 150, "and it evaluates as the scatter it was");
+ let extent = g.positions().iter().map(|p| Vec3::from(*p).length()).fold(0.0, f32::max);
+ assert!(extent <= 0.7 + 1e-3 && extent > 0.5);
}
#[test]
diff --git a/src/scatter.rs b/src/scatter.rs
new file mode 100644
index 0000000..4b1038d
--- /dev/null
+++ b/src/scatter.rs
@@ -0,0 +1,165 @@
+//! Points on a surface, and pushing points apart: the Scatter node's
+//! Surface mode and the Relax node's Repel mode, which together are what
+//! hou-control's Scatter SOP (with Relax Points on) and Relax SOP do.
+//!
+//! Both came in with the Embryo, which needed them as pipeline steps; they
+//! live here as node modes so the Embryo can be a template of nodes rather
+//! than a pipeline of its own.
+
+use crate::detail::Detail;
+use crate::spatial::{PointGrid, TriGrid};
+use glam::Vec3;
+
+/// A xorshift32, seeded from the float the parameter carries.
+///
+/// The Seed is a float because Houdini's is; two seeds that differ in any
+/// digit give different bit patterns, and that is all a seed needs.
+struct Rng(u32);
+
+impl Rng {
+ fn from_seed(seed: f32) -> Rng {
+ let bits = seed.to_bits() ^ 0x9e37_79b9;
+ Rng(if bits == 0 { 1 } else { bits })
+ }
+
+ fn next_u32(&mut self) -> u32 {
+ let mut x = self.0;
+ x ^= x << 13;
+ x ^= x >> 17;
+ x ^= x << 5;
+ self.0 = x;
+ x
+ }
+
+ /// Uniform in [0, 1).
+ fn next_f32(&mut self) -> f32 {
+ (self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
+ }
+}
+
+/// The surface's triangles, fanned from its primitives.
+fn triangles(d: &Detail) -> Vec<[Vec3; 3]> {
+ d.triangulate(|pos, _| Vec3::from(pos))
+ .chunks_exact(3)
+ .map(|t| [t[0], t[1], t[2]])
+ .collect()
+}
+
+fn tri_area(t: &[Vec3; 3]) -> f32 {
+ 0.5 * (t[1] - t[0]).cross(t[2] - t[0]).length()
+}
+
+pub fn surface_area(d: &Detail) -> f32 {
+ triangles(d).iter().map(tri_area).sum()
+}
+
+/// `count` points scattered uniformly by area over the surface.
+///
+/// Uniform by AREA, not by primitive: a triangle is chosen with probability
+/// proportional to its area, then a point inside it by the square-root
+/// barycentric draw, so a big face gets its share and a sliver gets almost
+/// none. Deterministic for a seed, so a scrub or a reload gives the same
+/// points.
+pub fn scatter_on_surface(surface: &Detail, count: usize, seed: f32) -> Vec<Vec3> {
+ let tris = triangles(surface);
+ if tris.is_empty() || count == 0 {
+ return Vec::new();
+ }
+ let mut cumulative = Vec::with_capacity(tris.len());
+ let mut total = 0.0;
+ for t in &tris {
+ total += tri_area(t);
+ cumulative.push(total);
+ }
+ if total <= 0.0 {
+ return Vec::new();
+ }
+ let mut rng = Rng::from_seed(seed);
+ let mut out = Vec::with_capacity(count);
+ for _ in 0..count {
+ let r = rng.next_f32() * total;
+ let i = cumulative.partition_point(|&c| c < r).min(tris.len() - 1);
+ let [a, b, c] = tris[i];
+ let u = rng.next_f32().sqrt();
+ let v = rng.next_f32();
+ out.push(a * (1.0 - u) + b * (u * (1.0 - v)) + c * (u * v));
+ }
+ out
+}
+
+/// The radius each scattered point pushes with when relaxing: derived from
+/// the area it has to itself, so spheres of that radius roughly tile the
+/// surface, scaled by `scale` (the Scatter SOP's Scale Radii By; 1.248 is
+/// what hou-control's author settled on) and capped at `max` when given.
+pub fn relax_radius(surface: &Detail, count: usize, scale: f32, max: Option<f32>) -> f32 {
+ let per_point = surface_area(surface) / count.max(1) as f32;
+ let r = scale * (per_point / std::f32::consts::PI).sqrt();
+ match max {
+ Some(m) => r.min(m),
+ None => r,
+ }
+}
+
+/// One pass of pushing overlapping spheres apart. Returns the displacements
+/// rather than applying them, so a caller can constrain them first.
+fn repulsion(pts: &[Vec3], radius: f32) -> Vec<Vec3> {
+ let mut moves = vec![Vec3::ZERO; pts.len()];
+ if radius <= 0.0 || pts.len() < 2 {
+ return moves;
+ }
+ let reach = 2.0 * radius;
+ let grid = PointGrid::build(pts, reach.max(1e-6));
+ let mut near = Vec::new();
+ for (i, &p) in pts.iter().enumerate() {
+ grid.within(p, reach, &mut near);
+ for &j in &near {
+ let j = j as usize;
+ if j == i {
+ continue;
+ }
+ let d = p - pts[j];
+ let len = d.length();
+ if len >= reach {
+ continue;
+ }
+ // Each of the pair moves half the overlap; a coincident pair has
+ // no direction to move in, so it is nudged along an axis and the
+ // next pass separates it properly.
+ let dir = if len > 1e-9 { d / len } else { Vec3::X };
+ moves[i] += dir * ((reach - len) * 0.5);
+ }
+ }
+ moves
+}
+
+/// Push points apart across a surface: repel, then put every point back on
+/// the nearest surface point, `iterations` times.
+pub fn relax_on_surface(pts: &mut [Vec3], surface: &TriGrid, radius: f32, iterations: usize) {
+ if surface.is_empty() {
+ return;
+ }
+ for _ in 0..iterations {
+ let moves = repulsion(pts, radius);
+ for (p, m) in pts.iter_mut().zip(moves) {
+ let moved = *p + m;
+ *p = surface.closest(moved).map_or(moved, |h| h.point);
+ }
+ }
+}
+
+/// The Relax SOP: push spheres of `radius` apart. With `normals`, each
+/// point's move is flattened into its tangent plane, so a relaxed mesh keeps
+/// its shape and only its points slide; without, points move freely.
+pub fn relax_points(pts: &mut [Vec3], normals: Option<&[Vec3]>, radius: f32, iterations: usize) {
+ for _ in 0..iterations {
+ let moves = repulsion(pts, radius);
+ for (i, (p, mut m)) in pts.iter_mut().zip(moves).enumerate() {
+ if let Some(n) = normals.and_then(|ns| ns.get(i)) {
+ if n.length_squared() > 0.0 {
+ m -= *n * m.dot(*n);
+ }
+ }
+ *p += m;
+ }
+ }
+}