feat: preserve modeling intent across sketches, assemblies, and fabrication

Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands.

Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected.
This commit is contained in:
bherbruck committed 2026-10-11 01:28:01 -04:00
1 parent d71bdb78ec
commit 07932d0f42
65 files changed
+6145 -484

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
//! 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
}
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)
}