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

@@ -250,3 +250,20 @@ fn press_pull_on_the_enclosure_lid_is_prompt() {
}
assert!(total(&s) > before, "{before} -> {}", total(&s));
}
/// A lip swept round a block's rim, flush with its sides and round its corners, joins it as one
/// solid of both volumes: the block (50 × 40 with R4.5 corners, 10 high) and the lip (1 × 1 along
/// 41 + 31 + 41 mm of straight rim and two quarter turns between radii 3.5 and 4.5).
#[test]
fn lip_swept_flush_round_a_corner_joins() {
let s = replay(include_str!("../../../examples/regressions/lip_swept_flush_round_corner.json"));
let bodies = &s.model.state().bodies;
assert_eq!(bodies.len(), 1);
let b = &bodies[0];
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
assert_eq!(b.body.shell_count(), 1);
let block = (50.0 * 40.0 - (4.0 - std::f64::consts::PI) * 4.5 * 4.5) * 10.0;
let lip = 41.0 + 31.0 + 41.0 + 2.0 * std::f64::consts::PI * (4.5 * 4.5 - 3.5 * 3.5) / 4.0;
let volume = b.body.tessellate(1e-5).unwrap().measure().volume;
assert!((volume - block - lip).abs() < 0.02, "{volume} expected {}", block + lip);
}
+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);
@@ -0,0 +1,33 @@
{
"description": "A 1 x 1 mm lip swept round three sides of a block's top, flush with its sides and round its R4.5 corners, joined to it: each straight inner side of the lip runs on tangent into its round inner corner, so imprinting the contact along the sides' whole surfaces left pieces that did not match, and the join failed.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
{"command": "sketch.line", "params": {"points": [[20.5, -20.0], [-20.5, -20.0]]}},
{"command": "sketch.line", "params": {"points": [[-25.0, -15.5], [-25.0, 15.5]]}},
{"command": "sketch.line", "params": {"points": [[-20.5, 20.0], [20.5, 20.0]]}},
{"command": "sketch.line", "params": {"points": [[25.0, 15.5], [25.0, -15.5]]}},
{"command": "sketch.arc.center_point", "params": {"center": [-20.5, -15.5], "start": [-25.0, -15.5], "end": [-20.5, -20.0]}},
{"command": "sketch.arc.center_point", "params": {"center": [-20.5, 15.5], "start": [-20.5, 20.0], "end": [-25.0, 15.5]}},
{"command": "sketch.arc.center_point", "params": {"center": [20.5, 15.5], "start": [25.0, 15.5], "end": [20.5, 20.0]}},
{"command": "sketch.arc.center_point", "params": {"center": [20.5, -15.5], "start": [20.5, -20.0], "end": [25.0, -15.5]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Base", "operation": "new", "distance": "10 mm", "body_name": "Block"}},
{"command": "sketch.create", "params": {"plane": {"origin": [20.5, 0.0, 0.0], "x_dir": [0.0, 1.0, 0.0], "y_dir": [0.0, 0.0, 1.0]}, "name": "Profile"}},
{"command": "sketch.line", "params": {"points": [[-20, 10], [-19, 10]]}},
{"command": "sketch.line", "params": {"points": [[-19, 10], [-19, 11]]}},
{"command": "sketch.line", "params": {"points": [[-19, 11], [-20, 11]]}},
{"command": "sketch.line", "params": {"points": [[-20, 11], [-20, 10]]}},
{"command": "sketch.finish", "params": {}},
{"command": "sketch.create", "params": {"plane": {"origin": [0.0, 0.0, 10.0], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0]}, "name": "Path"}},
{"command": "sketch.line", "params": {"points": [[20.5, -20.0], [-20.5, -20.0]]}},
{"command": "sketch.line", "params": {"points": [[-25.0, -15.5], [-25.0, 15.5]]}},
{"command": "sketch.line", "params": {"points": [[-20.5, 20.0], [20.5, 20.0]]}},
{"command": "sketch.line", "params": {"points": [[25.0, 15.5], [25.0, -15.5]]}},
{"command": "sketch.arc.center_point", "params": {"center": [-20.5, -15.5], "start": [-25.0, -15.5], "end": [-20.5, -20.0]}},
{"command": "sketch.arc.center_point", "params": {"center": [-20.5, 15.5], "start": [-20.5, 20.0], "end": [-25.0, 15.5]}},
{"command": "sketch.arc.center_point", "params": {"center": [20.5, 15.5], "start": [25.0, 15.5], "end": [20.5, 20.0]}},
{"command": "sketch.arc.center_point", "params": {"center": [20.5, -15.5], "start": [20.5, -20.0], "end": [25.0, -15.5]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.sweep", "params": {"sketch": "Profile", "path_sketch": "Path", "path": ["l1", "a1", "l2", "a2", "l3"], "operation": "join", "name": "Lip", "profiles": [{"point": [-19.5, 10.5]}], "targets": ["Block"]}}
]
}