Fillets and chamfers where edges meet at corners on a curved wall or a knife-edge tip (#851) (#935)

Rolling-ball blends now handle knife edges, where the two faces have opposite normals; an edge that runs out along an end face tangent to one side; and rounds that end in an earlier round on curved faces, picked together or one after another. The three edges at a vertex of smooth faces, rounded to one radius, end in a piece of sphere. When the given order of up to four edges is refused, or leaves a face boundary off its surface, other orders are tried.

Closes #851.

Co-authored-by: lasselian <[email protected]>
This commit is contained in:
Lasse Lianandlasselian authored and GitHub committed 2026-10-13 23:21:47 -04:00
1 parent 45f92277cb
commit a2c7396160
10 files changed
+1364 -54

No files matched your search

+96 -22
View File
@@ -460,7 +460,14 @@ fn blend_geometry(
}
}
let cap = cap.ok_or_else(|| unsupported("the blend's end on a curved face"))?;
ends.push(End { v: v.clone(), g: vec![ga], e1, e2, e3: None, a, b, planes: Vec::new(), ca: None, cb: None, cap: Some(cap), reach: far });
// A rail that ends at the far end of its side edge uses that edge up (an earlier round's
// end arc, when the new blend meets it exactly where that round meets the next face).
let far_end = |e: &mt::Edge, q: Vec3| {
let f = if e.front() == v { e.back() } else { e.front() };
(vtx(f).dist(q) <= on_tol).then(|| f.clone())
};
let (ca, cb) = (far_end(&e1, a), far_end(&e2, b));
ends.push(End { v: v.clone(), g: vec![ga], e1, e2, e3: None, a, b, planes: Vec::new(), ca, cb, cap: Some(cap), reach: far });
continue;
}
let normal = |g: usize| -> Result<Vec3> {
@@ -579,12 +586,9 @@ fn blend_geometry(
// A side face whose corner edges are consumed at both ends is used up: its remaining edge,
// parallel to the old edge at the setback, becomes the rail and the face is removed.
let used_up = |fi: usize, c0: &Option<mt::Vertex>, c1: &Option<mt::Vertex>| -> Result<Option<mt::Edge>> {
let (Some(x0), Some(x1)) = (c0, c1) else {
return match (c0, c1) {
(None, None) => Ok(None),
_ => Err(unsupported("the blend uses up a neighbouring face at one end only")),
};
};
// Used up only where it is consumed at both ends; consumed at one, the face stays and
// loses that edge.
let (Some(x0), Some(x1)) = (c0, c1) else { return Ok(None) };
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
if f.boundaries().len() != 1 || f.edge_iter().count() != 4 {
return Err(unsupported("the blend uses up a neighbouring face with more edges"));
@@ -683,11 +687,11 @@ fn blend_geometry(
let nf = if i == i1 {
let mut m = subst.clone();
m.insert(edge.id(), la.clone());
rebuild_face(f, &m, &[])?
rebuild_face_dropping(f, &m, &dropped, &[])?
} else if i == i2 {
let mut m = subst.clone();
m.insert(edge.id(), lb.clone());
rebuild_face(f, &m, &[])?
rebuild_face_dropping(f, &m, &dropped, &[])?
} else if end0.g.contains(&i) {
rebuild_face_dropping(f, &subst, &dropped, c0)?
} else if end1.g.contains(&i) {
@@ -758,7 +762,64 @@ fn blend_geometry(
Ok((solid, rec))
}
/// Most edges whose other orders are tried when the order given is refused.
const MAX_REORDERED: usize = 4;
/// All orders of `items` (the given one first).
fn orders<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
if items.len() <= 1 {
return vec![items.to_vec()];
}
let mut out = Vec::new();
for i in 0..items.len() {
let mut rest = items.to_vec();
let head = rest.remove(i);
for mut tail in orders(&rest) {
tail.insert(0, head.clone());
out.push(tail);
}
}
out
}
/// Blend the edges nearest the points, one at a time in an order that works. A blend ends in an
/// earlier round where they meet, which not every order allows (a blend over a round that an
/// edge it runs into left, or past a crease). So when the order given is refused for a reason
/// that is not the size, the other orders are tried too, and when its result has a face whose
/// boundary leaves its surface (a blend that ran on past the end of a round's surface, which a
/// round of a curved edge does not have) another order that has none is preferred.
fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
let sound = |b: &Body| crate::tess::boundaries_on_surfaces(&b.solid, (b.size() * 1e-4).max(1e-6));
let first = blend_in_order(body, edges, r, shape, what);
let reorder = (2..=MAX_REORDERED).contains(&edges.len())
&& match &first {
Ok(b) => !sound(b),
Err(KernelError::Failed(_)) => true,
Err(_) => false,
};
if !reorder {
return first;
}
let mut fallback: Option<Body> = None;
for order in orders(edges).into_iter().skip(1) {
if let Ok(done) = blend_in_order(body, &order, r, shape, what) {
if sound(&done) {
return Ok(done);
}
fallback.get_or_insert(done);
}
}
match (first, fallback) {
(Ok(b), _) => Ok(b),
(Err(e), Some(done)) => {
let _ = e;
Ok(done)
}
(Err(e), None) => Err(e),
}
}
fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
if !(r.is_finite() && r > 1e-6 && r < 1e6) {
return Err(KernelError::Invalid(format!("{what} size must be positive")));
}
@@ -801,8 +862,8 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
{
let _ = groups;
let loop_pts: Vec<Vec3> = edges.iter().filter(|p| !rest.iter().any(|q| q.dist(**p) < 1e-12)).copied().collect();
cur = record::after(blend(&cur, &rest, r, shape, what)?, &cur, &counts);
return Ok(record::after(blend(&cur, &loop_pts, r, shape, what)?, &cur, &counts));
cur = record::after(blend_in_order(&cur, &rest, r, shape, what)?, &cur, &counts);
return Ok(record::after(blend_in_order(&cur, &loop_pts, r, shape, what)?, &cur, &counts));
}
if let Some(groups) = loop_groups(&cur, edges)? {
for g in groups {
@@ -898,18 +959,28 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
&& chain.as_ref().is_some_and(|ids| {
ids.len() > 1 && Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && !matches!(x.curve(), mt::Curve::Line(_)))
});
let local = || guard(what, || blend_one(&solid, &edge, size, r, shape));
let attempt = match chain.as_deref().filter(|_| runs_on).map(roll) {
Some(Ok(o)) => Ok(o),
Some(Err(e2)) => guard(what, || blend_one(&solid, &edge, size, r, shape)).map(|o| (o, vec![j])).map_err(|_| e2),
None => match guard(what, || blend_one(&solid, &edge, size, r, shape)) {
// The chain cannot be rolled: the edge alone, locally or as a ball rolled along it
// (ending in the round the chain ran into).
Some(Err(e2)) => local()
.map(|o| (o, vec![j]))
.or_else(|_| if chain.as_ref().is_some_and(|c| c.len() > 1) { roll(&[edge.id()]) } else { Err(e2.clone()) })
.map_err(|_| e2),
None => match local() {
Ok(o) => Ok((o, vec![j])),
// A curved edge, or one between curved faces: roll a ball along its smooth chain.
Err(e) if !curved_case(&solid, &edge) => Err(e),
Err(e) => match chain.as_deref() {
Some(ids) => roll(ids).map_err(|e2| match e2 {
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
e2 => KernelError::Failed(format!("{e2} ({e})")),
}),
// The chain that cannot be rolled (it runs on into the edge an earlier round
// left where it met another face): the edge alone, ending on that round.
Some(ids) => roll(ids)
.or_else(|first| if ids.len() > 1 { roll(&[edge.id()]).map_err(|_| first) } else { Err(first) })
.map_err(|e2| match e2 {
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
e2 => KernelError::Failed(format!("{e2} ({e})")),
}),
None => Err(e),
},
},
@@ -1258,11 +1329,14 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
blend(body, edges, radius, Shape::Round, "fillet").or_else(|e| {
let sel: Vec<(Vec3, f64)> = edges.iter().map(|p| (*p, radius)).collect();
// The local blend's refusal, unless only the corner build found the radius too large.
crate::blend_corner::fillet_radii(body, &sel).map_err(|e2| match (&e, e2) {
(KernelError::TooLarge(_), _) => e,
(_, e2 @ KernelError::TooLarge(_)) => e2,
_ => e,
})
crate::blend_corner::fillet_radii(body, &sel)
.map_err(|e2| match (&e, e2) {
(KernelError::TooLarge(_), _) => e,
(_, e2 @ KernelError::TooLarge(_)) => e2,
_ => e,
})
// Three edges at a vertex of any faces end in a sphere.
.or_else(|e| if edges.len() >= 3 { crate::blend_vertex::fillet_ball_vertices(body, edges, radius).map_err(|_| e) } else { Err(e) })
})
}
+400
View File
@@ -0,0 +1,400 @@
//! Fillets of the three edges at a convex vertex of any smooth faces, of one radius: the rounds
//! end in a piece of sphere, as they do at the corner of a box.
//!
//! The ball of the fillets' radius that touches the three faces there (its centre `c`, its
//! contacts `q`) ends each round: round `k` stops at the circle where the ball meets the plane
//! through `c` and the contacts of its two faces, and the sphere's patch joins the three circles.
//! The construction uses only what a round with a plane end does already:
//! 1. cut off the corner beyond the three planes (a skewed box whose three faces are those
//! planes), so each of the three edges ends square on a plane face;
//! 2. round the three edges one after another, each ending in its plane face, which a round's
//! end arc and the sides it uses up reduce to a quarter disc of the ball's section;
//! 3. replace the three quarter discs, which meet along radii at `c`, by the sphere's patch,
//! which has the three end arcs for its boundary.
use std::collections::{HashMap, HashSet};
use solidcraft_geom::Vec3;
use truck_modeling::{self as mt};
use crate::blend::{edge_mid, too_large, unsupported, vtx};
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::curveblend::project;
use crate::record;
use crate::{BoolOp, KernelError, Result};
/// Round the edges nearest the points, `radius` each. The three edges meeting at a vertex that
/// all lie among them end in a sphere; the others are rounded as they would be alone.
pub(crate) fn fillet_ball_vertices(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
body.require_brep("fillet")?;
if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) {
return Err(KernelError::Invalid("fillet size must be positive".into()));
}
if edges.len() < 3 {
return Err(unsupported("a vertex with three rounded edges"));
}
let counts = record::counts(&[body]);
let mut cur = crate::boolean_record::healed(body)?;
let mut todo: Vec<Vec3> = edges.to_vec();
let mut done = false;
while let Some((at, picks)) = find_vertex(&cur, &todo)? {
let next = round_vertex(&cur, at, radius)?;
cur = record::after(next, &cur, &counts);
todo.retain(|p| !picks.iter().any(|q| q.dist(*p) < 1e-12));
done = true;
}
if !done {
return Err(unsupported("a vertex with three rounded edges"));
}
if todo.is_empty() { Ok(cur) } else { crate::blend::fillet(&cur, &todo, radius) }
}
/// A vertex of three edges all among the picked ones (its position) and their picks.
fn find_vertex(cur: &Body, picks: &[Vec3]) -> Result<Option<(Vec3, Vec<Vec3>)>> {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let solid = cur.deep_copy();
let all = Body::unique_edges(&solid);
// The picked edges, by identity, with the pick each came from.
let mut picked: Vec<(mt::EdgeID, Vec3)> = Vec::new();
for p in picks {
let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Ok(None) };
if dist > size * 0.05 + 1e-3 {
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
}
if let Some(e) = all.get(idx)
&& !picked.iter().any(|(id, _)| *id == e.id())
{
picked.push((e.id(), *p));
}
}
let mut at_vertex: HashMap<mt::VertexID, Vec<(mt::EdgeID, Vec3)>> = HashMap::new();
for e in &all {
let Some((_, p)) = picked.iter().find(|(id, _)| *id == e.id()) else { continue };
for v in [e.absolute_front(), e.absolute_back()] {
at_vertex.entry(v.id()).or_default().push((e.id(), *p));
}
}
for (vid, list) in at_vertex {
let degree = all.iter().filter(|e| e.absolute_front().id() == vid || e.absolute_back().id() == vid).count();
if list.len() == 3 && degree == 3 {
let v = all.iter().find_map(|e| [e.absolute_front(), e.absolute_back()].into_iter().find(|v| v.id() == vid));
if let Some(v) = v {
return Ok(Some((vtx(v), list.iter().map(|(_, p)| *p).collect())));
}
}
}
Ok(None)
}
/// The ball touching three faces from inside, near the corner `v`: its centre and contacts.
struct Ball {
c: Vec3,
/// Contact with each face, and the face's outward normal there.
q: [Vec3; 3],
}
fn ball_at(faces: &[&mt::Face; 3], v: Vec3, r: f64, tol: f64) -> Result<Ball> {
let rows = |n: [Vec3; 3]| [[n[0].x, n[0].y, n[0].z], [n[1].x, n[1].y, n[1].z], [n[2].x, n[2].y, n[2].z]];
let near = |p: Vec3, hint: &mut [Option<(f64, f64)>; 3]| -> Option<([Vec3; 3], [Vec3; 3])> {
let mut q = [Vec3::ZERO; 3];
let mut n = [Vec3::ZERO; 3];
for (i, f) in faces.iter().enumerate() {
let (qi, ni, uv) = project(f, p, hint[i])?;
(q[i], n[i], hint[i]) = (qi, ni, Some(uv));
}
Some((q, n))
};
let mut hint = [None; 3];
let (_, n0) = near(v, &mut hint).ok_or_else(|| unsupported("the faces at the vertex"))?;
// The planes' ball first: each face r inside its tangent plane at the vertex.
let x = crate::polyhedron::solve3_pub(rows(n0), [-r, -r, -r]).ok_or_else(|| unsupported("a vertex whose faces do not meet in a corner"))?;
let mut c = v + Vec3::new(x[0], x[1], x[2]);
for _ in 0..60 {
let (q, n) = near(c, &mut hint).ok_or_else(|| unsupported("the ball at the vertex"))?;
let g = [(c - q[0]).dot(n[0]) + r, (c - q[1]).dot(n[1]) + r, (c - q[2]).dot(n[2]) + r];
let d = crate::polyhedron::solve3_pub(rows(n), [-g[0], -g[1], -g[2]])
.ok_or_else(|| unsupported("a vertex whose faces do not meet in a corner"))?;
let dc = Vec3::new(d[0], d[1], d[2]);
c += dc;
if !c.is_finite() {
break;
}
if dc.len() < tol * 1e-3 && g.iter().all(|x| x.abs() < tol) {
let (q, n) = near(c, &mut hint).ok_or_else(|| unsupported("the ball at the vertex"))?;
// A convex corner: the vertex lies out from every face the ball touches.
if n.iter().any(|ni| (v - c).dot(*ni) < 0.25 * r) {
return Err(unsupported("rounds at a corner that is not convex"));
}
return Ok(Ball { c, q });
}
}
Err(unsupported("the ball does not fit at the vertex"))
}
/// Round the three edges at the vertex at `at`, ending in a sphere.
fn round_vertex(cur: &Body, at: Vec3, r: f64) -> Result<Body> {
let size = cur.size();
let tol = (size * 1e-7).max(1e-9);
let solid = cur.deep_copy();
let all = Body::unique_edges(&solid);
let face_list: Vec<&mt::Face> = solid.face_iter().collect();
// The vertex, its three edges and the faces between them.
let v = all
.iter()
.flat_map(|e| [e.absolute_front().clone(), e.absolute_back().clone()])
.find(|v| vtx(v).dist(at) <= size * 1e-9 + 1e-12)
.ok_or_else(|| KernelError::Failed("fillet vertex".into()))?;
let edges: Vec<mt::Edge> = all.iter().filter(|e| *e.absolute_front() == v || *e.absolute_back() == v).cloned().collect();
let faces_of = |e: &mt::Edge| -> Vec<usize> {
(0..face_list.len()).filter(|i| face_list.get(*i).is_some_and(|f| f.edge_iter().any(|x| x.id() == e.id()))).collect()
};
let mut around: Vec<usize> = Vec::new();
for e in &edges {
for f in faces_of(e) {
if !around.contains(&f) {
around.push(f);
}
}
}
let [fa, fb, fc] = around[..] else { return Err(unsupported("a vertex of three faces")) };
let (Some(a), Some(b), Some(c)) = (face_list.get(fa), face_list.get(fb), face_list.get(fc)) else {
return Err(KernelError::Failed("faces".into()));
};
let faces = [*a, *b, *c];
let ball = ball_at(&faces, vtx(&v), r, tol * 100.0)?;
// The plane of each edge's end: through the centre and the contacts of the edge's two faces,
// its normal out toward the vertex.
let mut normals: Vec<Vec3> = Vec::with_capacity(3);
for e in &edges {
let sides: Vec<usize> = faces_of(e).iter().filter_map(|f| around.iter().position(|x| x == f)).collect();
let [i, j] = sides[..] else { return Err(unsupported("an edge not between two faces")) };
let (Some(qi), Some(qj)) = (ball.q.get(i), ball.q.get(j)) else { return Err(KernelError::Failed("contacts".into())) };
let m = (*qi - ball.c).cross(*qj - ball.c).normalized().ok_or_else(|| unsupported("a ball that touches an edge's faces at one point"))?;
normals.push(if m.dot(vtx(&v) - ball.c) < 0.0 { -m } else { m });
}
let [m0, m1, m2] = normals[..] else { return Err(KernelError::Failed("planes".into())) };
// The corner beyond the three planes, a skewed box from the centre along the three lines
// where two of the planes meet, long enough to hold the corner of the body.
let gen_ = |x: Vec3, y: Vec3, toward: Vec3| -> Result<Vec3> {
let g = x.cross(y).normalized().ok_or_else(|| unsupported("planes that do not meet in a corner"))?;
Ok(if g.dot(toward) < 0.0 { -g } else { g })
};
let reach = 3.0 * (vtx(&v).dist(ball.c) + r);
let (g0, g1, g2) = (gen_(m1, m2, m0)? * reach, gen_(m2, m0, m1)? * reach, gen_(m0, m1, m2)? * reach);
let unit = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0))?;
let m = [[g0.x, g0.y, g0.z, 0.0], [g1.x, g1.y, g1.z, 0.0], [g2.x, g2.y, g2.z, 0.0], [ball.c.x, ball.c.y, ball.c.z, 1.0]];
let cutter = crate::ops::transform_matrix(&unit, m)?;
let notched = crate::ops::boolean(cur, &cutter, BoolOp::Cut)?.ok_or_else(|| unsupported("a corner this small"))?;
// The cut splits a curved face along the trace of its plane (the whole way across, not just
// where the plane cuts): the pieces are one face again.
let notched = merge_split_faces(&notched, size)?;
// A point on what is left of each edge, half way from where its plane crosses it to its far end.
let mut picks: Vec<Vec3> = Vec::with_capacity(3);
for (e, m) in edges.iter().zip(&normals) {
picks.push(remaining_pick(e, &v, ball.c, *m)?);
}
// Round them one after another, in an order that works.
let mut last: Option<KernelError> = None;
for order in [[0usize, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
let mut step = notched.clone();
let mut ok = true;
for k in order {
let Some(p) = picks.get(k) else { return Err(KernelError::Failed("pick".into())) };
match crate::blend::fillet(&step, &[*p], r) {
Ok(b) => step = b,
Err(e) => {
last = Some(e);
ok = false;
break;
}
}
}
if ok {
return close_with_sphere(&step, &ball, [m0, m1, m2], r, vtx(&v));
}
}
Err(last.unwrap_or_else(|| unsupported("the three rounds at a vertex")))
}
/// A point on edge `e` between where the plane (through `c`, normal `m`) crosses it and its far
/// end (the end that is not `v`).
fn remaining_pick(e: &mt::Edge, v: &mt::Vertex, c: Vec3, m: Vec3) -> Result<Vec3> {
use mt::{BoundedCurve, ParametricCurve};
let curve = e.curve();
let (lo, hi) = curve.range_tuple();
// From the end at the vertex (outside the plane's far side) to the far end.
let (near, far) = if e.absolute_front() == v { (lo, hi) } else { (hi, lo) };
let side = |t: f64| (from_p3(curve.subs(t)) - c).dot(m);
let (mut a, mut b) = (near, far);
if !(side(a) > 0.0 && side(b) < 0.0) {
return Err(too_large("the blend is larger than the neighbouring faces"));
}
for _ in 0..80 {
let mid = 0.5 * (a + b);
if side(mid) > 0.0 { a = mid } else { b = mid }
}
Ok(from_p3(curve.subs(0.5 * (0.5 * (a + b) + far))))
}
/// `step` has the three rounds ending in three quarter discs; the sphere's patch takes their place.
fn close_with_sphere(step: &Body, ball: &Ball, planes: [Vec3; 3], r: f64, v: Vec3) -> Result<Body> {
let size = step.size();
let on_tol = (size * 1e-6).max(1e-9);
let solid = step.deep_copy();
let faces: Vec<mt::Face> = solid.face_iter().cloned().collect();
// The face in each plane: a plane face through the centre.
let mut discs: Vec<usize> = Vec::new();
for m in planes {
let found = faces.iter().position(|f| {
crate::blend::plane_normal(f).is_some_and(|n| n.dot(m).abs() > 1.0 - 1e-9)
&& f.vertex_iter().next().is_some_and(|p| (vtx(&p) - ball.c).dot(m).abs() <= on_tol * 10.0)
&& f.vertex_iter().any(|p| vtx(&p).dist(ball.c) <= on_tol * 10.0)
});
let Some(i) = found else { return Err(unsupported("the ends of the three rounds")) };
discs.push(i);
}
// The arc of each: its one edge that is not a straight line.
let mut arcs: Vec<mt::Edge> = Vec::with_capacity(3);
for i in &discs {
let f = faces.get(*i).ok_or_else(|| KernelError::Failed("face".into()))?;
let curved: Vec<mt::Edge> = f.edge_iter().filter(|e| !matches!(e.curve(), mt::Curve::Line(_))).collect();
let [arc] = &curved[..] else { return Err(unsupported("the ends of the three rounds")) };
arcs.push(arc.absolute_clone());
}
// The three arcs in a loop.
let first_arc = arcs.first().cloned().ok_or_else(|| KernelError::Failed("arc".into()))?;
let mut used: HashSet<mt::EdgeID> = HashSet::from([first_arc.id()]);
let mut wire: Vec<mt::Edge> = vec![first_arc];
for _ in 0..2 {
let end = wire.last().map(|e| e.back().clone()).ok_or_else(|| KernelError::Failed("arc".into()))?;
let next = arcs
.iter()
.find(|a| !used.contains(&a.id()) && (*a.front() == end || *a.back() == end))
.ok_or_else(|| unsupported("the ends of the three rounds"))?;
used.insert(next.id());
wire.push(if *next.front() == end { next.clone() } else { next.inverse() });
}
if wire.last().map(|e| e.back().clone()) != wire.first().map(|e| e.front().clone()) {
return Err(unsupported("the ends of the three rounds"));
}
// A sphere about the centre, its poles and seam away from the patch.
let out = (v - ball.c).normalized().ok_or_else(|| KernelError::Failed("sphere".into()))?;
let side = out.any_perp();
let (np, sp, back) = (ball.c + side * r, ball.c - side * r, ball.c - out * r);
let meridian = mt::builder::circle_arc(&mt::builder::vertex(p3(np)), &mt::builder::vertex(p3(sp)), p3(back));
let mut surf =
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(ball.c), v3(side))));
{
use mt::{ParametricSurface3D, SearchNearestParameter};
if let Some((u, w)) = surf.search_nearest_parameter(p3(ball.c + out * r), None, 100) {
let n = surf.normal(u, w);
if Vec3::new(n.x, n.y, n.z).dot(out) < 0.0 {
surf = mt::Invertible::inverse(&surf);
}
}
}
// The patch runs each arc opposite to the round that ends on it.
let rest: Vec<&mt::Face> = faces.iter().enumerate().filter(|(i, _)| !discs.contains(i)).map(|(_, f)| f).collect();
let uses = |id: mt::EdgeID| {
rest.iter().find_map(|f| {
f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::<Vec<_>>()).find(|x| x.id() == id).map(|x| x.front().id())
})
};
if let (Some(first), Some(front)) = (wire.first(), wire.first().and_then(|e| uses(e.id())))
&& front == first.front().id()
{
wire = wire.iter().rev().map(|e| e.inverse()).collect();
}
let patch = mt::Face::try_new(vec![mt::Wire::from(wire)], surf).map_err(|e| KernelError::Failed(format!("sphere patch: {e}")))?;
let mut new_faces: Vec<mt::Face> = rest.into_iter().cloned().collect();
new_faces.push(patch);
let mut shells = solid.boundaries().clone();
let si = shells.iter().position(|sh| sh.face_iter().any(|f| discs.iter().any(|i| faces.get(*i).is_some_and(|d| d.id() == f.id())))).unwrap_or(0);
if let Some(sh) = shells.get_mut(si) {
*sh = new_faces.into();
}
let out: Solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("rounds ending in a sphere: {e}")))?;
record::rebuilt(out, &[&step.solid], &record::Rebuild::default())
}
/// `body` with the curved faces that are pieces of one input face (as the cut that made `body`
/// recorded them) joined again: the edges two pieces share go, the rest make the one boundary.
fn merge_split_faces(body: &Body, size: f64) -> Result<Body> {
let tol = (size * 1e-6).max(1e-9);
let Some(history) = body.history() else { return Ok(body.clone()) };
let solid = body.deep_copy();
let faces: Vec<mt::Face> = solid.face_iter().cloned().collect();
if history.faces.len() != faces.len() {
return Ok(body.clone());
}
let on = |a: &mt::Face, p: Vec3| project(a, p, None).is_some_and(|(q, _, _)| q.dist(p) <= tol * 10.0);
// Pieces by the input face they come from.
let mut groups: Vec<Vec<usize>> = Vec::new();
for (i, f) in history.faces.iter().enumerate() {
let curved = faces.get(i).is_some_and(|x| crate::blend::plane_normal(x).is_none());
if !(curved && f.change == crate::history::Change::Split && f.origins.len() == 1) {
continue;
}
match groups.iter_mut().find(|g| g.first().and_then(|k| history.faces.get(*k)).is_some_and(|o| o.origins == f.origins)) {
Some(g) => g.push(i),
None => groups.push(vec![i]),
}
}
let mut out: Vec<Option<mt::Face>> = faces.iter().cloned().map(Some).collect();
let mut merged_any = false;
for group in groups.iter().filter(|g| g.len() >= 2) {
let pieces: Vec<&mt::Face> = group.iter().filter_map(|i| faces.get(*i)).collect();
if pieces.iter().any(|f| f.boundaries().len() != 1) {
continue;
}
// Edges used once, as the pieces walk them.
let all: Vec<mt::Edge> = pieces
.iter()
.flat_map(|f| f.absolute_boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::<Vec<_>>()).collect::<Vec<_>>())
.collect();
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
for e in &all {
*uses.entry(e.id()).or_default() += 1;
}
let mut left: Vec<mt::Edge> = all.into_iter().filter(|e| uses.get(&e.id()) == Some(&1)).collect();
let Some(start) = left.pop() else { continue };
let mut wire = vec![start];
while let Some(end) = wire.last().map(|e| e.back().clone()) {
if wire.first().is_some_and(|e| *e.front() == end) {
break;
}
let Some(at) = left.iter().position(|e| *e.front() == end) else { break };
wire.push(left.swap_remove(at));
}
// One closed boundary, all of it used.
if !left.is_empty() || wire.first().map(|e| e.front().clone()) != wire.last().map(|e| e.back().clone()) {
continue;
}
// On the surface of the piece whose patch reaches every corner of all.
let all_on = |f: &mt::Face| {
pieces.iter().all(|p| p.vertex_iter().all(|v| on(f, vtx(&v))) && p.edge_iter().all(|e| edge_mid(&e).is_none_or(|m| on(f, m))))
};
let Some(like) = pieces.iter().copied().find(|f| all_on(f)) else { continue };
let Some(joined) = crate::heal::absolute_face(like, vec![mt::Wire::from(wire)]) else { continue };
let Some(first) = group.first() else { continue };
for i in group {
if let Some(slot) = out.get_mut(*i) {
*slot = None;
}
}
if let Some(slot) = out.get_mut(*first) {
*slot = Some(joined);
}
merged_any = true;
}
if !merged_any {
return Ok(body.clone());
}
let mut shells = solid.boundaries().clone();
if let Some(sh) = shells.first_mut() {
*sh = out.into_iter().flatten().collect::<Vec<_>>().into();
}
let rebuilt = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("a corner cut: {e}")))?;
record::rebuilt(rebuilt, &[&body.solid], &record::Rebuild::default())
}
+300 -29
View File
@@ -26,6 +26,15 @@ const MAX_SAMPLES: usize = 369;
const MAX_CHAIN_SAMPLES: usize = 100_000;
/// Sections past a slanted end of an open chain.
const EXTENSION: usize = 8;
/// `1 + n1·n2` below this: a knife edge, where the faces' normals are opposite and the ball's
/// centre is found by sliding along the faces rather than from their normals.
const KNIFE: f64 = 1e-4;
/// An end face this close to square-on-to-its-sides' normal counts as tangent to a side.
const TANGENT_END: f64 = 1e-6;
/// Times the run past a tangent end is halved when the ball does not fit along it.
const TANGENT_RETRIES: usize = 8;
/// Slides of the ball's centre along a knife edge's faces, each 1.6 times the last.
const KNIFE_STEPS: usize = 40;
struct Fit {
surface: mt::Surface,
@@ -177,7 +186,7 @@ fn section_fits(fit: &Fit, section: &Section, along: f64, round: bool, sigma: f6
/// Where a blend strip run on past a chain end meets the end face: the contacts' parameters
/// there and the end curve from contact 1 to contact 2 (fitted through points where the
/// strip's cross-section curves meet the face's surface, and checked against both).
fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, round: bool, tolerance: f64) -> Result<(f64, f64, mt::Curve)> {
fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, seams: [Option<&mt::Edge>; 2], round: bool, tolerance: f64) -> Result<(f64, f64, mt::Curve)> {
use mt::{ParametricCurve, ParametricSurface, SearchNearestParameter};
let fail = || unsupported("the blend's end on its end face");
let (surf, gs) = (&fit.surface, g.surface());
@@ -235,6 +244,7 @@ fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, round: bool, tolerance: f64) -
vs.push(v);
pts.push(from_p3(surf.subs(i as f64 / m as f64, v)));
}
snap_to_seams(&mut pts, seams, tolerance);
let bs = crate::build::interpolate_cubic(&pts).ok_or_else(fail)?;
let fits = (0..m).all(|i| {
let q = p3(from_p3(bs.subs((i as f64 + 0.5) / m as f64)));
@@ -256,6 +266,148 @@ fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, round: bool, tolerance: f64) -
Err(fail())
}
/// The ends of an end curve (contact 1 to contact 2) that land within `tolerance` of an end of
/// their side's seam are that point exactly: the blend and the faces there meet at one vertex.
fn snap_to_seams(pts: &mut [Vec3], seams: [Option<&mt::Edge>; 2], tolerance: f64) {
let snap = |p: &mut Vec3, seam: Option<&mt::Edge>| {
for q in seam.into_iter().flat_map(|e| [from_p3(e.front().point()), from_p3(e.back().point())]) {
if p.dist(q) <= tolerance {
*p = q;
}
}
};
if let Some(first) = pts.first_mut() {
snap(first, seams[0]);
}
if let Some(last) = pts.last_mut() {
snap(last, seams[1]);
}
}
/// Where a blend strip run on past a chain end meets an end face that lies along the edge and is
/// tangent to one side there (a wall running out into the face, an earlier round that touches a
/// side along its contact): the end curve from contact 1 to contact 2, the same result as
/// [`end_cap`]. The strip touches the face along the tangent side's contact, so a Newton solve
/// for the crossing is singular there.
///
/// Both ends of the curve are where a contact meets a seam, which is a plain crossing: the
/// tangent side's contact leaves the end face where it crosses the seam of that side with the
/// face, the other contact where it crosses the other side's seam. Between those stations each
/// cross-section crosses the face once, a simple root. Either station may lie before or after
/// the vertex's section: the curve runs inwards along the edge or out along the extension.
fn tangent_cap(fit: &Fit, g: &mt::Face, tangent_side: u8, seams: [Option<&mt::Edge>; 2], tolerance: f64) -> Result<(f64, f64, mt::Curve)> {
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, SearchNearestParameter};
let fail = || unsupported("the blend's end on its end face");
let surf = &fit.surface;
let (lo, hi) = (fit.range.0.min(fit.range.1), fit.range.0.max(fit.range.1));
let (u_t, u_o, seam_t, seam_o) = if tangent_side == 1 { (0.0, 1.0, seams[0], seams[1]) } else { (1.0, 0.0, seams[1], seams[0]) };
let (seam_t, seam_o) = (seam_t.ok_or_else(fail)?, seam_o.ok_or_else(fail)?);
// The station where the strip's contact `u` crosses `seam`.
let crossing = |u: f64, seam: &mt::Edge| -> Option<f64> {
let curve = seam.curve();
let (a, b) = curve.range_tuple();
let polyline: Vec<Vec3> = (0..=1024).map(|i| from_p3(curve.subs(a + (b - a) * f64::from(i) / 1024.0))).collect();
let dist = |v: f64| -> f64 {
let x = from_p3(surf.subs(u, v));
polyline.windows(2).map(|w| x.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min)
};
let n = 400;
let step = (hi - lo) / f64::from(n);
let (mut best, mut best_d) = (lo, f64::INFINITY);
for i in 0..=n {
let v = lo + step * f64::from(i);
let d = dist(v);
if d < best_d {
(best, best_d) = (v, d);
}
}
let (mut x0, mut x1) = ((best - step).max(lo), (best + step).min(hi));
let phi = 0.618_033_988_749_894_9;
for _ in 0..80 {
let (c, d) = (x1 - phi * (x1 - x0), x0 + phi * (x1 - x0));
if dist(c) < dist(d) {
x1 = d;
} else {
x0 = c;
}
}
let v = 0.5 * (x0 + x1);
(dist(v) <= tolerance).then_some(v)
};
let (rt, ra) = (crossing(u_t, seam_t), crossing(u_o, seam_o));
let v_t = rt.ok_or_else(fail)?;
let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces"))?;
let inside = |t: f64| t > lo + 1e-6 && t < hi - 1e-6;
if !(inside(v_a) && inside(v_t)) || (v_a - v_t).abs() < 1e-9 {
return Err(too_large("the blend is larger than the neighbouring faces"));
}
// How far the strip's point (u, v) is outside the end face (negative: inside the body).
let outside = |u: f64, v: f64| -> Option<f64> {
let x = from_p3(surf.subs(u, v));
let (q, n, _) = project(g, x, None)?;
Some((x - q).dot(n))
};
// Root of `f` between `lo` and `hi`, `f(lo)` and `f(hi)` of different sign.
let bisect = |f: &dyn Fn(f64) -> Option<f64>, mut x0: f64, mut x1: f64| -> Option<f64> {
let f0 = f(x0)?;
for _ in 0..60 {
let mid = 0.5 * (x0 + x1);
let fm = f(mid)?;
if fm.abs() <= tolerance * 1e-6 {
return Some(mid);
}
if fm.is_sign_negative() == f0.is_sign_negative() {
x0 = mid;
} else {
x1 = mid;
}
}
Some(0.5 * (x0 + x1))
};
for m in [16usize, 32, 64, 128] {
// The curve at stations v_i from the crossing contact to the tangent one, as (u, v, point).
let mut stations: Vec<(f64, f64, Vec3)> = Vec::with_capacity(m + 1);
for i in 0..=m {
// Clustered at both ends, where the curve can meet a contact tangentially (a square
// root in the station) and a uniform spacing would fit it poorly.
let v = v_a + (v_t - v_a) * 0.5 * (1.0 - (std::f64::consts::PI * i as f64 / m as f64).cos());
let u = if i == 0 {
u_o
} else if i == m {
u_t
} else {
let at_t = outside(u_t, v).ok_or_else(fail)?;
let at_o = outside(u_o, v).ok_or_else(fail)?;
// No crossing where the contacts are on one side: the curve is at the tangent one.
if at_t * at_o >= 0.0 { u_t } else { bisect(&|x| outside(x, v), u_o, u_t).ok_or_else(fail)? }
};
stations.push((u, v, from_p3(surf.subs(u, v))));
}
let mut pts: Vec<Vec3> = stations.iter().map(|x| x.2).collect();
// The curve runs from contact 1 to contact 2.
if u_o == 1.0 {
pts.reverse();
}
snap_to_seams(&mut pts, seams, tolerance);
let bs = crate::build::interpolate_cubic(&pts).ok_or_else(fail)?;
let gs = g.surface();
let fits = stations.windows(2).enumerate().all(|(i, w)| {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return false };
let t = if u_o == 1.0 { 1.0 - (i as f64 + 0.5) / m as f64 } else { (i as f64 + 0.5) / m as f64 };
let q = p3(from_p3(bs.subs(t)));
let hint = (0.5 * (a.0 + b.0), 0.5 * (a.1 + b.1));
let on_g = gs.search_nearest_parameter(q, None, 100).is_some_and(|(x, y)| from_p3(gs.subs(x, y)).dist(from_p3(q)) <= tolerance);
let on_s = surf.search_nearest_parameter(q, Some(hint), 100).is_some_and(|(x, y)| from_p3(surf.subs(x, y)).dist(from_p3(q)) <= tolerance);
on_g && on_s
});
if fits {
let (t1, t2) = if u_o == 0.0 { (v_a, v_t) } else { (v_t, v_a) };
return Ok((t1, t2, mt::Curve::BSplineCurve(bs)));
}
}
Err(fail())
}
/// One cross-section of the blend: ball centre and its contacts with side 1 and side 2.
#[derive(Clone, Copy, Debug)]
struct Section {
@@ -265,7 +417,7 @@ struct Section {
}
/// Nearest point of a face's surface and the outward normal there.
fn project(f: &mt::Face, p: Vec3, hint: Option<(f64, f64)>) -> Option<(Vec3, Vec3, (f64, f64))> {
pub(crate) fn project(f: &mt::Face, p: Vec3, hint: Option<(f64, f64)>) -> Option<(Vec3, Vec3, (f64, f64))> {
use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
let s = f.oriented_surface();
let uv = s.search_nearest_parameter(p3(p), hint, 100).or_else(|| s.search_nearest_parameter(p3(p), None, 100))?;
@@ -294,8 +446,20 @@ fn in_range(f: &mt::Face, uv: (f64, f64)) -> bool {
/// Solve for the ball touching both sides near `p`. `extra` adds a third condition (a plane the
/// side-k contact must lie in); without it the centre stays in the plane through `p` square to
/// `t`. `sigma` is +1 when the ball sits outside the material (concave edge), −1 inside.
/// `cusp` is ±1 at a knife edge (the faces' normals opposite: both leave the edge the same way,
/// along `cusp · n1 × t`, and the ball lies where the cusp has opened to its width), else 0.
#[allow(clippy::too_many_arguments)]
fn solve(f1: &[&mt::Face], f2: &[&mt::Face], p: Vec3, t: Vec3, r: f64, sigma: f64, extra: Option<(usize, Vec3, Vec3)>, tol: f64) -> Option<Section> {
fn solve(
f1: &[&mt::Face],
f2: &[&mt::Face],
p: Vec3,
t: Vec3,
r: f64,
sigma: f64,
cusp: f64,
extra: Option<(usize, Vec3, Vec3)>,
tol: f64,
) -> Option<Section> {
// The nearest face of a side (several faces on one smooth surface group). Each face's
// search starts from its last answer: the centre moves little between iterations. An
// answer outside the surface's parameters, or farther than that last point (the search
@@ -320,10 +484,47 @@ fn solve(f1: &[&mt::Face], f2: &[&mt::Face], p: Vec3, t: Vec3, r: f64, sigma: f6
let (q2, n2) = near(f2, p)?;
let _ = (q1, q2);
let den = 1.0 + n1.dot(n2);
if den.abs() < 1e-9 {
return None;
}
let mut c = p + (n1 + n2) * (sigma * r / den);
let mut c = if cusp != 0.0 && den.abs() < KNIFE {
// The normals are (nearly) opposite: `(n1 + n2) / den` has no direction. The centre is r
// inside face 1 and slides along it, away from the edge, until it is r from face 2 too.
let w = n1.cross(t).normalized()? * cusp;
let centre = |s: f64| -> Option<Vec3> {
let (qa, na) = near(f1, p + w * s)?;
Some(qa + na * (sigma * r))
};
let gap = |s: f64| -> Option<f64> {
let c = centre(s)?;
let (q2, n2) = near(f2, c)?;
Some((c - q2).dot(n2) - sigma * r)
};
let (mut lo, mut g_lo) = (0.0, gap(0.0)?);
let mut hi = None;
let mut s = r * 0.05;
for _ in 0..KNIFE_STEPS {
let Some(g) = gap(s) else { break };
if g == 0.0 || g.is_sign_negative() != g_lo.is_sign_negative() {
hi = Some(s);
break;
}
(lo, g_lo) = (s, g);
s *= 1.6;
}
let mut hi = hi?;
for _ in 0..60 {
let mid = 0.5 * (lo + hi);
match gap(mid) {
Some(g) if g.is_sign_negative() == g_lo.is_sign_negative() => lo = mid,
Some(_) => hi = mid,
None => break,
}
}
centre(0.5 * (lo + hi))?
} else {
if den.abs() < 1e-9 {
return None;
}
p + (n1 + n2) * (sigma * r / den)
};
for _ in 0..60 {
let (q1, n1) = near(f1, c)?;
let (q2, n2) = near(f2, c)?;
@@ -549,7 +750,23 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
(Some(x), Some(y)) => (x.1, y.1),
_ => return Err(KernelError::Failed("blend: normals".into())),
};
let convex = t1.dot(n1.cross(n2)) > 0.0;
// A knife edge (opposite normals): both faces leave the edge along `w`; the second one lies
// below the first (against its outward normal) when the cusp is material, above when it is
// a gap. The cross product of the normals, which tells the other edges apart, is zero here.
let knife = 1.0 + n1.dot(n2) < KNIFE;
let (convex, cusp) = if knife {
let w = n1.cross(t1).normalized().ok_or_else(|| KernelError::Failed("blend: cusp direction".into()))?;
// Second order: from the point of face 1 a little way along `w`, face 2's nearest point.
let probe = project(f1, pm + w * r, None)
.and_then(|(qa, na, _)| s2.first().and_then(|f| project(f, qa, None)).map(|(qb, _, _)| (qb - qa).dot(na)))
.filter(|depth| depth.abs() > size * 1e-9)
.ok_or_else(|| unsupported("a knife edge whose faces lie flat on each other"))?;
// Whether the chain runs along the edge as face 1 uses it decides which way `w` points
// from the chain's tangent.
(probe < 0.0, if in_f1.front() == e0.front() { 1.0 } else { -1.0 })
} else {
(t1.dot(n1.cross(n2)) > 0.0, 0.0)
};
let sigma = if convex { -1.0 } else { 1.0 };
if n1.dot(n2) > 1.0 - 1e-6 {
return Err(unsupported("blending a smooth edge"));
@@ -559,8 +776,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut seams: Vec<Vec<(usize, mt::Edge)>> = Vec::with_capacity(nv);
// At an open chain's ends: the end face (planar, square to the edge there).
let mut end_faces: Vec<(usize, usize)> = Vec::new(); // (vertex index, face index)
// Chain ends whose end face is not a plane square to the edge: (vertex, face, point, tangent).
let mut slanted: Vec<(usize, usize, Vec3, Vec3)> = Vec::new();
// Chain ends whose end face is not a plane square to the edge: (vertex, face, point, tangent,
// the side (1 or 2) the end face is tangent to when it lies along the edge there).
let mut slanted: Vec<(usize, usize, Vec3, Vec3, Option<u8>)> = Vec::new();
for (k, v) in verts.iter().enumerate() {
let at_end = !closed && (k == 0 || k == nv - 1);
let (k1, k2) = {
@@ -629,7 +847,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
_ => false,
};
if !square {
slanted.push((k, fi, p, t));
slanted.push((k, fi, p, t, None));
}
}
let extra = match here.first().cloned().filter(|_| !at_end) {
@@ -654,7 +872,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
};
let (a1, a2) = edge_sides(&vertex_edges(k));
let s = solve(&a1, &a2, p, t, r, sigma, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?;
let s = solve(&a1, &a2, p, t, r, sigma, cusp, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?;
// A seam on the other side too must pass through that side's contact.
if let Some((side, seam)) = here.get(1).filter(|_| !at_end) {
let q = if *side == 1 { s.q1 } else { s.q2 };
@@ -668,25 +886,46 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
// Past a slanted end: sections of the same rolling ball beyond the end vertex (far to near
// at the chain's start, near to far at its end), far enough to cross the end face.
let mut beyond: Vec<(usize, Vec<Section>)> = Vec::new();
for (k, fi, p, t) in &slanted {
for (k, fi, p, t, tangent) in slanted.iter_mut() {
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
let (_, ng, _) = project(g, *p, None).ok_or_else(|| unsupported("the blend's end on its end face"))?;
let cos = ng.dot(*t).abs();
if cos < 0.15 {
// An end face along the edge and tangent to one side there (a wall that runs out into
// the face): the blend meets it along a curve that touches that side's contact at the
// vertex. Any other end face that close to the edge is refused.
let (e1, e2) = edge_sides(&vertex_edges(*k));
*tangent = if cos < TANGENT_END {
[(1u8, &e1), (2u8, &e2)].into_iter().find_map(|(side, faces)| {
faces.iter().filter_map(|f| project(f, *p, None)).any(|(_, n, _)| n.dot(ng).abs() > 1.0 - TANGENT_END).then_some(side)
})
} else {
None
};
if cos < 0.15 && tangent.is_none() {
return Err(unsupported("an end face almost along the edge"));
}
let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + r;
let reach = 1.5 * width / cos;
let reach = 1.5 * width / if tangent.is_some() { 1.0 } else { cos };
let out = if *k == 0 { -*t } else { *t };
let (e1, e2) = edge_sides(&vertex_edges(*k));
// Past a tangent end the straight run along the tangent leaves the curved spine: where
// the ball no longer fits, the run is shortened (it only gives the fit its context).
let mut list = Vec::with_capacity(EXTENSION);
for j in 1..=EXTENSION {
let anchor = *p + out * (reach * j as f64 / EXTENSION as f64);
list.push(
solve(&e1, &e2, anchor, *t, r, sigma, None, tol * 100.0)
.ok_or_else(|| unsupported("the ball doesn't fit past the end of the edge"))?,
);
let mut reach = reach;
for attempt in 0..(if tangent.is_some() { TANGENT_RETRIES } else { 1 }) {
list.clear();
let sections = (1..=EXTENSION).map(|j| {
let anchor = *p + out * (reach * j as f64 / EXTENSION as f64);
solve(&e1, &e2, anchor, *t, r, sigma, cusp, None, tol * 100.0)
});
if let Some(all) = sections.collect::<Option<Vec<_>>>() {
list = all;
break;
}
if attempt + 1 == TANGENT_RETRIES || tangent.is_none() {
return Err(unsupported("the ball doesn't fit past the end of the edge"));
}
reach *= 0.5;
}
if *k == 0 {
list.reverse();
@@ -716,7 +955,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?;
let anchor = p + t * (start * (1.0 - along) + end * along);
let (a1, a2) = edge_sides(&[k]);
solve(&a1, &a2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
solve(&a1, &a2, anchor, t, r, sigma, cusp, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
};
let mut samples = SAMPLES.min(max_samples);
let (esec, fits) = loop {
@@ -753,7 +992,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?;
let (a1, a2) = edge_sides(&[k]);
solve(&a1, &a2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
solve(&a1, &a2, anchor, tangent, r, sigma, cusp, None, tol * 100.0)
.ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
};
let esec: Vec<Vec<Section>> = (0..n)
.map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::<Result<Vec<_>>>())
@@ -812,12 +1052,17 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
// Where each extended blend meets its slanted end face: (vertex, edge, cut parameters of
// the two contacts, the end curve from contact 1 to contact 2).
let mut caps: Vec<(usize, usize, f64, f64, mt::Curve)> = Vec::new();
for (k, fi, _, _) in &slanted {
for (k, fi, _, _, tangent) in &slanted {
let ke = if *k == 0 { 0 } else { n - 1 };
let fit = fits.get(ke).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
let cap = crate::guard("curve blend end", || end_cap(fit, g, &at, round, fit_tolerance))?;
let seam = |s: u8| seams.get(*k).and_then(|l| l.iter().find(|(x, _)| *x as u8 == s)).map(|(_, e)| e);
let both = [seam(1), seam(2)];
let cap = crate::guard("curve blend end", || match tangent {
Some(side) => tangent_cap(fit, g, *side, both, fit_tolerance),
None => end_cap(fit, g, &at, both, round, fit_tolerance),
})?;
caps.push((*k, ke, cap.0, cap.1, cap.2));
}
// Both ends of one edge cut back: the contacts must keep a piece between them.
@@ -840,6 +1085,23 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
*b = builder::vertex(curve.subs(hi));
}
}
// A chain end (a cap, or the section at a square end) on the far end of its seam uses that
// seam up: that vertex is the end.
let mut consumed: HashMap<mt::EdgeID, mt::Vertex> = HashMap::new();
for (k, _) in &end_faces {
for (side, seam) in seams.get(*k).into_iter().flatten() {
let Some(old) = verts.get(*k) else { continue };
let far = if seam.front() == old { seam.back() } else { seam.front() };
let slot = if *side == 1 { v1.get_mut(*k) } else { v2.get_mut(*k) };
if let Some(nv) = slot
&& far != old
&& from_p3(far.point()).dist(from_p3(nv.point())) <= fit_tolerance
{
*nv = far.clone();
consumed.insert(seam.id(), far.clone());
}
}
}
let mut arcs = Vec::with_capacity(nv);
for (k, s) in vsec.iter().enumerate() {
let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?);
@@ -922,6 +1184,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
for (id, (seam, a, b)) in ends {
if consumed.contains_key(&id) {
continue;
}
// At a slanted end the blend may stop past the seam's old end: the seam then runs
// on along its line or circle, within its planar face.
let one_end = match (&a, &b) {
@@ -988,13 +1253,17 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
None => e.clone(),
};
// Arriving at an old corner: continue along that end's arc.
// Arriving at an old corner: continue along that end's arc. A seam the
// blend used up is left out, and the arc starts at its far end.
let arrives = corners.iter().copied().find(|k| verts.get(*k).is_some_and(|v| *e.back() == *v));
es.push(ne.clone());
let used_up = consumed.contains_key(&e.id());
if !used_up {
es.push(ne.clone());
}
if let Some(k) = arrives {
let arc = arcs.get(k).ok_or_else(|| fail("arc"))?;
let (a1, a2) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?);
let to = ne.back();
let to = if used_up { e.front() } else { ne.back() };
let corner = if to == a1 {
arc.clone()
} else if to == a2 {
@@ -1029,6 +1298,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
if e.front() == chain_e.front() { base.clone() } else { base.inverse() }
} else if let Some(s) = seam_subst.get(&e.id()) {
if e.front() == e.absolute_front() { s.clone() } else { s.inverse() }
} else if consumed.contains_key(&e.id()) {
continue;
} else {
e.clone()
};
+23
View File
@@ -4,6 +4,7 @@
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
use super::*;
use crate::blend::plane_normal;
use crate::{Body, BoolOp, boolean, cylinder, fillet};
fn tee(scale: f64, origin: Vec3) -> Body {
@@ -352,6 +353,28 @@ fn a_refused_edge_is_not_rolled_again_after_blends_far_from_it() {
assert!(fillet(&body, &ends, 3.0).is_ok());
}
/// At a knife edge (the plane and the wall's normals opposite) the ball's centre is the same
/// whichever face is side 1: `r` inside the plane and `r` outside the wall's cylinder.
#[test]
fn knife_edge_ball_centre_does_not_depend_on_the_side_order() {
const R: f64 = 21.0;
let tip = Vec3::new(62.0 + 1.0 / 6.0, 0.0, 5.0);
let slab = crate::box_solid(Vec3::new(0.0, -R, 0.0), Vec3::new(tip.x, 0.0, 10.0)).unwrap();
let disc = cylinder(Vec3::new(tip.x, -R, 0.0), Vec3::Z, R, 10.0).unwrap();
let body = boolean(&slab, &disc, BoolOp::Cut).unwrap().unwrap();
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
let plane = faces.iter().copied().find(|f| plane_normal(f).is_some_and(|n| n.y > 0.99)).unwrap();
let wall = faces.iter().copied().find(|f| plane_normal(f).is_none()).unwrap();
for r in [0.5, 2.0, 5.0] {
let centre = Vec3::new(tip.x - (4.0 * R * r).sqrt(), -r, tip.z);
// `cusp` is +1 when n1 × t points into the cusp: the wall's normal is −y, the plane's +y.
for (f1, f2, cusp) in [(wall, plane, 1.0), (plane, wall, -1.0)] {
let s = solve(&[f1], &[f2], tip, Vec3::Z, r, -1.0, cusp, None, 1e-6).unwrap();
assert!(s.c.dist(centre) < 1e-6, "r={r} cusp={cusp}: centre {:?}, expected {centre:?}", s.c);
}
}
}
/// #896: the seams where rounds run into the faces beside them are picked to be rounded again.
/// The faces meet tangentially there, so there is nothing to round: the refusal says so, rather
/// than that blending such an edge isn't supported yet.
+394
View File
@@ -0,0 +1,394 @@
//! Fillets and chamfers where a curved wall runs out into a flat face in a knife edge (#851).
use crate::*;
use solidcraft_geom::Vec3;
use std::f64::consts::PI;
const R_WALL: f64 = 21.0;
const HEIGHT: f64 = 10.0;
/// x of the knife edge: the arc wall's far end.
const TIP_X: f64 = 62.0 + 1.0 / 6.0;
/// A 41⅙ × 21 plate plus the corner a circle of radius 21 leaves, extruded 10 mm: the arc wall
/// meets the front face square and runs out into the back face (y = 0) in a knife edge at (TIP_X, 0).
fn part() -> Body {
let x0 = TIP_X - R_WALL;
let plate = box_solid(Vec3::new(0.0, -R_WALL, 0.0), Vec3::new(x0, 0.0, HEIGHT)).unwrap();
let corner = box_solid(Vec3::new(x0, -R_WALL, 0.0), Vec3::new(TIP_X, 0.0, HEIGHT)).unwrap();
let disc = cylinder(Vec3::new(TIP_X, -R_WALL, 0.0), Vec3::Z, R_WALL, HEIGHT).unwrap();
let corner = boolean(&corner, &disc, BoolOp::Cut).unwrap().unwrap();
boolean(&plate, &corner, BoolOp::Union).unwrap().unwrap()
}
/// Area a ball of radius `r` takes off the cusp: the quadrilateral tip–wall-contact–wall-centre–
/// ball-centre-contact, less the wall's sector and the ball's.
fn removed_area(r: f64) -> f64 {
let beta = ((R_WALL - r) / (R_WALL + r)).acos();
let o = (TIP_X - (4.0 * R_WALL * r).sqrt(), -r);
let c = (TIP_X, -R_WALL);
let tip = (TIP_X, 0.0);
let t1 = (o.0, 0.0);
// Shoelace over t1 → tip → c → o.
let pts = [t1, tip, c, o];
let quad: f64 = (0..4).map(|i| pts[i].0 * pts[(i + 1) % 4].1 - pts[(i + 1) % 4].0 * pts[i].1).sum::<f64>().abs() / 2.0;
quad - 0.5 * R_WALL * R_WALL * beta - 0.5 * r * r * (PI - beta)
}
fn knife() -> Vec3 {
Vec3::new(TIP_X, 0.0, HEIGHT / 2.0)
}
/// The same part with the corner joined first: its faces come in another order, so the plane
/// and the wall swap sides at the knife edge.
fn part_other_order() -> Body {
let x0 = TIP_X - R_WALL;
let plate = box_solid(Vec3::new(0.0, -R_WALL, 0.0), Vec3::new(x0, 0.0, HEIGHT)).unwrap();
let corner = box_solid(Vec3::new(x0, -R_WALL, 0.0), Vec3::new(TIP_X, 0.0, HEIGHT)).unwrap();
let disc = cylinder(Vec3::new(TIP_X, -R_WALL, 0.0), Vec3::Z, R_WALL, HEIGHT).unwrap();
let both = boolean(&corner, &plate, BoolOp::Union).unwrap().unwrap();
boolean(&both, &disc, BoolOp::Cut).unwrap().unwrap()
}
/// The same solid as one box less the wall's cylinder: the plane comes before the wall in the
/// shell, so the plane is side 1 at the knife edge.
fn part_cut_from_a_box() -> Body {
let slab = box_solid(Vec3::new(0.0, -R_WALL, 0.0), Vec3::new(TIP_X, 0.0, HEIGHT)).unwrap();
let disc = cylinder(Vec3::new(TIP_X, -R_WALL, 0.0), Vec3::Z, R_WALL, HEIGHT).unwrap();
boolean(&slab, &disc, BoolOp::Cut).unwrap().unwrap()
}
/// Area a chamfer of distance `d` takes off the cusp: the triangle between the plane's contact,
/// the tip and the wall's contact, less the wall's circular segment.
fn chamfered_area(d: f64) -> f64 {
let beta = ((R_WALL - d) / (R_WALL + d)).acos();
let o = (TIP_X - (4.0 * R_WALL * d).sqrt(), -d);
let c = (TIP_X, -R_WALL);
let k = R_WALL / (R_WALL + d);
let q2 = (c.0 + (o.0 - c.0) * k, c.1 + (o.1 - c.1) * k);
let (t1, tip) = ((o.0, 0.0), (TIP_X, 0.0));
let triangle = 0.5 * ((tip.0 - t1.0) * (q2.1 - t1.1) - (q2.0 - t1.0) * (tip.1 - t1.1)).abs();
triangle - 0.5 * R_WALL * R_WALL * (beta - beta.sin())
}
#[test]
fn fillet_the_knife_edge() {
for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] {
let before = measure(&body).unwrap().volume;
for r in [0.5, 2.0, 5.0] {
let rounded = fillet(&body, &[knife()], r).unwrap_or_else(|e| panic!("{name} r={r}: {e}"));
assert!(rounded.validity().is_empty(), "{name} r={r}: {:?}", rounded.validity());
assert_eq!(rounded.face_count(), body.face_count() + 1, "{name} r={r}: one blend face");
let v = measure(&rounded).unwrap().volume;
let expected = before - HEIGHT * removed_area(r);
assert!((v - expected).abs() < 1e-3 * expected.abs(), "{name} r={r}: volume {v}, expected {expected}");
}
}
}
#[test]
fn chamfer_the_knife_edge() {
for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] {
let before = measure(&body).unwrap().volume;
for d in [0.5, 2.0, 5.0] {
let cut = chamfer(&body, &[knife()], d).unwrap_or_else(|e| panic!("{name} d={d}: {e}"));
assert!(cut.validity().is_empty(), "{name} d={d}: {:?}", cut.validity());
assert_eq!(cut.face_count(), body.face_count() + 1, "{name} d={d}: one chamfer face");
let v = measure(&cut).unwrap().volume;
let expected = before - HEIGHT * chamfered_area(d);
assert!((v - expected).abs() < 1e-3 * expected.abs(), "{name} d={d}: volume {v}, expected {expected}");
}
}
}
/// A ball that does not fit between the faces, and sizes at the ends of the range, are refused
/// or blended; they never panic or give an invalid solid.
#[test]
fn knife_edge_sizes_never_panic() {
let body = part();
for r in [1e-5, 1e-3, 10.0, 21.0, 40.0, 1e3, 1e6 - 1.0] {
for round in [true, false] {
let made = if round { fillet(&body, &[knife()], r) } else { chamfer(&body, &[knife()], r) };
if let Ok(b) = made {
assert!(b.validity().is_empty(), "r={r}: {:?}", b.validity());
}
}
}
}
/// Mirrored parts reverse the edge and its faces' use of it: the blend is the same.
#[test]
fn knife_edge_blends_the_same_in_a_mirror() {
let body = part();
let reference = measure(&fillet(&body, &[knife()], 2.0).unwrap()).unwrap().volume;
let mirror = |axis: usize| {
let mut m = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
m[axis][axis] = -1.0;
m
};
for (axis, at) in [(0, Vec3::new(-TIP_X, 0.0, 5.0)), (1, Vec3::new(TIP_X, 0.0, 5.0)), (2, Vec3::new(TIP_X, 0.0, -5.0))] {
let mirrored = transform_matrix(&body, mirror(axis)).unwrap();
let v = measure(&fillet(&mirrored, &[at], 2.0).unwrap()).unwrap().volume;
assert!((v - reference).abs() < 1e-6, "mirror {axis}: volume {v}, expected {reference}");
}
}
/// A point on the arc's top edge (half way round the wall).
fn arc_top() -> Vec3 {
let (s, c) = (std::f64::consts::FRAC_PI_4.sin(), std::f64::consts::FRAC_PI_4.cos());
Vec3::new(TIP_X - R_WALL * c, -R_WALL + R_WALL * s, HEIGHT)
}
/// Volume the arc's top edge takes off, by integrating the corner of the blend over the wall's
/// angle: the part of the sweep above the back face (y = 0) is not material. Rounds: the corner
/// between the wall and the top, less the ball's quarter disc; chamfers: its triangle.
fn arc_top_removed(size: f64, round: bool) -> f64 {
let n = 2000;
let step = size / f64::from(n);
let mut total = 0.0;
for i in 0..n {
let rho = R_WALL + (f64::from(i) + 0.5) * step;
for j in 0..n {
let z = HEIGHT - size + (f64::from(j) + 0.5) * step;
let inside = if round {
(rho - (R_WALL + size)).powi(2) + (z - (HEIGHT - size)).powi(2) >= size * size
} else {
(rho - R_WALL) + (HEIGHT - z) <= size
};
if inside {
total += rho * (R_WALL / rho).asin() * step * step;
}
}
}
total
}
/// Volume through the display planner (the mesh the app draws): closed and exact where the
/// legacy mesher behind `measure` can leak at a zero-angle corner like the one at the tip.
fn planned_volume(b: &Body) -> f64 {
let mesh = b.display_plan(2e-4).unwrap().evaluate();
let key = |p: Vec3| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()];
let mut uses: std::collections::HashMap<([u64; 3], [u64; 3]), i32> = std::collections::HashMap::new();
for t in &mesh.triangles {
let [a, b, c] = mesh.tri(t).unwrap();
for (p, q) in [(a, b), (b, c), (c, a)] {
*uses.entry((key(p), key(q))).or_default() += 1;
*uses.entry((key(q), key(p))).or_default() -= 1;
}
}
assert!(uses.values().all(|n| *n == 0), "the mesh has cracks");
mesh.measure().volume
}
/// The arc's top edge runs out tangent to the back face at the knife edge's top: the blend is cut
/// by that face along a curve that touches the wall's contact at the tip.
#[test]
fn fillet_and_chamfer_the_arc_top_edge() {
for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] {
let before = planned_volume(&body);
for r in [0.5, 2.0, 4.0, 8.0] {
for round in [true, false] {
let what = if round { "fillet" } else { "chamfer" };
let made = if round { fillet(&body, &[arc_top()], r) } else { chamfer(&body, &[arc_top()], r) };
let made = made.unwrap_or_else(|e| panic!("{name} {what} r={r}: {e}"));
assert!(made.validity().is_empty(), "{name} {what} r={r}: {:?}", made.validity());
assert_eq!(made.face_count(), body.face_count() + 1, "{name} {what} r={r}: one blend face");
let removed = before - planned_volume(&made);
let expected = arc_top_removed(r, round);
assert!((removed - expected).abs() < 0.01 * expected + 0.01, "{name} {what} r={r}: removed {removed}, expected {expected}");
}
}
}
}
/// Mirrored parts take the edge the other way round and the tip at the other end of the chain.
#[test]
fn the_arc_top_edge_blends_the_same_in_a_mirror() {
let body = part();
let reference = planned_volume(&fillet(&body, &[arc_top()], 2.0).unwrap());
let mirror = |axis: usize| {
let mut m = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
m[axis][axis] = -1.0;
m
};
let at = arc_top();
for (axis, p) in [(0, Vec3::new(-at.x, at.y, at.z)), (1, Vec3::new(at.x, -at.y, at.z)), (2, Vec3::new(at.x, at.y, -at.z))] {
let mirrored = transform_matrix(&body, mirror(axis)).unwrap();
let v = planned_volume(&fillet(&mirrored, &[p], 2.0).unwrap_or_else(|e| panic!("mirror {axis}: {e}")));
assert!((v - reference).abs() < 1e-4 * reference, "mirror {axis}: volume {v}, expected {reference}");
}
}
/// Sizes from tiny to larger than the wall: blended, or refused, never a panic or a broken solid.
#[test]
fn arc_top_edge_sizes_never_panic() {
let body = part();
for r in [1e-5, 1e-3, 6.0, 10.0, 21.0, 40.0, 1e3] {
for round in [true, false] {
let made = if round { fillet(&body, &[arc_top()], r) } else { chamfer(&body, &[arc_top()], r) };
if let Ok(b) = made {
assert!(b.validity().is_empty(), "r={r}: {:?}", b.validity());
}
}
}
}
fn tf() -> Vec3 {
Vec3::new(20.0, -R_WALL, HEIGHT)
}
fn fl() -> Vec3 {
Vec3::new(0.0, -R_WALL, HEIGHT / 2.0)
}
fn fu() -> Vec3 {
Vec3::new(TIP_X - R_WALL, -R_WALL, HEIGHT / 2.0)
}
/// Volume two blends take off together: each alone, less the corner both would take.
fn removed_together(body: &Body, picks: &[Vec3], r: f64, overlap: f64) -> f64 {
let before = planned_volume(body);
picks.iter().map(|p| before - planned_volume(&fillet(body, &[*p], r).unwrap())).sum::<f64>() - overlap
}
/// The front's top edge and its left upright meet at a box corner; the edge's far end runs into
/// the curved wall. In either order, and picked together, the rounds end in each other where
/// they meet: the body is the one both would make (the two corner regions overlap by the
/// volume integrated over the box corner).
#[test]
fn fillet_the_top_front_edge_and_the_front_left_upright() {
let body = part();
let (before, r) = (planned_volume(&body), 2.0);
let want = removed_together(&body, &[tf(), fl()], r, 0.766_957);
let check = |name: &str, made: Result<Body>| {
let made = made.unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(made.validity().is_empty(), "{name}: {:?}", made.validity());
let removed = before - planned_volume(&made);
assert!((removed - want).abs() < 0.02, "{name}: removed {removed}, expected {want}");
made.face_count()
};
let first = fillet(&body, &[tf()], r).unwrap();
let second = fillet(&body, &[fl()], r).unwrap();
let faces = [
check("top front, then the upright", fillet(&first, &[fl()], r)),
check("upright, then the top front", fillet(&second, &[tf()], r)),
check("together", fillet(&body, &[tf(), fl()], r)),
];
assert!(faces.iter().all(|f| *f == faces[0]), "face counts {faces:?}");
}
/// The front's top edge and the front upright at the arc meet at the wall: the round of the one
/// ends in the other. Either way round and picked together, the body is the one both make: each
/// alone, less the corner both would take.
#[test]
fn fillet_the_top_front_edge_and_the_front_upright_at_the_arc() {
let body = part();
let (before, r) = (planned_volume(&body), 2.0);
let want = removed_together(&body, &[tf(), fu()], r, 0.699_134);
let check = |name: &str, made: Result<Body>| {
let made = made.unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(made.validity().is_empty(), "{name}: {:?}", made.validity());
let removed = before - planned_volume(&made);
assert!((removed - want).abs() < 0.02, "{name}: removed {removed}, expected {want}");
};
let first = fillet(&body, &[tf()], r).unwrap();
check("top front, then the upright", fillet(&first, &[fu()], r));
check("together", fillet(&body, &[tf(), fu()], r));
check("together, the other way", fillet(&body, &[fu(), tf()], r));
}
/// A blend of the edge nearest `p` (round or flat), size `r`.
fn blend_one_edge(body: &Body, p: Vec3, r: f64, round: bool) -> Result<Body> {
if round { fillet(body, &[p], r) } else { chamfer(body, &[p], r) }
}
/// The arc's top edge and the front's top edge meet at the wall, where the wall's edge with the
/// front stays sharp: the blend of each ends in the other. Either way round, picked together or
/// one after the other, the body is each alone less the corner the two take together (volumes
/// integrated over the swept corners).
#[test]
fn blend_the_arc_top_edge_and_the_top_front_edge() {
let body = part();
let before = planned_volume(&body);
for (name, round, overlap) in [("fillet", true, 0.759), ("chamfer", false, 2.69)] {
let alone = |p: Vec3| before - planned_volume(&blend_one_edge(&body, p, 2.0, round).unwrap());
let want = alone(tf()) + alone(arc_top()) - overlap;
let check = |how: &str, made: Result<Body>| {
let made = made.unwrap_or_else(|e| panic!("{name} {how}: {e}"));
assert!(made.validity().is_empty(), "{name} {how}: {:?}", made.validity());
let removed = before - planned_volume(&made);
assert!((removed - want).abs() < 0.06, "{name} {how}: removed {removed}, expected {want}");
};
let (a, t) = (blend_one_edge(&body, arc_top(), 2.0, round).unwrap(), blend_one_edge(&body, tf(), 2.0, round).unwrap());
check("arc, then the front", blend_one_edge(&a, tf(), 2.0, round));
check("front, then the arc", blend_one_edge(&t, arc_top(), 2.0, round));
let both = |picks: &[Vec3]| if round { fillet(&body, picks, 2.0) } else { chamfer(&body, picks, 2.0) };
check("together", both(&[tf(), arc_top()]));
check("together, the other way", both(&[arc_top(), tf()]));
}
}
/// The arc's top edge and the front upright at the arc meet at the same corner, with the front's
/// top edge sharp: again the body is each alone less the corner both take.
#[test]
fn fillet_the_arc_top_edge_and_the_front_upright_at_the_arc() {
let body = part();
let (before, r) = (planned_volume(&body), 2.0);
let alone = |p: Vec3| before - planned_volume(&fillet(&body, &[p], r).unwrap());
let want = alone(arc_top()) + alone(fu()) - 0.682_379;
let check = |how: &str, made: Result<Body>| {
let made = made.unwrap_or_else(|e| panic!("{how}: {e}"));
assert!(made.validity().is_empty(), "{how}: {:?}", made.validity());
let removed = before - planned_volume(&made);
assert!((removed - want).abs() < 0.06, "{how}: removed {removed}, expected {want}");
};
// (The upright first, then the arc as a feature of its own, is not supported yet: the arc's
// blend would run on over the upright's round, past the crease where the two meet.)
let a = fillet(&body, &[arc_top()], r).unwrap();
check("arc, then the upright", fillet(&a, &[fu()], r));
check("together", fillet(&body, &[arc_top(), fu()], r));
check("together, the other way", fillet(&body, &[fu(), arc_top()], r));
}
/// The three edges at the arc's front corner (the front's top edge, the arc's top edge and the
/// front upright at the arc) end in a sphere of the rounds' radius. The volume is the three
/// rounds, each cut where the ball that touches the three faces ends it, and the corner beyond
/// those cuts less the ball's part of it (integrated over the swept corners and the sphere's
/// wedge).
#[test]
fn fillet_the_three_edges_at_the_arcs_front_corner() {
let body = part();
let before = planned_volume(&body);
let want = 67.05;
for (name, picks) in [("as picked", [tf(), arc_top(), fu()]), ("another order", [fu(), tf(), arc_top()]), ("a third", [arc_top(), fu(), tf()])] {
let made = fillet(&body, &picks, 2.0).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(made.validity().is_empty(), "{name}: {:?}", made.validity());
// The body's six faces, the three rounds and the sphere's patch.
assert_eq!(made.face_count(), 10, "{name}");
let removed = before - planned_volume(&made);
assert!((removed - want).abs() < 0.12, "{name}: removed {removed}, expected {want}");
}
}
/// The construction does not depend on the faces being curved: at a box's corner it gives the
/// body the planar corner builder does (a sphere's octant where the three rounds meet).
#[test]
fn the_sphere_at_a_box_corner_is_the_planar_corner_builders() {
let b = box_solid(Vec3::ZERO, Vec3::new(30.0, 20.0, 10.0)).unwrap();
let picks = [Vec3::new(15.0, 0.0, 10.0), Vec3::new(0.0, 10.0, 10.0), Vec3::new(0.0, 0.0, 5.0)];
for r in [1.0, 2.5] {
let local = crate::blend_corner::fillet_radii(&b, &picks.iter().map(|p| (*p, r)).collect::<Vec<_>>()).unwrap();
let ball = crate::blend_vertex::fillet_ball_vertices(&b, &picks, r).unwrap_or_else(|e| panic!("r={r}: {e}"));
assert!(ball.validity().is_empty(), "r={r}: {:?}", ball.validity());
assert_eq!(ball.face_count(), local.face_count(), "r={r}");
let (a, c) = (planned_volume(&local), planned_volume(&ball));
assert!((a - c).abs() < 1e-3 * a, "r={r}: planar corner {a}, ball corner {c}");
}
}
/// Sizes at the ends of the range at a ball vertex: rounded or refused, never a panic.
#[test]
fn rounds_ending_in_a_sphere_of_any_size_never_panic() {
let body = part();
for r in [1e-5, 1e-3, 5.0, 9.0, 10.0, 21.0, 1e3] {
if let Ok(b) = fillet(&body, &[tf(), arc_top(), fu()], r) {
assert!(b.validity().is_empty(), "r={r}: {:?}", b.validity());
}
}
}
+3
View File
@@ -15,6 +15,7 @@
mod blend;
mod blend_corner;
mod blend_variable;
mod blend_vertex;
mod body;
mod boolean_diagnostics;
mod boolean_guard;
@@ -44,6 +45,8 @@ mod issue_616_tests;
mod issue_674_tests;
#[cfg(test)]
mod issue_679_tests;
#[cfg(test)]
mod issue_851_tests;
mod lip;
mod loopblend;
mod measure;
+27
View File
@@ -152,6 +152,33 @@ pub(crate) fn plan(solid: &Solid, tol: f64, turn: Option<size::Turn>) -> Result<
Ok((plan, stats))
}
/// Whether every curved face's boundary lies on the face's surface, within `accept`: a point
/// off the surface's own parameter range is off it. The planner needs this of every face; a
/// blend that runs on past the end of a neighbouring round's surface does not have it.
pub(crate) fn boundaries_on_surfaces(solid: &Solid, accept: f64) -> bool {
use mt::{BoundedCurve, ParametricCurve};
for face in solid.face_iter() {
let Some(eval) = surface::eval_surface(&face.surface()) else { continue };
if matches!(eval, EvalSurface::Plane { .. }) {
continue;
}
let dom = uv::domain(&eval);
let mut prev: Option<[f64; 2]> = None;
for e in face.edge_iter() {
let c = e.oriented_curve();
let (lo, hi) = c.range_tuple();
for k in 0..=32 {
let p = crate::body::from_p3(c.subs(lo + (hi - lo) * f64::from(k) / 32.0));
match uv::project(&eval, &dom, p, prev, accept) {
Some(uv) => prev = Some(uv),
None => return false,
}
}
}
}
true
}
/// Bounded work for the facet angle, as the reference mesher bounds it: a face whose boundary
/// would get more than `MAX_ANGLE_BOUNDARY` segments has its curved edges sampled again at twice
/// the angle, as often as it takes, down to the tolerance alone (a plate with a thousand small