A Join unioned its tools into the first target only, leaving other target bodies the tool overlapped or touched as separate, interpenetrating bodies. Each tool now merges with every target it meets (shared material or face contact, tested on the operands' boundaries) into one body under the first one's name; absorbed bodies are removed and their threads follow. A tool that meets no target still joins the first target as a separate lump. Sheet metal and mesh bodies are never absorbed. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
261 lines
11 KiB
Rust
261 lines
11 KiB
Rust
//! Group disconnected material regions without moving a cavity to another enclosing shell.
|
|
//! A valid shell hierarchy alternates material and void at every nesting level.
|
|
|
|
use solvecraft_geom::{Aabb3, Mesh, Vec3};
|
|
|
|
use crate::{KernelError, Result};
|
|
|
|
pub(super) const MAX_SHELLS: usize = 1024;
|
|
pub(super) const MAX_POINTS: usize = 100_000;
|
|
pub(super) const MAX_TRIANGLES: usize = 100_000;
|
|
const MAX_PAIR_WORK: usize = 40_000_000;
|
|
const MAX_PROBES: usize = 2_000_000;
|
|
const MAX_CONTAINMENT_WORK: usize = 40_000_000;
|
|
|
|
fn failed(detail: &str) -> KernelError {
|
|
KernelError::Failed(format!("lumps: {detail}"))
|
|
}
|
|
|
|
fn box_of(points: &[Vec3]) -> Aabb3 {
|
|
let mut bounds = Aabb3::EMPTY;
|
|
for p in points {
|
|
bounds.add(*p);
|
|
}
|
|
bounds
|
|
}
|
|
|
|
fn boxes_separate(a: Aabb3, b: Aabb3, gap: f64) -> bool {
|
|
a.max.x < b.min.x - gap
|
|
|| b.max.x < a.min.x - gap
|
|
|| a.max.y < b.min.y - gap
|
|
|| b.max.y < a.min.y - gap
|
|
|| a.max.z < b.min.z - gap
|
|
|| b.max.z < a.min.z - gap
|
|
}
|
|
|
|
fn unit(v: Vec3) -> Option<Vec3> {
|
|
let scale = v.x.abs().max(v.y.abs()).max(v.z.abs());
|
|
if !scale.is_finite() || scale == 0.0 {
|
|
return None;
|
|
}
|
|
(v / scale).normalized()
|
|
}
|
|
|
|
/// Separating axes include the in-plane edge normals for coplanar triangles.
|
|
fn triangles_meet(a: &[Vec3; 3], b: &[Vec3; 3], gap: f64) -> bool {
|
|
let [a0, a1, a2] = *a;
|
|
let [b0, b1, b2] = *b;
|
|
let ea = [a1 - a0, a2 - a1, a0 - a2];
|
|
let eb = [b1 - b0, b2 - b1, b0 - b2];
|
|
let na = (a1 - a0).cross(a2 - a0);
|
|
let nb = (b1 - b0).cross(b2 - b0);
|
|
let separated = |axis: Vec3| {
|
|
let Some(n) = unit(axis) else { return false };
|
|
// Translate projections first so a far-away body does not lose its clearance.
|
|
let range = |points: &[Vec3; 3]| {
|
|
points.iter().map(|p| (*p - a0).dot(n)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x), hi.max(x)))
|
|
};
|
|
let ((alo, ahi), (blo, bhi)) = (range(a), range(b));
|
|
ahi < blo - gap || bhi < alo - gap
|
|
};
|
|
if separated(na) || separated(nb) {
|
|
return false;
|
|
}
|
|
for e in ea {
|
|
if separated(na.cross(e)) {
|
|
return false;
|
|
}
|
|
for f in eb {
|
|
if separated(e.cross(f)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
for e in eb {
|
|
if separated(nb.cross(e)) {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Whether two closed meshes share material or touch: a boundary triangle of one comes within
|
|
/// `gap` of one of the other's, or one encloses the other.
|
|
pub(super) fn meshes_meet(a: &Mesh, b: &Mesh, gap: f64) -> Result<bool> {
|
|
if !gap.is_finite() || gap <= 0.0 {
|
|
return Err(failed("invalid contact clearance"));
|
|
}
|
|
let (ba, bb) = (a.bounds(), b.bounds());
|
|
if ba.is_empty() || bb.is_empty() || boxes_separate(ba, bb, gap) {
|
|
return Ok(false);
|
|
}
|
|
// Only triangles near the other body can touch it.
|
|
let near = |m: &Mesh, other: Aabb3| -> Result<Vec<([Vec3; 3], Aabb3)>> {
|
|
let mut out = Vec::new();
|
|
for t in &m.triangles {
|
|
let tri = m.tri(t).ok_or_else(|| failed("invalid contact triangle"))?;
|
|
let bounds = box_of(&tri);
|
|
if !boxes_separate(bounds, other, gap) {
|
|
out.push((tri, bounds));
|
|
}
|
|
}
|
|
Ok(out)
|
|
};
|
|
let (ta, tb) = (near(a, bb)?, near(b, ba)?);
|
|
let work = ta.len().checked_mul(tb.len()).ok_or_else(|| failed("contact work exceeds the limit"))?;
|
|
if work > MAX_PAIR_WORK {
|
|
return Err(failed("contact work exceeds the limit"));
|
|
}
|
|
for (x, xb) in &ta {
|
|
for (y, yb) in &tb {
|
|
if !boxes_separate(*xb, *yb, gap) && triangles_meet(x, y, gap) {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
}
|
|
// No boundary contact: they meet only if one lies wholly inside the other.
|
|
Ok(b.positions.first().is_some_and(|p| a.contains(*p)) || a.positions.first().is_some_and(|p| b.contains(*p)))
|
|
}
|
|
|
|
pub(super) fn groups(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result<Vec<Vec<usize>>> {
|
|
if meshes.is_empty() || meshes.len() != volumes.len() || meshes.len() > MAX_SHELLS {
|
|
return Err(failed("shell count exceeds the grouping limit or is invalid"));
|
|
}
|
|
if !gap.is_finite() || gap <= 0.0 {
|
|
return Err(failed("invalid shell clearance"));
|
|
}
|
|
let mut points = 0usize;
|
|
let mut triangle_count = 0usize;
|
|
let mut triangles = Vec::with_capacity(meshes.len());
|
|
let mut triangle_bounds = Vec::with_capacity(meshes.len());
|
|
let mut bounds = Vec::with_capacity(meshes.len());
|
|
for (mesh, volume) in meshes.iter().zip(volumes) {
|
|
points = points.checked_add(mesh.positions.len()).ok_or_else(|| failed("point count exceeds the grouping limit"))?;
|
|
triangle_count = triangle_count.checked_add(mesh.triangles.len()).ok_or_else(|| failed("triangle count exceeds the grouping limit"))?;
|
|
if points > MAX_POINTS || triangle_count > MAX_TRIANGLES {
|
|
return Err(failed("mesh exceeds the shell grouping limit"));
|
|
}
|
|
if !volume.is_finite()
|
|
|| *volume == 0.0
|
|
|| mesh.positions.is_empty()
|
|
|| mesh.triangles.is_empty()
|
|
|| mesh.positions.iter().any(|p| !p.is_finite())
|
|
{
|
|
return Err(failed("cannot determine a closed shell's orientation"));
|
|
}
|
|
let tris: Vec<_> = mesh.triangles.iter().map(|t| mesh.tri(t).ok_or_else(|| failed("invalid shell triangle"))).collect::<Result<_>>()?;
|
|
if tris.iter().any(|[a, b, c]| unit((*b - *a).cross(*c - *a)).is_none()) {
|
|
return Err(failed("degenerate shell triangle"));
|
|
}
|
|
triangle_bounds.push(tris.iter().map(|t| box_of(t)).collect::<Vec<_>>());
|
|
triangles.push(tris);
|
|
bounds.push(mesh.bounds());
|
|
}
|
|
let mut inside = vec![vec![false; meshes.len()]; meshes.len()];
|
|
let mut pair_work = 0usize;
|
|
let mut probes = 0usize;
|
|
let mut containment_work = 0usize;
|
|
for (i, mesh) in meshes.iter().enumerate() {
|
|
for j in i + 1..meshes.len() {
|
|
let (Some(a_bounds), Some(b_bounds)) = (bounds.get(i), bounds.get(j)) else { return Err(failed("missing shell bounds")) };
|
|
if boxes_separate(*a_bounds, *b_bounds, gap) {
|
|
continue;
|
|
}
|
|
let (Some(a), Some(b)) = (triangles.get(i), triangles.get(j)) else { return Err(failed("missing shell triangles")) };
|
|
let work = a.len().checked_mul(b.len()).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?;
|
|
pair_work = pair_work.checked_add(work).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?;
|
|
if pair_work > MAX_PAIR_WORK {
|
|
return Err(failed("shell boundary work exceeds the grouping limit"));
|
|
}
|
|
for (ai, ta) in a.iter().enumerate() {
|
|
for (bi, tb) in b.iter().enumerate() {
|
|
let (Some(ba), Some(bb)) = (triangle_bounds.get(i).and_then(|x| x.get(ai)), triangle_bounds.get(j).and_then(|x| x.get(bi)))
|
|
else {
|
|
return Err(failed("missing triangle bounds"));
|
|
};
|
|
if !boxes_separate(*ba, *bb, gap) && triangles_meet(ta, tb, gap) {
|
|
return Err(failed("shell boundaries intersect or touch; cavity ownership is ambiguous"));
|
|
}
|
|
}
|
|
}
|
|
for (inner, outer) in [(i, j), (j, i)] {
|
|
let ib = bounds.get(inner).ok_or_else(|| failed("missing shell bounds"))?;
|
|
let ob = bounds.get(outer).ok_or_else(|| failed("missing shell bounds"))?;
|
|
// With boundary intersections already excluded, a connected shell
|
|
// extending beyond another's bounds cannot be wholly contained.
|
|
if ib.min.x < ob.min.x - gap
|
|
|| ib.min.y < ob.min.y - gap
|
|
|| ib.min.z < ob.min.z - gap
|
|
|| ib.max.x > ob.max.x + gap
|
|
|| ib.max.y > ob.max.y + gap
|
|
|| ib.max.z > ob.max.z + gap
|
|
{
|
|
continue;
|
|
}
|
|
let inner_mesh = if inner == i { mesh } else { meshes.get(inner).ok_or_else(|| failed("missing shell"))? };
|
|
probes = probes.checked_add(inner_mesh.positions.len()).ok_or_else(|| failed("shell probes exceed the grouping limit"))?;
|
|
if probes > MAX_PROBES {
|
|
return Err(failed("shell probes exceed the grouping limit"));
|
|
}
|
|
let outer_mesh = meshes.get(outer).ok_or_else(|| failed("missing shell"))?;
|
|
let probe_count = inner_mesh.positions.len();
|
|
let work = probe_count
|
|
.checked_mul(outer_mesh.triangles.len())
|
|
.and_then(|n| n.checked_mul(3))
|
|
.ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?;
|
|
containment_work = containment_work.checked_add(work).ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?;
|
|
if containment_work > MAX_CONTAINMENT_WORK {
|
|
return Err(failed("shell containment work exceeds the grouping limit"));
|
|
}
|
|
let mut contained = None;
|
|
for p in inner_mesh.positions.iter().copied() {
|
|
let hit = outer_mesh.contains(p);
|
|
if contained.is_some_and(|previous| previous != hit) {
|
|
return Err(failed("shell containment is inconsistent; cavity ownership is ambiguous"));
|
|
}
|
|
contained = Some(hit);
|
|
}
|
|
if let Some(slot) = inside.get_mut(inner).and_then(|row| row.get_mut(outer)) {
|
|
*slot = contained == Some(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut parent = Vec::with_capacity(meshes.len());
|
|
for i in 0..meshes.len() {
|
|
let row = inside.get(i).ok_or_else(|| failed("missing containment"))?;
|
|
let candidates: Vec<_> = row.iter().enumerate().filter_map(|(j, hit)| hit.then_some(j)).collect();
|
|
for (k, a) in candidates.iter().enumerate() {
|
|
for b in candidates.iter().skip(k + 1) {
|
|
if !inside.get(*a).and_then(|r| r.get(*b)).copied().unwrap_or(false)
|
|
&& !inside.get(*b).and_then(|r| r.get(*a)).copied().unwrap_or(false)
|
|
{
|
|
return Err(failed("multiple overlapping shell owners"));
|
|
}
|
|
}
|
|
}
|
|
let owner = candidates.into_iter().min_by(|a, b| {
|
|
volumes.get(*a).copied().unwrap_or(f64::INFINITY).abs().total_cmp(&volumes.get(*b).copied().unwrap_or(f64::INFINITY).abs())
|
|
});
|
|
let outward = volumes.get(i).is_some_and(|v| *v > 0.0);
|
|
match owner {
|
|
None if !outward => return Err(failed("inward shell has no enclosing material shell")),
|
|
Some(p) if volumes.get(p).is_some_and(|v| (*v > 0.0) == outward) => {
|
|
return Err(failed("nested shells do not alternate material and void"));
|
|
}
|
|
_ => {}
|
|
}
|
|
parent.push(owner);
|
|
}
|
|
let mut groups = Vec::new();
|
|
for (i, v) in volumes.iter().enumerate() {
|
|
if *v <= 0.0 {
|
|
continue;
|
|
}
|
|
let mut group = vec![i];
|
|
group.extend(parent.iter().enumerate().filter_map(|(j, p)| (*p == Some(i)).then_some(j)));
|
|
groups.push(group);
|
|
}
|
|
Ok(groups)
|
|
}
|