graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the Embryo node, ported from hou-control's developer_embryo
The seed geometry a simulation starts from, as a native node: Source
(a polygon sphere of Radius with Base Resolution rows and columns, or the
Input), Method (Basic as is; Scatter scatters Scatter Count points by area,
relaxes them apart across the surface and wraps them in a convex hull), the
Relax SOP on the result's points (off by default), Subdivision Depth, and
normals last as N. Defaults are the HDA's, and the template is pinned to
them by test.
The convex hull is new to the app: the incremental algorithm, with a
size-relative tolerance standing in for the HDA's Remove Inline Points.
Two deliberate differences from the HDA are written down in CLAUDE.md —
Subdivide does not smooth here, and the seed input is the node's one Input.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 34 ++++
nodes/embryo.json | 24 +++
src/embryo.rs | 467 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/geometry.rs | 57 +++++++
src/main.rs | 165 +++++++++++++++++++
5 files changed, 747 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 5a64dca..845b5c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -379,6 +379,40 @@ wires it as a node parameter), so the free-form float ramp is ported as the
three-way choice the falloff parameters already use. Linear is the default
because linear is what the cast that worked used.
+### 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.
+
+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.
+
### The volume representation
`src/volume.rs` is a dense signed distance field — `Volume { origin, voxel,
diff --git a/nodes/embryo.json b/nodes/embryo.json
new file mode 100644
index 0000000..833384c
--- /dev/null
+++ b/nodes/embryo.json
@@ -0,0 +1,24 @@
+{
+ "name": "Embryo",
+ "type": "embryo",
+ "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 }
+ ]
+}
diff --git a/src/embryo.rs b/src/embryo.rs
new file mode 100644
index 0000000..4027e36
--- /dev/null
+++ b/src/embryo.rs
@@ -0,0 +1,467 @@
+//! 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 b122a6f..38be3e5 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -735,6 +735,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("boolean") {
resolve_boolean_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("export") {
@@ -1776,6 +1778,51 @@ 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(
+ 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_node_by_name(root, &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,
+ }
+}
+
/// The Export node: geometry out of the app.
///
/// A pass-through in the chain — it hands its input straight on, so it can sit
@@ -4955,6 +5002,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
|| nt == "export"
|| nt == "boolean"
|| nt == "mold_shell"
+ || nt == "embryo"
|| nt == "volume"
|| nt == "deform"
|| nt == "valence"
@@ -5242,6 +5290,15 @@ pub fn network_sphere_vertices_with_errors(
out.merge(&geom);
}
}
+ } else if node.node_type.eq_ignore_ascii_case("embryo") {
+ 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) {
+ out.merge(&geom);
+ }
+ }
} else if node.node_type.eq_ignore_ascii_case("export") {
let _idx = *count;
*count += 1;
diff --git a/src/main.rs b/src/main.rs
index 2bb8f6b..fc587c4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -29,6 +29,7 @@ pub mod command;
pub mod dialog;
pub mod layout;
pub mod mold;
+pub mod embryo;
pub mod page;
pub mod thumbnail;
@@ -5269,6 +5270,170 @@ mod tests {
}
/// The shell end to end, through the resolver the viewport calls.
+ // ----- The Embryo node (src/embryo.rs) -----
+
+ /// 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;
+ let mut pts = Vec::new();
+ for x in [-1.0, 1.0] {
+ for y in [-1.0, 1.0] {
+ for z in [-1.0, 1.0] {
+ pts.push(Vec3::new(x, y, z));
+ }
+ }
+ }
+ for i in 0..50 {
+ let t = i as f32 / 50.0;
+ pts.push(Vec3::new(t * 0.9 - 0.45, (t * 7.0).sin() * 0.5, (t * 3.0).cos() * 0.5));
+ }
+ let hull = convex_hull(&pts).expect("a cube spans a volume");
+ 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));
+ let n = (b - a).cross(c - a).normalize();
+ assert!(a.dot(n) > 0.0, "face {prim} winds outward");
+ for q in &pts {
+ 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());
+ }
+
+ /// Scattered points lie on the surface, in the number asked for, and a
+ /// seed reproduces its draw.
+ #[test]
+ fn embryo_scatter_lands_on_the_surface_and_is_seeded() {
+ use crate::embryo::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);
+ let grid = crate::spatial::TriGrid::build(&sphere);
+ for p in &a {
+ let hit = grid.closest(*p).unwrap();
+ assert!(hit.distance < 1e-4, "point {p:?} is {} off the surface", hit.distance);
+ }
+ 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");
+ }
+ 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();
+ 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());
+ }
+ 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");
+ }
+
+ /// The node reads the template's parameters into the pipeline, and the
+ /// template's defaults are the HDA's.
+ #[test]
+ fn embryo_node_reads_its_template() {
+ use crate::embryo::{EmbryoParams, Method, Source};
+ 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");
+
+ 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 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);
+ }
+
#[test]
fn test_the_mold_shell_node_builds_a_two_sided_shell() {
use crate::geometry::resolve_mold_shell_geometry_with_errors;