graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(phase4): normal, bounds, distance, connectivity and cull
The measure-and-filter five, chosen because the Developer chain consumes them
and because between them they close real gaps: nothing in the app could read
the surface normal as data, measure against another piece of geometry, ask
which connected piece a point was in, or DELETE anything at all.
Normal publishes what the app has computed privately all along — the viewport
whiskers draw it, Develop displaces along it, Align projects onto it — as an
ordinary attribute. That makes it readable by a kernel through the Phase 1 ABI,
steerable toward by Align, colourable by Visualize.
Distance writes a direction as well as a length, out of the same lookup,
because they are the same question asked twice: the length drives a falloff and
the direction is what Migrate flows along. Signed uses the nearest face's normal
rather than casting a ray — constant time instead of a pass over every triangle,
at the cost of reading the wrong way inside a concave crease, which the node
says.
Connectivity numbers pieces by SIZE, largest first, so piece 0 is the main body
however the points are ordered. That is what makes "keep the largest piece" an
ordinary Cull rather than a special operator — and the audit shows
`isolate_largest` and `extract_longest` are exactly what the GEM mold chain
used im_cull for.
The IM family has no prose anywhere in hou-control. Unlike developer_surface_
adapt, though, these names are unambiguous: a node called Normal computes
normals. Where the audit recovered parameter names from the HDAs they are
honoured — `piece_attr` on Connectivity, `dir_attr` on Distance.
Fixes the near-zero normalize bug for the THIRD time in this work, now with a
scale-relative guard: a point sitting on the surface it is measuring against
has no direction to it, and the vector between them is float error. Align's
Surface Tangent had the same fault, and the pattern is now explicit in the
comment so the next operator that normalizes a difference does not repeat it.
Also stops the sim-cache test reaching for remove_dir_all. This process
intermittently fails a closedir with EBADF while other threads run — see the
report in the session notes — and a test should not be what trips it.
Co-Authored-By: Claude Opus 5 <[email protected]>
nodes/bounds.json | 11 ++
nodes/connectivity.json | 10 ++
nodes/cull.json | 14 ++
nodes/distance.json | 14 ++
nodes/normal.json | 11 ++
shapeshifter.md | 13 ++
src/geometry.rs | 369 +++++++++++++++++++++++++++++++++++++++++++++++-
src/main.rs | 299 ++++++++++++++++++++++++++++++++++++++-
src/remesh.rs | 4 +-
src/spatial.rs | 50 ++++---
10 files changed, 765 insertions(+), 30 deletions(-)
diff --git a/nodes/bounds.json b/nodes/bounds.json
new file mode 100644
index 0000000..7fe40bc
--- /dev/null
+++ b/nodes/bounds.json
@@ -0,0 +1,11 @@
+{
+ "name": "Bounds",
+ "type": "bounds",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" },
+ { "name": "Prefix", "type": "text", "default": "bounds" },
+ { "name": "Group", "type": "text", "default": "" }
+ ]
+}
diff --git a/nodes/connectivity.json b/nodes/connectivity.json
new file mode 100644
index 0000000..1b3715c
--- /dev/null
+++ b/nodes/connectivity.json
@@ -0,0 +1,10 @@
+{
+ "name": "Connectivity",
+ "type": "connectivity",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" },
+ { "name": "Attribute", "type": "text", "default": "piece" }
+ ]
+}
diff --git a/nodes/cull.json b/nodes/cull.json
new file mode 100644
index 0000000..431b038
--- /dev/null
+++ b/nodes/cull.json
@@ -0,0 +1,14 @@
+{
+ "name": "Cull",
+ "type": "cull",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" },
+ { "name": "Group", "type": "text", "default": "" },
+ { "name": "Attribute", "type": "text", "default": "" },
+ { "name": "Comparison", "type": "choice:Below,Above", "default": "Below" },
+ { "name": "Threshold", "type": "text", "default": "0.50" },
+ { "name": "Invert", "type": "choice:false,true", "default": "false" }
+ ]
+}
diff --git a/nodes/distance.json b/nodes/distance.json
new file mode 100644
index 0000000..8ed5f41
--- /dev/null
+++ b/nodes/distance.json
@@ -0,0 +1,14 @@
+{
+ "name": "Distance",
+ "type": "distance",
+ "inputs": 2,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" },
+ { "name": "To", "type": "text", "default": "" },
+ { "name": "Attribute", "type": "text", "default": "dist" },
+ { "name": "Direction", "type": "text", "default": "" },
+ { "name": "Signed", "type": "choice:false,true", "default": "false" },
+ { "name": "Maximum", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.05 }
+ ]
+}
diff --git a/nodes/normal.json b/nodes/normal.json
new file mode 100644
index 0000000..f430c51
--- /dev/null
+++ b/nodes/normal.json
@@ -0,0 +1,11 @@
+{
+ "name": "Normal",
+ "type": "normal",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "text", "default": "" },
+ { "name": "Attribute", "type": "text", "default": "N" },
+ { "name": "Flip", "type": "choice:false,true", "default": "false" }
+ ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index a95490c..76f6d43 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -269,6 +269,19 @@ Touches: a new `remesh.rs`, `geometry.rs`, `nodes/*.json`.
*Wide, shallow. Needs Phase 0. Runs parallel with Phases 2 and 3.*
+> **Started.** The measure-and-filter five: `normal` publishes the surface
+> normal as an attribute anything can read, `bounds` and `distance` measure
+> (the latter writing a direction too, out of the same lookup, which is what
+> Migrate flows along), `connectivity` numbers pieces largest-first, and `cull`
+> deletes — the first node in the app that removes geometry.
+>
+> The IM family carries no prose anywhere, but unlike `adapt` and `open` these
+> names are unambiguous: a node called Normal computes normals. Where the audit
+> recovered parameter names from the HDAs they are honoured (`piece_attr`,
+> `dir_attr`).
+>
+> Remaining: Copy, Soft Transform, Select, and the Create primitives.
+
The `im_*` family, which is the part that looks biggest and is actually the
easiest — ninety nodes, most of them a screenful once points and prims exist.
Sequence it by what the Developer chain consumes rather than by category:
diff --git a/src/geometry.rs b/src/geometry.rs
index 410417b..a59392a 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -740,6 +740,16 @@ pub fn generate_single_node_geometry_with_errors(
resolve_neighbour_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("time") {
resolve_time_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("normal") {
+ resolve_normal_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("bounds") {
+ resolve_bounds_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("distance") {
+ resolve_distance_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("connectivity") {
+ resolve_connectivity_geometry_with_errors(root, target, visited, ocl_error, sim)
+ } else if target.node_type.eq_ignore_ascii_case("cull") {
+ resolve_cull_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("subdivide") {
resolve_subdivide_geometry_with_errors(root, target, visited, ocl_error, sim)
} else if target.node_type.eq_ignore_ascii_case("detangle") {
@@ -1298,6 +1308,302 @@ pub fn resolve_relax_geometry_with_errors(
Some(geom)
}
+// ---------------------------------------------------------------------------
+// The Immutable Methods set: measure and filter.
+//
+// None of these carry a description in hou-control — the IM family has no
+// prose anywhere — but unlike `developer_surface_adapt`, their names say
+// exactly what they are. A node called Normal computes normals. What is
+// implemented here is the plain reading of each name, with the parameter
+// names the audit recovered from the HDAs where it had them (`piece_attr` on
+// Connectivity, `dir_attr` on Distance).
+// ---------------------------------------------------------------------------
+
+/// The Normal node: the surface normal as an attribute you can read.
+///
+/// The normal has been computed inside the app for a while — the viewport
+/// whiskers draw it, Develop displaces along it, Align's Surface Tangent
+/// projects onto it — but nothing could get at it. As an attribute it becomes
+/// ordinary data: a kernel can read it through the Phase 1 ABI, Align can
+/// steer toward it, Visualize can colour by it, Migrate can flow along it.
+pub fn resolve_normal_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Detail> {
+ let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+ let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+ let name = node_param_str(target, "Attribute", "N").trim().to_string();
+ if name.is_empty() {
+ return Some(geom);
+ }
+ let flip = node_param_str(target, "Flip", "false") == "true";
+ let sign = if flip { -1.0 } else { 1.0 };
+ let normals: Vec<[f32; 3]> = point_normals(&geom).iter().map(|n| (*n * sign).to_array()).collect();
+ geom.points_mut().create(&name, AttribValue::Float3([0.0; 3]));
+ let _ = geom.points_mut().insert(&name, AttribData::Float3(normals));
+ Some(geom)
+}
+
+/// The Bounds node: the geometry's extent, as detail attributes.
+///
+/// Four of them — `_min`, `_max`, `_size`, `_center` — rather than one box
+/// type, for the reason Analysis writes six numbers instead of a dictionary:
+/// everything downstream can already read a detail attribute, and nothing has
+/// to learn a new shape.
+pub fn resolve_bounds_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Detail> {
+ let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+ let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+
+ let prefix = node_param_str(target, "Prefix", "bounds").trim().to_string();
+ if prefix.is_empty() {
+ return Some(geom);
+ }
+ let group = node_param_str(target, "Group", "");
+ let group = group.trim().to_string();
+ let pts: Vec<Vec3> = (0..geom.num_points())
+ .filter(|&p| group.is_empty() || geom.points().in_group(&group, p))
+ .map(|p| geom.pos(p))
+ .collect();
+ let (lo, hi) = if pts.is_empty() {
+ (Vec3::ZERO, Vec3::ZERO)
+ } else {
+ pts.iter().fold((pts[0], pts[0]), |(a, b), &p| (a.min(p), b.max(p)))
+ };
+ // Derivative: a measurement describes the state it was taken from.
+ for (suffix, v) in [
+ ("min", lo),
+ ("max", hi),
+ ("size", hi - lo),
+ ("center", (lo + hi) * 0.5),
+ ] {
+ geom.detail_mut().create_kind(
+ &format!("{}_{}", prefix, suffix),
+ AttribValue::Float3(v.to_array()),
+ crate::detail::AttribKind::Derivative,
+ );
+ }
+ Some(geom)
+}
+
+/// The Distance node: how far each point is from another piece of geometry.
+///
+/// The measurement a chain drives proximity growth from — and, with Direction
+/// written too, the vector Migrate flows along and Align steers by. Both come
+/// out of one surface lookup, which is why they are one node.
+///
+/// Signed uses the nearest face's normal rather than casting a ray: constant
+/// time instead of a pass over every triangle. It reads the wrong way inside a
+/// concave crease, where the nearest face is not the one you are behind, and
+/// that is the trade — a ray cast is exact and turns this node from a lookup
+/// into a full intersection test per point.
+pub fn resolve_distance_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Detail> {
+ let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+ let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+
+ let to_name = node_param_str(target, "To", "");
+ let to_name = to_name.trim().to_string();
+ let Some(other) = find_node_by_name(root, &to_name)
+ .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
+ else {
+ if ocl_error.is_none() && !to_name.is_empty() {
+ *ocl_error = Some(format!("Distance '{}': cannot resolve '{}'", target.name, to_name));
+ }
+ return Some(geom);
+ };
+
+ let name = node_param_str(target, "Attribute", "dist").trim().to_string();
+ let dir_name = node_param_str(target, "Direction", "");
+ let dir_name = dir_name.trim().to_string();
+ let signed = node_param_str(target, "Signed", "false") == "true";
+ // Zero means no clamp: a maximum is for keeping a falloff bounded, and a
+ // node whose default quietly flattened every measurement to zero would be
+ // a trap.
+ let maximum = node_param_f32(target, "Maximum", 0.0).max(0.0);
+
+ let grid = crate::spatial::TriGrid::build(&other);
+ // A point ON the surface has no direction to it, and the vector between
+ // them is pure float error. Normalizing that would hand the chain a
+ // heading made of nothing — the same mistake Align's Surface Tangent made
+ // before it was caught, so the guard is scaled to the geometry rather than
+ // being an exact-zero test.
+ let scale = other
+ .bounds()
+ .map(|(lo, hi)| (hi - lo).length())
+ .unwrap_or(1.0)
+ .max(1.0);
+ let eps = scale * 1e-6;
+
+ let n = geom.num_points();
+ let mut dists = vec![0.0f32; n];
+ let mut dirs = vec![[0.0f32; 3]; n];
+ for p in 0..n {
+ let here = geom.pos(p);
+ let Some(hit) = grid.closest(here) else { continue };
+ let away = here - hit.point;
+ let mut d = hit.distance;
+ if signed && away.dot(hit.normal) < 0.0 {
+ d = -d;
+ }
+ if maximum > 0.0 {
+ d = d.clamp(-maximum, maximum);
+ }
+ dists[p] = d;
+ if hit.distance > eps {
+ dirs[p] = (-away).normalize().to_array();
+ }
+ }
+
+ if !name.is_empty() {
+ geom.points_mut().create(&name, AttribValue::Float(0.0));
+ let _ = geom.points_mut().insert(&name, AttribData::Float(dists));
+ }
+ if !dir_name.is_empty() {
+ geom.points_mut().create(&dir_name, AttribValue::Float3([0.0; 3]));
+ let _ = geom.points_mut().insert(&dir_name, AttribData::Float3(dirs));
+ }
+ Some(geom)
+}
+
+/// The Connectivity node: which connected piece each point belongs to.
+///
+/// Pieces are numbered by SIZE, largest first, so piece 0 is the main body
+/// however the points happen to be ordered. That is what makes "keep the
+/// largest piece" a Cull with a threshold rather than a special operator —
+/// and the audit shows `isolate_largest` and `extract_longest` were exactly
+/// what the GEM mold chain used `im_cull` for.
+pub fn resolve_connectivity_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Detail> {
+ let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+ let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+ apply_connectivity(&mut geom, target);
+ Some(geom)
+}
+
+/// Exposed for tests that build their geometry by hand rather than by graph.
+pub fn apply_connectivity_for_test(geom: &mut Detail, target: &FsNode, _err: &mut Option<String>) {
+ apply_connectivity(geom, target);
+}
+
+pub(crate) fn apply_connectivity(geom: &mut Detail, target: &FsNode) {
+ let name = node_param_str(target, "Attribute", "piece").trim().to_string();
+ if name.is_empty() {
+ return;
+ }
+
+ let n = geom.num_points();
+ let mut label = vec![u32::MAX; n];
+ let mut sizes: Vec<(u32, usize)> = Vec::new();
+ for seed in 0..n {
+ if label[seed] != u32::MAX {
+ continue;
+ }
+ let id = sizes.len() as u32;
+ let mut count = 0usize;
+ let mut stack = vec![seed];
+ label[seed] = id;
+ while let Some(p) = stack.pop() {
+ count += 1;
+ for &q in geom.point_neighbours(p) {
+ if label[q as usize] == u32::MAX {
+ label[q as usize] = id;
+ stack.push(q as usize);
+ }
+ }
+ }
+ sizes.push((id, count));
+ }
+
+ // Renumber by size, descending. Ties break on the original label so the
+ // answer does not depend on sort stability.
+ sizes.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
+ let mut rank = vec![0i32; sizes.len()];
+ for (r, (id, _)) in sizes.iter().enumerate() {
+ rank[*id as usize] = r as i32;
+ }
+
+ let data: Vec<i32> = label.iter().map(|&l| rank[l as usize]).collect();
+ geom.points_mut().create(&name, AttribValue::Int(0));
+ let _ = geom.points_mut().insert(&name, AttribData::Int(data));
+}
+
+/// The Cull node: remove points, and the primitives that needed them.
+///
+/// Selection is the intersection of a Group and an attribute comparison, and
+/// what is selected is DELETED — the name says remove. Invert keeps the
+/// selection instead, which is how "isolate the largest piece" reads: a
+/// Connectivity, then a Cull inverted on `piece` below 1.
+///
+/// Nothing else in the app deletes geometry, which is why this one is in the
+/// first five.
+pub fn resolve_cull_geometry_with_errors(
+ root: &FsNode,
+ target: &FsNode,
+ visited: &mut Vec<String>,
+ ocl_error: &mut Option<String>,
+ sim: &mut EvalSim,
+) -> Option<Detail> {
+ let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+ let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+
+ let group = node_param_str(target, "Group", "");
+ let group = group.trim().to_string();
+ let attr = node_param_str(target, "Attribute", "");
+ let attr = attr.trim().to_string();
+ if group.is_empty() && attr.is_empty() {
+ return Some(geom);
+ }
+ if !attr.is_empty() && !geom.points().has(&attr) {
+ if ocl_error.is_none() {
+ *ocl_error = Some(format!(
+ "Cull '{}': no point attribute named '{}'",
+ target.name, attr
+ ));
+ }
+ return Some(geom);
+ }
+
+ let below = node_param_str(target, "Comparison", "Below").eq_ignore_ascii_case("below");
+ let threshold = node_param_f32(target, "Threshold", 0.5);
+ let invert = node_param_str(target, "Invert", "false") == "true";
+
+ let selected: Vec<bool> = (0..geom.num_points())
+ .map(|p| {
+ let in_group = group.is_empty() || geom.points().in_group(&group, p);
+ let passes = attr.is_empty()
+ || geom
+ .points()
+ .value(&attr, p)
+ .map(|v| if below { v.as_f32() < threshold } else { v.as_f32() > threshold })
+ .unwrap_or(false);
+ (in_group && passes) != invert
+ })
+ .collect();
+
+ let keep: Vec<bool> = selected.iter().map(|&s| !s).collect();
+ geom.keep_points(&keep);
+ Some(geom)
+}
+
/// The Subdivide node: four triangles where there was one.
pub fn resolve_subdivide_geometry_with_errors(
root: &FsNode,
@@ -1512,7 +1818,8 @@ pub(crate) fn apply_suture(geom: &mut Detail, against: Option<&Detail>, target:
let grid = crate::spatial::TriGrid::build(other);
for p in 0..n {
let here = geom.pos(p);
- let Some((closest, dist)) = grid.closest(here) else { continue };
+ let Some(hit) = grid.closest(here) else { continue };
+ let (closest, dist) = (hit.point, hit.distance);
// Inclusive, with room for the float error: a point resolved to
// exactly the threshold last step is STILL in contact this step.
// Comparing strictly would have every resolved contact read as
@@ -3960,6 +4267,11 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
|| nt == "suture"
|| nt == "detangle"
|| nt == "subdivide"
+ || nt == "cull"
+ || nt == "connectivity"
+ || nt == "distance"
+ || nt == "bounds"
+ || nt == "normal"
|| nt == "attribute"
|| nt == "simnet"
}
@@ -4107,6 +4419,51 @@ pub fn network_sphere_vertices_with_errors(
out.merge(&geom);
}
}
+ } else if node.node_type.eq_ignore_ascii_case("normal") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_normal_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(&geom);
+ }
+ }
+ } else if node.node_type.eq_ignore_ascii_case("bounds") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_bounds_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(&geom);
+ }
+ }
+ } else if node.node_type.eq_ignore_ascii_case("distance") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_distance_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(&geom);
+ }
+ }
+ } else if node.node_type.eq_ignore_ascii_case("connectivity") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_connectivity_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(&geom);
+ }
+ }
+ } else if node.node_type.eq_ignore_ascii_case("cull") {
+ let _idx = *count;
+ *count += 1;
+ if is_visible {
+ let mut visited = Vec::new();
+ if let Some(geom) = resolve_cull_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+ out.merge(&geom);
+ }
+ }
} else if node.node_type.eq_ignore_ascii_case("subdivide") {
let _idx = *count;
*count += 1;
@@ -6905,8 +7262,12 @@ mod simnet_tests {
#[test]
fn test_the_disk_cache_resumes_only_the_solve_it_belongs_to() {
+ // A directory of its own, and files removed one by one at the end
+ // rather than with remove_dir_all: this process has been seen to fail
+ // a closedir with EBADF while other threads are running, and a test
+ // should not be the thing that trips it.
let dir = std::env::temp_dir().join(format!("cce-simcache-test-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&dir);
+ let _ = std::fs::create_dir_all(&dir);
let path = dir.join("sim.simcache");
let mut state = sphere_detail(Vec3::ZERO, 0.5, 4, 6);
state.points_mut().create("acc", AttribValue::Float(9.0));
@@ -6931,7 +7292,9 @@ mod simnet_tests {
std::fs::write(&path, b"rubbish").unwrap();
assert!(read_sim_cache_at(&path, 0xABCD, 20).is_none());
- let _ = std::fs::remove_dir_all(&dir);
+ let _ = std::fs::remove_file(&path);
+ let _ = std::fs::remove_file(path.with_extension("simcache.tmp"));
+ let _ = std::fs::remove_dir(&dir);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 168cb3b..e3ac565 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3362,6 +3362,290 @@ mod tests {
}
}
+ // ---- Phase 4: the modelling set ----
+
+ /// Two spheres far apart: two connected pieces, the first much larger.
+ fn two_pieces() -> Detail {
+ let mut d = sphere_detail(Vec3::ZERO, 1.0, 8, 12);
+ d.merge(&sphere_detail(Vec3::new(10.0, 0.0, 0.0), 0.3, 4, 6));
+ d
+ }
+
+ fn eval_node(root: &FsNode, name: &str) -> (Detail, Option<String>) {
+ let target = root.children.iter().find(|c| c.name == name).unwrap();
+ let mut visited = Vec::new();
+ let mut err = None;
+ let mut cache = crate::geometry::SimCache::default();
+ let mut sim = crate::geometry::EvalSim::new(0, 0, &mut cache);
+ let g = crate::geometry::generate_single_node_geometry_with_errors(
+ root, target, &mut visited, &mut err, &mut sim,
+ )
+ .unwrap_or_else(|| panic!("{name} evaluates"));
+ (g, err)
+ }
+
+ /// A root holding a native sphere plus the nodes described, each already
+ /// named "<type> 1" by `phase3_node`.
+ fn modelling_root(radius: &str, nodes: Vec<FsNode>) -> FsNode {
+ let mut children = vec![phase3_node("sphere", &[("Radius", radius)])];
+ children.extend(nodes);
+ FsNode {
+ id: "root".into(),
+ name: "root".into(),
+ node_type: "node".into(),
+ children,
+ params: vec![],
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 0,
+ outputs: 0,
+ }
+ }
+
+ #[test]
+ fn test_normal_publishes_the_surface_normal_as_data() {
+ let root = modelling_root(
+ "1.0",
+ vec![phase3_node("normal", &[("Input", "sphere 1"), ("Attribute", "N")])],
+ );
+ let (g, err) = eval_node(&root, "normal 1");
+ assert!(err.is_none(), "{err:?}");
+
+ // The normal has been computed inside the app all along; as an
+ // attribute it becomes ordinary data that anything can read.
+ assert!(g.points().has("N"));
+ // The `sphere` node places itself by its index in the graph, so the
+ // centre comes off the bounds rather than being assumed to be zero.
+ let (lo, hi) = g.bounds().unwrap();
+ let centre = (lo + hi) * 0.5;
+ for p in 0..g.num_points() {
+ let n = g.points().value("N", p).unwrap().as_vec3();
+ assert!((n.length() - 1.0).abs() < 1e-4, "point {p} normal is not unit: {n:?}");
+ assert!(
+ n.dot((g.pos(p) - centre).normalize()) > 0.99,
+ "point {p} does not face outward"
+ );
+ }
+
+ let flipped = modelling_root(
+ "1.0",
+ vec![phase3_node("normal", &[("Input", "sphere 1"), ("Flip", "true")])],
+ );
+ let (f, _) = eval_node(&flipped, "normal 1");
+ let (lo, hi) = f.bounds().unwrap();
+ let radial = f.pos(0) - (lo + hi) * 0.5;
+ assert!(f.points().value("N", 0).unwrap().as_vec3().dot(radial) < 0.0);
+ }
+
+ #[test]
+ fn test_bounds_measures_into_detail_attributes() {
+ let root = modelling_root(
+ "2.0",
+ vec![phase3_node("bounds", &[("Input", "sphere 1"), ("Prefix", "bb")])],
+ );
+ let (g, err) = eval_node(&root, "bounds 1");
+ assert!(err.is_none(), "{err:?}");
+
+ let v = |n: &str| g.detail().value(n, 0).unwrap().as_vec3();
+ // A radius-2 sphere is 4 across wherever the node happens to place it,
+ // and min/max/center have to agree with each other and with the points.
+ assert!((v("bb_size") - Vec3::splat(4.0)).length() < 0.02, "{:?}", v("bb_size"));
+ assert!((v("bb_max") - v("bb_min") - v("bb_size")).length() < 1e-4);
+ assert!(((v("bb_min") + v("bb_max")) * 0.5 - v("bb_center")).length() < 1e-4);
+ let (lo, hi) = g.bounds().unwrap();
+ assert!((v("bb_min") - lo).length() < 1e-5 && (v("bb_max") - hi).length() < 1e-5);
+ // A measurement describes the state it was taken from.
+ assert_eq!(g.detail().kind("bb_min"), AttribKind::Derivative);
+ }
+
+ #[test]
+ fn test_distance_measures_to_another_geometry_and_points_at_it() {
+ let mut root = modelling_root(
+ "1.0",
+ vec![
+ phase3_node("points", &[]),
+ phase3_node(
+ "distance",
+ &[
+ ("Input", "points 1"),
+ ("To", "sphere 1"),
+ ("Attribute", "dist"),
+ ("Direction", "toward"),
+ ],
+ ),
+ ],
+ );
+ // Put the sample points somewhere known: a Points node in "Line" mode
+ // lays them along X.
+ root.children[1]
+ .params
+ .push(crate::app::ParamDef {
+ name: "Shape".into(),
+ label: String::new(),
+ param_type: "text".into(),
+ default: "Line".into(),
+ options: vec![],
+ min: None,
+ max: None,
+ step: None,
+ });
+
+ let (g, err) = eval_node(&root, "distance 1");
+ assert!(err.is_none(), "{err:?}");
+ assert!(g.points().has("dist") && g.points().has("toward"));
+
+ // Every distance is non-negative unsigned, and the direction is a unit
+ // vector pointing at the surface — which is exactly what Migrate wants
+ // to flow along and Align wants to steer by, out of one lookup.
+ for p in 0..g.num_points() {
+ let d = g.points().value("dist", p).unwrap().as_f32();
+ assert!(d >= 0.0, "point {p} unsigned distance is negative: {d}");
+ // Unit, except where there is nothing to point at: a point
+ // sitting ON the surface has no direction to it, and inventing one
+ // would be worse than leaving it zero.
+ let dir = g.points().value("toward", p).unwrap().as_vec3();
+ if d > 1e-5 {
+ assert!((dir.length() - 1.0).abs() < 1e-3, "point {p} direction is not unit");
+ } else {
+ assert_eq!(dir, Vec3::ZERO, "point {p} on the surface invented a direction");
+ }
+ }
+
+ // A missing target is reported rather than silently writing zeros.
+ let broken = modelling_root(
+ "1.0",
+ vec![phase3_node("distance", &[("Input", "sphere 1"), ("To", "nope")])],
+ );
+ let (_, err) = eval_node(&broken, "distance 1");
+ assert!(err.as_deref().unwrap_or("").contains("nope"), "{err:?}");
+ }
+
+ #[test]
+ fn test_distance_signed_tells_inside_from_outside() {
+ // A point cloud straddling a sphere's surface.
+ let mut cloud = Detail::new();
+ cloud.add_point(Vec3::new(0.0, 0.0, 0.0)); // inside
+ cloud.add_point(Vec3::new(3.0, 0.0, 0.0)); // outside
+ let sphere = sphere_detail(Vec3::ZERO, 1.0, 12, 16);
+
+ let grid = crate::spatial::TriGrid::build(&sphere);
+ let signed = |p: Vec3| {
+ let h = grid.closest(p).unwrap();
+ if (p - h.point).dot(h.normal) < 0.0 { -h.distance } else { h.distance }
+ };
+ assert!(signed(cloud.pos(0)) < 0.0, "the centre should read as inside");
+ assert!(signed(cloud.pos(1)) > 0.0, "a point outside should read as outside");
+ }
+
+ #[test]
+ fn test_connectivity_numbers_pieces_largest_first() {
+ let mut geom = two_pieces();
+ let big = sphere_detail(Vec3::ZERO, 1.0, 8, 12).num_points();
+
+ let node = phase3_node("connectivity", &[("Attribute", "piece")]);
+ // Exercised through the resolver's own labelling by hand, since the
+ // input is built here rather than by a graph.
+ let root = modelling_root("1.0", vec![node]);
+ let _ = &root;
+ let labels = {
+ // Same walk the node does.
+ let n = geom.num_points();
+ let mut label = vec![u32::MAX; n];
+ let mut sizes: Vec<(u32, usize)> = Vec::new();
+ for seed in 0..n {
+ if label[seed] != u32::MAX {
+ continue;
+ }
+ let id = sizes.len() as u32;
+ let mut count = 0;
+ let mut stack = vec![seed];
+ label[seed] = id;
+ while let Some(p) = stack.pop() {
+ count += 1;
+ for &q in geom.point_neighbours(p) {
+ if label[q as usize] == u32::MAX {
+ label[q as usize] = id;
+ stack.push(q as usize);
+ }
+ }
+ }
+ sizes.push((id, count));
+ }
+ (label, sizes)
+ };
+ assert_eq!(labels.1.len(), 2, "two spheres are two pieces");
+ assert_eq!(labels.1.iter().map(|(_, c)| c).sum::<usize>(), geom.num_points());
+
+ // Through the node: piece 0 is the BIGGEST however the points are
+ // ordered, which is what makes "keep the largest" an ordinary Cull.
+ let mut err = None;
+ let g = {
+ let n = phase3_node("connectivity", &[("Attribute", "piece")]);
+ crate::geometry::apply_connectivity_for_test(&mut geom, &n, &mut err);
+ geom
+ };
+ let zeros = (0..g.num_points())
+ .filter(|&p| g.points().value("piece", p).unwrap().as_f32() as i32 == 0)
+ .count();
+ assert_eq!(zeros, big, "piece 0 should be the large sphere");
+ }
+
+ #[test]
+ fn test_cull_removes_what_it_selects_and_invert_keeps_it() {
+ let root = modelling_root(
+ "1.0",
+ vec![
+ phase3_node("connectivity", &[("Input", "sphere 1"), ("Attribute", "piece")]),
+ phase3_node(
+ "cull",
+ &[
+ ("Input", "connectivity 1"),
+ ("Attribute", "piece"),
+ ("Comparison", "Below"),
+ ("Threshold", "1.00"),
+ ("Invert", "false"),
+ ],
+ ),
+ ],
+ );
+ // One sphere is one piece, so culling piece < 1 removes everything.
+ let (all_gone, err) = eval_node(&root, "cull 1");
+ assert!(err.is_none(), "{err:?}");
+ assert_eq!(all_gone.num_points(), 0);
+
+ // Inverted, the same selection is what SURVIVES — which is how
+ // "isolate the largest piece" reads, and what the GEM mold chain used
+ // im_cull for.
+ let mut kept_root = root.clone();
+ kept_root
+ .children
+ .iter_mut()
+ .find(|c| c.name == "cull 1")
+ .unwrap()
+ .params
+ .iter_mut()
+ .find(|p| p.name == "Invert")
+ .unwrap()
+ .default = "true".into();
+ let (kept, _) = eval_node(&kept_root, "cull 1");
+ assert_eq!(kept.num_points(), sphere_detail(Vec3::ZERO, 1.0, 16, 24).num_points());
+ assert!(kept.num_prims() > 0, "the surface survived with its primitives");
+
+ // A missing attribute is reported, and nothing is deleted on a guess.
+ let broken = modelling_root(
+ "1.0",
+ vec![phase3_node("cull", &[("Input", "sphere 1"), ("Attribute", "nope")])],
+ );
+ let (g, err) = eval_node(&broken, "cull 1");
+ assert!(err.as_deref().unwrap_or("").contains("nope"), "{err:?}");
+ assert!(g.num_points() > 0, "nothing should be culled on an error");
+
+ // With neither a group nor an attribute there is no selection at all.
+ let idle = modelling_root("1.0", vec![phase3_node("cull", &[("Input", "sphere 1")])]);
+ let (g, _) = eval_node(&idle, "cull 1");
+ assert_eq!(g.num_points(), sphere_detail(Vec3::ZERO, 1.0, 16, 24).num_points());
+ }
+
// ---- Phase 3: surface development ----
use crate::geometry::sphere_detail;
@@ -3687,7 +3971,7 @@ mod tests {
let rest = TriGrid::build(&sphere);
let drift = |d: &Detail| {
(0..d.num_points())
- .filter_map(|p| rest.closest(d.pos(p)).map(|(_, dist)| dist))
+ .filter_map(|p| rest.closest(d.pos(p)).map(|h| h.distance))
.sum::<f32>()
/ d.num_points() as f32
};
@@ -3720,19 +4004,22 @@ mod tests {
// A point well outside: the closest surface point is along the ray to
// the centre, at about the radius.
- let (q, d) = grid.closest(Vec3::new(3.0, 0.0, 0.0)).unwrap();
- assert!((d - 2.0).abs() < 0.05, "distance {d}");
- assert!(q.x > 0.9 && q.y.abs() < 0.2 && q.z.abs() < 0.2, "{q:?}");
+ let h = grid.closest(Vec3::new(3.0, 0.0, 0.0)).unwrap();
+ assert!((h.distance - 2.0).abs() < 0.05, "distance {}", h.distance);
+ assert!(h.point.x > 0.9 && h.point.y.abs() < 0.2 && h.point.z.abs() < 0.2, "{:?}", h.point);
+ // The hit carries the face's normal, which is what lets a caller tell
+ // inside from outside without casting a ray.
+ assert!(h.normal.dot(Vec3::X) > 0.5, "the nearest face should look outward: {:?}", h.normal);
// A point ON the surface finds itself.
let on = sphere.pos(20);
- let (_, d) = grid.closest(on).unwrap();
+ let d = grid.closest(on).unwrap().distance;
assert!(d < 1e-3, "a point on the surface is {d} from it");
// The centre is a radius from everywhere, and the search still
// terminates — the expanding box has to grow several times to find
// anything at all.
- let (_, d) = grid.closest(Vec3::ZERO).unwrap();
+ let d = grid.closest(Vec3::ZERO).unwrap().distance;
assert!((d - 1.0).abs() < 0.05, "from the centre: {d}");
assert!(TriGrid::build(&Detail::new()).closest(Vec3::ZERO).is_none());
diff --git a/src/remesh.rs b/src/remesh.rs
index e4bc318..946657d 100644
--- a/src/remesh.rs
+++ b/src/remesh.rs
@@ -583,8 +583,8 @@ fn project_pass(m: &mut Mesh, rest: &crate::spatial::TriGrid) {
if m.dead_point[p] {
continue;
}
- if let Some((q, _)) = rest.closest(m.pos[p]) {
- m.pos[p] = q;
+ if let Some(hit) = rest.closest(m.pos[p]) {
+ m.pos[p] = hit.point;
}
}
}
diff --git a/src/spatial.rs b/src/spatial.rs
index 31161ae..cf812ab 100644
--- a/src/spatial.rs
+++ b/src/spatial.rs
@@ -119,6 +119,20 @@ impl Grid {
}
}
+/// What a surface lookup found.
+#[derive(Clone, Copy, Debug)]
+pub struct Hit {
+ pub point: Vec3,
+ pub distance: f32,
+ /// The face normal of the triangle the hit is on, normalized. Lets a
+ /// caller tell inside from outside in constant time — the sign of
+ /// `(p - point) . normal` — instead of casting a ray through the whole
+ /// mesh. It reads the wrong way in a concave crease, where the nearest
+ /// face is not the one facing you, which is why the node that uses it
+ /// says so.
+ pub normal: Vec3,
+}
+
/// A grid over a surface's triangles, for asking what the nearest surface
/// point is.
pub struct TriGrid {
@@ -172,39 +186,37 @@ impl TriGrid {
/// Searches an expanding box until the best hit is closer than the box is
/// wide — at which point nothing outside can beat it, because anything out
/// there is at least that far away.
- pub fn closest(&self, p: Vec3) -> Option<(Vec3, f32)> {
+ pub fn closest(&self, p: Vec3) -> Option<Hit> {
if self.tris.is_empty() {
return None;
}
+ let hit = |i: usize| {
+ let t = self.tris[i];
+ let q = closest_point_on_triangle(p, t[0], t[1], t[2]);
+ Hit {
+ point: q,
+ distance: (q - p).length(),
+ normal: (t[1] - t[0]).cross(t[2] - t[0]).normalize_or_zero(),
+ }
+ };
+ let nearer = |a: &Hit, b: &Hit| {
+ a.distance.partial_cmp(&b.distance).unwrap_or(std::cmp::Ordering::Equal)
+ };
+
let mut reach = self.grid.cell;
let mut scratch = Vec::new();
for _ in 0..12 {
self.grid
.gather(p - Vec3::splat(reach), p + Vec3::splat(reach), &mut scratch);
- let best = scratch
- .iter()
- .map(|&i| {
- let t = self.tris[i as usize];
- let q = closest_point_on_triangle(p, t[0], t[1], t[2]);
- (q, (q - p).length())
- })
- .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+ let best = scratch.iter().map(|&i| hit(i as usize)).min_by(nearer);
match best {
- Some(hit) if hit.1 <= reach => return Some(hit),
+ Some(h) if h.distance <= reach => return Some(h),
_ => reach *= 2.0,
}
}
// The grid is clamped to the geometry's bounds, so twelve doublings
// have gathered everything; whatever it found is the answer.
- let best = self
- .tris
- .iter()
- .map(|t| {
- let q = closest_point_on_triangle(p, t[0], t[1], t[2]);
- (q, (q - p).length())
- })
- .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
- best
+ (0..self.tris.len()).map(hit).min_by(nearer)
}
}