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

+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,
}
}