feat: preserve adaptive sketch profiles and curved modeling workflows

This commit is contained in:
bherbruck committed 2026-10-11 09:06:25 -04:00
1 parent 4c4a0449a4
commit 4e595deff2
62 files changed
+7114 -362

No files matched your search

+30 -1
View File
@@ -957,6 +957,24 @@ impl Body {
if groups.len() == 1 {
return Ok(vec![self.clone()]);
}
// Grouping keeps existing shells; certificates belong to their
// exact face identities, not the new lump's positional face index.
// This is not propagation through a geometry-changing operation.
let sources_by_face = if let Some(sources) = &self.face_sources {
if sources.len() != self.solid.face_iter().count() {
return Err(KernelError::Invalid("lumps: face source table does not match the body".into()));
}
let mut by_id = std::collections::HashMap::new();
for (face, source) in self.solid.face_iter().zip(sources.iter()) {
if by_id.insert(face.id(), Some((face.orientation(), source))).is_some() {
// A repeated identity has no unique source association.
by_id.insert(face.id(), None);
}
}
Some(by_id)
} else {
None
};
groups
.into_iter()
.map(|g| {
@@ -964,7 +982,18 @@ impl Body {
.into_iter()
.map(|i| shells.get(i).cloned().ok_or_else(|| KernelError::Failed("lumps: missing shell".into())))
.collect::<Result<Vec<_>>>()?;
Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)
let result = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)?;
let sources = sources_by_face.as_ref().map(|old| {
result
.solid
.face_iter()
.map(|face| match old.get(&face.id()) {
Some(Some((orientation, source))) if *orientation == face.orientation() => (*source).clone(),
_ => None,
})
.collect()
});
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)))
})
.collect()
})
+79
View File
@@ -222,3 +222,82 @@ fn shortcut_clearance_refuses_curved_contact_hidden_between_mesh_vertices() {
let invalid = joined_shells(outer.deep_copy().boundaries().iter().chain(protruding.boundaries()).cloned().collect());
assert!(invalid.separated_lumps().is_err());
}
fn certified_lump_witness() -> Body {
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
// The current trimmed lower prism: a rectangular tool with a circular stop
// removed. Its cap footprint differs materially from the original sketch.
let region = Region2 {
outer: Loop2::polygon(&[Vec2::new(20., 20.), Vec2::new(30., 20.), Vec2::new(30., 30.), Vec2::new(20., 30.)]),
holes: vec![Loop2::circle(Vec2::new(25., 25.), 2.)],
};
let lower = crate::extrude(&Plane::XY, &[region], 0., 1.).unwrap().pop().unwrap();
let named = box_solid(Vec3::new(40., 20., 5.), Vec3::new(42., 23., 7.)).unwrap().with_box_certificate(202).unwrap();
let unknown = box_solid(Vec3::new(50., 20., 5.), Vec3::new(51., 21., 6.)).unwrap().with_face_sources(None);
let sources: std::collections::HashMap<_, _> = [&lower, &named]
.into_iter()
.flat_map(|body| body.solid.face_iter().zip(body.face_sources().unwrap()).map(|(face, source)| (face.id(), source.clone())))
.collect();
// Deliberately put the larger named box first, then the trimmed tool. An
// index-based copy of the source table would attach certificates wrongly.
let shells = [&named, &unknown, &lower].into_iter().flat_map(|b| b.solid.boundaries().iter().cloned()).collect();
let body = Body::new(Solid::try_new(shells).unwrap()).unwrap();
let table = body.solid.face_iter().map(|f| sources.get(&f.id()).cloned().flatten()).collect();
body.with_face_sources(Some(std::sync::Arc::new(table)))
}
#[test]
fn grouping_lumps_preserves_only_unchanged_face_certificates_and_trimmed_caps() {
let body = certified_lump_witness();
let expected: std::collections::HashMap<_, _> =
body.solid.face_iter().zip(body.face_sources().unwrap()).map(|(f, source)| (f.id(), (f.orientation(), source.clone()))).collect();
let before = step_export(&[&body], "source").unwrap();
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 3);
let mut total_faces = 0;
let mut raw_caps = 0;
let mut named_faces = 0;
let mut unknown_faces = 0;
for lump in &lumps {
assert_eq!(lump.face_sources().unwrap().len(), lump.face_count());
for (f, source) in lump.solid.face_iter().zip(lump.face_sources().unwrap()) {
let (orientation, original) = expected.get(&f.id()).expect("grouping must retain original face IDs");
assert_eq!(*orientation, f.orientation());
assert_eq!(source, original);
match source {
Some(FaceSource::ExtrudeCap { region, high, plane }) => {
assert_eq!(region.holes.len(), 1);
assert!((region.area() - (100. - 4. * std::f64::consts::PI)).abs() < 1e-10);
assert!((plane.origin.z - if *high { 1. } else { 0. }).abs() < 1e-10);
raw_caps += 1;
}
Some(FaceSource::Box { producer, .. }) => {
assert_eq!(*producer, Some(202));
named_faces += 1;
}
None => unknown_faces += 1,
_ => {}
}
total_faces += 1;
}
let mesh = lump.tessellate(1e-5).unwrap();
if mesh.contains(Vec3::new(21., 21., 0.5)) {
assert!(!mesh.contains(Vec3::new(25., 25., 0.5)), "trimmed stop hole must not refill");
assert!((mesh.measure().volume - (100. - 4. * std::f64::consts::PI)).abs() < 0.002);
}
}
assert_eq!(total_faces, body.face_count());
assert_eq!(raw_caps, 2);
assert_eq!(named_faces, 6);
assert_eq!(unknown_faces, 6);
assert_eq!(step_export(&[&body], "source").unwrap(), before);
}
#[test]
fn grouping_lumps_does_not_infer_absent_or_misaligned_sources() {
let body = certified_lump_witness();
let absent = body.clone().with_face_sources(None);
assert!(absent.lumps().unwrap().iter().all(|b| b.face_sources().is_none()));
let malformed = body.with_face_sources(Some(std::sync::Arc::new(vec![None])));
assert!(malformed.lumps().unwrap_err().to_string().contains("face source table"));
}
+1
View File
@@ -38,6 +38,7 @@ mod ops;
mod polybool;
mod polyhedron;
mod prism;
mod prism_curve;
pub mod provenance;
mod sew;
mod splitface;
+60 -8
View File
@@ -225,28 +225,41 @@ impl NotPlanar {
/// The plane of each face (from its mesh, which must be flat), or what stands in the way: the
/// curved faces by the kind of surface they lie on.
fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result<Vec<PlaneEq>, NotPlanar> {
use crate::FaceSurface;
use crate::offset::{Surf, surf_of};
// Match face_surface's classification tolerance, but compute the body bounds only
// once. Walking to each indexed face separately made curved-body diagnostics quadratic.
let surface_tol = (b.size() * 1e-7).max(1e-9) * 100.0;
let mut surfaces = b.solid.face_iter();
let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new();
let mut out = Vec::with_capacity(faces.len());
for f in faces {
for (index, f) in faces.iter().enumerate() {
let Some(surface) = surfaces.next() else {
return Err(NotPlanar::Other("mesh is inconsistent".into()));
};
if f.index != index {
return Err(NotPlanar::Other("mesh is inconsistent".into()));
}
if f.area <= 0.0 {
return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index)));
}
match f.plane_normal {
Some(n) => out.push(PlaneEq { n, w: n.dot(f.centroid) }),
None => {
let kind = match crate::face_surface(b, f.index) {
Some(FaceSurface::Cylinder { .. }) => "cylinder",
Some(FaceSurface::Cone { .. }) => "cone",
Some(FaceSurface::Sphere { .. }) => "sphere",
Some(FaceSurface::Torus { .. }) => "torus",
Some(FaceSurface::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))),
let kind = match surf_of(surface, surface_tol) {
Some(Surf::Cylinder { .. }) => "cylinder",
Some(Surf::Cone { .. }) => "cone",
Some(Surf::Sphere { .. }) => "sphere",
Some(Surf::Torus { .. }) => "torus",
Some(Surf::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))),
None => "freeform",
};
*curved.entry(kind).or_insert(0) += 1;
}
}
}
if surfaces.next().is_some() {
return Err(NotPlanar::Other("mesh is inconsistent".into()));
}
if curved.is_empty() { Ok(out) } else { Err(NotPlanar::Curved(curved)) }
}
@@ -792,3 +805,42 @@ fn rebuild(polys: Vec<Poly>, size: f64) -> Result<Body> {
Body::new(solid)
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod diagnostic_tests {
use super::*;
#[test]
fn compound_curved_diagnostics_count_every_surface_kind() {
// Many independent shells exercise the error path without performing a costly
// Boolean. Each cylinder has two analytic half-cylinder walls and two flat caps.
let mut shells = Vec::new();
for i in 0..64 {
let cylinder = crate::cylinder(Vec3::X * (i as f64 * 6.0), Vec3::Z, 2.0, 5.0).unwrap();
shells.extend(cylinder.solid.boundaries().iter().cloned());
}
let sphere = crate::sphere(Vec3::new(0.0, 12.0, 0.0), 2.0).unwrap();
let sphere_faces = sphere.face_count();
shells.extend(sphere.solid.boundaries().iter().cloned());
let body = guard("diagnostic compound", || Body::new(Solid::try_new(shells).unwrap())).unwrap();
let faces = body.faces(0.02).unwrap();
let error = face_planes(&body, &faces).err().unwrap();
let expected = std::collections::BTreeMap::from([("cylinder", 128), ("sphere", sphere_faces)]);
assert_eq!(error, NotPlanar::Curved(expected));
assert_eq!(error.describe("tool"), format!("the tool has {} curved faces (cylinder, sphere)", 128 + sphere_faces));
}
#[test]
fn planar_mesh_inconsistency_does_not_become_curved_classification() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(4.0, 5.0, 6.0)).unwrap();
let mut faces = body.faces(0.01).unwrap();
assert_eq!(face_planes(&body, &faces).unwrap().len(), 6);
faces[0].plane_normal = None;
assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("face 0 is planar but its mesh is not flat".into()));
faces[0].area = 0.0;
assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("face 0 could not be meshed".into()));
faces[0].index = 999;
assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("mesh is inconsistent".into()));
}
}
+434 -70
View File
@@ -120,68 +120,110 @@ struct Prism {
regions: Vec<Region2>,
}
/// Edges of a cap face as 2D segments (lines and arcs only).
fn seg2(e: &mt::Edge, plane: &Plane, tol: f64) -> Option<Vec<Seg2>> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
let pts: Vec<Vec2> = (0..=16).map(|k| plane.to_local(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0)))).collect();
let (a, b) = (*pts.first()?, *pts.last()?);
let closed = a.dist(b) < tol;
let seg_dist = |p: Vec2| {
let ab = b - a;
let t = ((p - a).dot(ab) / ab.dot(ab).max(1e-300)).clamp(0.0, 1.0);
(a + ab * t).dist(p)
/// Certify that a curved wall is swept along `d` from its surface representation.
/// Seam vertices alone cannot establish this: a half-cylinder's seam normals can
/// both be perpendicular to a transverse direction, silently filling its hole.
fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool {
if let mt::Surface::RevolutedCurve(processor) = surface {
let revolution = processor.entity();
let mt::Curve::Line(line) = revolution.entity_curve() else { return false };
let axis = revolution.axis();
let meridian = line.1 - line.0;
let local_axis = Vec3::new(axis.x, axis.y, axis.z);
let local_meridian = Vec3::new(meridian.x, meridian.y, meridian.z);
let (Some(a), Some(m)) = (local_axis.normalized(), local_meridian.normalized()) else { return false };
if a.cross(m).len() > 1e-9 {
return false; // A conical meridian is not a cylindrical wall.
}
let placed = *processor.transform() * mt::Vector4::new(axis.x, axis.y, axis.z, 0.0);
return placed.w.abs() < 1e-12 && Vec3::new(placed.x, placed.y, placed.z).normalized().is_some_and(|a| a.cross(d).len() < 1e-9);
}
let dimensions = match surface {
mt::Surface::BSplineSurface(s) => (s.control_points().len(), s.control_points().first().map_or(0, Vec::len)),
mt::Surface::NurbsSurface(s) => (s.control_points().len(), s.control_points().first().map_or(0, Vec::len)),
_ => return false,
};
if !closed && pts.iter().all(|p| seg_dist(*p) < tol) {
return Some(vec![Seg2::Line { a, b }]);
let (rows, cols) = dimensions;
let count = rows.saturating_mul(cols);
let rectangular = match surface {
mt::Surface::BSplineSurface(s) => s.control_points().iter().all(|r| r.len() == cols),
mt::Surface::NurbsSurface(s) => s.control_points().iter().all(|r| r.len() == cols),
_ => false,
};
if rows < 2 || cols < 2 || count > 16_384 || !rectangular {
return false;
}
// Circle through three samples.
let (p, q, r) = (a, *pts.get(5)?, *pts.get(11)?);
let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y));
if d.abs() < 1e-300 {
return None;
}
let sq = |v: Vec2| v.x * v.x + v.y * v.y;
let cx = (sq(p) * (q.y - r.y) + sq(q) * (r.y - p.y) + sq(r) * (p.y - q.y)) / d;
let cy = (sq(p) * (r.x - q.x) + sq(q) * (p.x - r.x) + sq(r) * (q.x - p.x)) / d;
let center = Vec2::new(cx, cy);
let radius = center.dist(a);
if !pts.iter().all(|x| (x.dist(center) - radius).abs() < tol) {
return None;
}
let ang = |v: Vec2| (v - center).angle();
let start = ang(a);
// Signed sweep by unwrapping the samples.
let mut sweep = 0.0;
let mut prev = start;
for x in pts.iter().skip(1) {
let mut da = ang(*x) - prev;
while da > std::f64::consts::PI {
da -= std::f64::consts::TAU;
let Some(left) = remaining.checked_sub(count.saturating_mul(2)) else { return false };
*remaining = left;
let points: Vec<Vec<(Vec3, f64)>> = match surface {
mt::Surface::BSplineSurface(s) => s.control_points().iter().map(|r| r.iter().map(|p| (from_p3(*p), 1.0)).collect()).collect(),
mt::Surface::NurbsSurface(s) => {
s.control_points().iter().map(|r| r.iter().map(|p| (Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w), p.w)).collect()).collect()
}
while da < -std::f64::consts::PI {
da += std::f64::consts::TAU;
_ => return false,
};
if points.iter().any(|r| r.len() != cols)
|| points.iter().flatten().any(|(p, w)| !w.is_finite() || *w <= 0.0 || [p.x, p.y, p.z].iter().any(|x| !x.is_finite()))
{
return false;
}
let max_weight = points.iter().flatten().map(|p| p.1).fold(0.0, f64::max);
if points.iter().flatten().any(|p| p.1 / max_weight < 1e-12) {
return false;
}
// Either parameter can be the extrusion direction. Projected positions must
// be invariant in that parameter; rational weights must also separate into
// a product of one factor per parameter, otherwise mixed weights bend the wall.
for transpose in [false, true] {
let (groups, length) = if transpose { (cols, rows) } else { (rows, cols) };
let at = |i: usize, j: usize| if transpose { points[j][i] } else { points[i][j] };
let mut valid = true;
let mut extended = false;
for i in 0..groups {
let (base, base_w) = at(i, 0);
for j in 0..length {
let (p, w) = at(i, j);
let delta = p - base;
let magnitude = [p.x.abs(), p.y.abs(), p.z.abs(), base.x.abs(), base.y.abs(), base.z.abs()].into_iter().fold(1.0, f64::max);
if delta.cross(d).len() > tol * 1e-3 + magnitude * f64::EPSILON * 128.0 {
valid = false;
break;
}
extended |= delta.dot(d).abs() > tol * 1e-3;
let lhs = w / max_weight * (at(0, 0).1 / max_weight);
let rhs = base_w / max_weight * (at(0, j).1 / max_weight);
if (lhs - rhs).abs() > 1e-12 * lhs.abs().max(rhs.abs()) {
valid = false;
break;
}
}
if !valid {
break;
}
}
if valid && extended {
return true;
}
sweep += da;
prev = ang(*x);
}
if closed {
let h = sweep / 2.0;
return Some(vec![Seg2::Arc { center, radius, start, sweep: h }, Seg2::Arc { center, radius, start: start + h, sweep: h }]);
}
Some(vec![Seg2::Arc { center, radius, start, sweep }])
false
}
/// The body as a prism along `d` (unit), seen in `plane` (normal `d`).
fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
use mt::{ParametricSurface3D, SearchNearestParameter};
// A simple extrusion with N section edges has N walls plus its cap faces.
// Even disconnected two-arc sections need at most 2N faces. Keep that
// complete planar arrangement envelope, independently of stepped limits.
let mut remaining_faces = MAX_PLANAR_SEGMENTS * 2;
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
let mut tops: Vec<&mt::Face> = Vec::new();
let mut top_h = Vec::new();
let mut bottom_h = Vec::new();
let mut wall_work = 262_144;
let mut curve_work = 2_000_000usize;
for f in b.solid.face_iter() {
remaining_faces = remaining_faces.checked_sub(1)?;
curve_work = curve_work.checked_sub(1)?;
let surf = f.oriented_surface();
if let mt::Surface::Plane(p) = &surf {
let n = p.normal();
@@ -203,15 +245,8 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
}
continue;
}
// Walls: normals square to d at sampled boundary points.
for w in f.boundaries() {
for v in w.vertex_iter() {
let (u, s) = surf.search_nearest_parameter(v.point(), None, 50)?;
let n = surf.normal(u, s);
if Vec3::new(n.x, n.y, n.z).dot(d).abs() > 1e-7 {
return None;
}
}
if !wall_along(&surf, d, tol, &mut wall_work) {
return None;
}
}
// One height for every top (and bottom) cap.
@@ -219,12 +254,17 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
return None;
}
let mut regions = Vec::new();
let mut remaining_segments = MAX_PLANAR_SEGMENTS;
let mut remaining_loops = MAX_PLANAR_SEGMENTS;
for f in tops {
let mut loops = Vec::new();
for w in f.boundaries() {
remaining_loops = remaining_loops.checked_sub(1)?;
let mut segs = Vec::new();
for e in w.edge_iter() {
segs.extend(seg2(e, plane, tol)?);
let segments = crate::prism_curve::segment(e, plane, tol, &mut curve_work)?;
remaining_segments = remaining_segments.checked_sub(segments.len())?;
segs.extend(segments);
}
loops.push(Loop2 { segs });
}
@@ -648,6 +688,85 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
/// Sew the exact sections between successive levels, including empty gaps.
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
if levels.len() != slabs.len() + 1 || slabs.len() > 128 || plane.normal().dot(d) < 1. - 1e-12 {
return None;
}
let mut blocks = Vec::new();
let mut start = None;
for (i, slab) in slabs.iter().enumerate() {
if slab.is_empty() {
if let Some(first) = start.take() {
blocks.push((first, i));
}
} else if start.is_none() {
start = Some(i);
}
}
if let Some(first) = start {
blocks.push((first, slabs.len()));
}
if blocks.is_empty() {
return None;
}
Some((|| {
let mut pieces = Vec::new();
let mut remaining_constructed_faces = MAX_PLANAR_SEGMENTS * 2;
let mut remaining_faces = MAX_PLANAR_SEGMENTS * 2;
for (first, end) in blocks {
let section = slabs.get(first).ok_or_else(|| KernelError::Failed("missing prism section".into()))?;
if slabs[first..end].iter().all(|s| s == section) {
let edge_count = section.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).flat_map(|l| &l.segs).fold(0usize, |n, s| {
n.saturating_add(match s {
Seg2::Arc { sweep, .. } if sweep.abs() > 1.5 * std::f64::consts::PI => 2,
_ => 1,
})
});
remaining_constructed_faces = remaining_constructed_faces
.checked_sub(edge_count.saturating_add(section.len().saturating_mul(2)))
.ok_or_else(|| KernelError::Failed("prism construction face budget".into()))?;
// This is a NEW exact extrusion of the current Boolean section,
// not copied lineage from either operand's old footprint.
for piece in crate::extrude(plane, section, levels[first], levels[end])? {
remaining_faces =
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
pieces.push(piece);
}
} else {
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol)
.ok_or_else(|| KernelError::Failed("stepped prism construction failed".into()))??;
remaining_faces =
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
pieces.push(piece);
}
}
let mut old = std::collections::HashMap::new();
let mut shells = Vec::new();
let mut remaining_map_faces = MAX_PLANAR_SEGMENTS * 2;
for piece in &pieces {
for (index, face) in piece.solid.face_iter().enumerate() {
remaining_map_faces = remaining_map_faces.checked_sub(1).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
let source = piece.face_sources().and_then(|s| s.get(index)).and_then(Option::as_ref);
if old.insert(face.id(), Some((face.orientation(), source))).is_some() {
old.insert(face.id(), None);
}
}
shells.extend(piece.solid.boundaries().iter().cloned());
}
let body = Body::new(crate::body::Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("prism compound: {e}")))?)?;
let sources = body
.solid
.face_iter()
.map(|face| match old.get(&face.id()) {
Some(Some((orientation, source))) if *orientation == face.orientation() => source.cloned(),
_ => None,
})
.collect();
Ok(body.with_face_sources(Some(std::sync::Arc::new(sources))))
})())
}
/// A genuinely stepped connected axial block has no single extrusion footprint.
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
// Faces at each level: up where the slab below sticks out, down where the one above does.
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
@@ -727,13 +846,18 @@ fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<
let tol = (size * 1e-7).max(1e-9) * 10.0;
// Candidate directions: a's planar face normals (each line once).
let mut dirs: Vec<Vec3> = Vec::new();
let mut remaining_direction_faces = MAX_PLANAR_SEGMENTS * 2;
for f in a.solid.face_iter() {
remaining_direction_faces = remaining_direction_faces.checked_sub(1)?;
if let mt::Surface::Plane(p) = f.oriented_surface() {
let n = p.normal();
if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized()
&& !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9)
{
dirs.push(if n.dot(Vec3::new(1e-3, 1e-6, 1.0)) < 0.0 { -n } else { n });
if dirs.len() == 8 {
break;
}
}
}
}
@@ -792,7 +916,6 @@ fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<
/// A stepped extrusion (case floor plus walls), recovered from its cap events.
/// Downward caps add a section above their level; upward caps remove one.
fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
use mt::{ParametricSurface3D, SearchNearestParameter};
if let Some(p) = prism_of(b, d, plane, tol) {
return Some(vec![p]);
}
@@ -800,6 +923,8 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
return None;
}
let mut events = Vec::new();
let mut wall_work = 262_144;
let mut curve_work = 2_000_000;
for f in b.solid.face_iter() {
let surf = f.oriented_surface();
if let mt::Surface::Plane(p) = &surf {
@@ -810,7 +935,7 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
for w in f.boundaries() {
let mut segs = Vec::new();
for e in w.edge_iter() {
segs.extend(seg2(e, plane, tol)?);
segs.extend(crate::prism_curve::segment(e, plane, tol, &mut curve_work)?);
}
loops.push(Loop2 { segs });
}
@@ -823,13 +948,8 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
return None;
}
} else {
// Curved walls must be extruded along d, not slanted or rounded caps.
for v in f.vertex_iter() {
let (u, w) = surf.search_nearest_parameter(v.point(), None, 50)?;
let n = surf.normal(u, w);
if Vec3::new(n.x, n.y, n.z).dot(d).abs() > 1e-7 {
return None;
}
if !wall_along(&surf, d, tol, &mut wall_work) {
return None;
}
}
}
@@ -862,9 +982,9 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
}
fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
// Common-cap joins and partial-depth cuts of aligned stepped prisms can
// Common-cap joins and overlapping partial-depth cuts of aligned stepped prisms can
// retain their exact section boundaries instead of intersecting meshes.
// Other extents retain their existing B-rep path.
// Nonaligned bodies retain their existing B-rep path.
if !matches!(op, BoolOp::Union | BoolOp::Cut) {
return None;
}
@@ -892,8 +1012,12 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
let pa = stack_of(a, d, &plane, tol)?;
let pb = stack_of(b, d, &plane, tol)?;
let (ea, eb) = (ends(&pa), ends(&pb));
if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol {
return None;
match op {
BoolOp::Union if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol => return None,
// A cut tool may extend past a cap into empty space. Exact slab sections
// discard that space rather than intersecting coincident cylinder walls.
BoolOp::Cut if ea.1 <= eb.0 + tol || eb.1 <= ea.0 + tol => return None,
_ => {}
}
Some((d, plane, pa, pb))
})?;
@@ -945,6 +1069,246 @@ mod planar_tests {
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
}
#[test]
fn simple_prism_retains_large_analytic_section_budget() {
let count = 2050;
let radius = 1000.;
let points: Vec<_> = (0..count)
.map(|i| {
let angle = std::f64::consts::TAU * i as f64 / count as f64;
Vec2::new(radius * angle.cos(), radius * angle.sin())
})
.collect();
let region = Region2 { outer: Loop2::polygon(&points), holes: vec![] };
let stock = crate::extrude(&Plane::XY, &[region], 0., 2.).unwrap().pop().unwrap();
assert_eq!(stock.face_count(), count + 2);
assert!(stock.face_count() > 2048 && stock.validity().is_empty());
let before: Vec<_> = stock.solid.vertex_iter().map(|v| (v.id(), v.point())).collect();
let recovered = prism_of(&stock, Vec3::Z, &Plane::XY, 1e-5).expect("large simple line prism remains supported");
assert_eq!(recovered.regions.len(), 1);
assert_eq!(recovered.regions[0].outer.segs.len(), count);
assert!(recovered.regions[0].outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. })));
let area = count as f64 * radius * radius * (std::f64::consts::TAU / count as f64).sin() / 2.;
assert!((recovered.regions[0].area() - area).abs() < area * 1e-12);
assert!((recovered.lo - 0.).abs() < 1e-8 && (recovered.hi - 2.).abs() < 1e-8);
let cutter = crate::box_solid(Vec3::new(-1001., -1001., -1.), Vec3::new(1001., 1001., 3.)).unwrap();
assert!(
simple_prism_boolean(&stock, &cutter, BoolOp::Cut)
.expect("large analytic section routes through the exact simple path")
.unwrap()
.is_none()
);
assert_eq!(stock.solid.vertex_iter().map(|v| (v.id(), v.point())).collect::<Vec<_>>(), before);
}
#[test]
fn curved_stop_cut_constructs_fresh_current_cap_for_only_uniform_lump() {
let cylinder = crate::cylinder(Vec3::new(25., 25., 0.), Vec3::Z, 2., 20.).unwrap();
let stop = crate::box_solid(Vec3::new(19., 19., 1.), Vec3::new(31., 31., 2.)).unwrap();
let roof = crate::boolean(&cylinder, &stop, BoolOp::Union).unwrap().unwrap();
let original = rectangle(Vec2::new(20., 20.), Vec2::new(30., 30.));
let tool = crate::extrude(&Plane::XY, &[original], 0., 25.).unwrap().pop().unwrap();
let before_tool = crate::step_export(&[&tool], "tool").unwrap();
let before_roof = crate::step_export(&[&roof], "roof").unwrap();
let trimmed = crate::boolean(&tool, &roof, BoolOp::Cut).unwrap().unwrap();
let lumps = trimmed.lumps().unwrap();
assert_eq!(lumps.len(), 2);
let mut lower = 0;
let mut upper = 0;
for lump in &lumps {
assert!(lump.validity().is_empty());
let mesh = lump.tessellate(1e-5).unwrap();
if mesh.contains(Vec3::new(21., 21., 0.5)) {
lower += 1;
assert!(!mesh.contains(Vec3::new(25., 25., 0.5)));
assert!(!mesh.contains(Vec3::new(21., 21., 1.5)));
assert!((mesh.measure().volume - (100. - 4. * std::f64::consts::PI)).abs() < 0.002);
let sources = lump.face_sources().expect("fresh actual extrusion has complete sources");
assert_eq!(sources.len(), lump.face_count());
assert!(sources.iter().all(Option::is_some));
let mut caps = 0;
for source in sources.iter().flatten() {
match source {
crate::FaceSource::ExtrudeCap { region, plane, .. } => {
assert_eq!(region.holes.len(), 1);
assert!((region.area() - (100. - 4. * std::f64::consts::PI)).abs() < 1e-8);
assert!(plane.normal().z.abs() > 1. - 1e-10);
assert!((plane.origin.z - if plane.normal().z > 0. { 1. } else { 0. }).abs() < 1e-8);
caps += 1;
}
crate::FaceSource::ExtrudeSide { .. } => {}
_ => panic!("old producer lineage must not survive a changed Boolean footprint"),
}
}
assert_eq!(caps, 2);
} else {
upper += 1;
assert!(mesh.contains(Vec3::new(21., 21., 3.)));
assert!(!mesh.contains(Vec3::new(25., 25., 3.)));
assert!(mesh.contains(Vec3::new(25., 25., 22.)));
assert!(lump.face_sources().is_none_or(|s| s.iter().all(Option::is_none)), "genuinely stepped geometry remains uncertified");
assert!((mesh.measure().volume - (18. * (100. - 4. * std::f64::consts::PI) + 500.)).abs() < 0.02);
}
}
assert_eq!((lower, upper), (1, 1));
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
}
#[test]
fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() {
let plane = Plane::XY;
let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0));
region.holes.push(Loop2::circle(Vec2::new(20.0, 15.0), 5.0));
let mut stock = crate::extrude(&plane, std::slice::from_ref(&region), 0.0, 20.0).unwrap().pop().unwrap();
for (lo, hi) in [(20.0, 30.0), (30.0, 35.0)] {
let positive = crate::extrude(&plane, std::slice::from_ref(&region), lo, hi).unwrap().pop().unwrap();
stock = crate::boolean(&stock, &positive, BoolOp::Union).unwrap().unwrap();
}
let mut grown = rectangle(Vec2::new(-0.3, -0.3), Vec2::new(40.3, 30.3));
grown.holes = region.holes;
let tool = crate::extrude(&plane, &[grown], 30.0, 35.6).unwrap().pop().unwrap();
let tol = stock.size().max(tool.size()) * 1e-6;
let a = stack_of(&stock, Vec3::Z, &plane, tol).expect("annular stock qualifies");
let b = stack_of(&tool, Vec3::Z, &plane, tol).expect("overrun tool qualifies");
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 1);
assert!((a[0].lo - 0.0).abs() < tol && (a[0].hi - 35.0).abs() < tol);
assert!((b[0].lo - 30.0).abs() < tol && (b[0].hi - 35.6).abs() < tol);
assert_eq!(a[0].regions[0].holes.len(), 1);
assert_eq!(b[0].regions[0].holes.len(), 1);
assert!((a[0].regions[0].area() - (1200.0 - 25.0 * std::f64::consts::PI)).abs() < 1e-8);
assert!(simple_prism_boolean(&stock, &tool, BoolOp::Cut).is_none());
let result = stack_boolean(&stock, &tool, BoolOp::Cut).expect("qualified overlapping stacks").unwrap().unwrap();
assert_eq!(result.shell_count(), 1);
assert!(result.validity().is_empty());
let mesh = result.tessellate(1e-5).unwrap();
let expected = (1200.0 - 25.0 * std::f64::consts::PI) * 30.0;
// An inscribed circular mesh at radial chord error 1e-5 has volume
// deficit below 2*pi*R*height*error, conservatively 0.01 mm^3 here.
assert!((mesh.measure().volume - expected).abs() < 0.02);
for (point, present) in [
(Vec3::new(5.0, 5.0, 29.0), true),
(Vec3::new(5.0, 5.0, 31.0), false),
(Vec3::new(20.0, 15.0, 29.0), false),
(Vec3::new(24.8, 15.0, 29.0), false),
(Vec3::new(25.2, 15.0, 29.0), true),
] {
assert_eq!(mesh.contains(point), present, "{point:?}");
}
}
#[test]
fn annular_partial_cut_overruns_and_interior_lumps_preserve_material() {
let area = 1200.0 - 25.0 * std::f64::consts::PI;
for seam in [0.0, 0.37] {
for plane in [Plane::XY, Plane::new(Vec3::new(30.0, 20.0, 10.0), Vec3::X, Vec3::Z).unwrap()] {
let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0));
region.holes.push(Loop2::circle_from(Vec2::new(20.0, 15.0), 5.0, seam));
let stock = crate::extrude(&plane, std::slice::from_ref(&region), 0.0, 35.0).unwrap().pop().unwrap();
let before = crate::step_export(&[&stock], "source").unwrap();
for (lo, hi, retained, shells) in [(30.0, 35.6, 30.0, 1), (-0.6, 5.0, 30.0, 1), (10.0, 15.0, 30.0, 2), (30.0, 35.0, 30.0, 1)] {
let tool = crate::extrude(&plane, std::slice::from_ref(&region), lo, hi).unwrap().pop().unwrap();
let result = stack_boolean(&stock, &tool, BoolOp::Cut).expect("exact aligned cut path").unwrap().unwrap();
assert_eq!(result.shell_count(), shells);
assert!(result.validity().is_empty());
let mesh = result.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - retained * area).abs() < 0.02);
let at = |x: f64, y: f64, z: f64| plane.to_world(Vec2::new(x, y)) + plane.normal() * z;
for z in [2.5, 12.5, 32.5] {
let material = !(lo < z && z < hi);
assert_eq!(mesh.contains(at(5.0, 5.0, z)), material);
assert!(!mesh.contains(at(20.0, 15.0, z)));
assert_eq!(mesh.contains(at(25.2, 15.0, z)), material);
}
let public = crate::boolean(&stock, &tool, BoolOp::Cut).unwrap().unwrap();
assert!((public.tessellate(1e-5).unwrap().measure().volume - retained * area).abs() < 0.02);
if lo == 30.0 && hi > 35.0 {
let imported = crate::step_import(&crate::step_export(&[&result], "inward annular cut").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
let saved = &imported.bodies.first().unwrap().body;
assert!((saved.tessellate(1e-5).unwrap().measure().volume - 30.0 * area).abs() < 0.02);
let next = crate::extrude(&plane, std::slice::from_ref(&region), 25.0, 31.0).unwrap().pop().unwrap();
let shortened = crate::boolean(saved, &next, BoolOp::Cut).unwrap().unwrap();
let next_volume = shortened.tessellate(1e-5).unwrap().measure().volume;
assert!(
(next_volume - 25.0 * area).abs() < 0.02,
"seam {seam}, plane {plane:?}, next cut volume {next_volume}, expected {}",
25.0 * area
);
assert!(shortened.contains(at(5.0, 5.0, 24.0)));
assert!(!shortened.contains(at(5.0, 5.0, 26.0)));
assert!(!shortened.contains(at(20.0, 15.0, 24.0)));
}
}
let clear = crate::extrude(&plane, std::slice::from_ref(&region), 36.0, 40.0).unwrap().pop().unwrap();
assert!(stack_boolean(&stock, &clear, BoolOp::Cut).is_none());
let unchanged = crate::boolean(&stock, &clear, BoolOp::Cut).unwrap().unwrap();
assert!(std::sync::Arc::ptr_eq(&stock.solid, &unchanged.solid), "disjoint Cut must retain exact source topology");
let unchanged_mesh = unchanged.tessellate(1e-5).unwrap();
let source_mesh = stock.tessellate(1e-5).unwrap();
assert_eq!(unchanged_mesh.measure().volume, source_mesh.measure().volume);
// tessellate() floors tolerance at body.size()*1e-6. The arc
// polygon's area error is at most perimeter*radial chord error.
let bound = std::f64::consts::TAU * 5.0 * 35.0 * (stock.size() * 1e-6).max(1e-5) + 1e-5;
assert!((unchanged_mesh.measure().volume - 35.0 * area).abs() < bound);
assert!(!unchanged_mesh.contains(plane.to_world(Vec2::new(20.0, 15.0)) + plane.normal() * 12.5));
let all = crate::extrude(&plane, std::slice::from_ref(&region), -1.0, 36.0).unwrap().pop().unwrap();
assert!(stack_boolean(&stock, &all, BoolOp::Cut).unwrap().unwrap().is_none());
assert_eq!(crate::step_export(&[&stock], "source").unwrap(), before);
}
}
}
#[test]
fn annular_partial_cut_preserves_unequal_radii_and_conservative_refusals() {
let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0));
region.holes.push(Loop2::circle(Vec2::new(20.0, 15.0), 5.0));
let stock = crate::extrude(&Plane::XY, std::slice::from_ref(&region), 0.0, 35.0).unwrap().pop().unwrap();
let before = crate::step_export(&[&stock], "unchanged source").unwrap();
let mut larger_hole = region.clone();
larger_hole.holes = vec![Loop2::circle(Vec2::new(20.0, 15.0), 5.1)];
let tool = crate::extrude(&Plane::XY, &[larger_hole], 30.0, 36.0).unwrap().pop().unwrap();
let result = stack_boolean(&stock, &tool, BoolOp::Cut).unwrap().unwrap().unwrap();
let expected = (1200.0 - 25.0 * std::f64::consts::PI) * 30.0 + std::f64::consts::PI * (5.1_f64.powi(2) - 25.0) * 5.0;
let mesh = result.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 0.02);
assert!(mesh.contains(Vec3::new(25.05, 15.0, 32.0)));
assert!(!mesh.contains(Vec3::new(25.2, 15.0, 32.0)));
assert!(!mesh.contains(Vec3::new(20.0, 15.0, 32.0)));
let interior = crate::extrude(&Plane::XY, std::slice::from_ref(&region), 10.0, 15.0).unwrap().pop().unwrap();
for d in [Vec3::X, Vec3::Y] {
let plane = Plane::from_normal(Vec3::ZERO, d).unwrap();
let tol = stock.size() * 1e-6;
assert!(stack_of(&stock, d, &plane, tol).is_none(), "transverse cylinder axis must not fill the hole");
assert!(stack_of(&interior, d, &plane, tol).is_none());
}
assert!(stack_boolean(&stock, &interior, BoolOp::Union).is_none(), "Union retains its common-cap policy");
let tapered = crate::extrude_tapered(&Plane::XY.offset(30.0), &rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0)), 6.0, 1.0, 0.05).unwrap();
assert!(stack_boolean(&stock, &tapered, BoolOp::Cut).is_none(), "slanted walls are not aligned prisms");
let curved_cap = crate::sphere(Vec3::new(20.0, 15.0, 35.0), 5.0).unwrap();
assert!(stack_boolean(&stock, &curved_cap, BoolOp::Cut).is_none());
let pieces =
(0..350).map(|i| crate::box_solid(Vec3::new(i as f64 * 3.0, 0.0, 0.0), Vec3::new(i as f64 * 3.0 + 1.0, 1.0, 1.0)).unwrap()).collect();
let over_budget = crate::ops::join_pieces(pieces).unwrap().unwrap();
assert!(over_budget.face_count() > 2048);
assert!(stack_boolean(&over_budget, &interior, BoolOp::Cut).is_none());
assert_eq!(crate::step_export(&[&stock], "unchanged source").unwrap(), before);
}
#[test]
fn curved_prism_wall_requires_positive_separable_rational_weights() {
let surface = |mixed: bool| {
let weight = if mixed { 2.0 } else { 1.0 };
let points = vec![
vec![mt::Vector4::new(0.0, 0.0, 0.0, 1.0), mt::Vector4::new(0.0, 0.0, 10.0, 1.0)],
vec![mt::Vector4::new(5.0, 0.0, 0.0, 1.0), mt::Vector4::new(5.0 * weight, 0.0, 10.0 * weight, weight)],
];
mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
(mt::KnotVec::bezier_knot(1), mt::KnotVec::bezier_knot(1)),
points,
)))
};
assert!(wall_along(&surface(false), Vec3::Z, 1e-5, &mut 1000));
assert!(!wall_along(&surface(true), Vec3::Z, 1e-5, &mut 1000), "parallel Euclidean controls alone do not certify a rational sweep");
assert!(!wall_along(&surface(false), Vec3::Y, 1e-5, &mut 1000));
assert!(!wall_along(&surface(false), Vec3::Z, 1e-5, &mut 0));
}
#[test]
fn partial_depth_cuts_touching_a_through_hole_use_exact_stacked_sections() {
let plane = Plane::named("XY").unwrap();
let mut plate = rectangle(Vec2::ZERO, Vec2::new(100., 60.));
+409
View File
@@ -0,0 +1,409 @@
//! Whole-span certificates for analytic planar prism boundaries.
//! Samples propose a circle; only positive rational Bernstein bounds accept it.
use crate::body::from_p3;
use solvecraft_geom::{Plane, Seg2, Vec2};
use truck_modeling::{self as mt, BoundedCurve, Invertible, ParametricCurve};
const MAX_DEGREE: usize = 32;
const MAX_CONTROLS: usize = 4096;
type H = [f64; 3];
fn charge(work: &mut usize, n: usize) -> Option<()> {
*work = work.checked_sub(n)?;
Some(())
}
fn preflight(c: &mt::Curve, work: &mut usize) -> Option<()> {
charge(work, 1)?;
let (knots, controls) = match c {
mt::Curve::Line(_) => return Some(()),
mt::Curve::BSplineCurve(b) => (b.knot_vec(), b.control_points().len()),
mt::Curve::NurbsCurve(b) => (b.knot_vec(), b.control_points().len()),
_ => return None,
};
let degree = knots.len().checked_sub(controls + 1)?;
if !(1..=MAX_DEGREE).contains(&degree) || controls > MAX_CONTROLS || knots.len() > 8192 {
return None;
}
if knots.iter().any(|t| !t.is_finite()) || knots.windows(2).any(|w| w[1] < w[0]) {
return None;
}
// Charge decomposition before its cloning / knot insertion allocations.
charge(work, controls.checked_mul(knots.len())?.checked_mul(degree + 1)?)
}
fn controls(c: &mt::Curve, plane: &Plane, h: f64, tol: f64) -> Option<(Vec<Vec<H>>, f64)> {
let spans: Vec<Vec<mt::Vector4>> = match c {
mt::Curve::BSplineCurve(b) => {
b.bezier_decomposition().into_iter().map(|s| s.control_points().iter().map(|p| mt::Vector4::new(p.x, p.y, p.z, 1.)).collect()).collect()
}
mt::Curve::NurbsCurve(b) => b.non_rationalized().bezier_decomposition().into_iter().map(|s| s.control_points().clone()).collect(),
_ => return None,
};
if spans.is_empty() || spans.len() > 256 || spans.iter().map(Vec::len).sum::<usize>() > 8192 {
return None;
}
let mut error: f64 = 0.;
let mut out = Vec::new();
for s in spans {
if !(2..=MAX_DEGREE + 1).contains(&s.len()) {
return None;
}
let max_w = s.iter().map(|p| p.w).fold(0., f64::max);
if !max_w.is_finite() || max_w <= 0. {
return None;
}
let mut span = Vec::new();
for p in s {
if ![p.x, p.y, p.z, p.w].iter().all(|x| x.is_finite()) || p.w / max_w < 1e-12 {
return None;
}
let v = from_p3(mt::Point3::new(p.x / p.w, p.y / p.w, p.z / p.w));
let local = plane.to_local(v);
let magnitude = v.x.abs() + v.y.abs() + v.z.abs() + plane.origin.len();
let e = magnitude * f64::EPSILON * 256.;
if !local.x.is_finite() || !local.y.is_finite() || (plane.height(v) - h).abs() + e > tol {
return None;
}
error = error.max(e);
let w = p.w / max_w;
span.push([local.x * w, local.y * w, w]);
}
out.push(span);
}
(error < tol * 0.1).then_some((out, error))
}
fn product(a: &[f64], b: &[f64], work: &mut usize) -> Option<Vec<f64>> {
charge(work, a.len().checked_mul(b.len())?)?;
let (m, n) = (a.len() - 1, b.len() - 1);
let choose = |degree: usize| {
let mut v = vec![1.; degree + 1];
for i in 1..=degree {
v[i] = v[i - 1] * (degree + 1 - i) as f64 / i as f64;
}
v
};
let (cm, cn, ct) = (choose(m), choose(n), choose(m + n));
let mut out = vec![0.; m + n + 1];
for (i, ai) in a.iter().enumerate() {
for (j, bj) in b.iter().enumerate() {
out[i + j] += ai * bj * cm[i] * cn[j] / ct[i + j];
}
}
out.iter().all(|x| x.is_finite()).then_some(out)
}
fn derivative(a: &[f64]) -> Vec<f64> {
let n = (a.len() - 1) as f64;
a.windows(2).map(|w| (w[1] - w[0]) * n).collect()
}
fn determinant(a: &[f64], b: &[f64], work: &mut usize) -> Option<(Vec<f64>, f64)> {
let (ab, ba) = (product(a, &derivative(b), work)?, product(b, &derivative(a), work)?);
let max = |v: &[f64]| v.iter().map(|x| x.abs()).fold(0., f64::max);
// Bernstein product weights are positive and sum to one. Bound absolute
// summands from the inputs, never from a possibly cancelled output sum.
let scale = max(a) * max(&derivative(b)) + max(b) * max(&derivative(a));
let error = scale * f64::EPSILON * 128. * (a.len() * a.len()) as f64;
Some((ab.iter().zip(&ba).map(|(x, y)| x - y).collect(), error))
}
fn split(c: &[H]) -> (Vec<H>, Vec<H>) {
let mut q = c.to_vec();
let mut left = vec![q[0]];
let mut right = vec![q[q.len() - 1]];
while q.len() > 1 {
q = q.windows(2).map(|p| std::array::from_fn(|i| (p[0][i] + p[1][i]) * 0.5)).collect();
left.push(q[0]);
right.push(q[q.len() - 1]);
}
right.reverse();
(left, right)
}
fn point(p: H) -> Vec2 {
Vec2::new(p[0] / p[2], p[1] / p[2])
}
fn line_span(c: &[H], a: Vec2, b: Vec2, tol: f64, error: f64, work: &mut usize) -> Option<()> {
let d = b - a;
let length = d.len();
if length <= tol {
return None;
}
let d = d / length;
let mut projected = Vec::new();
let weights: Vec<_> = c.iter().map(|p| p[2]).collect();
for p in c {
let v = point(*p) - a;
let t = v.dot(d);
if (v.x * d.y - v.y * d.x).abs() + error > tol || t < -tol + error || t > length + tol - error {
return None;
}
projected.push(t * p[2]);
}
// W*T' - T*W' proves oriented traversal, rather than merely collinearity.
let (der, mut err) = determinant(&weights, &projected, work)?;
err += error * (c.len() * c.len()) as f64 * 32.;
if !der.iter().all(|d| *d > err) {
return None;
}
Some(())
}
fn circle_span(c: &[H], center: Vec2, radius: f64, tol: f64, source_error: f64, depth: usize, work: &mut usize) -> Option<f64> {
charge(work, c.len())?;
let x: Vec<_> = c.iter().map(|p| p[0] - center.x * p[2]).collect();
let y: Vec<_> = c.iter().map(|p| p[1] - center.y * p[2]).collect();
let w: Vec<_> = c.iter().map(|p| p[2]).collect();
let min_w = w.iter().copied().fold(f64::INFINITY, f64::min);
if min_w < 1e-12 {
return None;
}
let (xx, yy, ww) = (product(&x, &x, work)?, product(&y, &y, work)?, product(&w, &w, work)?);
let max = |v: &[f64]| v.iter().map(|x| x.abs()).fold(0., f64::max);
let scale = max(&x).powi(2) + max(&y).powi(2) + radius * radius * max(&w).powi(2);
let coordinate_error = source_error + (center.len() + radius) * f64::EPSILON * (depth + 1) as f64 * 128. * c.len() as f64;
let roundoff = scale * f64::EPSILON * 256. * (c.len() * c.len()) as f64;
let residual = xx.iter().zip(&yy).zip(&ww).map(|((a, b), w)| (a + b - radius * radius * w).abs()).fold(0., f64::max);
let radial_tol = tol - coordinate_error;
if radial_tol <= 0. || (residual + roundoff) / (min_w * min_w) > radial_tol * (2. * radius - radial_tol) {
return None;
}
let (angular, mut error) = determinant(&x, &y, work)?;
let extent = x.iter().chain(&y).map(|v| v.abs()).fold(0., f64::max);
error += coordinate_error * extent * (c.len() * c.len()) as f64 * 32.;
let positive = angular.iter().all(|v| *v > error);
let negative = angular.iter().all(|v| *v < -error);
let a = point(c[0]) - center;
let b = point(c[c.len() - 1]) - center;
let mid = a + b;
// One open angular half-plane plus a derivative sign rules out hidden turns.
let half_plane = mid.len() > radius * 1e-8 && c.iter().all(|p| (point(*p) - center).dot(mid) > coordinate_error * mid.len());
if (positive || negative) && half_plane {
let cross = a.x * b.y - a.y * b.x;
let sweep = cross.atan2(a.dot(b));
if (positive && sweep > 0.) || (negative && sweep < 0.) {
return Some(sweep);
}
return None;
}
if depth >= 12 {
return None;
}
let (left, right) = split(c);
let a = circle_span(&left, center, radius, tol, source_error, depth + 1, work)?;
let b = circle_span(&right, center, radius, tol, source_error, depth + 1, work)?;
(a.signum() == b.signum()).then_some(a + b)
}
pub(super) fn segment(e: &mt::Edge, plane: &Plane, tol: f64, work: &mut usize) -> Option<Vec<Seg2>> {
if !tol.is_finite() || tol <= 0. {
return None;
}
let mut c = e.curve();
preflight(&c, work)?;
if !e.orientation() {
c.invert();
}
let (t0, t1) = c.range_tuple();
if !t0.is_finite() || !t1.is_finite() || t1 <= t0 {
return None;
}
let (a3, b3) = (from_p3(c.subs(t0)), from_p3(c.subs(t1)));
let (a, b) = (plane.to_local(a3), plane.to_local(b3));
if !a3.is_finite()
|| !b3.is_finite()
|| ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite())
|| a3.dist(from_p3(e.front().point())) > tol
|| b3.dist(from_p3(e.back().point())) > tol
{
return None;
}
if matches!(c, mt::Curve::Line(_)) {
let error = (a3.len() + b3.len() + plane.origin.len()) * f64::EPSILON * 256.;
return (error < tol * 0.1 && a.dist(b) > tol && (plane.height(a3) - plane.height(b3)).abs() + error <= tol)
.then_some(vec![Seg2::Line { a, b }]);
}
let (spans, error) = controls(&c, plane, plane.height(a3), tol)?;
if spans.windows(2).any(|pair| point(pair[0][pair[0].len() - 1]).dist(point(pair[1][0])) + 2. * error > tol)
|| point(spans.first()?.first().copied()?).dist(a) + error > tol
|| point(spans.last()?.last().copied()?).dist(b) + error > tol
{
return None;
}
if a.dist(b) > tol && spans.iter().all(|s| line_span(s, a, b, tol, error, work).is_some()) {
return Some(vec![Seg2::Line { a, b }]);
}
// Samples are only a candidate proposal. Whole-span implicit and oriented
// angular proofs below are required before converting any spline to an arc.
let q = plane.to_local(from_p3(c.subs(t0 + (t1 - t0) * 5. / 16.))) - a;
let r = plane.to_local(from_p3(c.subs(t0 + (t1 - t0) * 11. / 16.))) - a;
let det = 2. * (q.x * r.y - q.y * r.x);
if !det.is_finite() || det.abs() < 1e-300 {
return None;
}
let offset = Vec2::new((q.dot(q) * r.y - r.dot(r) * q.y) / det, (q.x * r.dot(r) - r.x * q.dot(q)) / det);
let (center, radius) = (a + offset, offset.len());
if !radius.is_finite() || radius <= tol || !center.x.is_finite() || !center.y.is_finite() {
return None;
}
let mut sweep: f64 = 0.;
for span in &spans {
let ds = circle_span(span, center, radius, tol, error, 0, work)?;
if sweep != 0. && sweep.signum() != ds.signum() {
return None;
}
sweep += ds;
}
let start = (a - center).angle();
let end = center + Vec2::new((start + sweep).cos(), (start + sweep).sin()) * radius;
if end.dist(b) + error > tol {
return None;
}
let closed = a.dist(b) < tol;
let angular_tol = tol / radius;
if sweep.abs() > std::f64::consts::TAU + angular_tol || sweep.abs() < angular_tol {
return None;
}
if closed {
if (sweep.abs() - std::f64::consts::TAU).abs() > angular_tol {
return None;
}
let h = sweep.signum() * std::f64::consts::PI;
Some(vec![Seg2::Arc { center, radius, start, sweep: h }, Seg2::Arc { center, radius, start: start + h, sweep: h }])
} else {
Some(vec![Seg2::Arc { center, radius, start, sweep }])
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use mt::builder;
fn choose(n: usize, k: usize) -> f64 {
(0..k).fold(1., |v, i| v * (n - i) as f64 / (i + 1) as f64)
}
fn bernstein(power: &[f64], degree: usize) -> Vec<f64> {
(0..=degree).map(|i| (0..=i.min(power.len() - 1)).map(|k| power[k] * choose(i, k) / choose(degree, k)).sum()).collect()
}
fn multiply(a: &[f64], b: &[f64]) -> Vec<f64> {
let mut out = vec![0.; a.len() + b.len() - 1];
for (i, x) in a.iter().enumerate() {
for (j, y) in b.iter().enumerate() {
out[i + j] += x * y;
}
}
out
}
fn trap() -> Vec<f64> {
let mut p = vec![1.];
for k in 0..=16 {
p = multiply(&p, &[-(k as f64) / 16., 1.]);
}
let mid = (0..=16).map(|k| 1. / 32. - k as f64 / 16.).product::<f64>();
p.iter_mut().for_each(|v| *v /= mid);
p
}
fn edge(x: &[f64], y: &[f64], w: &[f64], degree: usize) -> mt::Edge {
let (x, y, w) = (bernstein(x, degree), bernstein(y, degree), bernstein(w, degree));
let controls = x.iter().zip(y).zip(w).map(|((x, y), w)| mt::Vector4::new(*x, y, 0., w)).collect();
let c = mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::bezier_knot(degree), controls)));
let a = mt::Vertex::new(c.subs(0.));
let b = mt::Vertex::new(c.subs(1.));
mt::Edge::new(&a, &b, c)
}
#[test]
fn sample_aliasing_does_not_certify_spline_caps() {
let e = edge(&[0., 10.], &trap(), &[1.], 17);
for k in 0..=16 {
assert!(e.curve().subs(k as f64 / 16.).y.abs() < 1e-4, "old sample locations must see a line");
}
assert!((e.curve().subs(1. / 32.).y - 1.).abs() < 1e-6);
assert!(segment(&e, &Plane::XY, 1e-3, &mut 2_000_000).is_none());
let front = e.front();
let back = e.back();
let low_right = mt::Vertex::new(mt::Point3::new(10., -3., 0.));
let low_left = mt::Vertex::new(mt::Point3::new(0., -3., 0.));
let wire: mt::Wire =
vec![e.clone(), builder::line(back, &low_right), builder::line(&low_right, &low_left), builder::line(&low_left, front)].into();
let cap = builder::try_attach_plane(&[wire]).unwrap();
let body = crate::body::Body::new(builder::tsweep(&cap, mt::Vector3::new(0., 0., 1.))).unwrap();
assert!(body.validity().is_empty());
let tool = crate::box_solid(solvecraft_geom::Vec3::new(-1., -4., 0.5), solvecraft_geom::Vec3::new(12., 7., 1.)).unwrap();
assert!(
crate::prism::prism_boolean(&body, &tool, crate::BoolOp::Cut).is_none(),
"an actual extruded aliased spline cap must reach the generic route"
);
let q = trap();
let mut perturb = q.iter().map(|v| v * 0.1).collect::<Vec<_>>();
perturb[0] += 1.;
let w = std::f64::consts::FRAC_1_SQRT_2;
let x = multiply(&[10., 20. * (w - 1.), 10. * (1. - 2. * w)], &perturb);
let y = multiply(&[0., 20. * w, 10. * (1. - 2. * w)], &perturb);
let circle = edge(&x, &y, &[1., 2. * (w - 1.), 2. * (1. - w)], 19);
for k in 0..=16 {
let p = circle.curve().subs(k as f64 / 16.);
assert!((p.x.hypot(p.y) - 10.).abs() < 1e-3, "old sample locations must see a circle");
}
let p = circle.curve().subs(1. / 32.);
assert!((p.x.hypot(p.y) - 11.).abs() < 1e-5);
assert!(segment(&circle, &Plane::XY, 1e-3, &mut 2_000_000).is_none());
}
#[test]
fn genuine_curves_and_oriented_traversal_are_certified() {
let a = mt::Vertex::new(mt::Point3::new(10., 0., 0.));
let b = mt::Vertex::new(mt::Point3::new(0., 10., 0.));
let arc = builder::circle_arc(&a, &b, mt::Point3::new(10. / 2f64.sqrt(), 10. / 2f64.sqrt(), 0.));
for e in [&arc, &arc.inverse()] {
let result = segment(e, &Plane::XY, 1e-5, &mut 2_000_000).unwrap();
let Seg2::Arc { center, radius, sweep, .. } = result[0] else { panic!("expected arc") };
assert!(center.len() < 1e-8 && (radius - 10.).abs() < 1e-8);
assert!((sweep.abs() - std::f64::consts::FRAC_PI_2).abs() < 1e-8);
assert_eq!(sweep > 0., e.orientation());
}
let line = edge(&[0., 10.], &[0.], &[1.], 17);
assert!(matches!(segment(&line, &Plane::XY, 1e-5, &mut 2_000_000).unwrap()[0], Seg2::Line { .. }));
// The longitudinal derivative is negative at t=1/3, independently
// establishing genuine retracing rather than a stationary tangent.
let backtrack = edge(&[0., 10., -35., 35.], &[0.], &[1.], 3);
assert!(backtrack.curve().der(1. / 3.).x < -1.);
assert!(segment(&backtrack, &Plane::XY, 1e-5, &mut 2_000_000).is_none());
assert!(segment(&arc, &Plane::XY, 1e-5, &mut 0).is_none());
}
#[test]
fn discontinuous_and_multiple_winding_curves_decline() {
let edge_from = |knots: Vec<f64>, points: Vec<mt::Vector4>| {
let c = mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::from(knots), points)));
let (lo, hi) = c.range_tuple();
mt::Edge::new(&mt::Vertex::new(c.subs(lo)), &mt::Vertex::new(c.subs(hi)), c)
};
let w = std::f64::consts::FRAC_1_SQRT_2;
let p = |x, y, w| mt::Vector4::new(x, y, 0., w);
let disconnected = edge_from(
vec![0., 0., 0., 1., 1., 1., 2., 2., 2.],
vec![p(10., 0., 1.), p(10. * w, 10. * w, w), p(0., 10., 1.), p(-10., 0., 1.), p(-10. * w, -10. * w, w), p(0., -10., 1.)],
);
assert!(segment(&disconnected, &Plane::XY, 1e-5, &mut 2_000_000).is_none());
let skipped_line = edge_from(vec![0., 0., 1., 1., 2., 2.], vec![p(0., 0., 1.), p(2., 0., 1.), p(5., 0., 1.), p(10., 0., 1.)]);
assert!(segment(&skipped_line, &Plane::XY, 1e-5, &mut 2_000_000).is_none());
let mut points = vec![p(10., 0., 1.)];
let mut knots = vec![0., 0., 0.];
for i in 0..8 {
let a = i as f64 * std::f64::consts::FRAC_PI_2;
let b = a + std::f64::consts::FRAC_PI_2;
points.push(p(10. * ((a + b) / 2.).cos(), 10. * ((a + b) / 2.).sin(), w));
points.push(p(10. * b.cos(), 10. * b.sin(), 1.));
knots.extend([i as f64 + 1.; 2]);
}
knots.push(8.);
let twice = edge_from(knots, points);
assert!(segment(&twice, &Plane::XY, 1e-5, &mut 2_000_000).is_none());
}
}