fix: a lip swept flush around a rounded rim joins the body it sits on (#1164)

fix: a lip swept flush around a rounded rim joins the body it sits on.
This commit is contained in:
Que authored and GitHub committed 2026-10-14 18:49:45 -04:00
1 parent 099d7e298a
commit 1cc65cbc91
10 files changed
+699 -5

No files matched your search

+10
View File
@@ -1836,6 +1836,16 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
}
ends.push(cur);
}
// A straight profile edge square to an arc's axis turns in its own plane: that face is
// given a plane, so that it is found lying on a planar face of another body.
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
shell.vertex_iter().for_each(|v| bounds.add(from_p3(v.point())));
let size = bounds.size().len();
if size.is_finite()
&& let Some(flat) = crate::heal::planar_surfaces(&shell, (size * 1e-7).max(1e-9))
{
shell = flat;
}
// truck sweeps the faces: each profile edge's run along the whole path, path segment
// after segment.
let sides: Vec<Side> = match record::columns(shell.face_iter(), &seeds(&profile), None, record::Column::ToEnd, &[]) {
+1 -1
View File
@@ -529,7 +529,7 @@ fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
}
/// The faces of `body` lying in the plane (n, d), facing along n.
fn faces_in(body: &Body, n: Vec3, d: f64, tol: f64) -> Vec<usize> {
pub(crate) fn faces_in(body: &Body, n: Vec3, d: f64, tol: f64) -> Vec<usize> {
planes(body).into_iter().filter(|(_, p)| p.n.dot(n) > 1.0 - 1e-9 && (p.d - d).abs() < tol).map(|(i, _)| i).collect()
}
+1 -1
View File
@@ -417,7 +417,7 @@ fn exact_edges(shell: &mt::Shell, tol: f64, trace: &mut Trace) -> Option<mt::She
/// 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.
fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
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();
+3
View File
@@ -65,6 +65,9 @@ mod native;
mod numbered;
mod offset;
mod ops;
mod outline;
#[cfg(test)]
mod outline_tests;
mod par;
pub use par::{for_each_on_threads, map_on_threads};
#[cfg(test)]
+5
View File
@@ -662,6 +662,11 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// to the retry guard's checks all the same).
if op == BoolOp::Union && crate::coplanar::faces_touch(a, b) {
let accept = |body: &Body| check(body).0 && !unfused(body) && crate::boolean_guard::check_retry(a, b, op, Some(body)).is_ok();
// Imprinted along the contact's own outline (faces there bounded by lines and arcs), then
// along the sides' surfaces.
if let Some(body) = crate::outline::outline_union(a, b, &accept) {
return Ok(Some(body));
}
if let Some(body) = crate::coplanar::imprint_union(a, b, &accept) {
return Ok(Some(body));
}
+563
View File
@@ -0,0 +1,563 @@
//! Union of bodies touching back to back over part of a planar face, imprinted along the
//! outline of the contact itself.
//!
//! [`crate::coplanar::imprint_union`] splits each body's face along the other's sides, extended
//! to whole planes and cylinders. Where a side's surface runs on into the contact (the inner
//! wall of a lip swept round a corner, whose straight part is tangent to its round part), the
//! two bodies' pieces don't match and the faces can't be glued. Here each face in the plane is
//! divided only by the other body's edges in the plane, as far as they run across it: both
//! bodies' faces in the plane are lines and circular arcs, every edge is cut where the other's
//! edges cross or touch it, the other's edges lying inside a face are copied into it, and the
//! face is rebuilt as the regions those edges bound. The common regions are then the same faces
//! in both bodies, and [`crate::coplanar::glue`] takes them away.
use std::collections::HashMap;
use std::f64::consts::TAU;
use mt::{BoundedCurve, ParametricCurve};
use solidcraft_geom::{Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3};
use crate::history::History;
use crate::record::Rebuild;
use crate::{KernelError, Result, guard, record};
/// Most edges of a body's faces in the plane the outline imprint takes on.
const MAX_EDGES: usize = 2000;
/// A frame in the plane, `x × y` its normal.
#[derive(Clone, Copy)]
struct Frame {
o: Vec3,
x: Vec3,
y: Vec3,
}
impl Frame {
/// The plane `n · p = d` (`n` a unit normal).
fn new(n: Vec3, d: f64) -> Frame {
let x = n.any_perp();
Frame { o: n * d, x, y: n.cross(x) }
}
fn normal(&self) -> Vec3 {
self.x.cross(self.y)
}
/// The same plane seen from its other side.
fn flipped(&self) -> Frame {
Frame { o: self.o, x: self.x, y: -self.y }
}
fn local(&self, p: Vec3) -> Vec2 {
let v = p - self.o;
Vec2::new(v.dot(self.x), v.dot(self.y))
}
fn world(&self, q: Vec2) -> Vec3 {
self.o + self.x * q.x + self.y * q.y
}
}
/// A line or circular arc in the plane, in the direction of the edge it is.
#[derive(Clone, Copy, Debug)]
enum Piece {
Line {
a: Vec2,
b: Vec2,
},
/// From the angle `t0` about `c`, turning by `sweep` (counter-clockwise when positive).
Arc {
c: Vec2,
r: f64,
t0: f64,
sweep: f64,
},
}
/// A fixed direction for counting crossings, clear of the axes and of round angles.
const RAY: (f64, f64) = (0.951_056_516_295_153_6, 0.309_016_994_374_947_45);
impl Piece {
fn at(&self, s: f64) -> Vec2 {
match *self {
Piece::Line { a, b } => a.lerp(b, s),
Piece::Arc { c, r, t0, sweep } => c + Vec2::from_angle(t0 + sweep * s) * r,
}
}
fn len(&self) -> f64 {
match *self {
Piece::Line { a, b } => a.dist(b),
Piece::Arc { r, sweep, .. } => r * sweep.abs(),
}
}
/// Where the angle `t` lies along the arc (0 at its start, 1 at its end), if it does.
fn arc_fraction(t0: f64, sweep: f64, t: f64) -> Option<f64> {
let turned = if sweep >= 0.0 { (t - t0).rem_euclid(TAU) } else { (t0 - t).rem_euclid(TAU) };
let span = sweep.abs();
if turned <= span {
Some(turned / span)
} else if span >= TAU - 1e-12 {
Some(0.0)
} else {
None
}
}
/// Distance from `p` to the piece.
fn dist(&self, p: Vec2) -> f64 {
match *self {
Piece::Line { a, b } => {
let d = b - a;
let l2 = d.len2();
let s = if l2 > 0.0 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
p.dist(a + d * s)
}
Piece::Arc { c, r, t0, sweep } => match Self::arc_fraction(t0, sweep, (p - c).angle()) {
Some(_) => ((p - c).len() - r).abs(),
None => p.dist(self.at(0.0)).min(p.dist(self.at(1.0))),
},
}
}
/// How many times the ray from `p` along [`RAY`] crosses the piece.
fn crossings(&self, p: Vec2) -> usize {
let dir = Vec2::new(RAY.0, RAY.1);
match *self {
Piece::Line { a, b } => {
let e = b - a;
let den = dir.cross(e);
if den.abs() < 1e-300 {
return 0;
}
let w = a - p;
let t = w.cross(e) / den;
let s = w.cross(dir) / den;
usize::from(t > 0.0 && (0.0..1.0).contains(&s))
}
Piece::Arc { c, r, t0, sweep } => {
// p + t·dir on the circle: t² + 2t(w·dir) + |w|² − r² = 0, w = p − c.
let w = p - c;
let bq = w.dot(dir);
let disc = bq * bq - (w.len2() - r * r);
if !(disc > 0.0) {
return 0;
}
let root = disc.sqrt();
[-bq - root, -bq + root]
.into_iter()
.filter(|t| *t > 0.0)
.filter(|t| Self::arc_fraction(t0, sweep, (p + dir * *t - c).angle()).is_some_and(|f| f < 1.0 || sweep.abs() >= TAU - 1e-12))
.count()
}
}
}
/// The unit direction from the piece's start (`end` false) or end (`end` true) toward a
/// point a little along it: the direction it leaves or enters its vertex, with its bend.
fn leaving(&self, end: bool) -> Option<Vec2> {
let len = self.len();
let s = if len > 0.0 { (1e-3 / len).min(0.25) } else { 0.25 };
let (v, q) = if end { (self.at(1.0), self.at(1.0 - s)) } else { (self.at(0.0), self.at(s)) };
(q - v).normalized()
}
}
/// The edge `e` (in its own direction) as a line or arc in the frame's plane; `None` when it is
/// off the plane or neither.
fn piece_of(e: &mt::Edge, fr: &Frame, tol: f64) -> Option<Piece> {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
let n = fr.normal();
let pts: Vec<Vec3> = (0..=8).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 8.0))).collect();
if pts.iter().any(|p| (*p - fr.o).dot(n).abs() > tol) {
return None;
}
let q: Vec<Vec2> = pts.iter().map(|p| fr.local(*p)).collect();
let (&a, &b, &m) = (q.first()?, q.last()?, q.get(4)?);
let closed = a.dist(b) <= tol;
if !closed {
let line = Piece::Line { a, b };
if q.iter().all(|p| line.dist(*p) <= tol) {
return Some(line);
}
}
// The circle through three of the points.
let (p1, p2, p3) = if closed { (a, *q.get(3)?, *q.get(6)?) } else { (a, m, b) };
let (u, v) = (p2 - p1, p3 - p1);
let den = 2.0 * u.cross(v);
if den.abs() < 1e-300 {
return None;
}
let centre = p1 + Vec2::new(v.y * u.len2() - u.y * v.len2(), u.x * v.len2() - v.x * u.len2()) * (1.0 / den);
let r = (a - centre).len();
if !(r > tol) || q.iter().any(|p| ((*p - centre).len() - r).abs() > tol) {
return None;
}
let start = (a - centre).angle();
let ccw = (a - centre).cross(*q.get(1)? - centre) > 0.0;
let sweep = if closed {
if ccw { TAU } else { -TAU }
} else if ccw {
((b - centre).angle() - start).rem_euclid(TAU)
} else {
-((start - (b - centre).angle()).rem_euclid(TAU))
};
// The samples run along it in order (a rational arc is not even in angle).
let mut last = -1e-9;
for p in q.iter().skip(1).take(7) {
let f = Piece::arc_fraction(start, sweep, (*p - centre).angle())?;
if f < last {
return None;
}
last = f;
}
Some(Piece::Arc { c: centre, r, t0: start, sweep })
}
/// Where two pieces cross or touch (each point within `tol` of both).
fn meetings(p: &Piece, q: &Piece, tol: f64) -> Vec<Vec2> {
let mut out: Vec<Vec2> = Vec::new();
let line_circle = |a: Vec2, b: Vec2, c: Vec2, r: f64, out: &mut Vec<Vec2>| {
let Some(dir) = (b - a).normalized() else { return };
let foot = a + dir * (c - a).dot(dir);
let h = (c - foot).len();
if h > r + tol {
return;
}
if (h - r).abs() <= tol {
out.push(foot);
} else {
let k = (r * r - h * h).max(0.0).sqrt();
out.extend([foot - dir * k, foot + dir * k]);
}
};
match (*p, *q) {
(Piece::Line { a, b }, Piece::Line { a: c, b: d }) => {
let (e, f) = (b - a, d - c);
let den = e.cross(f);
if den.abs() > 1e-12 * e.len() * f.len() {
let s = (c - a).cross(f) / den;
out.push(a + e * s);
}
}
(Piece::Line { a, b }, Piece::Arc { c, r, .. }) | (Piece::Arc { c, r, .. }, Piece::Line { a, b }) => line_circle(a, b, c, r, &mut out),
(Piece::Arc { c: c1, r: r1, .. }, Piece::Arc { c: c2, r: r2, .. }) => {
let d = c1.dist(c2);
if d > tol && d <= r1 + r2 + tol && d + tol >= (r1 - r2).abs() {
let a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
let h = (r1 * r1 - a * a).max(0.0).sqrt();
let u = (c2 - c1) * (1.0 / d);
let base = c1 + u * a;
if h <= tol {
out.push(base);
} else {
out.extend([base + u.perp() * h, base - u.perp() * h]);
}
}
}
}
// The ends of either lying on the other (a T, or where overlapping pieces part).
for (x, y) in [(p, q), (q, p)] {
for end in [x.at(0.0), x.at(1.0)] {
if y.dist(end) <= tol {
out.push(end);
}
}
}
out.retain(|m| p.dist(*m) <= tol && q.dist(*m) <= tol);
out
}
/// The edges of `body`'s faces in the plane of `fr` that face along its normal, once each, in
/// their absolute direction, with their pieces. `None` when one is neither a line nor an arc.
fn plane_edges(body: &Body, faces: &[usize], fr: &Frame, tol: f64) -> Option<Vec<(mt::Edge, Piece)>> {
let fs: Vec<&mt::Face> = body.solid.face_iter().collect();
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for f in faces.iter().filter_map(|i| fs.get(*i)) {
for e in f.edge_iter() {
if !seen.insert(e.id()) {
continue;
}
let abs = if e.orientation() { e.clone() } else { e.inverse() };
out.push((abs.clone(), piece_of(&abs, fr, tol)?));
if out.len() > MAX_EDGES {
return None;
}
}
}
Some(out)
}
/// Points where the edges of `own` are to be cut so that every piece of them lies inside,
/// outside or along the edges of `other` (in world coordinates).
fn cuts(own: &[(mt::Edge, Piece)], other: &[(mt::Edge, Piece)], fr: &Frame, tol: f64) -> Vec<Vec3> {
let mut out = Vec::new();
for (_, p) in own {
for (_, q) in other {
for m in meetings(p, q, tol) {
if m.dist(p.at(0.0)) > tol && m.dist(p.at(1.0)) > tol {
out.push(fr.world(m));
}
}
}
}
out
}
/// One edge use in a face being rebuilt: the edge as the face uses it, its piece in that
/// direction, its ends, and its reverse use when it is a copy used both ways.
struct Use {
edge: mt::Edge,
piece: Piece,
from: mt::VertexID,
to: mt::VertexID,
twin: Option<usize>,
}
/// Does the point lie inside the region bounded by `pieces` (closed loops, holes included)?
fn inside(pieces: &[Piece], p: Vec2) -> bool {
pieces.iter().map(|x| x.crossings(p)).sum::<usize>() % 2 == 1
}
/// The loops of uses, each next use the one leaving the vertex furthest clockwise from the way
/// the last one came in: each loop runs round one region with it on the left.
fn loops(uses: &[Use]) -> Option<Vec<Vec<usize>>> {
let mut out_of: HashMap<mt::VertexID, Vec<usize>> = HashMap::new();
for (i, u) in uses.iter().enumerate() {
out_of.entry(u.from).or_default().push(i);
}
let mut used = vec![false; uses.len()];
let mut out = Vec::new();
for start in 0..uses.len() {
if used.get(start).copied().unwrap_or(true) {
continue;
}
let mut lp = vec![start];
if let Some(x) = used.get_mut(start) {
*x = true;
}
let mut cur = start;
loop {
let u = uses.get(cur)?;
let back = u.piece.leaving(true)?;
let next = out_of
.get(&u.to)?
.iter()
.copied()
.filter(|k| Some(*k) != u.twin)
.filter_map(|k| {
let d = uses.get(k)?.piece.leaving(false)?;
// Counter-clockwise from the way back, in (0, 2π].
let turn = back.cross(d).atan2(back.dot(d));
Some((k, if turn <= 0.0 { turn + TAU } else { turn }))
})
.max_by(|x, y| x.1.total_cmp(&y.1))
.map(|(k, _)| k)?;
if next == start {
break;
}
if used.get(next).copied().unwrap_or(true) || lp.len() > uses.len() {
return None;
}
if let Some(x) = used.get_mut(next) {
*x = true;
}
lp.push(next);
cur = next;
}
out.push(lp);
}
Some(out)
}
/// The signed area a loop bounds (positive counter-clockwise).
fn area(uses: &[Use], lp: &[usize]) -> f64 {
let pts: Vec<Vec2> = lp.iter().filter_map(|i| uses.get(*i)).flat_map(|u| (0..16).map(move |k| u.piece.at(k as f64 / 16.0))).collect();
(0..pts.len()).map(|i| pts.get(i).zip(pts.get((i + 1) % pts.len())).map(|(a, b)| a.cross(*b)).unwrap_or(0.0)).sum::<f64>() * 0.5
}
/// `body` with its faces `faces` (in the plane of `fr`, facing along its normal) divided by those
/// edges of `other` that run inside them: each such edge is copied into the face, and the face
/// is rebuilt as the regions its own edges and the copies bound. History over `[body]`. `None`
/// when a face can't be rebuilt so.
fn divided(body: &Body, faces: &[usize], other: &[(mt::Edge, Piece)], fr: &Frame, tol: f64) -> Result<Option<Body>> {
let fs: Vec<&mt::Face> = body.solid.face_iter().collect();
// Each face's own edge uses, in the direction it runs them (counter-clockwise about the
// frame's normal round its outside).
let mut own: Vec<(usize, Vec<(mt::Edge, Piece)>)> = Vec::new();
for &fi in faces {
let Some(f) = fs.get(fi) else { continue };
let mut es = Vec::new();
for w in f.boundaries() {
for e in w.edge_iter() {
let Some(p) = piece_of(e, fr, tol) else { return Ok(None) };
es.push((e.clone(), p));
}
}
own.push((fi, es));
}
let all_own: Vec<Piece> = own.iter().flat_map(|(_, es)| es.iter().map(|(_, p)| *p)).collect();
// The vertices the copies end on: the faces' own where they meet, else new ones shared.
let mut pool: Vec<mt::Vertex> = own.iter().flat_map(|(_, es)| es.iter().flat_map(|(e, _)| [e.front().clone(), e.back().clone()])).collect();
let mut vertex = |p: Vec3| -> mt::Vertex {
if let Some(v) = pool.iter().find(|v| from_p3(v.point()).dist(p) <= tol) {
return v.clone();
}
let v = mt::Vertex::new(crate::body::p3(p));
pool.push(v.clone());
v
};
let mut copies: HashMap<usize, Vec<(mt::Edge, Piece)>> = HashMap::new();
for (e, p) in other {
// Along the faces' own edges: nothing to add.
if [0.25, 0.5, 0.75].iter().all(|s| all_own.iter().any(|o| o.dist(p.at(*s)) <= tol * 10.0)) {
continue;
}
let mid = p.at(0.5);
let Some((fi, _)) = own.iter().find(|(_, es)| inside(&es.iter().map(|(_, x)| *x).collect::<Vec<_>>(), mid)) else { continue };
let (v0, v1) = (vertex(from_p3(e.front().point())), vertex(from_p3(e.back().point())));
// On the face's own vertices, which lie within `tol` of the edge's ends.
let copy = mt::Edge::new_unchecked(&v0, &v1, e.curve());
let Some(cp) = piece_of(&copy, fr, tol) else { return Ok(None) };
copies.entry(*fi).or_default().push((copy, cp));
}
if copies.is_empty() {
return Ok(None);
}
let mut rebuilt: HashMap<usize, Vec<mt::Face>> = HashMap::new();
for (fi, es) in &own {
let Some(cs) = copies.get(fi) else { continue };
let Some(f) = fs.get(*fi) else { continue };
let mut uses: Vec<Use> =
es.iter().map(|(e, p)| Use { edge: e.clone(), piece: *p, from: e.front().id(), to: e.back().id(), twin: None }).collect();
for (e, p) in cs {
let k = uses.len();
let back = e.inverse();
let Some(bp) = piece_of(&back, fr, tol) else { return Ok(None) };
uses.push(Use { edge: e.clone(), piece: *p, from: e.front().id(), to: e.back().id(), twin: Some(k + 1) });
uses.push(Use { from: back.front().id(), to: back.back().id(), edge: back, piece: bp, twin: Some(k) });
}
let Some(lps) = loops(&uses) else { return Ok(None) };
let (outer, holes): (Vec<(Vec<usize>, f64)>, Vec<(Vec<usize>, f64)>) = lps
.into_iter()
.map(|lp| {
let a = area(&uses, &lp);
(lp, a)
})
.partition(|(_, a)| *a > 0.0);
let pieces_of = |lp: &[usize]| -> Vec<Piece> { lp.iter().filter_map(|i| uses.get(*i)).map(|u| u.piece).collect() };
let mut groups: Vec<(Vec<usize>, f64, Vec<Vec<usize>>)> = outer.into_iter().map(|(lp, a)| (lp, a, Vec::new())).collect();
for (hole, _) in holes {
let Some(p) = hole.first().and_then(|i| uses.get(*i)).map(|u| u.piece.at(0.5)) else { return Ok(None) };
let Some(g) = groups.iter_mut().filter(|(lp, _, _)| inside(&pieces_of(lp), p)).min_by(|x, y| x.1.total_cmp(&y.1)) else {
return Ok(None);
};
g.2.push(hole);
}
let wire = |lp: &[usize]| -> mt::Wire { lp.iter().filter_map(|i| uses.get(*i)).map(|u| u.edge.clone()).collect::<Vec<_>>().into() };
let mut made = Vec::new();
for (lp, _, hs) in &groups {
let wires: Vec<mt::Wire> = std::iter::once(wire(lp)).chain(hs.iter().map(|h| wire(h))).collect();
// The loops run round the face as its normal sees them; a face turned against its
// surface stores them the other way.
let nf = if f.orientation() {
mt::Face::try_new(wires, f.surface())
} else {
mt::Face::try_new(wires.iter().map(|w| w.inverse()).collect(), f.surface()).map(|mut x| {
x.invert();
x
})
};
made.push(nf.map_err(|x| KernelError::Failed(format!("rebuilding a face of the contact: {x}")))?);
}
rebuilt.insert(*fi, made);
}
let mut rec = Rebuild::default();
let mut index = 0;
let mut shells = Vec::new();
for sh in body.solid.boundaries() {
let mut faces = Vec::new();
for f in sh.face_iter() {
match rebuilt.get(&index) {
Some(made) => {
for nf in made {
rec.whole(nf, f);
faces.push(nf.clone());
}
}
None => {
rec.whole(f, f);
faces.push(f.clone());
}
}
index += 1;
}
shells.push(mt::Shell::from(faces));
}
let Ok(solid) = mt::Solid::try_new(shells) else { return Ok(None) };
let split = Body {
solid: std::sync::Arc::new(solid),
size: 0.0,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: None,
color: body.color,
paint: None,
splits: body.splits.clone(),
face_sources: None,
history: None,
regularization_protected: true,
display_of: None,
};
Ok(Some(record::rebuilt_body(split, &[&body.solid], &rec)))
}
/// Union of bodies touching back to back over part of a planar face whose edges there are lines
/// and circular arcs (a lip swept along a rim, flush with a round side): imprinted on each other
/// along the outline of the contact and glued. `None` when that does not give a result
/// `accept`ed.
pub(crate) fn outline_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option<Body> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
let back: Vec<(Vec3, f64)> = crate::coplanar::coincident_planes(a, b).into_iter().filter(|(_, _, same)| !*same).map(|(n, d, _)| (n, d)).collect();
if back.is_empty() {
return None;
}
let run = || -> Result<Option<Body>> {
let (mut a2, mut ha): (Body, Option<History>) = (a.clone(), crate::boolean_record::kept(a, 0, 1));
let (mut b2, mut hb): (Body, Option<History>) = (b.clone(), crate::boolean_record::kept(b, 0, 1));
for (n, d) in &back {
// a's faces face along n in the plane; b's the other way.
let fa = Frame::new(*n, *d);
let fb = fa.flipped();
let in_a = |x: &Body| crate::coplanar::faces_in(x, *n, *d, tol);
let in_b = |x: &Body| crate::coplanar::faces_in(x, -*n, -*d, tol);
let (Some(ea), Some(eb)) = (plane_edges(&a2, &in_a(&a2), &fa, tol), plane_edges(&b2, &in_b(&b2), &fa, tol)) else { return Ok(None) };
let a3 = crate::splitface::split_edges_at(&a2, &cuts(&ea, &eb, &fa, tol))?;
ha = crate::boolean_record::through(&a3, 0, ha.as_ref());
let b3 = crate::splitface::split_edges_at(&b2, &cuts(&eb, &ea, &fa, tol))?;
hb = crate::boolean_record::through(&b3, 0, hb.as_ref());
// Each side's edges as the other side's frame sees them.
let (Some(eb), Some(ea)) = (plane_edges(&b3, &in_b(&b3), &fa, tol), plane_edges(&a3, &in_a(&a3), &fb, tol)) else { return Ok(None) };
let a4 = divided(&a3, &in_a(&a3), &eb, &fa, tol)?.unwrap_or(a3);
let b4 = divided(&b3, &in_b(&b3), &ea, &fb, tol)?.unwrap_or(b3);
ha = crate::boolean_record::through(&a4, 0, ha.as_ref());
hb = crate::boolean_record::through(&b4, 0, hb.as_ref());
(a2, b2) = (a4, b4);
}
let Some(r) = crate::coplanar::glue(&a2, &b2) else { return Ok(None) };
let r = r?;
let (ha, hb) = (crate::boolean_record::placed(ha, 0, 2), crate::boolean_record::placed(hb, 1, 2));
let h = crate::boolean_record::of_pair(&r, &ha, &hb);
Ok(Some(r.with_checked_history(h, &record::counts(&[a, b]))))
};
match guard("outline union", run) {
Ok(Some(r)) if accept(&r) => Some(r),
_ => None,
}
}
+56
View File
@@ -0,0 +1,56 @@
//! A lip swept along the rim of a round body joins it, flush with its side or set in from it:
//! the lip's foot lies on the body's top, and its sides are round where the rim is.
use std::f64::consts::{FRAC_PI_2, PI};
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use crate::*;
/// A 1 × 1 mm lip on the top (z = 10) of a round body of radius 10 about Z, its outer side
/// `inset` in from the body's side, swept a quarter turn from the XZ plane.
fn lip(inset: f64) -> Body {
let plane = Plane::new(Vec3::ZERO, Vec3::X, Vec3::Z).unwrap();
let (x0, x1) = (9.0 - inset, 10.0 - inset);
let outer = Loop2::polygon(&[Vec2::new(x0, 10.0), Vec2::new(x1, 10.0), Vec2::new(x1, 11.0), Vec2::new(x0, 11.0)]);
let path = [PathSeg::Arc { a: Vec3::new(10.0, 0.0, 10.0), center: Vec3::new(0.0, 0.0, 10.0), axis: Vec3::Z, angle: FRAC_PI_2 }];
sweep(&plane, &Region2 { outer, holes: vec![] }, &path).unwrap()
}
#[test]
fn a_lip_swept_on_a_round_rim_joins_it() {
let body = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 10.0).unwrap();
for inset in [0.0, 0.5] {
let (r0, r1) = (9.0 - inset, 10.0 - inset);
// Measured as the result is (the round faces' volume from their meshes).
let mesh_volume = |b: &Body| b.tessellate(1e-5).unwrap().measure().volume;
let want = mesh_volume(&body) + mesh_volume(&lip(inset));
assert!((want - PI * 1000.0 - PI * (r1 * r1 - r0 * r0) / 4.0).abs() < 0.05);
for (a, b) in [(&body, &lip(inset)), (&lip(inset), &body)] {
let (r, d) = capture_boolean_diagnostics(|| boolean(a, b, BoolOp::Union));
let r = r.unwrap().unwrap();
assert_eq!(r.shell_count(), 1, "inset {inset}: one solid");
assert!(r.validity().is_empty(), "inset {inset}: {:?}", r.validity());
let v = mesh_volume(&r);
assert!((v - want).abs() < 1e-3, "inset {inset}: volume {v} vs {want}");
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "inset {inset}: an exact join: {d:?}");
}
}
}
/// The swept lip's foot over the turn is a plane, so it is found lying on the body's top.
#[test]
fn a_swept_lips_foot_is_a_plane() {
let l = lip(0.0);
let foot = crate::coplanar::faces_in(&l, -Vec3::Z, -10.0, 1e-6);
let m = l.tessellate(1e-5).unwrap();
let area: f64 = m
.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, f)| foot.contains(&(**f as usize)))
.filter_map(|(t, _)| m.tri(t))
.map(|[a, b, c]| (b - a).cross(c - a).len() / 2.0)
.sum();
assert!((area - PI * (100.0 - 81.0) / 4.0).abs() < 1e-3, "{} faces, {area} mm²", foot.len());
}
+10 -3
View File
@@ -485,11 +485,18 @@ fn expand(e: &mt::Edge, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))], out: &mut Vec
}
}
/// The parameter of the point of `c` nearest `q`, searched from the nearest of points along it (a
/// long arc's search from its middle can settle on the far side).
fn nearest(c: &mt::Curve, q: mt::Point3) -> Option<f64> {
let (t0, t1) = c.range_tuple();
let hint = (0..=16).map(|k| t0 + (t1 - t0) * k as f64 / 16.0).min_by(|x, y| c.subs(*x).distance2(q).total_cmp(&c.subs(*y).distance2(q)));
c.search_nearest_parameter(q, hint, 100)
}
/// `body` with its edges cut at the given points where they pass through them (not at their
/// ends): the faces either side keep the same region, bounded by the pieces. Used to give two
/// faces that cover the same region the same vertices before they are glued (#674).
pub(crate) fn split_edges_at(body: &Body, points: &[Vec3]) -> Result<Body> {
use mt::SearchNearestParameter;
let size = body.size();
let tol = size * 1e-7 + 1e-9;
guard("split edges", || {
@@ -512,11 +519,11 @@ pub(crate) fn split_edges_at(body: &Body, points: &[Vec3]) -> Result<Body> {
}
let c = e.curve();
let (t0, t1) = c.range_tuple();
c.search_nearest_parameter(q, None, 100).is_some_and(|t| t > t0.min(t1) && t < t0.max(t1) && c.subs(t).distance(q) <= tol * 10.0)
nearest(&c, q).is_some_and(|t| t > t0.min(t1) && t < t0.max(t1) && c.subs(t).distance(q) <= tol * 10.0)
});
let Some(k) = hit else { continue };
let (e, root) = current.swap_remove(k);
let Some(t) = e.curve().search_nearest_parameter(q, None, 100) else { continue };
let Some(t) = nearest(&e.curve(), q) else { continue };
let (_, cut) = cut_edge(&e, t, q, tol)?;
if let Some((a, b)) = cut {
rec.piece(&a, &root);