fix(kernel): fillet mixed arc and line loop corners (#604)
Thanks @onenewcode.
This commit is contained in:
1 parent
78c6364b02
commit
87d4eb55e5
4 files changed
+460
-10
No files matched your search
+3
-1
@@ -330,7 +330,9 @@ completed drawings publish atomically. The browser still exports synchronously.
|
|||||||
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
|
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
|
||||||
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||||
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
|
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
|
||||||
|
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
|
||||||
|
checked within 0.1% of analytic offset slices), runs of
|
||||||
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
||||||
with the edge between them too, the corner becomes a sphere octant),
|
with the edge between them too, the corner becomes a sphere octant),
|
||||||
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
|
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
|
||||||
|
|||||||
@@ -1773,7 +1773,20 @@ fn loop_split(cur: &Body, pts: &[Vec3]) -> Result<Option<(Vec<Vec<Vec3>>, Vec<Ve
|
|||||||
let Some(e) = near.iter().find(|e| planar_edge.contains(&e.id())).or(near.first()) else { return Ok(None) };
|
let Some(e) = near.iter().find(|e| planar_edge.contains(&e.id())).or(near.first()) else { return Ok(None) };
|
||||||
per.push((*p, e.id()));
|
per.push((*p, e.id()));
|
||||||
}
|
}
|
||||||
let ids: Vec<mt::EdgeID> = per.iter().map(|x| x.1).collect();
|
// Fillets follow tangent chains in the original body. Include those edges before
|
||||||
|
// recognising a whole loop, too: two picks across a sharp arc/line joint can cover
|
||||||
|
// the entire rim once each pick's smooth chain is included.
|
||||||
|
let ids = guard("loop tangent chains", || {
|
||||||
|
let mut ids: std::collections::HashSet<mt::EdgeID> = per.iter().map(|x| x.1).collect();
|
||||||
|
for (_, id) in &per {
|
||||||
|
if let Some(edge) = all.iter().find(|e| e.id() == *id)
|
||||||
|
&& let Some(chain) = smooth_chain(&solid, edge)
|
||||||
|
{
|
||||||
|
ids.extend(chain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ids)
|
||||||
|
})?;
|
||||||
let mut covered: Vec<mt::EdgeID> = Vec::new();
|
let mut covered: Vec<mt::EdgeID> = Vec::new();
|
||||||
let mut groups = Vec::new();
|
let mut groups = Vec::new();
|
||||||
for f in solid.face_iter() {
|
for f in solid.face_iter() {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
//! Blending a whole boundary loop of a planar face whose neighbours are walls standing
|
//! Blending a whole boundary loop of a planar face whose neighbours are walls standing
|
||||||
//! perpendicular to it (planes, and cylinders with axes along the face normal): a pocket floor,
|
//! perpendicular to it (planes, and cylinders with axes along the face normal): a pocket floor,
|
||||||
//! the top outline of an extruded plate. The loop must be smooth (lines and arcs meeting
|
//! the top outline of an extruded plate. Lines and arcs can meet tangentially or at mitred sharp
|
||||||
//! tangentially). The face shrinks by the radius, the walls are trimmed by it, and each loop edge
|
//! corners. The face shrinks by the radius, the walls are trimmed by it, and each loop edge
|
||||||
//! gets a blend face: a cylinder (or plane, for a chamfer) along a line, a torus (or cone) along
|
//! gets a blend face: a cylinder (or plane, for a chamfer) along a line, a torus (or cone) along
|
||||||
//! an arc, all meeting along the profile arcs at the joints. The result is exact.
|
//! an arc, all meeting along the profile curves at the joints. Sharp joints at perpendicular
|
||||||
|
//! curved walls follow the intersections of the exact offsets, fitted within tolerance.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -56,6 +57,74 @@ impl Seg {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Intersection of two inward offsets nearest their shared corner, in the face plane.
|
||||||
|
fn offset_joint(prev: &Seg, cur: &Seg, n: Vec3, d: f64) -> Option<Vec3> {
|
||||||
|
let p = cur.start();
|
||||||
|
let line_circle = |line: &Seg, arc: &Seg| {
|
||||||
|
let Seg::Arc { a, c, .. } = *arc else { return None };
|
||||||
|
let tangent = line.tangent(p)?;
|
||||||
|
let origin = p + n.cross(tangent) * d;
|
||||||
|
let radius = a.dist(c) + n.cross(arc.tangent(p)?).dot((p - c).normalized()?) * d;
|
||||||
|
let foot = origin + tangent * (c - origin).dot(tangent);
|
||||||
|
let height2 = radius * radius - foot.dist(c).powi(2);
|
||||||
|
if !(radius > 0.0 && height2 >= 0.0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (a, b) = (foot + tangent * height2.sqrt(), foot - tangent * height2.sqrt());
|
||||||
|
Some(if a.dist(p) < b.dist(p) { a } else { b })
|
||||||
|
};
|
||||||
|
match (prev, cur) {
|
||||||
|
(Seg::Line { .. }, Seg::Arc { .. }) => line_circle(prev, cur),
|
||||||
|
(Seg::Arc { .. }, Seg::Line { .. }) => line_circle(cur, prev),
|
||||||
|
(Seg::Arc { a: a0, c: c0, .. }, Seg::Arc { a: a1, c: c1, .. }) => {
|
||||||
|
let delta = *c1 - *c0;
|
||||||
|
let distance = delta.len();
|
||||||
|
let axis = delta.normalized()?;
|
||||||
|
let r0 = a0.dist(*c0) + n.cross(prev.tangent(p)?).dot((p - *c0).normalized()?) * d;
|
||||||
|
let r1 = a1.dist(*c1) + n.cross(cur.tangent(p)?).dot((p - *c1).normalized()?) * d;
|
||||||
|
let along = (r0 * r0 - r1 * r1 + distance * distance) / (2.0 * distance);
|
||||||
|
let height2 = r0 * r0 - along * along;
|
||||||
|
if !(r0 > 0.0 && r1 > 0.0 && height2 >= 0.0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let foot = *c0 + axis * along;
|
||||||
|
let across = n.cross(axis) * height2.sqrt();
|
||||||
|
let (a, b) = (foot + across, foot - across);
|
||||||
|
Some(if a.dist(p) < b.dist(p) { a } else { b })
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A curved-wall joint is generally not planar. Fit its exact offset slices rather than
|
||||||
|
/// projecting a circular section onto a tangent mitre and revolving that distorted section.
|
||||||
|
fn offset_profile(prev: &Seg, cur: &Seg, n: Vec3, side: f64, r: f64, round: bool, tol: f64) -> Result<mt::Curve> {
|
||||||
|
use mt::ParametricCurve;
|
||||||
|
let point = |u: f64| {
|
||||||
|
let (d, h) = if round {
|
||||||
|
let (sin, cos) = (u * std::f64::consts::FRAC_PI_2).sin_cos();
|
||||||
|
(r * (1.0 - sin), r * (1.0 - cos))
|
||||||
|
} else {
|
||||||
|
(r * (1.0 - u), r * u)
|
||||||
|
};
|
||||||
|
offset_joint(prev, cur, n, d).map(|p| p + n * (side * h))
|
||||||
|
};
|
||||||
|
for count in [8, 16, 32, 64, 128] {
|
||||||
|
let pts = (0..=count)
|
||||||
|
.map(|i| point(f64::from(i) / f64::from(count)))
|
||||||
|
.collect::<Option<Vec<_>>>()
|
||||||
|
.ok_or_else(|| too_large("the blend's offset walls do not meet"))?;
|
||||||
|
let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| unsupported("curved corner profile"))?;
|
||||||
|
if (0..count).all(|i| {
|
||||||
|
let u = (f64::from(i) + 0.5) / f64::from(count);
|
||||||
|
point(u).is_some_and(|p| p.dist(from_p3(curve.subs(u))) <= tol)
|
||||||
|
}) {
|
||||||
|
return Ok(mt::Curve::BSplineCurve(curve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(unsupported("could not fit the curved corner within tolerance"))
|
||||||
|
}
|
||||||
|
|
||||||
/// The exact shape of an oriented loop edge (a line, or an arc found from samples).
|
/// The exact shape of an oriented loop edge (a line, or an arc found from samples).
|
||||||
fn seg_of(e: &mt::Edge, tol: f64) -> Option<Seg> {
|
fn seg_of(e: &mt::Edge, tol: f64) -> Option<Seg> {
|
||||||
use mt::{BoundedCurve, ParametricCurve};
|
use mt::{BoundedCurve, ParametricCurve};
|
||||||
@@ -158,10 +227,19 @@ fn face_normal(f: &mt::Face, p: Vec3) -> Option<Vec3> {
|
|||||||
Vec3::new(n.x, n.y, n.z).normalized()
|
Vec3::new(n.x, n.y, n.z).normalized()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blend the loop of `edges` (by id) if it is a whole smooth boundary loop of a planar face with
|
/// Blend `edges` (by id) if they form a boundary loop or neighbouring run of a planar face with
|
||||||
/// perpendicular walls, with what it built from `solid` (each blend face in place of its loop
|
/// perpendicular walls, with what it built from `solid` (each blend face in place of its loop
|
||||||
/// edge). `None` when the edges are not such a loop.
|
/// edge). `None` when the edges are not such a loop.
|
||||||
pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option<Result<(Solid, Rebuild)>> {
|
pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option<Result<(Solid, Rebuild)>> {
|
||||||
|
match crate::guard("loop blend", || Ok(loop_blend_inner(solid, edge_ids, size, r, round))) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => Some(Err(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loop_blend_inner(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option<Result<(Solid, Rebuild)>> {
|
||||||
|
#[cfg(test)]
|
||||||
|
assert!(crate::guarded(), "loop selection must run inside the kernel guard");
|
||||||
let tol = (size * 1e-6).max(1e-7);
|
let tol = (size * 1e-6).max(1e-7);
|
||||||
for (si, shell) in solid.boundaries().iter().enumerate() {
|
for (si, shell) in solid.boundaries().iter().enumerate() {
|
||||||
let faces: Vec<&mt::Face> = shell.face_iter().collect();
|
let faces: Vec<&mt::Face> = shell.face_iter().collect();
|
||||||
@@ -204,6 +282,8 @@ fn build(
|
|||||||
round: bool,
|
round: bool,
|
||||||
run: Option<(usize, usize)>,
|
run: Option<(usize, usize)>,
|
||||||
) -> Result<(Solid, Rebuild)> {
|
) -> Result<(Solid, Rebuild)> {
|
||||||
|
#[cfg(test)]
|
||||||
|
assert!(crate::guarded(), "loop construction must run inside the kernel guard");
|
||||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||||
let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?;
|
let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?;
|
||||||
let all_edges: Vec<mt::Edge> = wire.edge_iter().cloned().collect();
|
let all_edges: Vec<mt::Edge> = wire.edge_iter().cloned().collect();
|
||||||
@@ -226,7 +306,8 @@ fn build(
|
|||||||
// Joints: k round a whole loop; k + 1 along a run (its two ends included).
|
// Joints: k round a whole loop; k + 1 along a run (its two ends included).
|
||||||
let nj = if open { k + 1 } else { k };
|
let nj = if open { k + 1 } else { k };
|
||||||
let nxt = |i: usize| if open { i + 1 } else { (i + 1) % k };
|
let nxt = |i: usize| if open { i + 1 } else { (i + 1) % k };
|
||||||
// Joints: smooth, or sharp between two lines (the blends then meet in a mitre).
|
// Joints: smooth, or sharp where the walls meet in one straight seam (the blends
|
||||||
|
// then meet in a mitre). A loop can therefore mix lines and arcs at its corners.
|
||||||
let mut mitre = vec![false; nj];
|
let mut mitre = vec![false; nj];
|
||||||
for i in 0..k {
|
for i in 0..k {
|
||||||
if open && i == 0 {
|
if open && i == 0 {
|
||||||
@@ -236,7 +317,7 @@ fn build(
|
|||||||
let p = cur.start();
|
let p = cur.start();
|
||||||
let (Some(ta), Some(tb)) = (prev.tangent(p), cur.tangent(p)) else { return Err(unsupported("degenerate loop edge")) };
|
let (Some(ta), Some(tb)) = (prev.tangent(p), cur.tangent(p)) else { return Err(unsupported("degenerate loop edge")) };
|
||||||
if ta.dot(tb) < 1.0 - 1e-6 {
|
if ta.dot(tb) < 1.0 - 1e-6 {
|
||||||
if !(matches!(prev, Seg::Line { .. }) && matches!(cur, Seg::Line { .. })) || ta.dot(tb) < -1.0 + 1e-3 {
|
if ta.dot(tb) < -1.0 + 1e-3 {
|
||||||
return Err(unsupported("blending a loop with sharp corners next to arcs"));
|
return Err(unsupported("blending a loop with sharp corners next to arcs"));
|
||||||
}
|
}
|
||||||
if let Some(x) = mitre.get_mut(i) {
|
if let Some(x) = mitre.get_mut(i) {
|
||||||
@@ -340,6 +421,7 @@ fn build(
|
|||||||
let mut pa = Vec::with_capacity(nj);
|
let mut pa = Vec::with_capacity(nj);
|
||||||
let mut pb = Vec::with_capacity(nj);
|
let mut pb = Vec::with_capacity(nj);
|
||||||
let mut centres = Vec::with_capacity(nj);
|
let mut centres = Vec::with_capacity(nj);
|
||||||
|
let mut curved_joint = vec![false; nj];
|
||||||
// Joint i: the start of piece i (the end of the last piece for a run's far end).
|
// Joint i: the start of piece i (the end of the last piece for a run's far end).
|
||||||
let joint_point = |i: usize| if i < k { segs.get(i).map(|s| s.start()) } else { segs.last().map(|s| s.end()) };
|
let joint_point = |i: usize| if i < k { segs.get(i).map(|s| s.start()) } else { segs.last().map(|s| s.end()) };
|
||||||
for i in 0..nj {
|
for i in 0..nj {
|
||||||
@@ -360,7 +442,18 @@ fn build(
|
|||||||
};
|
};
|
||||||
let x = d0.cross(d1);
|
let x = d0.cross(d1);
|
||||||
let lam = ((m * sb - m0 * sb0).cross(d1)).dot(x) / x.len2();
|
let lam = ((m * sb - m0 * sb0).cross(d1)).dot(x) / x.len2();
|
||||||
pa.push(p + m0 * sb0 + d0 * lam);
|
let exact = (matches!(segs.get(ip), Some(Seg::Arc { .. })) || matches!(segs.get(i), Some(Seg::Arc { .. })))
|
||||||
|
&& t.dot(n * side) > 1.0 - 1e-9
|
||||||
|
&& t0.dot(n * side) > 1.0 - 1e-9;
|
||||||
|
if exact {
|
||||||
|
let (Some(prev), Some(cur)) = (segs.get(ip), segs.get(i)) else { return Err(unsupported("corner segments")) };
|
||||||
|
pa.push(offset_joint(prev, cur, n, r).ok_or_else(|| too_large("the blend's offset walls do not meet"))?);
|
||||||
|
if let Some(slot) = curved_joint.get_mut(i) {
|
||||||
|
*slot = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pa.push(p + m0 * sb0 + d0 * lam);
|
||||||
|
}
|
||||||
let e = seam_dir(i, p).ok_or_else(|| unsupported("a sharp corner without one straight seam"))?;
|
let e = seam_dir(i, p).ok_or_else(|| unsupported("a sharp corner without one straight seam"))?;
|
||||||
let (h0, h1) = (e.dot(t0), e.dot(t));
|
let (h0, h1) = (e.dot(t0), e.dot(t));
|
||||||
if h0 < 1e-6 || h1 < 1e-6 || (sb0 / h0 - sb / h1).abs() > tol * 10.0 {
|
if h0 < 1e-6 || h1 < 1e-6 || (sb0 / h0 - sb / h1).abs() > tol * 10.0 {
|
||||||
@@ -453,6 +546,12 @@ fn build(
|
|||||||
let mut profiles: Vec<mt::Edge> = Vec::with_capacity(nj);
|
let mut profiles: Vec<mt::Edge> = Vec::with_capacity(nj);
|
||||||
for i in 0..nj {
|
for i in 0..nj {
|
||||||
let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { return Err(KernelError::Failed("joint".into())) };
|
let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { return Err(KernelError::Failed("joint".into())) };
|
||||||
|
if curved_joint.get(i).copied().unwrap_or(false) {
|
||||||
|
let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { return Err(unsupported("corner segments")) };
|
||||||
|
let curve = offset_profile(prev, cur, n, side, r, round, (r * 1e-7).max(1e-9))?;
|
||||||
|
profiles.push(mt::Edge::try_new(a, b, curve).map_err(|e| KernelError::Failed(format!("curved corner: {e}")))?);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if !round {
|
if !round {
|
||||||
profiles.push(builder::line(a, b));
|
profiles.push(builder::line(a, b));
|
||||||
continue;
|
continue;
|
||||||
@@ -471,6 +570,8 @@ fn build(
|
|||||||
let cc = centre(i) + d1 * lam;
|
let cc = centre(i) + d1 * lam;
|
||||||
let q = p + t * sb + d1 * lam;
|
let q = p + t * sb + d1 * lam;
|
||||||
let mid = cc + ((pa_i - cc) + (q - cc)).normalized().unwrap_or(t) * r;
|
let mid = cc + ((pa_i - cc) + (q - cc)).normalized().unwrap_or(t) * r;
|
||||||
|
// q projects onto the wall rail at pb_i; fitting through pb_i before projection
|
||||||
|
// would distort the circular cross-section at mixed line/arc corners.
|
||||||
let arc = builder::circle_arc(&builder::vertex(p3(pa_i)), &builder::vertex(p3(q)), p3(mid));
|
let arc = builder::circle_arc(&builder::vertex(p3(pa_i)), &builder::vertex(p3(q)), p3(mid));
|
||||||
let nb = (pa_i - p).cross(pb_i - p).normalized().ok_or_else(|| unsupported("mitre plane"))?;
|
let nb = (pa_i - p).cross(pb_i - p).normalized().ok_or_else(|| unsupported("mitre plane"))?;
|
||||||
let den = d1.dot(nb);
|
let den = d1.dot(nb);
|
||||||
@@ -692,7 +793,19 @@ fn build(
|
|||||||
let meridian = builder::circle_arc(&top, &bottom, eq);
|
let meridian = builder::circle_arc(&top, &bottom, eq);
|
||||||
builder::rsweep(&meridian, p3(c), v3(w), mt::Rad(std::f64::consts::TAU))
|
builder::rsweep(&meridian, p3(c), v3(w), mt::Rad(std::f64::consts::TAU))
|
||||||
}
|
}
|
||||||
Seg::Arc { c, axis, angle, .. } => builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle)),
|
Seg::Arc { c, axis, angle, .. }
|
||||||
|
if !curved_joint.get(i).copied().unwrap_or(false) && !curved_joint.get(nxt(i)).copied().unwrap_or(false) =>
|
||||||
|
{
|
||||||
|
builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle))
|
||||||
|
}
|
||||||
|
Seg::Arc { a, c, axis, angle, .. } => {
|
||||||
|
// Sweep a radial section, not the shared corner curve: the latter can be
|
||||||
|
// nonplanar and would change the round's radius all along this edge.
|
||||||
|
let (_, t, sb, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?;
|
||||||
|
let (fa, wa) = (builder::vertex(p3(a + into(s, a) * sb)), builder::vertex(p3(a + t * sb)));
|
||||||
|
let base = if round { builder::circle_arc(&fa, &wa, p3(profile_mid(i))) } else { builder::line(&fa, &wa) };
|
||||||
|
builder::rsweep(&base, p3(c), v3(axis), mt::Rad(angle))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let mut surf = swept.face_iter().next().map(|x| x.oriented_surface()).ok_or_else(|| KernelError::Failed("blend surface".into()))?;
|
let mut surf = swept.face_iter().next().map(|x| x.oriented_surface()).ok_or_else(|| KernelError::Failed("blend surface".into()))?;
|
||||||
// Outward: toward the profile centre on a concave loop, away from it on a convex one.
|
// Outward: toward the profile centre on a concave loop, away from it on a convex one.
|
||||||
|
|||||||
@@ -1939,6 +1939,328 @@ fn fillet_an_open_curved_edge() {
|
|||||||
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
|
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mixed_arc_and_line_prism() -> Body {
|
||||||
|
use solidcraft_geom::Seg2;
|
||||||
|
let d = Loop2 {
|
||||||
|
segs: vec![
|
||||||
|
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.0, sweep: PI },
|
||||||
|
Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
extrude(&Plane::XY, &[Region2 { outer: d, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fillet_smooth_arc_and_line_loop_keeps_exact_removal() {
|
||||||
|
use solidcraft_geom::Seg2;
|
||||||
|
// A stadium: four tangent joints, none of which should use a sharp-corner mitre.
|
||||||
|
let outline = Loop2 {
|
||||||
|
segs: vec![
|
||||||
|
Seg2::Line { a: Vec2::new(-10.0, -10.0), b: Vec2::new(10.0, -10.0) },
|
||||||
|
Seg2::Arc { center: Vec2::new(10.0, 0.0), radius: 10.0, start: -PI / 2.0, sweep: PI },
|
||||||
|
Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(-10.0, 10.0) },
|
||||||
|
Seg2::Arc { center: Vec2::new(-10.0, 0.0), radius: 10.0, start: PI / 2.0, sweep: PI },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap();
|
||||||
|
let blended =
|
||||||
|
fillet(&body, &[Vec3::new(0.0, -10.0, 15.0), Vec3::new(20.0, 0.0, 15.0), Vec3::new(0.0, 10.0, 15.0), Vec3::new(-20.0, 0.0, 15.0)], 0.5)
|
||||||
|
.unwrap();
|
||||||
|
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
|
||||||
|
let r = 0.5;
|
||||||
|
let area = r * r * (1.0 - PI / 4.0);
|
||||||
|
let centroid = r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI));
|
||||||
|
let want = area * (40.0 + 2.0 * PI * (10.0 - centroid));
|
||||||
|
let fine = |b: &Body| b.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||||
|
let removed = fine(&body) - fine(&blended);
|
||||||
|
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fillet_sharp_arc_and_arc_loop() {
|
||||||
|
use solidcraft_geom::Seg2;
|
||||||
|
// The intersection of two radius-10 disks whose centres are 10 mm apart.
|
||||||
|
let outline = Loop2 {
|
||||||
|
segs: vec![
|
||||||
|
Seg2::Arc { center: Vec2::new(-5.0, 0.0), radius: 10.0, start: -PI / 3.0, sweep: 2.0 * PI / 3.0 },
|
||||||
|
Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 10.0, start: 2.0 * PI / 3.0, sweep: 2.0 * PI / 3.0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap();
|
||||||
|
let blended = fillet(&body, &[Vec3::new(5.0, 0.0, 15.0), Vec3::new(-5.0, 0.0, 15.0)], 0.5).unwrap();
|
||||||
|
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
|
||||||
|
let mesh = blended.tessellate_with(1e-4, true).unwrap();
|
||||||
|
for p in &mesh.positions {
|
||||||
|
assert!((p.x - 5.0).hypot(p.y) <= 10.0 + 1e-3 && (p.x + 5.0).hypot(p.y) <= 10.0 + 1e-3, "outside the lens: {p:?}");
|
||||||
|
}
|
||||||
|
let area = |rho: f64| 2.0 * rho * rho * (5.0 / rho).acos() - 10.0 * (rho * rho - 25.0).sqrt();
|
||||||
|
let dt = (PI / 2.0) / 8192.0;
|
||||||
|
let want: f64 = (0..8192)
|
||||||
|
.map(|i| {
|
||||||
|
let theta = (f64::from(i) + 0.5) * dt;
|
||||||
|
(area(10.0) - area(10.0 - 0.5 * (1.0 - theta.cos()))) * 0.5 * theta.cos() * dt
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
let removed = body.tessellate_with(1e-4, true).unwrap().measure().volume - mesh.measure().volume;
|
||||||
|
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
|
||||||
|
use solidcraft_geom::Seg2;
|
||||||
|
// Reconstruct the outer outline from its seven public pick coordinates:
|
||||||
|
// R25 plate, x=+/-4 tab sides, R2 corners and a 4 mm straight top.
|
||||||
|
// The hole's dimensions come from the public reproduction, not an original design file.
|
||||||
|
let theta = (4.0_f64 / 25.0).acos();
|
||||||
|
let y = (625.0_f64 - 16.0).sqrt();
|
||||||
|
let corner_y = 280.0 / 9.0;
|
||||||
|
let hole_y = 262.0 / 9.0;
|
||||||
|
let outline = Loop2 {
|
||||||
|
segs: vec![
|
||||||
|
Seg2::Arc { center: Vec2::ZERO, radius: 25.0, start: PI - theta, sweep: PI + 2.0 * theta },
|
||||||
|
Seg2::Line { a: Vec2::new(4.0, y), b: Vec2::new(4.0, corner_y) },
|
||||||
|
Seg2::Arc { center: Vec2::new(2.0, corner_y), radius: 2.0, start: 0.0, sweep: PI / 2.0 },
|
||||||
|
Seg2::Line { a: Vec2::new(2.0, corner_y + 2.0), b: Vec2::new(-2.0, corner_y + 2.0) },
|
||||||
|
Seg2::Arc { center: Vec2::new(-2.0, corner_y), radius: 2.0, start: PI / 2.0, sweep: PI / 2.0 },
|
||||||
|
Seg2::Line { a: Vec2::new(-4.0, corner_y), b: Vec2::new(-4.0, y) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let body =
|
||||||
|
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![Loop2::circle(Vec2::new(0.0, hole_y), 1.5)] }], 0.0, 2.0).unwrap().pop().unwrap();
|
||||||
|
let inner = fillet(&body, &[Vec3::new(1.5, hole_y, 2.0)], 0.5).unwrap();
|
||||||
|
let edges = [
|
||||||
|
Vec3::new(-24.91935125502191, -2.0064727829797544, 2.0),
|
||||||
|
Vec3::new(24.919351255429795, -2.006472777913911, 2.0),
|
||||||
|
Vec3::new(3.999999997492012, 27.894518234675537, 2.0),
|
||||||
|
Vec3::new(3.4142135598750105, 32.525324672801695, 2.0),
|
||||||
|
Vec3::new(-2.480541994032137e-9, 33.11111111046627, 2.0),
|
||||||
|
Vec3::new(-3.4142135648720653, 32.52532467287703, 2.0),
|
||||||
|
Vec3::new(-4.0000000025087745, 27.89451823429679, 2.0),
|
||||||
|
];
|
||||||
|
// All 127 nonempty subsets: the whole rim, each individual edge, and every
|
||||||
|
// contiguous or disjoint partial selection of the issue's seven picks.
|
||||||
|
for mask in 1_u32..(1 << edges.len()) {
|
||||||
|
let picks: Vec<_> = edges.iter().enumerate().filter(|(i, _)| mask & (1 << i) != 0).map(|(_, p)| *p).collect();
|
||||||
|
let blended = fillet(&inner, &picks, 0.5).unwrap_or_else(|e| panic!("{picks:?}: {e}"));
|
||||||
|
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
|
||||||
|
let mesh = blended.display_mesh(0.01).unwrap();
|
||||||
|
for p in &mesh.positions {
|
||||||
|
let inside = p.x.hypot(p.y) <= 25.001
|
||||||
|
|| (p.x.abs() <= 4.001 && p.y >= y - 0.001 && p.y <= corner_y)
|
||||||
|
|| (p.x.abs() <= 2.001 && p.y >= corner_y && p.y <= corner_y + 2.001)
|
||||||
|
|| (p.x - 2.0).hypot(p.y - corner_y) <= 2.001
|
||||||
|
|| (p.x + 2.0).hypot(p.y - corner_y) <= 2.001;
|
||||||
|
assert!(inside && p.z >= -0.001 && p.z <= 2.001, "outside the plate: {p:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn partial_mixed_run_prism() -> Body {
|
||||||
|
use solidcraft_geom::Seg2;
|
||||||
|
let outline = Loop2 {
|
||||||
|
segs: vec![
|
||||||
|
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(40.0, 0.0) },
|
||||||
|
Seg2::Line { a: Vec2::new(40.0, 0.0), b: Vec2::new(40.0, 20.0) },
|
||||||
|
Seg2::Line { a: Vec2::new(40.0, 20.0), b: Vec2::new(30.0, 20.0) },
|
||||||
|
Seg2::Arc { center: Vec2::new(20.0, 20.0), radius: 10.0, start: 0.0, sweep: PI },
|
||||||
|
Seg2::Line { a: Vec2::new(10.0, 20.0), b: Vec2::new(0.0, 20.0) },
|
||||||
|
Seg2::Line { a: Vec2::new(0.0, 20.0), b: Vec2::new(0.0, 0.0) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_loop_blend_guards_selection_and_construction() {
|
||||||
|
let body = mixed_arc_and_line_prism();
|
||||||
|
let solid = body.deep_copy();
|
||||||
|
let top = solid
|
||||||
|
.boundaries()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|shell| shell.face_iter())
|
||||||
|
.find(|face| face.edge_iter().all(|edge| (edge.front().point().z - 15.0).abs() < 1e-9 && (edge.back().point().z - 15.0).abs() < 1e-9))
|
||||||
|
.unwrap();
|
||||||
|
let edges: Vec<_> = top.edge_iter().map(|edge| edge.id()).collect();
|
||||||
|
// The internal test-only assertions check the active guard during both selection and build.
|
||||||
|
assert!(!crate::guarded());
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.5, true)));
|
||||||
|
assert!(matches!(result, Ok(Some(Ok(_)))), "{result:?}");
|
||||||
|
assert!(!crate::guarded());
|
||||||
|
// A degenerate profile still returns a clean error and restores the guard depth.
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.0, true)));
|
||||||
|
assert!(matches!(result, Ok(Some(Err(_)))), "{result:?}");
|
||||||
|
assert!(!crate::guarded());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fillet_mixed_arc_and_line_perimeter_edges() {
|
||||||
|
let body = mixed_arc_and_line_prism();
|
||||||
|
let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], 0.5).unwrap();
|
||||||
|
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
|
||||||
|
let display_tol = (blended.size() * 1e-3).max(1e-3);
|
||||||
|
for mesh in [
|
||||||
|
blended.tessellate_with(1e-4, true).unwrap(),
|
||||||
|
blended.display_mesh_angular(display_tol, crate::DISPLAY_ANGLE).unwrap(),
|
||||||
|
blended.reference_display_mesh_angular(display_tol, crate::DISPLAY_ANGLE).unwrap(),
|
||||||
|
] {
|
||||||
|
let bounds = mesh.bounds();
|
||||||
|
let tol = 1e-3;
|
||||||
|
assert!(
|
||||||
|
bounds.min.x >= -10.0 - tol
|
||||||
|
&& bounds.max.x <= 10.0 + tol
|
||||||
|
&& bounds.min.y >= -tol
|
||||||
|
&& bounds.max.y <= 10.0 + tol
|
||||||
|
&& bounds.min.z >= -tol
|
||||||
|
&& bounds.max.z <= 15.0 + tol,
|
||||||
|
"{bounds:?}"
|
||||||
|
);
|
||||||
|
for p in &mesh.positions {
|
||||||
|
assert!(p.x.hypot(p.y) <= 10.0 + tol, "outside the source semicircle: {p:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integrate exact inward-offset D slices, including the arc/line corner overlaps.
|
||||||
|
/// At height H-r+r*sin(theta), the inset is d=r*(1-cos(theta)); the remaining
|
||||||
|
/// slice is the circular segment of radius R-d above y=d. No kernel geometry is used.
|
||||||
|
fn d_fillet_removed_reference(r: f64, steps: u32) -> f64 {
|
||||||
|
let dt = (PI / 2.0) / f64::from(steps);
|
||||||
|
(0..steps)
|
||||||
|
.map(|i| {
|
||||||
|
let theta = (f64::from(i) + 0.5) * dt;
|
||||||
|
let d = r * (1.0 - theta.cos());
|
||||||
|
let rho = 10.0 - d;
|
||||||
|
let remaining = rho * rho * (d / rho).acos() - d * (rho * rho - d * d).sqrt();
|
||||||
|
(PI * 50.0 - remaining) * r * theta.cos() * dt
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fillet_mixed_arc_and_line_removed_volume_converges() {
|
||||||
|
let body = mixed_arc_and_line_prism();
|
||||||
|
let source_exact = PI * 100.0 * 15.0 / 2.0;
|
||||||
|
let tolerances = [1e-4, 2.5e-5];
|
||||||
|
let source_volumes = tolerances.map(|tol| body.tessellate_with(tol, true).unwrap().measure().volume);
|
||||||
|
for r in [0.5, 0.25] {
|
||||||
|
let want = d_fillet_removed_reference(r, 8192);
|
||||||
|
assert!((want - d_fillet_removed_reference(r, 4096)).abs() < 1e-7, "reference quadrature must converge");
|
||||||
|
if r == 0.5 {
|
||||||
|
assert!((want - 2.715578447526581).abs() < 1e-7, "{want}");
|
||||||
|
}
|
||||||
|
let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], r).unwrap();
|
||||||
|
let mut previous = None;
|
||||||
|
for (tol, source) in tolerances.into_iter().zip(source_volumes) {
|
||||||
|
let volume = blended.tessellate_with(tol, true).unwrap().measure().volume;
|
||||||
|
// Equal chord tolerances reduce (not necessarily cancel) the error from the
|
||||||
|
// unchanged walls. The convergence and exact-source checks below guard this.
|
||||||
|
let removed = source - volume;
|
||||||
|
assert!(rel(removed, want) < 1e-3, "r={r}, tol={tol}: removed {removed}, expected {want}");
|
||||||
|
if let Some(coarser) = previous {
|
||||||
|
assert!(rel(removed, coarser) < 1e-3, "r={r}: removal has not converged: {coarser} -> {removed}");
|
||||||
|
let exact_removed = source_exact - volume;
|
||||||
|
// The source's inscribed semicylinder loses at most pi*R*H*tol.
|
||||||
|
// Do not charge its chord error to the much smaller fillet volume.
|
||||||
|
let source_error = (source_exact - source).abs();
|
||||||
|
assert!(source_error < PI * 10.0 * 15.0 * tol, "source chord error {source_error}");
|
||||||
|
assert!((exact_removed - want).abs() < source_error + want * 1e-3, "r={r}: exact-source removal {exact_removed}, expected {want}");
|
||||||
|
}
|
||||||
|
previous = Some(removed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loop_chamfer_mixed_arc_and_line_uses_offset_intersections() {
|
||||||
|
let body = mixed_arc_and_line_prism();
|
||||||
|
// Exercise the shared loop builder; public chamfer prefers boolean cutters first.
|
||||||
|
let solid = body.deep_copy();
|
||||||
|
let top = solid
|
||||||
|
.face_iter()
|
||||||
|
.find(|face| face.edge_iter().all(|edge| (edge.front().point().z - 15.0).abs() < 1e-9 && (edge.back().point().z - 15.0).abs() < 1e-9))
|
||||||
|
.unwrap();
|
||||||
|
let edges: Vec<_> = top.edge_iter().map(|edge| edge.id()).collect();
|
||||||
|
let (result, history) = crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.5, false).unwrap().unwrap();
|
||||||
|
let blended = crate::record::rebuilt(result, &[&solid], &history).unwrap();
|
||||||
|
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
|
||||||
|
// A chamfer has a linear inset through its height instead of a circular section.
|
||||||
|
let step = 0.5 / 8192.0;
|
||||||
|
let want: f64 = (0..8192)
|
||||||
|
.map(|i| {
|
||||||
|
let d = (f64::from(i) + 0.5) * step;
|
||||||
|
let rho = 10.0 - d;
|
||||||
|
let remaining = rho * rho * (d / rho).acos() - d * (rho * rho - d * d).sqrt();
|
||||||
|
(PI * 50.0 - remaining) * step
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
// The cone's triangulation needs this chord tolerance for the small volume difference.
|
||||||
|
let fine = |b: &Body| b.tessellate_with(2.5e-5, true).unwrap().measure().volume;
|
||||||
|
let removed = fine(&body) - fine(&blended);
|
||||||
|
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fillet_mixed_arc_and_line_joint_preserves_both_section_radii() {
|
||||||
|
use truck_modeling::{BoundedCurve, ParametricCurve};
|
||||||
|
|
||||||
|
let r = 0.5;
|
||||||
|
let body = mixed_arc_and_line_prism();
|
||||||
|
let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], r).unwrap();
|
||||||
|
crate::guard("inspect mixed fillet mitres", || {
|
||||||
|
let solid = blended.deep_copy();
|
||||||
|
let mut mitres = std::collections::HashSet::new();
|
||||||
|
for edge in solid.boundaries().iter().flat_map(|shell| shell.edge_iter()) {
|
||||||
|
let a = crate::body::from_p3(edge.front().point());
|
||||||
|
let b = crate::body::from_p3(edge.back().point());
|
||||||
|
// Only the two corner profiles join the top contact to the wall contact.
|
||||||
|
if (a.z - b.z).abs() < r - 1e-8 || a.z.min(b.z) < 15.0 - r - 1e-8 || a.x * b.x <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !mitres.insert(edge.id()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let curve = edge.oriented_curve();
|
||||||
|
let (lo, hi) = curve.range_tuple();
|
||||||
|
for i in 0..=257 {
|
||||||
|
let p = crate::body::from_p3(curve.subs(lo + (hi - lo) * f64::from(i) / 257.0));
|
||||||
|
// The joint lies on both the cylindrical line blend and the toroidal
|
||||||
|
// arc blend, not their tangent-plane approximation.
|
||||||
|
let radius2 = (p.y - r).powi(2) + (p.z - (15.0 - r)).powi(2);
|
||||||
|
assert!((radius2 - r * r).abs() < 1e-7, "distorted line section at {p:?}: radius squared {radius2}");
|
||||||
|
let radius2 = (p.x.hypot(p.y) - (10.0 - r)).powi(2) + (p.z - (15.0 - r)).powi(2);
|
||||||
|
assert!((radius2 - r * r).abs() < 1e-7, "distorted arc section at {p:?}: radius squared {radius2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(mitres.len(), 2, "both arc/line corners must be checked");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fillet_partial_mixed_arc_and_line_perimeter_run() {
|
||||||
|
let body = partial_mixed_run_prism();
|
||||||
|
let blended = fillet(&body, &[Vec3::new(35.0, 20.0, 15.0), Vec3::new(20.0, 30.0, 15.0), Vec3::new(5.0, 20.0, 15.0)], 0.5).unwrap();
|
||||||
|
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
|
||||||
|
let mesh = blended.tessellate_with(1e-4, true).unwrap();
|
||||||
|
let bounds = mesh.bounds();
|
||||||
|
let tol = 1e-3;
|
||||||
|
assert!(
|
||||||
|
bounds.min.x >= -tol
|
||||||
|
&& bounds.max.x <= 40.0 + tol
|
||||||
|
&& bounds.min.y >= -tol
|
||||||
|
&& bounds.max.y <= 30.0 + tol
|
||||||
|
&& bounds.min.z >= -tol
|
||||||
|
&& bounds.max.z <= 15.0 + tol,
|
||||||
|
"{bounds:?}"
|
||||||
|
);
|
||||||
|
for p in &mesh.positions {
|
||||||
|
if p.y > 20.0 + tol {
|
||||||
|
assert!((p.x - 20.0).hypot(p.y - 20.0) <= 10.0 + tol, "outside the source semicircle: {p:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Boolean survey: random boxes, cylinders and spheres combined every way; prints what fails.
|
/// Boolean survey: random boxes, cylinders and spheres combined every way; prints what fails.
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
|
|||||||
Reference in new issue
Block a user