feat: preserve adaptive sketch profiles and curved modeling workflows
This commit is contained in:
1 parent
4c4a0449a4
commit
4e595deff2
62 files changed
+7114
-362
No files matched your search
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -38,6 +38,7 @@ mod ops;
|
||||
mod polybool;
|
||||
mod polyhedron;
|
||||
mod prism;
|
||||
mod prism_curve;
|
||||
pub mod provenance;
|
||||
mod sew;
|
||||
mod splitface;
|
||||
|
||||
@@ -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
@@ -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(®ion), 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(®ion), 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(®ion), 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(®ion), 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(®ion), 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(®ion), 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(®ion), -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(®ion), 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(®ion), 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.));
|
||||
|
||||
@@ -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(°ree) || 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user