mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 19:15:57 +00:00
kernel: healing through SMLib's merge of faces; the boolean retries deleted
heal: a body's faces on one surface merged and its edges on one curve joined by SMLib (Brep::merge_faces, SmBrep::RemoveTopologicalEdgesAndVertices), the edges along its split lines kept, with SMLib's history over the body. Its booleans already end the same way (SmMerge::BooleanPostProcess). The truck healing is deleted with what only it did: coplanar and collinear merges by hand, flat surfaces rebuilt as planes, and approximate edges refitted as lines or arcs from samples. on_split_line stays; edge_points reads a record edge. Deleted, with no port: the retries around the old boolean's failures (touching_walls, bore_guard, tangent_cylinders, sphere_lens, tool_reseam, candidate_reach). Each re-ran a failed boolean with a changed tool or split faces; SMLib's boolean is the one path, and a case it fails is its defect to fix there. Their own tests go with them (the helpers' unit tests in issue_788_tests, bore_union_tests, candidate_reach_tests, and sphere_lens' relaxed-tool test); the #2750 lens cut's result test stays, as a boolean test, without the retry diagnostics. Heal's tests built truck prisms and a truck spline edge: deleted. (not built: base in progress) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
@@ -1,194 +0,0 @@
|
||||
//! Necessary boundary safety check for the narrowed-bore fallback.
|
||||
//! This is not a certificate of general Boolean equivalence. It prevents a
|
||||
//! cylinder recut from silently removing an additional material boundary that
|
||||
//! the fallback's coarse material samples do not see.
|
||||
use crate::body::{Body, from_p3};
|
||||
use solidcraft_geom::{Plane, Seg2, UnitVec3, Vec3};
|
||||
use std::collections::HashSet;
|
||||
use truck_modeling as mt;
|
||||
|
||||
pub(super) fn boundaries_clear(
|
||||
body: &Body,
|
||||
bore_faces: &[usize],
|
||||
origin: Vec3,
|
||||
axis: UnitVec3,
|
||||
radius: f64,
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
tol: f64,
|
||||
) -> Option<()> {
|
||||
let plane = Plane::from_normal(origin, axis)?;
|
||||
if !origin.is_finite()
|
||||
|| ![radius, lo, hi, tol].iter().all(|v| v.is_finite())
|
||||
|| radius <= tol
|
||||
|| hi <= lo
|
||||
|| tol <= 0.
|
||||
|| bore_faces.len() > 16_384
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut remaining_faces = 16_384usize;
|
||||
let mut remaining_uses = 65_536usize;
|
||||
for face in body.solid.face_iter() {
|
||||
remaining_faces = remaining_faces.checked_sub(1)?;
|
||||
for wire in face.absolute_boundaries() {
|
||||
for _ in wire.edge_iter() {
|
||||
remaining_uses = remaining_uses.checked_sub(1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Exempt only existing coincident bore boundaries, never newly inferred
|
||||
// edges or every edge of a planar face adjoining a cylinder.
|
||||
let bore_faces: HashSet<usize> = bore_faces.iter().copied().collect();
|
||||
let mut bore_edges = HashSet::new();
|
||||
for (i, face) in body.solid.face_iter().enumerate() {
|
||||
if bore_faces.contains(&i) {
|
||||
for edge in face.edge_iter() {
|
||||
bore_edges.insert(edge.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut work = 2_000_000usize;
|
||||
for edge in body.solid.edge_iter() {
|
||||
if !seen.insert(edge.id()) {
|
||||
continue;
|
||||
}
|
||||
let common_bore = bore_edges.contains(&edge.id());
|
||||
work = work.checked_sub(1)?;
|
||||
let curve = edge.curve();
|
||||
// Original matched-cylinder trims retain the legacy fallback policy.
|
||||
// Boolean-produced rational trims need not be exact analytic circles.
|
||||
// This exemption follows existing face/edge identity only; additional
|
||||
// trims must pass the whole-boundary proof below. It is deliberately
|
||||
// not a general proof that the cylinder contains no material.
|
||||
if common_bore && matches!(curve, mt::Curve::IntersectionCurve(_) | mt::Curve::BSplineCurve(_) | mt::Curve::NurbsCurve(_)) {
|
||||
continue;
|
||||
}
|
||||
if let mt::Curve::Line(line) = curve {
|
||||
let (a, b) = (from_p3(line.0), from_p3(line.1));
|
||||
if !a.is_finite() || !b.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let ha = plane.height(a);
|
||||
let hb = plane.height(b);
|
||||
let error = tol + 512. * f64::EPSILON * (1. + a.len() + b.len() + origin.len());
|
||||
if common_bore {
|
||||
let pa = plane.to_local(a);
|
||||
let pb = plane.to_local(b);
|
||||
if pa.dist(pb) <= error && (pa.len() - radius).abs() + (pb.len() - radius).abs() <= error * 2. {
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let (mut t0, mut t1): (f64, f64) = (0., 1.);
|
||||
let dh = hb - ha;
|
||||
if dh.abs() <= error {
|
||||
if ha.min(hb) > hi + error || ha.max(hb) < lo - error {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
let x = (lo - error - ha) / dh;
|
||||
let y = (hi + error - ha) / dh;
|
||||
t0 = t0.max(x.min(y));
|
||||
t1 = t1.min(x.max(y));
|
||||
if t0 > t1 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let pa = plane.to_local(a + (b - a) * t0);
|
||||
let pb = plane.to_local(a + (b - a) * t1);
|
||||
let d = pb - pa;
|
||||
let t = if d.len2() > 0. { (-pa.dot(d) / d.len2()).clamp(0., 1.) } else { 0. };
|
||||
if (pa + d * t).len() <= radius + error {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Positive rational controls enclose the entire axial extent. Project
|
||||
// the current curve onto the axis plane; the existing whole-span
|
||||
// certificate proves its Line/Arc image, without point sampling.
|
||||
let (knots, controls): (mt::KnotVec, Vec<mt::Vector4>) = match curve {
|
||||
mt::Curve::BSplineCurve(c) => {
|
||||
if c.control_points().len() > 4096 || c.knot_vec().len() > 8192 {
|
||||
return None;
|
||||
}
|
||||
work = work.checked_sub(c.control_points().len().checked_add(c.knot_vec().len())?)?;
|
||||
(c.knot_vec().clone(), c.control_points().iter().map(|p| mt::Vector4::new(p.x, p.y, p.z, 1.)).collect())
|
||||
}
|
||||
mt::Curve::NurbsCurve(c) => {
|
||||
if c.control_points().len() > 4096 || c.knot_vec().len() > 8192 {
|
||||
return None;
|
||||
}
|
||||
work = work.checked_sub(c.control_points().len().checked_add(c.knot_vec().len())?)?;
|
||||
(c.knot_vec().clone(), c.control_points().clone())
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut heights = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
let mut projection_error: f64 = 0.;
|
||||
let mut projected_origin = None;
|
||||
let mut projected_spread: f64 = 0.;
|
||||
let mut projected = Vec::with_capacity(controls.len());
|
||||
for p in controls {
|
||||
if ![p.x, p.y, p.z, p.w].iter().all(|v| v.is_finite()) || p.w <= 0. {
|
||||
return None;
|
||||
}
|
||||
let point = Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w);
|
||||
if !point.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let error = tol + 512. * f64::EPSILON * (1. + point.len() + origin.len());
|
||||
projection_error = projection_error.max(error);
|
||||
let h = plane.height(point);
|
||||
heights.0 = heights.0.min(h - error);
|
||||
heights.1 = heights.1.max(h + error);
|
||||
let q = plane.to_local(point);
|
||||
let first = *projected_origin.get_or_insert(q);
|
||||
projected_spread = projected_spread.max(q.dist(first));
|
||||
projected.push(mt::Vector4::new(q.x * p.w, q.y * p.w, 0., p.w));
|
||||
}
|
||||
if heights.0 > hi || heights.1 < lo {
|
||||
continue;
|
||||
}
|
||||
// A swept seam may be represented as a rational straight edge. Its
|
||||
// positive control hull proves a point-sized radial projection even
|
||||
// though a nondegenerate planar Line/Arc recognizer cannot accept it.
|
||||
if let Some(q) = projected_origin {
|
||||
if common_bore && projected_spread + projection_error + (q.len() - radius).abs() <= tol * 8. {
|
||||
continue;
|
||||
}
|
||||
if !common_bore && q.len() - projected_spread - projection_error > radius + tol * 4. {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let projected = mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::try_new(knots, projected).ok()?));
|
||||
let (a, b) = projected.range_tuple();
|
||||
let projected_edge = mt::Edge::new(&mt::Vertex::new(projected.subs(a)), &mt::Vertex::new(projected.subs(b)), projected);
|
||||
let segments = crate::prism_curve::segment(&projected_edge, &Plane::XY, tol, &mut work)?;
|
||||
for segment in segments {
|
||||
if common_bore {
|
||||
if let Seg2::Arc { center, radius: r, .. } = segment
|
||||
&& center.len() + (r - radius).abs() + projection_error <= tol * 8.
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let clearance = match segment {
|
||||
Seg2::Line { a, b } => {
|
||||
let d = b - a;
|
||||
let t = if d.len2() > 0. { (-a.dot(d) / d.len2()).clamp(0., 1.) } else { 0. };
|
||||
(a + d * t).len()
|
||||
}
|
||||
Seg2::Arc { center, radius, .. } => radius - center.len(),
|
||||
_ => return None,
|
||||
};
|
||||
if clearance <= radius + tol * 4. + projection_error {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use crate::*;
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, UnitVec3, Vec2, Vec3};
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
|
||||
fn fixture() -> (Body, Body, Vec3, f64) {
|
||||
let slope = 1f64.to_radians().tan();
|
||||
@@ -91,32 +91,6 @@ fn thin_connected_web_between_bore_stations_survives_union() {
|
||||
assert_eq!(step_export(&[&ring], "ring").unwrap(), ring_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unproved_curved_boundary_between_clear_endpoints_refuses_recut() {
|
||||
let a = Vec2::new(6., -1.);
|
||||
let b = Vec2::new(6., 1.);
|
||||
let c = Vec2::new(8., 1.);
|
||||
let d = Vec2::new(8., -1.);
|
||||
let region = Region2 {
|
||||
outer: Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Cubic { p0: a, p1: Vec2::new(3., -0.5), p2: Vec2::new(3., 0.5), p3: b },
|
||||
Seg2::Line { a: b, b: c },
|
||||
Seg2::Line { a: c, b: d },
|
||||
Seg2::Line { a: d, b: a },
|
||||
],
|
||||
}
|
||||
.reversed(),
|
||||
holes: vec![],
|
||||
};
|
||||
let body = extrude(&Plane::XY, &[region], 16.05, 16.07).unwrap().remove(0);
|
||||
assert!(a.len() > 5. && b.len() > 5., "endpoint samples miss the interior excursion");
|
||||
assert!(body.mesh(&crate::MeshTolerance::chord(1e-4)).unwrap().contains(Vec3::new(4.5, 0., 16.06)));
|
||||
let before = step_export(&[&body], "curved source").unwrap();
|
||||
assert!(crate::bore_guard::boundaries_clear(&body, &[], Vec3::ZERO, UnitVec3::Z, 5., 14., 24., 1e-6).is_none());
|
||||
assert_eq!(step_export(&[&body], "curved source").unwrap(), before);
|
||||
}
|
||||
|
||||
/// Posts sunk into one floor have their tops in a common plane but apart: they don't
|
||||
/// coincide, so joining another post doesn't go through pushing faces apart (#261).
|
||||
#[test]
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
//! A boolean candidate whose edges run far outside its operands is discarded before it is
|
||||
//! meshed (#3020).
|
||||
//!
|
||||
//! The retries of a boolean mesh every candidate to measure its volume. A candidate made at a
|
||||
//! coarse tolerance can come out with an edge that leaves the body altogether: in the design of
|
||||
//! #3020, a face of a revolved dovetail (a cone) got a trim loop several metres long on a part
|
||||
//! 155 mm across, its points kilometres from the cone, and meshing that face never finished.
|
||||
//! Its volume (±1e10 mm³) would have failed the check anyway; only measuring it hung.
|
||||
//!
|
||||
//! Every edge of a union, a cut or an intersection lies on the operands' faces, within the
|
||||
//! boolean's tolerance (a small fraction of their size). So a candidate with a point of an edge
|
||||
//! further outside the box the operands span than [`MARGIN`] of their size is not a result of
|
||||
//! the operation, whatever its faces are, and it is discarded without meshing it. That needs no
|
||||
//! search for the edge's points on its faces' surfaces, which is the very thing that runs away
|
||||
//! on such a face.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use solidcraft_geom::{Aabb3, Mesh, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "candidate_reach_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Samples along each edge, ends included: a runaway edge leaves the body over a good part of
|
||||
/// its length (the loop of #3020 was thousands of times the body's size).
|
||||
const EDGE_SAMPLES: u32 = 16;
|
||||
|
||||
/// How far an edge of a candidate may run outside the box of the operands' meshes, as a fraction
|
||||
/// of the operands' size: well above any boolean tolerance (2e-3 of the size at the coarsest) and
|
||||
/// the temporary face shifts of the retries (1.4e-2 of it), well below the metres a runaway edge
|
||||
/// spans.
|
||||
pub(crate) const MARGIN: f64 = 0.05;
|
||||
|
||||
/// Where a candidate's edges may run when the operands could not be meshed: their edges only
|
||||
/// bound them loosely (a sphere's seam spans half of it), so the box of those grows by their
|
||||
/// whole size.
|
||||
const MARGIN_UNMESHED: f64 = 1.0;
|
||||
|
||||
/// The box the operands of a boolean span, grown by the room a result's edges may take.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Reach {
|
||||
bounds: Aabb3,
|
||||
margin: f64,
|
||||
}
|
||||
|
||||
impl Reach {
|
||||
/// The reach of operands `a` and `b` of size `size`, from their meshes where both were made
|
||||
/// (`meshes`), else from their edges. `None` when neither gives a finite box.
|
||||
pub(crate) fn new(a: &Body, b: &Body, meshes: (Option<&Mesh>, Option<&Mesh>), size: f64) -> Option<Reach> {
|
||||
let mut bounds = Aabb3::EMPTY;
|
||||
let margin = match meshes {
|
||||
(Some(ma), Some(mb)) => {
|
||||
ma.positions.iter().chain(&mb.positions).for_each(|p| bounds.add(*p));
|
||||
MARGIN * size
|
||||
}
|
||||
_ => {
|
||||
for body in [a, b] {
|
||||
edge_points(body, |p| {
|
||||
bounds.add(p);
|
||||
true
|
||||
})?;
|
||||
}
|
||||
MARGIN_UNMESHED * size
|
||||
}
|
||||
};
|
||||
(!bounds.is_empty() && margin.is_finite() && margin > 0.0).then_some(Reach { bounds, margin })
|
||||
}
|
||||
|
||||
/// Why `candidate` cannot be a result of a boolean of these operands: an edge of it runs
|
||||
/// further outside them than the margin, or has a point that is not finite. `None` when its
|
||||
/// edges stay within reach, and when they could not be sampled (it is then meshed and
|
||||
/// checked as before).
|
||||
pub(crate) fn runaway(&self, candidate: &Body) -> Option<String> {
|
||||
let (lo, hi) = (self.bounds.min, self.bounds.max);
|
||||
let mut worst = 0.0f64;
|
||||
let mut infinite = false;
|
||||
edge_points(candidate, |p| {
|
||||
if !p.is_finite() {
|
||||
infinite = true;
|
||||
return false;
|
||||
}
|
||||
let outside = Vec3::new(excess(p.x, lo.x, hi.x), excess(p.y, lo.y, hi.y), excess(p.z, lo.z, hi.z)).len();
|
||||
worst = worst.max(outside);
|
||||
true
|
||||
})?;
|
||||
if infinite {
|
||||
Some("a result whose edge has points that are not finite".into())
|
||||
} else if worst > self.margin {
|
||||
Some(format!("a result whose edge runs {worst:.4e} mm outside the operands (more than {:.4e} mm), not meshed to measure it", self.margin))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far `x` lies outside `[lo, hi]`.
|
||||
fn excess(x: f64, lo: f64, hi: f64) -> f64 {
|
||||
(lo - x).max(x - hi).max(0.0)
|
||||
}
|
||||
|
||||
/// Visits [`EDGE_SAMPLES`] points along each edge of `body` (once per edge) until `visit` says
|
||||
/// stop. `None` when the curves could not be evaluated.
|
||||
fn edge_points(body: &Body, mut visit: impl FnMut(Vec3) -> bool) -> Option<()> {
|
||||
crate::guard("candidate edges", || {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let mut seen = HashSet::new();
|
||||
for edge in body.solid.edge_iter() {
|
||||
if !seen.insert(edge.id()) {
|
||||
continue;
|
||||
}
|
||||
let curve = edge.curve();
|
||||
let (t0, t1) = curve.range_tuple();
|
||||
for i in 0..=EDGE_SAMPLES {
|
||||
let t = t0 + (t1 - t0) * f64::from(i) / f64::from(EDGE_SAMPLES);
|
||||
if !visit(from_p3(curve.subs(t))) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//! A boolean candidate with a trim loop that runs off its surface is discarded fast (#3020).
|
||||
//!
|
||||
//! In the design of #3020 a coarse candidate of a nudged union came out with a face on a cone
|
||||
//! (a revolved line: its profile parameter bounded to [0, 1], its angle periodic) whose trim
|
||||
//! loop left the cone and ran thousands of profile lengths along its continuation, kilometres
|
||||
//! from the part. Meshing that face to measure the candidate never finished. Here such a face
|
||||
//! is made on purpose: the check finds its edge outside the operands without meshing it.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, ParametricSurface, builder};
|
||||
|
||||
use super::{MARGIN, Reach};
|
||||
use crate::body::Body;
|
||||
|
||||
/// The cone of the report: a profile line 2 mm out and 4 mm up from radius 20 mm (its length,
|
||||
/// √20 mm, is the |∂S/∂u| measured on the runaway face), turned about Z.
|
||||
fn cone() -> mt::Surface {
|
||||
let profile = mt::Curve::Line(mt::Line(mt::Point3::new(20.0, 0.0, 0.0), mt::Point3::new(22.0, 0.0, 4.0)));
|
||||
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(profile, mt::Point3::new(0.0, 0.0, 0.0), mt::Vector3::unit_z())))
|
||||
}
|
||||
|
||||
/// A solid of one face of `surface` bounded by straight edges through `corners`, given in the
|
||||
/// surface's parameters and moved `off` mm off it along its normal.
|
||||
fn face_body(surface: &mt::Surface, corners: &[(f64, f64, f64)]) -> Body {
|
||||
use mt::ParametricSurface3D;
|
||||
let vertices: Vec<mt::Vertex> = corners.iter().map(|&(u, v, off)| builder::vertex(surface.subs(u, v) + surface.normal(u, v) * off)).collect();
|
||||
let edges: Vec<mt::Edge> = (0..vertices.len()).map(|i| builder::line(&vertices[i], &vertices[(i + 1) % vertices.len()])).collect();
|
||||
let face = mt::Face::new(vec![edges.into_iter().collect()], surface.clone());
|
||||
let solid = mt::Solid::new_unchecked(vec![vec![face].into()]);
|
||||
Body::new(solid).unwrap()
|
||||
}
|
||||
|
||||
/// The operands: a plate and the cone's own patch, which spans u in [0, 1] (radius 20-22 mm,
|
||||
/// 4 mm high) over a quarter turn.
|
||||
fn operands() -> (Body, Body) {
|
||||
let plate = crate::box_solid(Vec3::new(-30.0, -30.0, -6.0), Vec3::new(30.0, 30.0, 0.0)).unwrap();
|
||||
let patch = face_body(&cone(), &[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.5, 0.0), (0.0, 1.5, 0.0)]);
|
||||
(plate, patch)
|
||||
}
|
||||
|
||||
fn reach(a: &Body, b: &Body) -> Reach {
|
||||
let size = a.size().max(b.size());
|
||||
let meshes = (a.mesh(&crate::MeshTolerance::chord(size * 5e-4)).unwrap(), b.mesh(&crate::MeshTolerance::chord(size * 5e-4)).unwrap());
|
||||
Reach::new(a, b, (Some(&meshes.0), Some(&meshes.1)), size).unwrap()
|
||||
}
|
||||
|
||||
/// The runaway face: on the cone, its loop leaves the patch at u = 0 and follows the cone's
|
||||
/// continuation out to u = -2450 (as the logged loop did, -2452 … 1349), 1.5 mm off it, and
|
||||
/// back.
|
||||
fn runaway() -> Body {
|
||||
face_body(&cone(), &[(1.0, 0.2, 0.0), (1.0, 1.3, 0.0), (0.0, 1.3, 0.0), (-2450.0, 0.9, 1.5), (-2450.0, 0.6, 1.5), (0.0, 0.2, 0.0)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_candidate_with_a_trim_loop_off_its_surface_is_rejected_fast() {
|
||||
let (a, b) = operands();
|
||||
let reach = reach(&a, &b);
|
||||
let candidate = runaway();
|
||||
let start = Instant::now();
|
||||
let reason = reach.runaway(&candidate);
|
||||
let took = start.elapsed();
|
||||
let reason = reason.expect("an edge kilometres outside the operands");
|
||||
assert!(reason.contains("outside the operands"), "{reason}");
|
||||
assert!(took < Duration::from_secs(1), "{took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_candidates_stay_within_reach() {
|
||||
let (a, b) = operands();
|
||||
let reach = reach(&a, &b);
|
||||
// The operands themselves, and a union of the plate with a cylinder standing on it.
|
||||
assert_eq!(reach.runaway(&a), None);
|
||||
assert_eq!(reach.runaway(&b), None);
|
||||
let post = crate::cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 5.0, 20.0).unwrap();
|
||||
let reach = self::reach(&a, &post);
|
||||
let union = crate::boolean(&a, &post, crate::BoolOp::Union).unwrap().unwrap();
|
||||
assert_eq!(reach.runaway(&union), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edge_just_past_the_margin_is_out_of_reach() {
|
||||
let (a, b) = operands();
|
||||
let size = a.size().max(b.size());
|
||||
let reach = reach(&a, &b);
|
||||
// A box reaching past the plate's side by twice the margin, and one within it.
|
||||
let top = Vec3::new(30.0, 30.0, 0.0);
|
||||
let past = crate::box_solid(Vec3::new(0.0, 0.0, -6.0), top + Vec3::X * (2.0 * MARGIN * size)).unwrap();
|
||||
let within = crate::box_solid(Vec3::new(0.0, 0.0, -6.0), top + Vec3::X * (0.5 * MARGIN * size)).unwrap();
|
||||
assert!(reach.runaway(&past).is_some());
|
||||
assert_eq!(reach.runaway(&within), None);
|
||||
}
|
||||
|
||||
/// The bound on a loop's span is in lengths of a bounded parameter's range: a plane's unit box
|
||||
/// is only a chart, so a plate thousands of millimetres across (its loops thousands of units
|
||||
/// wide) still meshes, as does a long cylinder.
|
||||
#[test]
|
||||
fn large_faces_still_mesh() {
|
||||
let plate = crate::box_solid(Vec3::new(-2500.0, -2500.0, 0.0), Vec3::new(2500.0, 2500.0, 3.0)).unwrap();
|
||||
let rod = crate::cylinder(Vec3::ZERO, Vec3::Z, 0.5, 3000.0).unwrap();
|
||||
for body in [plate, rod] {
|
||||
assert!(!body.mesh(&crate::MeshTolerance::chord(0.05)).unwrap().triangles.is_empty());
|
||||
assert_eq!(body.unmeshed_faces(), 0);
|
||||
}
|
||||
}
|
||||
+33
-693
@@ -1,36 +1,14 @@
|
||||
//! Clean-up after operations: coplanar neighbouring faces become one face, and two straight
|
||||
//! edges meeting in line at a vertex nothing else uses become one edge (they are kept as one).
|
||||
//! Clean-up after operations: faces on one surface that meet along an edge become one face, and
|
||||
//! two edges on one curve meeting at a vertex nothing else uses become one edge. SMLib does it
|
||||
//! (`Brep::merge_faces`, its `SmBrep::RemoveTopologicalEdgesAndVertices`; its booleans do the
|
||||
//! same at their end), keeping apart the faces split on purpose along the body's split lines.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::brep::Brep;
|
||||
use solidcraft_geom::brep::eval::CurveEval;
|
||||
|
||||
use solidcraft_geom::{Ring, Vec3};
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Solid, from_p3};
|
||||
use crate::history::{Change, EdgeOrigin, FaceProv};
|
||||
|
||||
fn plane_of(f: &mt::Face) -> Option<(Vec3, f64)> {
|
||||
match f.oriented_surface() {
|
||||
mt::Surface::Plane(p) => {
|
||||
let n = p.normal();
|
||||
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
|
||||
let o = p.origin();
|
||||
Some((n, n.dot(Vec3::new(o.x, o.y, o.z))))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A face like `f` with new boundary wires given in `f`'s absolute (stored) orientation:
|
||||
/// built on the stored surface and turned like `f` (rebuilding on the turned surface can make
|
||||
/// the mesher pick the wrong side of a trim on periodic surfaces).
|
||||
pub(crate) fn absolute_face(f: &mt::Face, wires: Vec<mt::Wire>) -> Option<mt::Face> {
|
||||
let mut nf = mt::Face::try_new(wires, f.surface()).ok()?;
|
||||
if !f.orientation() {
|
||||
nf.invert();
|
||||
}
|
||||
Some(nf)
|
||||
}
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
pub(crate) fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&q) = p.get(i) {
|
||||
@@ -42,34 +20,6 @@ pub(crate) fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
i
|
||||
}
|
||||
|
||||
/// Points along an oriented edge (for loop areas and containment).
|
||||
fn samples(e: &mt::Edge) -> Vec<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..8).map(|k| t0 + (t1 - t0) * k as f64 / 8.0).map(|t| from_p3(c.subs(t))).collect()
|
||||
}
|
||||
|
||||
fn loop_area(edges: &[mt::Edge], n: Vec3) -> f64 {
|
||||
edges.iter().flat_map(samples).collect::<Ring<Vec3>>().area_vector().dot(n)
|
||||
}
|
||||
|
||||
fn inside(p: Vec3, edges: &[mt::Edge], n: Vec3) -> bool {
|
||||
let u = n.any_perp();
|
||||
let w = n.cross(u);
|
||||
let pts: Vec<(f64, f64)> = edges.iter().flat_map(samples).map(|q| (q.dot(u), q.dot(w))).collect();
|
||||
let (px, py) = (p.dot(u), p.dot(w));
|
||||
let k = pts.len();
|
||||
let mut c = false;
|
||||
for i in 0..k {
|
||||
let (Some(&(ax, ay)), Some(&(bx, by))) = (pts.get(i), pts.get((i + 1) % k)) else { continue };
|
||||
if (ay > py) != (by > py) && px < ax + (py - ay) / (by - ay) * (bx - ax) {
|
||||
c = !c;
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Do the points lie along one of the lines faces were split along on purpose (polylines;
|
||||
/// chords of a curved line sag by under a tenth of their length)?
|
||||
pub(crate) fn on_split_line(pts: &[Vec3], keep: &[Vec<Vec3>]) -> bool {
|
||||
@@ -81,643 +31,33 @@ pub(crate) fn on_split_line(pts: &[Vec3], keep: &[Vec<Vec3>]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Points along an edge (ends and three inside).
|
||||
pub(crate) fn edge_points(e: &mt::Edge) -> Vec<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..=4).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 4.0))).collect()
|
||||
/// Points along edge `e` of the record `b`: its ends and three inside, evenly in its parameter
|
||||
/// (the middle one at the middle of its interval).
|
||||
pub(crate) fn edge_points(b: &Brep, e: u32) -> Vec<Vec3> {
|
||||
let Some(edge) = b.edges.get(e as usize) else { return Vec::new() };
|
||||
let Ok(c) = CurveEval::new(&edge.curve) else { return Vec::new() };
|
||||
let [t0, t1] = edge.interval;
|
||||
(0..=4).filter_map(|k| c.point(t0 + (t1 - t0) * k as f64 / 4.0)).collect()
|
||||
}
|
||||
|
||||
/// Merge each group of edge-connected coplanar faces into one face (keeping faces apart along
|
||||
/// the lines in `keep`). Returns the new shell and, for each of its faces, the faces of `shell`
|
||||
/// it was made from (by position); None when nothing changed or a merged face can't be built.
|
||||
fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec<Vec3>]) -> Option<(mt::Shell, Vec<Vec<usize>>)> {
|
||||
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
|
||||
let planes: Vec<Option<(Vec3, f64)>> = faces.iter().map(plane_of).collect();
|
||||
let mut parent: Vec<usize> = (0..faces.len()).collect();
|
||||
let mut by_edge: HashMap<mt::EdgeID, Vec<usize>> = HashMap::new();
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
for e in f.edge_iter() {
|
||||
by_edge.entry(e.id()).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut merged_any = false;
|
||||
let edges: HashMap<mt::EdgeID, mt::Edge> = shell.edge_iter().map(|e| (e.id(), e)).collect();
|
||||
for (id, fs) in &by_edge {
|
||||
if let [a, b] = fs[..]
|
||||
&& a != b
|
||||
&& (keep.is_empty() || !edges.get(id).is_some_and(|e| on_split_line(&edge_points(e), keep)))
|
||||
&& let (Some(Some((na, da))), Some(Some((nb, db)))) = (planes.get(a), planes.get(b))
|
||||
&& na.dot(*nb) > 1.0 - 1e-9
|
||||
&& (da - db).abs() < tol
|
||||
{
|
||||
// The lesser root wins, so each group's root is its first face whatever order the
|
||||
// edges (in hash order of their addresses) are met in (#707).
|
||||
let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
|
||||
if ra != rb {
|
||||
if let Some(x) = parent.get_mut(ra.max(rb)) {
|
||||
*x = ra.min(rb);
|
||||
}
|
||||
merged_any = true;
|
||||
/// `body` with its faces on one surface merged and its edges on one curve joined, except along
|
||||
/// the lines in `keep` (its split lines, as Split Face made them): each face and edge of the
|
||||
/// result comes from those of `body` as SMLib's history says (a merged face from every face
|
||||
/// merged into it), over `[body]`.
|
||||
pub(crate) fn heal(body: &Body, keep: &[Vec<Vec3>]) -> Result<Body> {
|
||||
let record = crate::native::brep_record(body)?;
|
||||
// Each edge along a split line, by the point halfway along it.
|
||||
let mut kept: Vec<[f64; 3]> = Vec::new();
|
||||
if !keep.is_empty() {
|
||||
for e in 0..record.edges.len() {
|
||||
let pts = edge_points(&record, e as u32);
|
||||
if on_split_line(&pts, keep) {
|
||||
let mid = pts.get(2).ok_or_else(|| KernelError::Failed(format!("healing: edge {e} has no middle point")))?;
|
||||
kept.push([mid.x, mid.y, mid.z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !merged_any {
|
||||
return None;
|
||||
}
|
||||
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for i in 0..faces.len() {
|
||||
let r = find(&mut parent, i);
|
||||
groups.entry(r).or_default().push(i);
|
||||
}
|
||||
let mut out: Vec<mt::Face> = Vec::new();
|
||||
let mut from: Vec<Vec<usize>> = Vec::new();
|
||||
let mut order: Vec<usize> = groups.keys().copied().collect();
|
||||
order.sort_unstable();
|
||||
for g in order {
|
||||
let members = groups.get(&g)?;
|
||||
if members.len() == 1 {
|
||||
out.push(faces.get(*members.first()?)?.clone());
|
||||
from.push(members.clone());
|
||||
continue;
|
||||
}
|
||||
let (n, _) = (*planes.get(*members.first()?)?)?;
|
||||
// Oriented boundary edges of the group; edges used twice inside it are dropped.
|
||||
let mut count: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
for m in members {
|
||||
for e in faces.get(*m)?.boundary_iters().into_iter().flatten() {
|
||||
*count.entry(e.id()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let mut rest: Vec<mt::Edge> = Vec::new();
|
||||
for m in members {
|
||||
for e in faces.get(*m)?.boundary_iters().into_iter().flatten() {
|
||||
if count.get(&e.id()).copied().unwrap_or(0) == 1 {
|
||||
rest.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chain into loops.
|
||||
let mut loops: Vec<Vec<mt::Edge>> = Vec::new();
|
||||
while let Some(first) = rest.pop() {
|
||||
let start = first.front().clone();
|
||||
let mut cur = first.back().clone();
|
||||
let mut lp = vec![first];
|
||||
let mut guard = 0;
|
||||
while cur != start && guard < 100_000 {
|
||||
guard += 1;
|
||||
let k = rest.iter().position(|e| e.front() == &cur)?;
|
||||
let e = rest.remove(k);
|
||||
cur = e.back().clone();
|
||||
lp.push(e);
|
||||
}
|
||||
loops.push(lp);
|
||||
}
|
||||
let (outer, holes): (Vec<Vec<mt::Edge>>, Vec<Vec<mt::Edge>>) = loops.into_iter().partition(|l| loop_area(l, n) > 0.0);
|
||||
let surface = faces.get(*members.first()?)?.oriented_surface();
|
||||
for o in &outer {
|
||||
let mut wires: Vec<mt::Wire> = vec![o.clone().into()];
|
||||
for h in &holes {
|
||||
let probe = h.first().map(|e| from_p3(e.front().point()))?;
|
||||
// The innermost outer loop around the hole owns it.
|
||||
let owner = outer.iter().filter(|x| inside(probe, x, n)).min_by(|a, b| loop_area(a, n).total_cmp(&loop_area(b, n)));
|
||||
if owner.is_some_and(|x| std::ptr::eq(x, o)) {
|
||||
wires.push(h.clone().into());
|
||||
}
|
||||
}
|
||||
out.push(mt::Face::try_new(wires, surface.clone()).ok()?);
|
||||
from.push(members.clone());
|
||||
}
|
||||
}
|
||||
Some((out.into(), from))
|
||||
}
|
||||
|
||||
/// Join runs of collinear straight edges at vertices only they use: each run becomes one edge,
|
||||
/// every face it borders is rebuilt once (linear in the shell, however long the runs; a faceted
|
||||
/// body such as a modeled thread has thousands of such edges).
|
||||
fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
|
||||
let mut faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
let mut index: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
let mut at: HashMap<mt::VertexID, Vec<usize>> = HashMap::new();
|
||||
for f in &faces {
|
||||
for e in f.edge_iter() {
|
||||
if index.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
index.insert(e.id(), edges.len());
|
||||
at.entry(e.front().id()).or_default().push(edges.len());
|
||||
at.entry(e.back().id()).or_default().push(edges.len());
|
||||
edges.push(e.absolute_clone());
|
||||
}
|
||||
}
|
||||
// Edges that continue each other in line through a vertex nothing else uses.
|
||||
let mut parent: Vec<usize> = (0..edges.len()).collect();
|
||||
let mut any = false;
|
||||
for (v, es) in &at {
|
||||
let [ia, ib] = es[..] else { continue };
|
||||
let (Some(a), Some(b)) = (edges.get(ia), edges.get(ib)) else { continue };
|
||||
if !matches!(a.curve(), mt::Curve::Line(_)) || !matches!(b.curve(), mt::Curve::Line(_)) {
|
||||
continue;
|
||||
}
|
||||
let other = |e: &mt::Edge| if e.front().id() == *v { e.back().clone() } else { e.front().clone() };
|
||||
let mid = if a.front().id() == *v { a.front().clone() } else { a.back().clone() };
|
||||
let (pa, pm, pb) = (from_p3(other(a).point()), from_p3(mid.point()), from_p3(other(b).point()));
|
||||
let (Some(u), Some(w)) = ((pm - pa).normalized(), (pb - pm).normalized()) else { continue };
|
||||
if u.dot(w) > 1.0 - 1e-10 {
|
||||
// The lesser root wins: each run's root is its first edge whatever order the
|
||||
// vertices are met in (#707).
|
||||
let (ra, rb) = (find(&mut parent, ia), find(&mut parent, ib));
|
||||
if ra != rb
|
||||
&& let Some(x) = parent.get_mut(ra.max(rb))
|
||||
{
|
||||
*x = ra.min(rb);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
// Ordered by run (and each run's ends by first use below): which end a joined edge starts
|
||||
// from must not follow hash order, or the same heal gives a different body from run to run.
|
||||
let mut runs: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
|
||||
for i in 0..edges.len() {
|
||||
let r = find(&mut parent, i);
|
||||
runs.entry(r).or_default().push(i);
|
||||
}
|
||||
// Each run's joined edge, and the position of its vertices along it.
|
||||
let mut joined: Vec<(mt::Edge, HashMap<mt::VertexID, usize>)> = Vec::new();
|
||||
let mut run_of: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
for members in runs.values().filter(|m| m.len() > 1) {
|
||||
let mut ends: Vec<(mt::VertexID, usize, mt::Vertex)> = Vec::new();
|
||||
for i in members {
|
||||
let Some(e) = edges.get(*i) else { continue };
|
||||
for v in [e.front(), e.back()] {
|
||||
match ends.iter_mut().find(|(id, ..)| *id == v.id()) {
|
||||
Some(end) => end.1 += 1,
|
||||
None => ends.push((v.id(), 1, v.clone())),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ends = ends.into_iter().filter(|(_, n, _)| *n == 1).map(|(.., v)| v);
|
||||
let (Some(first), Some(last), None) = (ends.next(), ends.next(), ends.next()) else { continue };
|
||||
// Walk the run from one end, collecting its vertices in order.
|
||||
let mut order = vec![first.id()];
|
||||
let (mut cur, mut prev) = (first.id(), usize::MAX);
|
||||
for _ in 0..members.len() {
|
||||
let next = at.get(&cur).and_then(|es| es.iter().copied().find(|i| *i != prev && members.contains(i)));
|
||||
let Some(e) = next.and_then(|i| edges.get(i).map(|e| (i, e))) else { break };
|
||||
cur = if e.1.front().id() == cur { e.1.back().id() } else { e.1.front().id() };
|
||||
prev = e.0;
|
||||
order.push(cur);
|
||||
}
|
||||
if order.last() != Some(&last.id()) || order.len() != members.len() + 1 {
|
||||
continue;
|
||||
}
|
||||
// Every piece must lie along the whole run, not just along its neighbour.
|
||||
let Some(dir) = (from_p3(last.point()) - from_p3(first.point())).normalized() else { continue };
|
||||
let straight = members
|
||||
.iter()
|
||||
.filter_map(|i| edges.get(*i))
|
||||
.all(|e| (from_p3(e.back().point()) - from_p3(e.front().point())).normalized().is_some_and(|d| d.dot(dir).abs() > 1.0 - 1e-10));
|
||||
if !straight {
|
||||
continue;
|
||||
}
|
||||
let line = builder::line(&first, &last);
|
||||
trace.made_of(&line, members.iter().filter_map(|i| edges.get(*i)).map(|e| e.id()));
|
||||
for i in members {
|
||||
if let Some(e) = edges.get(*i) {
|
||||
run_of.insert(e.id(), joined.len());
|
||||
}
|
||||
}
|
||||
joined.push((line, order.into_iter().enumerate().map(|(k, v)| (v, k)).collect()));
|
||||
}
|
||||
if joined.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for f in &mut faces {
|
||||
if !f.edge_iter().any(|e| run_of.contains_key(&e.id())) {
|
||||
continue;
|
||||
}
|
||||
let mut wires = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let mut done = std::collections::HashSet::new();
|
||||
let mut es: Vec<mt::Edge> = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
let Some(r) = run_of.get(&e.id()).copied() else {
|
||||
es.push(e.clone());
|
||||
continue;
|
||||
};
|
||||
// The run's pieces become its joined edge, in this wire's direction: once each
|
||||
// way, as a seam runs along its wire both ways.
|
||||
if let Some((line, pos)) = joined.get(r) {
|
||||
let forward = pos.get(&e.front().id()) < pos.get(&e.back().id());
|
||||
if done.insert((r, forward)) {
|
||||
es.push(if forward { line.clone() } else { line.inverse() });
|
||||
}
|
||||
}
|
||||
}
|
||||
wires.push(mt::Wire::from(es));
|
||||
}
|
||||
*f = absolute_face(f, wires)?;
|
||||
}
|
||||
Some(faces.into())
|
||||
}
|
||||
|
||||
/// The exact curve an approximate edge (a boolean's intersection curve) really is: a line or a
|
||||
/// circular arc, when every sample lies on it.
|
||||
fn exact_curve(e: &mt::Edge, tol: f64) -> Option<mt::Edge> {
|
||||
// Only the approximate curves booleans make; exact ones stay as they are.
|
||||
if !matches!(e.curve(), mt::Curve::IntersectionCurve(_)) {
|
||||
return None;
|
||||
}
|
||||
fit_exact(e, tol)
|
||||
}
|
||||
|
||||
/// A line or circular arc through the edge's samples, if they all lie on one.
|
||||
fn fit_exact(e: &mt::Edge, tol: f64) -> Option<mt::Edge> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let pts: Vec<Vec3> = (0..=16).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0))).collect();
|
||||
let (a, b) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()));
|
||||
if e.absolute_front() == e.absolute_back() || a.dist(b) < tol {
|
||||
return None;
|
||||
}
|
||||
if pts.iter().all(|p| p.dist_to_segment(a, b) < tol) {
|
||||
return Some(builder::line(e.absolute_front(), e.absolute_back()));
|
||||
}
|
||||
// A circle through the ends and the middle sample.
|
||||
let m = *pts.get(8)?;
|
||||
let n = (m - a).cross(b - a).normalized()?;
|
||||
let (ab, am) = (b - a, m - a);
|
||||
// Centre in the plane: solve |c-a| = |c-b| = |c-m|.
|
||||
let d = 2.0 * ab.cross(am).len2();
|
||||
if d < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
// Circumcentre: a + (|u|² v×w + |v|² w×u) / (2|w|²), u = b−a, v = m−a, w = u×v.
|
||||
let w = ab.cross(am);
|
||||
let centre = a + (am.cross(w) * ab.len2() + w.cross(ab) * am.len2()) * (1.0 / d);
|
||||
let r = centre.dist(a);
|
||||
if pts.iter().all(|p| (p.dist(centre) - r).abs() < tol && (*p - a).dot(n).abs() < tol) {
|
||||
return Some(builder::circle_arc(e.absolute_front(), e.absolute_back(), mt::Point3::new(m.x, m.y, m.z)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Replace approximate edges that are really lines or arcs with exact ones.
|
||||
fn exact_edges(shell: &mt::Shell, tol: f64, trace: &mut Trace) -> Option<mt::Shell> {
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for e in shell.edge_iter() {
|
||||
if !subst.contains_key(&e.id())
|
||||
&& let Some(x) = exact_curve(&e, tol)
|
||||
{
|
||||
trace.made(&x, [e.id()]);
|
||||
subst.insert(e.id(), x);
|
||||
}
|
||||
}
|
||||
if subst.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut faces = Vec::new();
|
||||
for f in shell.face_iter() {
|
||||
let mut wires = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let es: Vec<mt::Edge> = w
|
||||
.edge_iter()
|
||||
.map(|e| match subst.get(&e.id()) {
|
||||
// The new edge runs absolute front → back; match this use's direction.
|
||||
Some(n) => {
|
||||
if e.front() == e.absolute_front() {
|
||||
n.clone()
|
||||
} else {
|
||||
n.inverse()
|
||||
}
|
||||
}
|
||||
None => e.clone(),
|
||||
})
|
||||
.collect();
|
||||
wires.push(mt::Wire::from(es));
|
||||
}
|
||||
faces.push(absolute_face(f, wires)?);
|
||||
}
|
||||
Some(faces.into())
|
||||
}
|
||||
|
||||
/// Faces whose surface is flat but not a plane (a revolved line square to the axis, a swept
|
||||
/// line) get a real plane, so they merge with their coplanar neighbours and blend like planes.
|
||||
pub(crate) fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
|
||||
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
|
||||
let mut changed = false;
|
||||
let mut faces = Vec::new();
|
||||
for f in shell.face_iter() {
|
||||
let surf = f.surface();
|
||||
if matches!(surf, mt::Surface::Plane(_)) {
|
||||
faces.push(f.clone());
|
||||
continue;
|
||||
}
|
||||
// Parameter box of the boundary, then a grid of surface points in it.
|
||||
let mut uv: Vec<(f64, f64)> = Vec::new();
|
||||
let mut pts: Vec<Vec3> = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
for e in w.edge_iter() {
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
for k in 0..6 {
|
||||
let p = c.subs(t0 + (t1 - t0) * k as f64 / 6.0);
|
||||
pts.push(from_p3(p));
|
||||
if let Some(x) = surf.search_nearest_parameter(p, uv.last().copied(), 50) {
|
||||
uv.push(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if uv.len() < 3 || pts.len() < 3 {
|
||||
faces.push(f.clone());
|
||||
continue;
|
||||
}
|
||||
let (u0, u1) = uv.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |a, x| (a.0.min(x.0), a.1.max(x.0)));
|
||||
let (v0, v1) = uv.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |a, x| (a.0.min(x.1), a.1.max(x.1)));
|
||||
for i in 0..=4 {
|
||||
for j in 0..=4 {
|
||||
pts.push(from_p3(surf.subs(u0 + (u1 - u0) * i as f64 / 4.0, v0 + (v1 - v0) * j as f64 / 4.0)));
|
||||
}
|
||||
}
|
||||
// The plane through the points (normal from the surface).
|
||||
let (um, vm) = ((u0 + u1) / 2.0, (v0 + v1) / 2.0);
|
||||
let nn = surf.normal(um, vm);
|
||||
let Some(n) = Vec3::new(nn.x, nn.y, nn.z).normalized() else {
|
||||
faces.push(f.clone());
|
||||
continue;
|
||||
};
|
||||
let o = from_p3(surf.subs(um, vm));
|
||||
if pts.iter().any(|p| (*p - o).dot(n).abs() > tol) {
|
||||
faces.push(f.clone());
|
||||
continue;
|
||||
}
|
||||
let u = n.any_perp();
|
||||
let w = n.cross(u);
|
||||
let plane = mt::Plane::new(crate::body::p3(o), crate::body::p3(o + u), crate::body::p3(o + w));
|
||||
match mt::Face::try_new(f.absolute_boundaries().clone(), mt::Surface::Plane(plane)) {
|
||||
Ok(mut nf) => {
|
||||
if !f.orientation() {
|
||||
nf.invert();
|
||||
}
|
||||
faces.push(nf);
|
||||
changed = true;
|
||||
}
|
||||
Err(_) => faces.push(f.clone()),
|
||||
}
|
||||
}
|
||||
changed.then(|| faces.into())
|
||||
}
|
||||
|
||||
/// Heal a solid; returns the input unchanged when nothing applies or healing fails.
|
||||
#[cfg(test)]
|
||||
pub fn heal(solid: Solid, size: f64) -> Solid {
|
||||
healed(solid, size, &[], false, false).0
|
||||
}
|
||||
|
||||
/// What healing made each face and edge of its result from.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct HealMap {
|
||||
/// For each face of the result (in its face order), the input faces it was made from (by
|
||||
/// position in the input's face order): one, or several merged into it.
|
||||
pub faces: Vec<Vec<usize>>,
|
||||
/// For each edge of the result (by identity), the input edges it was made from (by
|
||||
/// position in the input's edge order): one, or several joined into it.
|
||||
pub edges: HashMap<mt::EdgeID, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl HealMap {
|
||||
/// The history of the healed solid, from that of its input (`faces` in the input's face
|
||||
/// order, each a region of operand faces; `edges` the origins of each edge, in its edge
|
||||
/// order): a face healing merged from several is Merged when they came from several, the
|
||||
/// rest keep their origins (see [`crate::record::regions`]). Faces in the healed solid's
|
||||
/// face order, edges by identity.
|
||||
pub(crate) fn carry(&self, faces: &[FaceProv], edges: &[Vec<EdgeOrigin>]) -> Option<(Vec<FaceProv>, HashMap<mt::EdgeID, Vec<EdgeOrigin>>)> {
|
||||
let regions = self
|
||||
.faces
|
||||
.iter()
|
||||
.map(|under| {
|
||||
let whole = matches!(under.as_slice(), [i] if faces.get(*i).is_some_and(|f| f.change == Change::Kept));
|
||||
let mut origins = BTreeSet::new();
|
||||
for i in under {
|
||||
origins.extend(faces.get(*i)?.origins.iter().copied());
|
||||
}
|
||||
Some((origins, whole))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let edges = self
|
||||
.edges
|
||||
.iter()
|
||||
.map(|(id, under)| {
|
||||
let mut origins = BTreeSet::new();
|
||||
for i in under {
|
||||
origins.extend(edges.get(*i)?.iter().cloned());
|
||||
}
|
||||
Some((*id, origins.into_iter().collect()))
|
||||
})
|
||||
.collect::<Option<HashMap<_, _>>>()?;
|
||||
Some((crate::record::regions(regions), edges))
|
||||
}
|
||||
|
||||
/// Every face and edge of `solid` as itself (nothing healed).
|
||||
fn identity(solid: &Solid) -> HealMap {
|
||||
let faces = (0..solid.face_iter().count()).map(|i| vec![i]).collect();
|
||||
let edges = crate::record::edge_index(solid).into_iter().map(|(id, i)| (id, vec![i])).collect();
|
||||
HealMap { faces, edges }
|
||||
}
|
||||
}
|
||||
|
||||
/// Heal a solid (the input unchanged when nothing applies or healing fails), keeping faces apart
|
||||
/// along lines they were split along on purpose (`keep`), with what each face and edge of the
|
||||
/// result was made from (`None` when that could not be followed).
|
||||
pub(crate) fn heal_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid, Option<HealMap>) {
|
||||
healed(solid, size, keep, true, false)
|
||||
}
|
||||
|
||||
/// [`heal_traced`] that only merges coplanar neighbouring faces and joins the straight edges
|
||||
/// they leave split in line (#1049), leaving every curve and surface as it is: for results
|
||||
/// whose geometry is already exact (a prism boolean's).
|
||||
pub(crate) fn merge_faces_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid, Option<HealMap>) {
|
||||
healed(solid, size, keep, true, true)
|
||||
}
|
||||
|
||||
/// The healed solid, and what it was made from when `traced`.
|
||||
fn healed(solid: Solid, size: f64, keep: &[Vec<Vec3>], traced: bool, faces_only: bool) -> (Solid, Option<HealMap>) {
|
||||
// Edges a boolean made are intersection curves, which panic where they no longer
|
||||
// evaluate; the solid then stays as it is.
|
||||
match crate::guard("heal", || Ok(heal_shells(&solid, size, keep, traced, faces_only))) {
|
||||
Ok(Some((healed, map))) => (healed, map),
|
||||
_ => {
|
||||
let map = if traced { crate::guard("heal map", || Ok(HealMap::identity(&solid))).ok() } else { None };
|
||||
(solid, map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The input edges each edge made while healing comes from. Input edges live as long as the
|
||||
/// healing (the input solid is borrowed throughout), so their identities are never reused; an
|
||||
/// edge healing makes is recorded when it is made, from edges then alive, which overwrites any
|
||||
/// entry left under its identity by an edge made earlier and dropped since. Records nothing
|
||||
/// when not `on`.
|
||||
struct Trace {
|
||||
on: bool,
|
||||
input: HashMap<mt::EdgeID, usize>,
|
||||
made: HashMap<mt::EdgeID, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Trace {
|
||||
/// The input edges under the live edge `id`.
|
||||
fn of(&self, id: &mt::EdgeID) -> Vec<usize> {
|
||||
match self.made.get(id) {
|
||||
Some(from) => from.clone(),
|
||||
None => self.input.get(id).map(|i| vec![*i]).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `edge` was made from the live edges `from`.
|
||||
fn made<const N: usize>(&mut self, edge: &mt::Edge, from: [mt::EdgeID; N]) {
|
||||
self.made_of(edge, from);
|
||||
}
|
||||
|
||||
/// `edge` was made from the live edges `from`, however many.
|
||||
fn made_of(&mut self, edge: &mt::Edge, from: impl IntoIterator<Item = mt::EdgeID>) {
|
||||
if !self.on {
|
||||
return;
|
||||
}
|
||||
let mut under: Vec<usize> = from.into_iter().flat_map(|id| self.of(&id)).collect();
|
||||
under.sort_unstable();
|
||||
under.dedup();
|
||||
self.made.insert(edge.id(), under);
|
||||
}
|
||||
}
|
||||
|
||||
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool, faces_only: bool) -> Option<(Solid, Option<HealMap>)> {
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let mut shells = Vec::new();
|
||||
let mut changed = false;
|
||||
let input = if traced { crate::record::edge_index(solid) } else { HashMap::new() };
|
||||
let mut trace = Trace { on: traced, input, made: HashMap::new() };
|
||||
// Each face of the shells so far, as the input faces it was made from.
|
||||
let mut faces: Vec<Vec<usize>> = Vec::new();
|
||||
let mut first = 0;
|
||||
for sh in solid.boundaries() {
|
||||
let mut cur = sh.clone();
|
||||
let count = cur.face_iter().count();
|
||||
let mut from: Vec<Vec<usize>> = (first..first + count).map(|i| vec![i]).collect();
|
||||
first += count;
|
||||
// Faces come out in place, one for one, except where coplanar ones merge.
|
||||
if !faces_only && let Some(m) = planar_surfaces(&cur, (size * 1e-7).max(1e-9)) {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
if !faces_only && let Some(m) = exact_edges(&cur, (size * 1e-5).max(1e-7), &mut trace) {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
if let Some((m, groups)) = merge_coplanar(&cur, tol, keep) {
|
||||
cur = m;
|
||||
from = groups.iter().map(|g| g.iter().filter_map(|i| from.get(*i)).flatten().copied().collect()).collect();
|
||||
changed = true;
|
||||
}
|
||||
// Straight edges the merged pieces leave split at vertices nothing else uses join
|
||||
// into one, after any boolean (#1049): later features meet one edge, not the pieces.
|
||||
if let Some(m) = merge_collinear(&cur, &mut trace) {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
faces.extend(from);
|
||||
shells.push(cur);
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
let healed = Solid::try_new(shells).ok()?;
|
||||
if !traced {
|
||||
return Some((healed, None));
|
||||
}
|
||||
let edges: Option<HashMap<mt::EdgeID, Vec<usize>>> = crate::body::edge_order(healed.face_iter())
|
||||
.0
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let under = trace.of(&e.id());
|
||||
(!under.is_empty()).then(|| (e.id(), under))
|
||||
})
|
||||
.collect();
|
||||
let map = edges.map(|edges| HealMap { faces, edges });
|
||||
Some((healed, map))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Body;
|
||||
|
||||
/// Distinct faces, edges and vertices of a shell (its iterators repeat shared ones).
|
||||
fn counts(shell: &mt::Shell) -> (usize, usize, usize) {
|
||||
let edges: std::collections::HashSet<_> = shell.edge_iter().map(|e| e.id()).collect();
|
||||
let vertices: std::collections::HashSet<_> = shell.vertex_iter().map(|v| v.id()).collect();
|
||||
(shell.face_iter().count(), edges.len(), vertices.len())
|
||||
}
|
||||
|
||||
/// A prism whose front face was cut into `n` strips along a single line: the strips merge
|
||||
/// into one face, which leaves `n` collinear pieces on each of its long edges.
|
||||
fn strip_prism(n: usize) -> Solid {
|
||||
let pts: Vec<mt::Vertex> = (0..=n)
|
||||
.map(|k| builder::vertex(mt::Point3::new(k as f64, 0.0, 0.0)))
|
||||
.chain([builder::vertex(mt::Point3::new(n as f64, 3.0, 0.0)), builder::vertex(mt::Point3::new(0.0, 3.0, 0.0))])
|
||||
.collect();
|
||||
let pts: solidcraft_geom::Ring<mt::Vertex> = pts.into();
|
||||
let edges: Vec<mt::Edge> = pts.edges().map(|(a, b)| builder::line(a, b)).collect();
|
||||
let face = builder::try_attach_plane(&[mt::Wire::from(edges)]).expect("a plane");
|
||||
builder::tsweep(&face, mt::Vector3::new(0.0, 0.0, 2.0))
|
||||
}
|
||||
|
||||
/// Runs of collinear edges join in one pass, however long: past the 10 000 pieces the old
|
||||
/// pairwise loop stopped at, and in time (a modeled thread has thousands of such pieces,
|
||||
/// and filleting beside one froze the app).
|
||||
#[test]
|
||||
fn long_collinear_runs_join_whole() {
|
||||
let n = 12_000;
|
||||
let started = std::time::Instant::now();
|
||||
let healed = heal(strip_prism(n), n as f64);
|
||||
let shell = healed.boundaries().first().expect("a shell").clone();
|
||||
assert_eq!(counts(&shell), (6, 12, 8));
|
||||
let body = Body::new(healed).expect("a valid body");
|
||||
let volume = crate::measure::measure(&body).expect("measures").volume;
|
||||
assert!((volume - n as f64 * 3.0 * 2.0).abs() < 1e-6 * volume, "{volume}");
|
||||
assert!(started.elapsed().as_secs() < 30, "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_collinear_runs_join_and_keep_the_corners() {
|
||||
let healed = heal(strip_prism(3), 3.0);
|
||||
let shell = healed.boundaries().first().expect("a shell").clone();
|
||||
assert_eq!(counts(&shell), (6, 12, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approximate_arcs_become_exact() {
|
||||
// A cubic (non-rational) spline through a quarter circle.
|
||||
let (a, b) = (builder::vertex(mt::Point3::new(10.0, 0.0, 0.0)), builder::vertex(mt::Point3::new(0.0, 10.0, 0.0)));
|
||||
let pts: Vec<Vec3> = (0..=40)
|
||||
.map(|k| {
|
||||
let t = std::f64::consts::FRAC_PI_2 * k as f64 / 40.0;
|
||||
Vec3::new(10.0 * t.cos(), 10.0 * t.sin(), 0.0)
|
||||
})
|
||||
.collect();
|
||||
let curve = mt::Curve::BSplineCurve(crate::build::interpolate_cubic(&pts).expect("spline"));
|
||||
let e = mt::Edge::new(&a, &b, curve);
|
||||
let x = fit_exact(&e, 1e-2).expect("an arc");
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = x.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let mid = c.subs((t0 + t1) / 2.0);
|
||||
assert!(((mid.x * mid.x + mid.y * mid.y).sqrt() - 10.0).abs() < 1e-9, "{mid:?}");
|
||||
}
|
||||
let tolerance = record.tolerance * 10.0;
|
||||
let (healed, history) = crate::offset_backend::run("healing", body, |b| b.merge_faces(&kept, tolerance))?;
|
||||
Ok(healed.with_checked_history(history, &crate::record::counts(&[body])).with_split_lines(body.splits.clone()))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Two bores of one radius crossing each other (#788): the surfaces touch where the two ellipses
|
||||
//! of the crossing meet, which a mesh intersection cannot trace and a boolean cannot take in the
|
||||
//! middle of a face.
|
||||
use crate::tangent_cylinders;
|
||||
use crate::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::f64::consts::PI;
|
||||
@@ -114,38 +113,3 @@ fn faces_split_for_the_crossing_leave_the_operands_alone() {
|
||||
assert_eq!(one.face_count(), faces);
|
||||
assert_eq!(volume(&one), volume_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossings_that_are_not_touching_are_left_alone() {
|
||||
let r = 10.0;
|
||||
let size = 100.0;
|
||||
let tol = size * 1e-6;
|
||||
let cyl = |face, origin: Vec3, axis: Vec3, radius| tangent_cylinders::Cylinder::new(face, origin, axis, radius).unwrap();
|
||||
let z = cyl(0, Vec3::ZERO, Vec3::Z, r);
|
||||
// Different radius, parallel axes, skew axes: no touching points to split at.
|
||||
assert!(tangent_cylinders::crossing(&z, &cyl(1, Vec3::ZERO, Vec3::Y, r * 0.9), tol).is_none());
|
||||
assert!(tangent_cylinders::crossing(&z, &cyl(1, Vec3::new(5.0, 0.0, 0.0), Vec3::Z, r), tol).is_none());
|
||||
assert!(tangent_cylinders::crossing(&z, &cyl(1, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1e-6, 0.0, 1.0), r), tol).is_none());
|
||||
assert!(tangent_cylinders::crossing(&z, &cyl(1, Vec3::new(1.0, 0.0, 0.0), Vec3::Y, r), tol).is_none());
|
||||
// Axes that meet: two touching points, a radius either side of the crossing.
|
||||
let found = tangent_cylinders::crossing(&z, &cyl(1, Vec3::ZERO, Vec3::Y, r), tol).unwrap();
|
||||
assert!(found.points.iter().any(|p| p.dist(Vec3::new(r, 0.0, 0.0)) < 1e-9));
|
||||
assert!(found.points.iter().any(|p| p.dist(Vec3::new(-r, 0.0, 0.0)) < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_cylinders_are_refused() {
|
||||
let tol = 1e-4;
|
||||
let cyl = |axis: Vec3, radius, origin: Vec3| tangent_cylinders::Cylinder::new(0, origin, axis, radius);
|
||||
// An axis with no direction cannot make a cylinder at all.
|
||||
assert!(cyl(Vec3::new(f64::NAN, 0.0, 1.0), 10.0, Vec3::ZERO).is_none());
|
||||
assert!(cyl(Vec3::ZERO, 10.0, Vec3::ZERO).is_none());
|
||||
let good = cyl(Vec3::Z, 10.0, Vec3::ZERO).unwrap();
|
||||
for bad in [
|
||||
cyl(Vec3::Y, f64::NAN, Vec3::ZERO).unwrap(),
|
||||
cyl(Vec3::Y, f64::INFINITY, Vec3::ZERO).unwrap(),
|
||||
cyl(Vec3::Y, 10.0, Vec3::new(f64::INFINITY, 0.0, 0.0)).unwrap(),
|
||||
] {
|
||||
assert!(tangent_cylinders::crossing(&good, &bad, tol).is_none(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! (`crate::offset_backend`).
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::brep::eval::CurveEval;
|
||||
use solidcraft_geom::brep::{Brep, Frame, Loop, Surface};
|
||||
|
||||
use crate::body::Body;
|
||||
@@ -134,14 +133,6 @@ pub(crate) fn face_edges(b: &Brep, face: usize) -> Vec<u32> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Points along edge `e` of `b` (its ends and three inside), for comparing it with lines.
|
||||
pub(crate) fn edge_points(b: &Brep, e: u32) -> Vec<Vec3> {
|
||||
let Some(edge) = b.edges.get(e as usize) else { return Vec::new() };
|
||||
let Ok(c) = CurveEval::new(&edge.curve) else { return Vec::new() };
|
||||
let [t0, t1] = edge.interval;
|
||||
(0..=4).filter_map(|k| c.point(t0 + (t1 - t0) * k as f64 / 4.0)).collect()
|
||||
}
|
||||
|
||||
/// The chosen faces and every face on the same surface joined to them (the halves of a hole's
|
||||
/// wall), as face indices. Pieces of a face split on purpose (Split Face) stay apart.
|
||||
pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
|
||||
@@ -164,7 +155,8 @@ pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
|
||||
surfs.get(*j).and_then(|x| x.as_ref()).is_some_and(|sj| si.same(sj, tol))
|
||||
&& edges.get(*j).is_some_and(|theirs| {
|
||||
theirs.iter().any(|e| {
|
||||
mine.binary_search(e).is_ok() && (keep.is_empty() || !crate::heal::on_split_line(&edge_points(&record, *e), keep))
|
||||
mine.binary_search(e).is_ok()
|
||||
&& (keep.is_empty() || !crate::heal::on_split_line(&crate::heal::edge_points(&record, *e), keep))
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
//! A lens extruded from a sphere's projected outline (#2750). One of its two
|
||||
//! cylinders encloses the whole target and only touches the sphere. Removing that
|
||||
//! redundant constraint gives the same cut without a tangent intersection.
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
use crate::boolean_record as recorded;
|
||||
use crate::history::Change;
|
||||
use crate::{Body, BoolOp, FaceSource, FaceSurface, Result, face_surface};
|
||||
|
||||
/// Maximum signed height of a line or circular arc, including interior extrema.
|
||||
fn height(segment: Seg2, plane: Plane, cap: Plane) -> Option<f64> {
|
||||
let at = |p| cap.height(plane.to_world(p));
|
||||
let ends = at(segment.start()).max(at(segment.end()));
|
||||
match segment {
|
||||
Seg2::Line { .. } => Some(ends),
|
||||
Seg2::Arc { center, radius, .. } => {
|
||||
let n = cap.normal();
|
||||
let direction = Vec2::new(n.dot(plane.x), n.dot(plane.y));
|
||||
let probe = center + direction.normalized().unwrap_or(Vec2::X) * radius;
|
||||
Some(ends.max(at(segment.point_at(segment.closest_param(probe)))))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize a quarter ball by its complete boundary: one spherical face, two
|
||||
/// perpendicular semicircular caps sharing their diameter, each cap inside the
|
||||
/// other's outward halfspace. The opposite cap orientation (three quarters of a
|
||||
/// ball) is refused. Whole-span curve certificates prove the caps, not samples.
|
||||
fn enclosing_ball(body: &Body, tol: f64) -> Option<(Vec3, f64, Vec<Plane>)> {
|
||||
if body.is_mesh() || body.shell_count() != 1 || body.face_count() != 3 {
|
||||
return None;
|
||||
}
|
||||
let surfaces = (0..3).map(|i| face_surface(body, i)).collect::<Option<Vec<_>>>()?;
|
||||
let (sphere, center, radius) = surfaces.iter().enumerate().find_map(|(i, surface)| match *surface {
|
||||
FaceSurface::Sphere { center, radius } => Some((i, center, radius)),
|
||||
_ => None,
|
||||
})?;
|
||||
let spherical = body.solid.face_iter().nth(sphere)?;
|
||||
if spherical.boundaries().len() != 1 || spherical.edge_iter().count() != 2 {
|
||||
return None;
|
||||
}
|
||||
let sphere_edges: std::collections::HashSet<_> = spherical.edge_iter().map(|e| e.id()).collect();
|
||||
let caps: Vec<_> = surfaces
|
||||
.iter()
|
||||
.filter_map(|surface| match *surface {
|
||||
FaceSurface::Plane { normal, point } => Plane::from_normal(point, normal),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let (cap0, cap1) = (*caps.first()?, *caps.get(1)?);
|
||||
if caps.len() != 2 || cap0.normal().dot(cap1.normal()).abs() > 1e-9 || cap0.height(center).abs() > tol || cap1.height(center).abs() > tol {
|
||||
return None;
|
||||
}
|
||||
let mut work = 2_000_000usize;
|
||||
let mut diameters = Vec::new();
|
||||
for (i, face) in body.solid.face_iter().enumerate() {
|
||||
if i == sphere {
|
||||
continue;
|
||||
}
|
||||
let FaceSurface::Plane { normal, point } = *surfaces.get(i)? else { return None };
|
||||
let plane = Plane::from_normal(point, normal)?;
|
||||
if face.boundaries().len() != 1 || face.edge_iter().count() != 2 {
|
||||
return None;
|
||||
}
|
||||
let c = plane.to_local(center);
|
||||
let mut arc = 0.;
|
||||
let mut lines = 0;
|
||||
for edge in face.edge_iter() {
|
||||
if plane.height(crate::body::from_p3(edge.front().point())).abs() > tol
|
||||
|| plane.height(crate::body::from_p3(edge.back().point())).abs() > tol
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for segment in crate::prism_curve::segment(&edge, &plane, tol, &mut work)? {
|
||||
match segment {
|
||||
Seg2::Line { a, b } if (a + b - c * 2.).len() <= tol && (a.dist(c) - radius).abs() <= tol => {
|
||||
diameters.push(edge.id());
|
||||
lines += 1;
|
||||
}
|
||||
Seg2::Arc { center: q, radius: r, sweep, .. } if q.dist(c) + (r - radius).abs() <= tol && sphere_edges.contains(&edge.id()) => {
|
||||
arc += sweep.abs();
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
if height(segment, plane, cap0)? > tol || height(segment, plane, cap1)? > tol {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if lines != 1 || (arc - std::f64::consts::PI).abs() > 1e-8 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if diameters.len() != 2 || diameters.first()? != diameters.get(1)? {
|
||||
return None;
|
||||
}
|
||||
Some((center, radius, caps))
|
||||
}
|
||||
|
||||
/// Replace only a construction-certified, convex two-circle lens. Its cutting cylinder stays exact; a cap moves
|
||||
/// only into a halfspace proved to be outside the target. The added tool material is outside the ball.
|
||||
fn relaxed_tool(a: &Body, b: &Body) -> Option<Body> {
|
||||
if b.is_mesh() || b.shell_count() != 1 || b.face_count() != 4 {
|
||||
return None;
|
||||
}
|
||||
let tol = (a.size().max(b.size()) * 1e-9).max(1e-8);
|
||||
let sources = b.face_sources()?;
|
||||
let (region, plane) = sources.iter().find_map(|source| match source {
|
||||
Some(FaceSource::ExtrudeCap { high: true, region, plane }) => Some((region, *plane)),
|
||||
_ => None,
|
||||
})?;
|
||||
let bottom = sources.iter().find_map(|source| match source {
|
||||
Some(FaceSource::ExtrudeCap { high: false, region: r, plane }) if r == region => Some(*plane),
|
||||
_ => None,
|
||||
})?;
|
||||
if !region.holes.is_empty() || region.outer.segs.len() != 2 || !region.outer.is_closed(tol) {
|
||||
return None;
|
||||
}
|
||||
let circles: Vec<(Vec2, f64)> = region
|
||||
.outer
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, sweep, .. } if sweep > 0.0 && sweep <= std::f64::consts::PI + 1e-9 => Some((center, radius)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let ((c0, r0), (c1, r1)) = (*circles.first()?, *circles.get(1)?);
|
||||
let distance = c0.dist(c1);
|
||||
if distance <= (r0 - r1).abs() + tol || distance >= r0 + r1 - tol {
|
||||
return None;
|
||||
}
|
||||
let (center, radius, caps) = enclosing_ball(a, tol)?;
|
||||
let projected = plane.to_local(center);
|
||||
let redundant = circles.iter().position(|(c, r)| projected.dist(*c) + radius <= *r + tol)?;
|
||||
let (c, r) = *circles.get(1 - redundant)?;
|
||||
let height = plane.height(bottom.origin);
|
||||
if !height.is_finite() || height >= -tol || plane.normal().dot(bottom.normal()) > -1.0 + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
// The proven quarter ball lies behind each cap. Extend a coincident tool
|
||||
// end outward into that empty halfspace; this avoids the generic nudge's
|
||||
// paper-thin lid across the opening, without moving the cutting cylinder.
|
||||
let on_cap = |end: Plane| caps.iter().any(|cap| cap.normal().dot(end.normal()) > 1.0 - 1e-9 && cap.height(end.origin).abs() <= tol);
|
||||
let (low, high) = (on_cap(bottom), on_cap(plane));
|
||||
if !low && !high {
|
||||
return None;
|
||||
}
|
||||
let margin = (a.size().max(b.size()) * 0.01).max(1e-5);
|
||||
let lo = height - if low { margin } else { 0. };
|
||||
let hi = if high { margin } else { 0. };
|
||||
let mut tool =
|
||||
crate::extrude(&plane, &[Region2 { outer: Loop2::circle_from(c, r, crate::build::SEAM_ANGLE), holes: vec![] }], lo, hi).ok()?.pop()?;
|
||||
// Each rebuilt surface names its original tool face. Extended portions never
|
||||
// meet the target, so every face used in the cut still lies in that input face.
|
||||
let mut faces = Vec::new();
|
||||
for i in 0..tool.face_count() {
|
||||
let surface = face_surface(&tool, i)?;
|
||||
let original = (0..b.face_count()).find(|j| match (surface, face_surface(b, *j)) {
|
||||
(FaceSurface::Plane { normal: n, .. }, Some(FaceSurface::Plane { normal: m, .. })) => n.dot(m) > 1.0 - 1e-9,
|
||||
(FaceSurface::Cylinder { origin: p, axis: n, radius: r }, Some(FaceSurface::Cylinder { origin: q, axis: m, radius: s })) => {
|
||||
n.dot(m).abs() > 1.0 - 1e-9 && (p - q).cross(n).len() + (r - s).abs() <= tol
|
||||
}
|
||||
_ => false,
|
||||
})?;
|
||||
faces.push(crate::record::from_face(0, original, Change::Modified));
|
||||
}
|
||||
let history = crate::record::from_faces(&tool.solid, 1, faces);
|
||||
tool = tool.with_checked_history(Some(history), &crate::record::counts(&[b]));
|
||||
Some(tool)
|
||||
}
|
||||
|
||||
pub(crate) fn cut(a: &Body, b: &Body, run: &dyn Fn(&Body, &Body, BoolOp) -> Result<Option<Body>>) -> Option<Result<Option<Body>>> {
|
||||
let tool = crate::guard("sphere lens tool", || Ok(relaxed_tool(a, b))).ok()??;
|
||||
let result = run(a, &tool, BoolOp::Cut).ok()?;
|
||||
crate::boolean_guard::check_retry(a, b, BoolOp::Cut, result.as_ref()).ok()?;
|
||||
Some(Ok(result.map(|body| {
|
||||
let history = recorded::through(&body, 1, tool.history());
|
||||
body.with_checked_history(history, &crate::record::counts(&[a, b]))
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sphere_lens_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
//! A lens extruded from a sphere's projected outline cut from the sphere (#2750): one of its
|
||||
//! cylinders encloses the target and only touches the sphere.
|
||||
use crate::history::BodyHistory;
|
||||
use crate::{Body, BoolOp};
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
fn dome() -> Body {
|
||||
@@ -37,9 +40,7 @@ fn tool(region: Region2, depth: f64) -> Body {
|
||||
fn projected_spherical_outline_does_not_block_a_lens_cut() {
|
||||
let target = dome();
|
||||
let cutter = tool(lens(25.), 30.);
|
||||
let (result, diagnostics) = crate::capture_boolean_diagnostics(|| crate::boolean(&target, &cutter, BoolOp::Cut));
|
||||
let result = result.unwrap().unwrap();
|
||||
assert!(diagnostics.events.iter().all(|event| !event.strategy.alters_result()), "{diagnostics:?}");
|
||||
let result = crate::boolean(&target, &cutter, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(result.validity().is_empty(), "{:?}", result.validity());
|
||||
assert_eq!(result.lumps().unwrap().len(), 1);
|
||||
// Integrating the half-disc cross-section left by the offset bore gives
|
||||
@@ -50,25 +51,7 @@ fn projected_spherical_outline_does_not_block_a_lens_cut() {
|
||||
assert!(result.contains(Vec3::new(0., -10., 2.)));
|
||||
assert!(!result.contains(Vec3::new(0., -10., 15.)));
|
||||
assert_eq!(measured.merged.face_types.get("cylinder"), Some(&1));
|
||||
let history = result.history().expect("the retry composes the tool's face history");
|
||||
let history = result.history().expect("the cut has a face history");
|
||||
history.check(&result, &[&target, &cutter]).unwrap();
|
||||
assert!(history.faces.iter().all(|f| !f.origins.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_enclosing_wall_can_be_removed_and_cap_depth_stays_exact() {
|
||||
let target = dome();
|
||||
assert!(relaxed_tool(&target, &tool(lens(24.), 30.)).is_none(), "a cylinder inside the sphere removes real material");
|
||||
let rectangle =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(-4., 5.), Vec2::new(4., 5.), Vec2::new(4., 15.), Vec2::new(-4., 15.)]), holes: vec![] };
|
||||
assert!(relaxed_tool(&target, &tool(rectangle, 30.)).is_none());
|
||||
let cutter = tool(lens(25.), 7.);
|
||||
let relaxed = relaxed_tool(&target, &cutter).unwrap();
|
||||
let bounds = crate::measure(&relaxed).unwrap().bbox;
|
||||
assert!((bounds.min.y + 7.).abs() < 1e-6 && bounds.max.y > 0., "only the end outside the quarter ball is extended: {bounds:?}");
|
||||
let cut = crate::boolean(&target, &cutter, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(cut.contains(Vec3::new(0., -8., 15.)), "material past the pocket floor stays");
|
||||
assert!(!cut.contains(Vec3::new(0., -6., 15.)), "the shallow pocket removes material up to its floor");
|
||||
let box_target = crate::box_solid(Vec3::new(-25., -25., 0.), Vec3::new(25., 0., 25.)).unwrap();
|
||||
assert!(relaxed_tool(&box_target, &cutter).is_none());
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
//! Cylinders of one radius whose axes cross (#788: a second bore of the same size drilled across
|
||||
//! a bore, a pipe tee of equal pipes).
|
||||
//!
|
||||
//! Two such cylinders meet in two ellipses that cross each other at two points, where the
|
||||
//! surfaces touch along a common normal. A mesh intersection cannot trace the curves through
|
||||
//! there, and a boolean with a crossing in the middle of a face has no way to tell which of the
|
||||
//! curves' four arms lies in which sector of the face. With the touching points on the edges of
|
||||
//! the faces instead, each arm is a curve running from an edge to an edge, which the boolean
|
||||
//! takes. So before the boolean, each of the two faces is split along the line of its cylinder
|
||||
//! through the touching points (the solids are unchanged), and the result's history is composed
|
||||
//! back over the operands as they were given.
|
||||
|
||||
use solidcraft_geom::{Plane, UnitVec3, Vec3};
|
||||
use truck_modeling::{ParametricCurve, SearchNearestParameter};
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
use crate::boolean_record as recorded;
|
||||
use crate::history::BodyHistory;
|
||||
use crate::history::History;
|
||||
use crate::splitface::{SplitTool, split_any};
|
||||
use crate::{BoolOp, FaceSurface, Result, face_surface};
|
||||
|
||||
/// Most faces split before the boolean (a face crossing several cylinders is split for each).
|
||||
const MAX_SPLITS: usize = 16;
|
||||
|
||||
/// A cylindrical face and its cylinder.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Cylinder {
|
||||
face: usize,
|
||||
origin: Vec3,
|
||||
axis: UnitVec3,
|
||||
radius: f64,
|
||||
}
|
||||
|
||||
impl Cylinder {
|
||||
#[cfg(test)]
|
||||
/// `None` for an axis with no direction.
|
||||
pub(crate) fn new(face: usize, origin: Vec3, axis: Vec3, radius: f64) -> Option<Self> {
|
||||
Some(Self { face, origin, axis: axis.unit()?, radius })
|
||||
}
|
||||
}
|
||||
|
||||
fn cylinders(body: &Body) -> Vec<Cylinder> {
|
||||
(0..body.face_count())
|
||||
.filter_map(|face| match face_surface(body, face)? {
|
||||
FaceSurface::Cylinder { origin, axis, radius } if origin.is_finite() && radius.is_finite() => {
|
||||
Some(Cylinder { face, origin, axis, radius })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where two cylinders of one radius with crossing axes touch, and the planes that hold the
|
||||
/// touching points and one cylinder's axis each.
|
||||
pub(crate) struct Crossing {
|
||||
pub(crate) points: [Vec3; 2],
|
||||
plane_x: Plane,
|
||||
plane_y: Plane,
|
||||
}
|
||||
|
||||
pub(crate) fn crossing(x: &Cylinder, y: &Cylinder, tol: f64) -> Option<Crossing> {
|
||||
let finite = [x, y].iter().all(|c| c.origin.is_finite() && c.radius.is_finite() && c.radius > 0.0);
|
||||
if !finite || !tol.is_finite() || (x.radius - y.radius).abs() > tol {
|
||||
return None;
|
||||
}
|
||||
let across = x.axis.cross(y.axis);
|
||||
// Axes within a hair of parallel touch along a line, which is another matter.
|
||||
let sine = across.len();
|
||||
if !(sine > 1e-4) {
|
||||
return None;
|
||||
}
|
||||
// The closest points of the two axes, which meet in a point.
|
||||
let (b, w) = (x.axis.dot(y.axis), x.origin - y.origin);
|
||||
let (d, e) = (x.axis.dot(w), y.axis.dot(w));
|
||||
let denominator = 1.0 - b * b;
|
||||
if !(denominator > 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let s = (b * e - d) / denominator;
|
||||
let t = (e - b * d) / denominator;
|
||||
let (on_x, on_y) = (x.origin + x.axis * s, y.origin + y.axis * t);
|
||||
if !(on_x.dist(on_y) <= tol) {
|
||||
return None;
|
||||
}
|
||||
let centre = (on_x + on_y) * 0.5;
|
||||
// Both cylinders reach their common normal `c` at a radius from the crossing.
|
||||
let c = across / sine;
|
||||
let points = [centre + c * x.radius, centre - c * x.radius];
|
||||
Some(Crossing { points, plane_x: Plane::from_normal(centre, x.axis.cross(c))?, plane_y: Plane::from_normal(centre, y.axis.cross(c))? })
|
||||
}
|
||||
|
||||
/// Whether `p` is on face `face` of the body whose mesh is `mesh`: within `reach` of one of the
|
||||
/// face's triangles.
|
||||
fn on_face(mesh: &solidcraft_geom::Mesh, face: usize, p: Vec3, reach: f64) -> bool {
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f as usize == face)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.any(|[a, b, c]| crate::boolean_guard::point_triangle_dist(p, a, b, c) <= reach)
|
||||
}
|
||||
|
||||
/// Whether `p` lies on an edge of face `face` (within `margin`).
|
||||
fn on_boundary(body: &Body, face: usize, p: Vec3, margin: f64) -> bool {
|
||||
let Some(face) = body.solid.face_iter().nth(face) else { return false };
|
||||
face.absolute_boundaries().iter().any(|wire| {
|
||||
wire.edge_iter().any(|edge| {
|
||||
let curve = edge.curve();
|
||||
curve.search_nearest_parameter(p3(p), None, 100).is_some_and(|t| from_p3(curve.subs(t)).dist(p) <= margin)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The split the bodies need next: `(whether in the first body, face, plane)`, if a touching
|
||||
/// point of two cylinders lies on both their faces but on the edges of neither yet.
|
||||
fn next_split(a: &Body, b: &Body, tol: f64) -> Option<(bool, usize, Plane)> {
|
||||
let (in_b, in_a) = (cylinders(b), cylinders(a));
|
||||
if in_a.is_empty() || in_b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let size = a.size().max(b.size());
|
||||
let (margin, chord) = ((size * 1e-6).max(1e-9), (size * 1e-3).max(1e-6));
|
||||
let mut meshes = None;
|
||||
for x in &in_a {
|
||||
for y in &in_b {
|
||||
let Some(crossing) = crossing(x, y, tol) else { continue };
|
||||
if meshes.is_none() {
|
||||
meshes = Some((a.mesh(&crate::MeshTolerance::chord(chord)).ok()?, b.mesh(&crate::MeshTolerance::chord(chord)).ok()?));
|
||||
}
|
||||
let (ma, mb) = meshes.as_ref()?;
|
||||
for p in crossing.points {
|
||||
if !(on_face(ma, x.face, p, 3.0 * chord) && on_face(mb, y.face, p, 3.0 * chord)) {
|
||||
continue;
|
||||
}
|
||||
if !on_boundary(a, x.face, p, margin) {
|
||||
return Some((true, x.face, crossing.plane_x));
|
||||
}
|
||||
if !on_boundary(b, y.face, p, margin) {
|
||||
return Some((false, y.face, crossing.plane_y));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the bodies have cylinders of one radius crossing each other that touch inside both
|
||||
/// their faces, so that [`boolean`] has faces to split.
|
||||
pub(crate) fn needed(a: &Body, b: &Body) -> bool {
|
||||
!a.is_mesh() && !b.is_mesh() && !SPLITTING.with(|c| c.get()) && next_split(a, b, (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0).is_some()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Set while the boolean of the split bodies runs (no second round of splitting).
|
||||
static SPLITTING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The boolean `op` of `a` and `b` with their faces split along the touching points of equal
|
||||
/// cylinders crossing each other, `run` doing the boolean of the split bodies. `None` when the
|
||||
/// bodies have no such cylinders, or the boolean of the split bodies gave no (checked) result:
|
||||
/// the caller then takes its other ways.
|
||||
pub(crate) fn boolean(a: &Body, b: &Body, op: BoolOp, run: &dyn Fn(&Body, &Body, BoolOp) -> Result<Option<Body>>) -> Option<Result<Option<Body>>> {
|
||||
if a.is_mesh() || b.is_mesh() || SPLITTING.with(|c| c.get()) {
|
||||
return None;
|
||||
}
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
let ((mut x, mut hx), (mut y, mut hy)) = ((a.clone(), Some(History::identity(a))), (b.clone(), Some(History::identity(b))));
|
||||
let mut split = false;
|
||||
for _ in 0..MAX_SPLITS {
|
||||
let Some((first, face, plane)) = next_split(&x, &y, tol) else { break };
|
||||
let (body, history) = if first { (&x, &hx) } else { (&y, &hy) };
|
||||
let (mut done, _) = split_any(body, &[face], &SplitTool::Plane(plane)).ok()?;
|
||||
// The split is no authored boundary: a later feature may still merge what it separates.
|
||||
done.regularization_protected = body.regularization_protected;
|
||||
let through = recorded::through(&done, 0, history.as_ref());
|
||||
if first {
|
||||
(x, hx) = (done, through);
|
||||
} else {
|
||||
(y, hy) = (done, through);
|
||||
}
|
||||
split = true;
|
||||
}
|
||||
if !split {
|
||||
return None;
|
||||
}
|
||||
SPLITTING.with(|c| c.set(true));
|
||||
let result = crate::guard("boolean of split faces", || run(&x, &y, op));
|
||||
SPLITTING.with(|c| c.set(false));
|
||||
let Ok(result) = result else { return None };
|
||||
// Held to what a boolean can do, as a retry's result is (see `boolean_guard`).
|
||||
crate::boolean_guard::check_retry(a, b, op, result.as_ref()).ok()?;
|
||||
let counts = crate::record::counts(&[a, b]);
|
||||
Some(Ok(result.map(|body| {
|
||||
// Each step's history over the operands `[a, b]`.
|
||||
let history = recorded::of_pair(&body, &recorded::placed(hx.clone(), 0, 2), &recorded::placed(hy.clone(), 1, 2));
|
||||
body.with_checked_history(history, &counts)
|
||||
})))
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//! Retry a failed cut with different boundaries between a round tool's faces (#2387).
|
||||
//! Every curved support must be a revolution about the same axis and every planar support
|
||||
//! perpendicular to it. Turning this closed solid about that axis changes only its seams.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, InnerSpace, Transform};
|
||||
|
||||
use super::{BoolOp, boolean_via, boolean_whole, nudged, transform};
|
||||
use crate::body::{Body, from_p3};
|
||||
use crate::boolean_diagnostics::{BooleanFallback, BooleanStrategy, capture_boolean_diagnostics, merge, record};
|
||||
use crate::{guard, record};
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Exact revolution axes, rather than a fit to sampled normals: splines and affine
|
||||
/// distortions of round tools must never be mistaken for rotational symmetry.
|
||||
fn axis(body: &Body) -> Option<(Vec3, Vec3)> {
|
||||
if body.is_mesh() || body.shell_count() != 1 || body.face_count() > 64 {
|
||||
return None;
|
||||
}
|
||||
guard("round tool axis", || {
|
||||
let mut axes = Vec::new();
|
||||
let mut normals = Vec::new();
|
||||
for face in body.solid.face_iter() {
|
||||
match face.surface() {
|
||||
mt::Surface::Plane(p) => {
|
||||
let n = p.normal();
|
||||
let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() else { return Ok(None) };
|
||||
normals.push(n);
|
||||
}
|
||||
mt::Surface::RevolutedCurve(s) => {
|
||||
let m = s.transform();
|
||||
let (x, y, z) = (m.x.truncate(), m.y.truncate(), m.z.truncate());
|
||||
// Only rigid transforms: no elliptic scaling of the revolution.
|
||||
if [x, y, z].iter().any(|v| (v.magnitude2() - 1.0).abs() > 1e-9)
|
||||
|| [x.dot(y), x.dot(z), y.dot(z)].iter().any(|v| v.abs() > 1e-9)
|
||||
|| m.x.w != 0.0
|
||||
|| m.y.w != 0.0
|
||||
|| m.z.w != 0.0
|
||||
|| m.w.w != 1.0
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let o = from_p3(m.transform_point(s.entity().origin()));
|
||||
let a = m.transform_vector(s.entity().axis());
|
||||
let Some(a) = Vec3::new(a.x, a.y, a.z).normalized() else { return Ok(None) };
|
||||
axes.push((o, a));
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
}
|
||||
let Some(&(o, a)) = axes.first() else { return Ok(None) };
|
||||
let tol = (body.size() * 1e-9).max(1e-10);
|
||||
let same = axes.iter().all(|(p, d)| a.cross(*d).len() < 1e-9 && (*p - o).cross(a).len() < tol);
|
||||
// Surface orientation can reverse the same axis. Choose a stable direction for retries.
|
||||
let first = [a.x, a.y, a.z].into_iter().find(|v| v.abs() > 1e-9).unwrap_or(1.0);
|
||||
let a = if first < 0.0 { -a } else { a };
|
||||
Ok((same && normals.iter().all(|n| n.cross(a).len() < 1e-9)).then_some((o, a)))
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub(super) fn cut(target: &Body, tool: &Body) -> Option<Body> {
|
||||
if ACTIVE.with(|a| a.get()) {
|
||||
return None;
|
||||
}
|
||||
let (origin, axis) = axis(tool)?;
|
||||
ACTIVE.with(|a| a.set(true));
|
||||
let result = guard("cut with reseamed tool", || {
|
||||
let counts = record::counts(&[target, tool]);
|
||||
// Bounded exact retries. Keep the original error if neither can be verified.
|
||||
for angle in [0.3, -0.3, 0.7, -0.7] {
|
||||
let turned = transform(tool, Vec3::ZERO, origin, axis, angle)?;
|
||||
let (result, diagnostics) = capture_boolean_diagnostics(|| boolean_via(target, &turned, BoolOp::Cut, &boolean_whole));
|
||||
if let Ok(Some(body)) = result
|
||||
&& crate::boolean_guard::check_retry(target, tool, BoolOp::Cut, Some(&body)).is_ok()
|
||||
{
|
||||
let body = nudged(body, &turned, &counts);
|
||||
merge(diagnostics);
|
||||
let mut event = BooleanFallback::new(BooleanStrategy::ToolReseam);
|
||||
event.rotation_rad = angle;
|
||||
record(event);
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
ACTIVE.with(|a| a.set(false));
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::transform_matrix;
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
|
||||
fn drill(angle: f64) -> Body {
|
||||
let profile =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0., 1.), Vec2::new(2.5, 1.), Vec2::new(2.5, -10.), Vec2::new(0., -11.5)]), holes: vec![] };
|
||||
crate::revolve(&Plane::XZ, &[profile], Vec2::ZERO, Vec2::Y, angle).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_complete_coaxial_revolutions_can_be_reseamed() {
|
||||
let tool = drill(std::f64::consts::TAU);
|
||||
assert!(axis(&tool).is_some());
|
||||
let placed = transform(&tool, Vec3::new(11., -7., 5.), Vec3::ZERO, Vec3::new(1., 2., 3.), 0.8).unwrap();
|
||||
let (o, a) = axis(&placed).unwrap();
|
||||
let turned = transform(&placed, Vec3::ZERO, o, a, 0.3).unwrap();
|
||||
assert!((crate::measure(&placed).unwrap().volume - crate::measure(&turned).unwrap().volume).abs() < 1e-6);
|
||||
// A partial turn's radial caps would move; an affine scale can make the bore elliptic.
|
||||
assert!(axis(&drill(std::f64::consts::PI)).is_none());
|
||||
let scaled = transform_matrix(&tool, [[2., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]]).unwrap();
|
||||
assert!(axis(&scaled).is_none());
|
||||
let block = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
|
||||
assert!(axis(&block).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
//! Cuts by a prism whose walls only touch the target. A corner of the tool's outline lies exactly
|
||||
//! on the target's face (on a ring's bore, in its end face) with one wall going into the target
|
||||
//! and the other staying outside it: both walls meet the target's face along the edge between
|
||||
//! them, the loops the B-rep boolean finds there have no area, and it gives up on every try. Only
|
||||
//! the last-resort nudge of the tool got through, after seconds, and left a face of no width at
|
||||
//! the corner.
|
||||
//!
|
||||
//! A wall that lies outside the target takes nothing from it wherever it stands. Such walls are
|
||||
//! pushed outward, past what the boolean takes for touching, their neighbours running on to meet
|
||||
//! them, and the cut is taken with that tool: the same result, exactly, with the contact gone. A wall is pushed only where the
|
||||
//! samples across its pushed place all lie outside the target's mesh, and the result is refused
|
||||
//! if any of its faces lies on a pushed wall (that wall took material after all).
|
||||
|
||||
use solidcraft_geom::{InsideIndex, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3};
|
||||
|
||||
/// Most walls a tool may have for this route.
|
||||
const MAX_WALLS: usize = 256;
|
||||
|
||||
/// Samples along and up each wall.
|
||||
const SAMPLES: usize = 7;
|
||||
|
||||
/// Where across the slab a wall sweeps it is sampled, as fractions of the push: the pushed
|
||||
/// wall itself, and two layers between.
|
||||
const LAYERS: [f64; 3] = [0.125, 0.5, 1.0];
|
||||
|
||||
/// How far a vertex may move, in pushes: a corner sharper than about six degrees moves farther,
|
||||
/// and is left as it is.
|
||||
const MAX_MOVE: f64 = 20.0;
|
||||
|
||||
/// A tool with some of its walls pushed outward, and the planes they were pushed to (outward
|
||||
/// unit normal and offset).
|
||||
pub(crate) struct Pushed {
|
||||
pub(crate) tool: Body,
|
||||
planes: Vec<(Vec3, f64)>,
|
||||
}
|
||||
|
||||
impl Pushed {
|
||||
/// Whether `result` has no face on a pushed wall's plane: the walls took nothing.
|
||||
pub(crate) fn untouched(&self, result: &Body) -> bool {
|
||||
let tol = (result.size() * 1e-9).max(1e-9);
|
||||
result.solid.face_iter().all(|f| {
|
||||
let mt::Surface::Plane(p) = f.oriented_surface() else { return true };
|
||||
let Some(n) = Vec3::new(p.normal().x, p.normal().y, p.normal().z).normalized() else { return true };
|
||||
!self.planes.iter().any(|(m, d)| n.dot(*m).abs() > 1.0 - 1e-9 && f.vertex_iter().all(|v| (m.dot(from_p3(v.point())) - d).abs() <= tol))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `b` with each wall pushed outward by `by` whose pushed place lies outside the target
|
||||
/// (`target`, an index of its mesh, meshed finer than `by`): `b` must be a prism with planar caps
|
||||
/// and planar walls, every edge a line, every vertex on two walls. The faces, edges and vertices
|
||||
/// keep their order, so a boolean's history over the pushed tool holds for `b`. `None` when `b`
|
||||
/// is no such prism, no wall can be pushed, or a corner would move too far.
|
||||
pub(crate) fn pushed_tool(b: &Body, target: &InsideIndex, by: f64) -> Option<Pushed> {
|
||||
if !(by.is_finite() && by > 0.0) || b.solid.boundaries().len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let faces: Vec<&mt::Face> = b.solid.face_iter().collect();
|
||||
if faces.len() < 5 || faces.len() > MAX_WALLS + 2 {
|
||||
return None;
|
||||
}
|
||||
let planes: Vec<(Vec3, f64)> = faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let mt::Surface::Plane(p) = f.oriented_surface() else { return None };
|
||||
let n = Vec3::new(p.normal().x, p.normal().y, p.normal().z).normalized()?;
|
||||
Some((n, n.dot(from_p3(p.origin()))))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
// The caps: a face whose normal every other face is square to, but for one facing away.
|
||||
let square = |n: Vec3, m: Vec3| n.dot(m).abs() < 1e-9;
|
||||
let caps = |i: usize| -> Option<usize> {
|
||||
let n = planes.get(i)?.0;
|
||||
let mut opposite = None;
|
||||
for (j, (m, _)) in planes.iter().enumerate() {
|
||||
if j == i || square(n, *m) {
|
||||
continue;
|
||||
}
|
||||
if n.dot(*m) < -1.0 + 1e-9 && opposite.is_none() {
|
||||
opposite = Some(j);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
opposite
|
||||
};
|
||||
let (top, bottom) = (0..planes.len()).find_map(|i| caps(i).map(|j| (i, j)))?;
|
||||
let d = planes.get(top)?.0;
|
||||
let size = b.size();
|
||||
let on = (size * 1e-9).max(1e-9);
|
||||
let walls: Vec<usize> = (0..faces.len()).filter(|i| *i != top && *i != bottom).collect();
|
||||
// Which walls go: every sample across the pushed place outside the target.
|
||||
let mut shift = vec![0.0; faces.len()];
|
||||
for &w in &walls {
|
||||
let (face, (n, o)) = (faces.get(w)?, planes.get(w)?);
|
||||
let corners: Vec<Vec3> = face.vertex_iter().map(|v| from_p3(v.point())).collect();
|
||||
if face.boundaries().len() != 1 || corners.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
// The wall's two ends along its cap edge and its two heights.
|
||||
let along = d.cross(*n);
|
||||
let (lo_u, hi_u) = corners.iter().map(|p| p.dot(along)).fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(x), m.1.max(x)));
|
||||
let (lo_h, hi_h) = corners.iter().map(|p| p.dot(d)).fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(x), m.1.max(x)));
|
||||
let base = *n * *o;
|
||||
let clear = LAYERS.iter().all(|layer| {
|
||||
(0..SAMPLES).all(|i| {
|
||||
(0..SAMPLES).all(|j| {
|
||||
let s = |k: usize, lo: f64, hi: f64| lo + (hi - lo) * (0.02 + 0.96 * k as f64 / (SAMPLES - 1) as f64);
|
||||
let p = base + along * s(i, lo_u, hi_u) + d * s(j, lo_h, hi_h) + *n * (by * layer);
|
||||
!target.contains(p)
|
||||
})
|
||||
})
|
||||
});
|
||||
if clear && let Some(x) = shift.get_mut(w) {
|
||||
*x = by;
|
||||
}
|
||||
}
|
||||
if shift.iter().all(|x| *x == 0.0) {
|
||||
return None;
|
||||
}
|
||||
let solid = b.deep_copy();
|
||||
let copied: Vec<&mt::Face> = solid.face_iter().collect();
|
||||
// Each vertex moves square to the walls so that it stays on both of its walls, pushed.
|
||||
let mut moved = std::collections::HashMap::new();
|
||||
for face in &copied {
|
||||
for v in face.vertex_iter() {
|
||||
if moved.contains_key(&v.id()) {
|
||||
continue;
|
||||
}
|
||||
let p = from_p3(v.point());
|
||||
let on_walls: Vec<usize> = walls.iter().copied().filter(|w| planes.get(*w).is_some_and(|(n, o)| (n.dot(p) - o).abs() <= on)).collect();
|
||||
let [i, j] = on_walls.as_slice() else { return None };
|
||||
let ((ni, _), (nj, _)) = (planes.get(*i)?, planes.get(*j)?);
|
||||
let (di, dj) = (*shift.get(*i)?, *shift.get(*j)?);
|
||||
let c = ni.dot(*nj);
|
||||
let det = 1.0 - c * c;
|
||||
let s = if di == 0.0 && dj == 0.0 {
|
||||
Vec3::ZERO
|
||||
} else if det < 1e-6 {
|
||||
return None;
|
||||
} else {
|
||||
*ni * ((di - c * dj) / det) + *nj * ((dj - c * di) / det)
|
||||
};
|
||||
if s.len() > MAX_MOVE * by {
|
||||
return None;
|
||||
}
|
||||
moved.insert(v.id(), (v.clone(), p + s));
|
||||
}
|
||||
}
|
||||
for (v, p) in moved.values() {
|
||||
v.set_point(mt::Point3::new(p.x, p.y, p.z));
|
||||
}
|
||||
// Edges run between their moved ends; each pushed wall is its plane moved along its normal.
|
||||
for face in &copied {
|
||||
for wire in face.boundaries() {
|
||||
for e in wire.edge_iter() {
|
||||
let mt::Curve::Line(_) = e.curve() else { return None };
|
||||
e.set_curve(mt::Curve::Line(mt::Line(e.absolute_front().point(), e.absolute_back().point())));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut pushed = Vec::new();
|
||||
for (k, face) in copied.iter().enumerate() {
|
||||
let (Some(&x), Some((n, o))) = (shift.get(k), planes.get(k)) else { return None };
|
||||
if x == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mt::Surface::Plane(p) = face.surface() else { return None };
|
||||
let t = mt::Vector3::new(n.x, n.y, n.z) * x;
|
||||
face.set_surface(mt::Surface::Plane(mt::Plane::new(p.origin() + t, p.origin() + p.u_axis() + t, p.origin() + p.v_axis() + t)));
|
||||
pushed.push((*n, o + x));
|
||||
}
|
||||
let tool = Body::new(solid).ok()?;
|
||||
if !tool.validity().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Pushed { tool, planes: pushed })
|
||||
}
|
||||
Reference in New Issue
Block a user