* Refuse fillets and chamfers that run past their faces or cross each other (#1080) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Blend fit: chamfers keep their old acceptance of crossed loops; skip the volume bound along tangent chains Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Blend fit: one selection per check, a coarse volume pass first; record the refused oversize blends in the action matrix baseline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6df69e9def
commit
563bcaff5b
8 files changed
+679
-46
No files matched your search
@@ -1814,6 +1814,17 @@ fn closed(b: Body) -> Result<Body> {
|
||||
/// largest that works).
|
||||
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
body.require_brep("fillet")?;
|
||||
// A round that would run past its faces into other geometry or another round (#1080).
|
||||
// Checked once built, so a refusal of the build itself stands as it is.
|
||||
let size = crate::blend_fit::Size::Round(radius);
|
||||
match fillet_built(body, edges, radius) {
|
||||
Ok(b) => crate::blend_fit::accept(body, b, edges, size),
|
||||
Err(e) => Err(crate::blend_fit::explain(body, edges, size, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`fillet`], once the radius is known to fit.
|
||||
fn fillet_built(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
// Corners the local blends refuse (an earlier round at an edge's end): radius per edge.
|
||||
blend(body, edges, radius, Shape::Round, "fillet")
|
||||
.and_then(closed)
|
||||
@@ -1946,7 +1957,15 @@ pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
|
||||
return fillet(body, edges, *r);
|
||||
}
|
||||
let sel: Vec<(Vec3, f64)> = edges.iter().copied().zip(radii.iter().copied()).collect();
|
||||
if let Ok(b) = crate::blend_corner::fillet_radii(body, &sel).and_then(closed) {
|
||||
// Each round must fit beside its own edge (#1080); the edges of each radius are checked
|
||||
// together below.
|
||||
let fit = |b: Body| -> Result<Body> {
|
||||
for (p, r) in &sel {
|
||||
crate::blend_fit::check(body, std::slice::from_ref(p), crate::blend_fit::Size::Round(*r))?;
|
||||
}
|
||||
Ok(b)
|
||||
};
|
||||
if let Ok(b) = crate::blend_corner::fillet_radii(body, &sel).and_then(closed).and_then(fit) {
|
||||
return Ok(b);
|
||||
}
|
||||
let mut by_radius: Vec<(f64, Vec<Vec3>)> = Vec::new();
|
||||
@@ -2019,6 +2038,17 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
if edges.is_empty() || edges.len() > 1000 {
|
||||
return Err(KernelError::Invalid("select 1…1000 edges".into()));
|
||||
}
|
||||
// A chamfer that would run past its faces into other geometry or another chamfer, or whose
|
||||
// result changes more than the chamfers can (#1080).
|
||||
let size = crate::blend_fit::Size::Chamfer(distance);
|
||||
match chamfer_built(body, edges, distance) {
|
||||
Ok(b) => crate::blend_fit::accept(body, b, edges, size),
|
||||
Err(e) => Err(crate::blend_fit::explain(body, edges, size, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`chamfer`], once the size is known to fit.
|
||||
fn chamfer_built(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
// Each step's result carries its history over `[body]`.
|
||||
let counts = record::counts(&[body]);
|
||||
// All cutters at once (their union mitres the corners), else one edge at a time.
|
||||
@@ -2224,6 +2254,7 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl
|
||||
if edges.is_empty() || edges.len() > 1000 {
|
||||
return Err(KernelError::Invalid("select 1…1000 edges".into()));
|
||||
}
|
||||
let size = crate::blend_fit::Size::Sides(d1, side, flip);
|
||||
let counts = record::counts(&[body]);
|
||||
let mut cur = crate::boolean_record::operand(body, 0, &counts);
|
||||
for (k, p) in edges.iter().enumerate() {
|
||||
@@ -2231,7 +2262,7 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl
|
||||
let step = cut_one(&cur, &t)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?;
|
||||
cur = record::after(step, &cur, &counts);
|
||||
}
|
||||
Ok(cur)
|
||||
crate::blend_fit::accept(body, cur, edges, size)
|
||||
}
|
||||
|
||||
/// The prism that cuts a chamfer: the body, which of its faces is the chamfer plane, and the
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Does a fillet or chamfer fit where it is asked for (#1080)? A blend of a straight edge
|
||||
//! between two planar faces takes away (or, on a concave edge, fills) at most the wedge between
|
||||
//! the edge and its two setback lines. Any other face of the body inside that wedge means the
|
||||
//! blend runs past a neighbouring face's boundary into other geometry (a hole, a pocket, an
|
||||
//! earlier round), and two such wedges overlapping mean two blends cross. Either way the result
|
||||
//! the operations build is not a blend of those edges, so it is refused with the largest size
|
||||
//! that does fit. After the build, the volume taken away is checked against the wedges too.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::blend::{ChamferSide, plane_normal, vtx};
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// What is asked for on every edge.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum Size {
|
||||
/// A round of this radius.
|
||||
Round(f64),
|
||||
/// An equal-distance chamfer.
|
||||
Chamfer(f64),
|
||||
/// A chamfer with a second distance or an angle (see [`crate::chamfer_sides`]).
|
||||
Sides(f64, ChamferSide, bool),
|
||||
}
|
||||
|
||||
impl Size {
|
||||
fn scaled(self, k: f64) -> Size {
|
||||
match self {
|
||||
Size::Round(r) => Size::Round(r * k),
|
||||
Size::Chamfer(s) => Size::Chamfer(s * k),
|
||||
Size::Sides(d, ChamferSide::Distance(d2), f) => Size::Sides(d * k, ChamferSide::Distance(d2 * k), f),
|
||||
Size::Sides(d, a, f) => Size::Sides(d * k, a, f),
|
||||
}
|
||||
}
|
||||
|
||||
fn value(self) -> f64 {
|
||||
match self {
|
||||
Size::Round(v) | Size::Chamfer(v) | Size::Sides(v, _, _) => v,
|
||||
}
|
||||
}
|
||||
|
||||
fn what(self) -> &'static str {
|
||||
match self {
|
||||
Size::Round(_) => "fillet",
|
||||
_ => "chamfer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A straight edge between two planar faces: its ends, the unit directions into each face
|
||||
/// (perpendicular to the edge) and the faces' outward normals.
|
||||
struct Frame {
|
||||
p0: Vec3,
|
||||
p1: Vec3,
|
||||
/// Into face a and face b, from the edge.
|
||||
ta: Vec3,
|
||||
tb: Vec3,
|
||||
na: Vec3,
|
||||
nb: Vec3,
|
||||
/// The faces (indices in the body's face order) the blend may meet: the two beside the
|
||||
/// edge and every face at its ends.
|
||||
allowed: Vec<usize>,
|
||||
ends: [mt::VertexID; 2],
|
||||
/// Another edge of the two faces runs on from an end tangentially: the blend runs on along
|
||||
/// that chain past this edge.
|
||||
chained: bool,
|
||||
}
|
||||
|
||||
/// One edge's region: the triangle edge, setback on face a, setback on face b, swept along the
|
||||
/// edge, as half-spaces `n·x <= c` and as its five boundary polygons.
|
||||
struct Wedge {
|
||||
planes: [(Vec3, f64); 5],
|
||||
faces: Vec<Vec<Vec3>>,
|
||||
/// Cross-section area times length: the most a blend of the edge takes away or fills.
|
||||
volume: f64,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// The setbacks along face a and face b for `size`; `None` where the faces are (nearly)
|
||||
/// tangent or the size doesn't make a blend.
|
||||
fn setbacks(&self, size: Size) -> Option<(f64, f64)> {
|
||||
let cos = self.ta.dot(self.tb).clamp(-1.0, 1.0);
|
||||
let phi = cos.acos();
|
||||
if !(1e-3..std::f64::consts::PI - 1e-3).contains(&phi) {
|
||||
return None;
|
||||
}
|
||||
match size {
|
||||
// The round touches each face at r / tan(φ/2) from the edge.
|
||||
Size::Round(r) => {
|
||||
let t = r / (phi * 0.5).tan();
|
||||
Some((t, t))
|
||||
}
|
||||
Size::Chamfer(s) => Some((s, s)),
|
||||
Size::Sides(d1, side, flip) => {
|
||||
// As `chamfer_tool_sides`: side 1 is the face facing up most, unless flipped.
|
||||
let up = Vec3::new(1e-6, 1e-3, 1.0);
|
||||
let a_is_first = (self.na.dot(up) >= self.nb.dot(up)) != flip;
|
||||
let d2 = match side {
|
||||
ChamferSide::Distance(d) => d,
|
||||
ChamferSide::Angle(a) => {
|
||||
let den = (phi + a).sin();
|
||||
if den <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
d1 * a.sin() / den
|
||||
}
|
||||
};
|
||||
Some(if a_is_first { (d1, d2) } else { (d2, d1) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wedge(&self, size: Size) -> Option<Wedge> {
|
||||
let (sa, sb) = self.setbacks(size)?;
|
||||
if !(sa.is_finite() && sb.is_finite() && sa > 0.0 && sb > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let d = (self.p1 - self.p0).normalized()?;
|
||||
let (a0, b0) = (self.p0 + self.ta * sa, self.p0 + self.tb * sb);
|
||||
let (a1, b1) = (self.p1 + self.ta * sa, self.p1 + self.tb * sb);
|
||||
// Each side's plane, facing away from the triangle's third corner.
|
||||
let side = |p: Vec3, q: Vec3, opposite: Vec3| -> Option<(Vec3, f64)> {
|
||||
let mut n = d.cross(q - p).normalized()?;
|
||||
if n.dot(opposite - p) > 0.0 {
|
||||
n = -n;
|
||||
}
|
||||
Some((n, n.dot(p)))
|
||||
};
|
||||
let planes = [side(self.p0, a0, b0)?, side(self.p0, b0, a0)?, side(a0, b0, self.p0)?, (-d, -d.dot(self.p0)), (d, d.dot(self.p1))];
|
||||
let faces =
|
||||
vec![vec![self.p0, a0, a1, self.p1], vec![self.p0, b0, b1, self.p1], vec![a0, b0, b1, a1], vec![self.p0, a0, b0], vec![self.p1, a1, b1]];
|
||||
let area = 0.5 * (self.ta * sa).cross(self.tb * sb).len();
|
||||
Some(Wedge { planes, faces, volume: area * self.p0.dist(self.p1) })
|
||||
}
|
||||
}
|
||||
|
||||
/// `poly` clipped to the half-spaces, each moved `eps` inwards: is anything left?
|
||||
fn reaches_inside(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64) -> bool {
|
||||
let mut cur: Vec<Vec3> = poly.to_vec();
|
||||
for (n, c) in planes {
|
||||
let c = c - eps;
|
||||
let mut next = Vec::with_capacity(cur.len() + 2);
|
||||
for (i, p) in cur.iter().enumerate() {
|
||||
let Some(q) = cur.get((i + 1) % cur.len()) else { continue };
|
||||
let (dp, dq) = (n.dot(*p) - c, n.dot(*q) - c);
|
||||
if dp <= 0.0 {
|
||||
next.push(*p);
|
||||
}
|
||||
if (dp < 0.0 && dq > 0.0) || (dp > 0.0 && dq < 0.0) {
|
||||
next.push(*p + (*q - *p) * (dp / (dp - dq)));
|
||||
}
|
||||
}
|
||||
if next.is_empty() {
|
||||
return false;
|
||||
}
|
||||
cur = next;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The body's straight edges between planar faces nearest the points (each once; `None` for a
|
||||
/// point whose edge is something else, which the check leaves alone).
|
||||
struct Selection {
|
||||
frames: Vec<Option<Frame>>,
|
||||
/// The body's triangles, with their face and whether that face is planar.
|
||||
tris: Vec<([Vec3; 3], usize)>,
|
||||
planar: Vec<bool>,
|
||||
size: f64,
|
||||
chord: f64,
|
||||
/// The body's volume and area from that mesh.
|
||||
coarse: (f64, f64),
|
||||
}
|
||||
|
||||
fn select(body: &Body, pts: &[Vec3]) -> Result<Selection> {
|
||||
// As the blends take it: coplanar neighbours as one face, collinear pieces as one edge.
|
||||
let healed = crate::boolean_record::healed(body)?;
|
||||
crate::guard("blend fit", || select_in(&healed, pts))
|
||||
}
|
||||
|
||||
fn select_in(body: &Body, pts: &[Vec3]) -> Result<Selection> {
|
||||
let size = body.size();
|
||||
let chord = (size * 1e-3).max(1e-4);
|
||||
let mesh = body.tessellate(chord)?;
|
||||
let solid = &*body.solid;
|
||||
let faces: Vec<&mt::Face> = solid.face_iter().collect();
|
||||
let planar: Vec<bool> = faces.iter().map(|f| plane_normal(f).is_some()).collect();
|
||||
let all = Body::unique_edges(solid);
|
||||
let mut taken: Vec<mt::EdgeID> = Vec::new();
|
||||
let mut frames = Vec::new();
|
||||
for p in pts {
|
||||
// The edge nearest the point, as the blends pick it.
|
||||
let best = mesh
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, poly)| (i, poly.windows(2).filter_map(|w| Some(p.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
// Points on the same edge (pieces healed into one) count once.
|
||||
let frame = match best.and_then(|(i, dist)| all.get(i).filter(|_| dist <= size * 0.05 + 1e-3)) {
|
||||
Some(e) if taken.contains(&e.id()) => continue,
|
||||
Some(e) => {
|
||||
taken.push(e.id());
|
||||
frame(&faces, e)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
frames.push(frame);
|
||||
}
|
||||
let m = mesh.measure();
|
||||
let coarse = (m.volume, m.area);
|
||||
let tris = mesh.triangles.iter().zip(&mesh.tri_face).filter_map(|(t, f)| Some((mesh.tri(t)?, *f as usize))).collect();
|
||||
Ok(Selection { frames, tris, planar, size, chord, coarse })
|
||||
}
|
||||
|
||||
fn frame(faces: &[&mt::Face], e: &mt::Edge) -> Option<Frame> {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return None;
|
||||
}
|
||||
let id = e.id();
|
||||
let ends = [e.front().id(), e.back().id()];
|
||||
let (p0, p1) = (vtx(e.absolute_front()), vtx(e.absolute_back()));
|
||||
let d = (p1 - p0).normalized()?;
|
||||
// Each face along the edge: its outward normal and the direction into it (its boundary runs
|
||||
// counter-clockwise about the normal, so the face lies to the left of the edge's use).
|
||||
let mut beside = Vec::new();
|
||||
let mut allowed = Vec::new();
|
||||
let mut chained = false;
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let mut uses: Vec<mt::Edge> = f.boundaries().iter().flat_map(|w| w.edge_iter()).filter(|x| x.id() == id).cloned().collect();
|
||||
let at_end = f.boundaries().iter().flat_map(|w| w.edge_iter()).any(|x| ends.contains(&x.front().id()) || ends.contains(&x.back().id()));
|
||||
if at_end {
|
||||
allowed.push(i);
|
||||
}
|
||||
if uses.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let n = plane_normal(f)?;
|
||||
let u = uses.pop()?;
|
||||
if !uses.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let dir = (vtx(u.back()) - vtx(u.front())).normalized()?;
|
||||
beside.push((n, n.cross(dir).normalized()?));
|
||||
chained |= f.boundaries().iter().flat_map(|w| w.edge_iter()).filter(|x| x.id() != id).any(|x| {
|
||||
ends.iter().any(|v| *v == x.front().id() && tangent_at(x, false).is_some_and(|t| t.dot(d).abs() > 1.0 - 1e-6))
|
||||
|| ends.iter().any(|v| *v == x.back().id() && tangent_at(x, true).is_some_and(|t| t.dot(d).abs() > 1.0 - 1e-6))
|
||||
});
|
||||
}
|
||||
let [(na, ta), (nb, tb)] = beside[..] else { return None };
|
||||
// Both directions perpendicular to the edge, within their faces.
|
||||
if ta.dot(d).abs() > 1e-6 || tb.dot(d).abs() > 1e-6 {
|
||||
return None;
|
||||
}
|
||||
Some(Frame { p0, p1, ta, tb, na, nb, allowed, ends, chained })
|
||||
}
|
||||
|
||||
/// The unit tangent of an edge (as used) at its start or end.
|
||||
fn tangent_at(e: &mt::Edge, at_end: bool) -> Option<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let d = c.der(if at_end { t1 } else { t0 });
|
||||
Vec3::new(d.x, d.y, d.z).normalized()
|
||||
}
|
||||
|
||||
/// What a blend that doesn't fit runs into.
|
||||
enum Clash {
|
||||
Face,
|
||||
Blend,
|
||||
}
|
||||
|
||||
fn first_clash(sel: &Selection, size: Size) -> Option<Clash> {
|
||||
let wedges: Vec<Option<Wedge>> = sel.frames.iter().map(|f| f.as_ref().and_then(|f| f.wedge(size))).collect();
|
||||
// Planar faces are exact in the mesh; curved ones are within the chord.
|
||||
let flat = (sel.size * 1e-7).max(1e-9);
|
||||
let curved = sel.chord * 2.0;
|
||||
for (f, w) in sel.frames.iter().zip(&wedges) {
|
||||
let (Some(f), Some(w)) = (f, w) else { continue };
|
||||
let hit = sel.tris.iter().filter(|(_, face)| !f.allowed.contains(face)).any(|(t, face)| {
|
||||
let eps = if sel.planar.get(*face).copied().unwrap_or(false) { flat } else { curved };
|
||||
reaches_inside(t, &w.planes, eps)
|
||||
});
|
||||
if hit {
|
||||
return Some(Clash::Face);
|
||||
}
|
||||
}
|
||||
// Two blends whose edges don't meet must not overlap (those meeting at a vertex mitre).
|
||||
let eps = (sel.size * 1e-7).max(1e-9);
|
||||
for (i, (fi, wi)) in sel.frames.iter().zip(&wedges).enumerate() {
|
||||
let (Some(fi), Some(wi)) = (fi, wi) else { continue };
|
||||
for (fj, wj) in sel.frames.iter().zip(&wedges).skip(i + 1) {
|
||||
let (Some(fj), Some(wj)) = (fj, wj) else { continue };
|
||||
if fi.ends.iter().any(|v| fj.ends.contains(v)) {
|
||||
continue;
|
||||
}
|
||||
let meet = wj.faces.iter().any(|p| reaches_inside(p, &wi.planes, eps)) || wi.faces.iter().any(|p| reaches_inside(p, &wj.planes, eps));
|
||||
if meet {
|
||||
return Some(Clash::Blend);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The step the largest fitting size is given to: three significant digits.
|
||||
fn step(v: f64) -> f64 {
|
||||
10f64.powf(v.log10().floor() - 3.0)
|
||||
}
|
||||
|
||||
/// Why a blend of `edges` at `size` doesn't fit `body` and the largest size that does (to
|
||||
/// three significant digits; `None` when not even the smallest step fits), or `None` when it
|
||||
/// fits or the check can't tell (edges other than straight ones between planar faces).
|
||||
fn misfit(body: &Body, edges: &[Vec3], size: Size) -> Result<Option<(Clash, Option<f64>)>> {
|
||||
if body.is_mesh() || edges.is_empty() || !size.value().is_finite() || size.value() <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
misfit_in(&select(body, edges)?, size)
|
||||
}
|
||||
|
||||
/// [`misfit`] on the selection.
|
||||
fn misfit_in(sel: &Selection, size: Size) -> Result<Option<(Clash, Option<f64>)>> {
|
||||
if sel.frames.iter().all(Option::is_none) || !size.value().is_finite() || size.value() <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(clash) = first_clash(sel, size) else { return Ok(None) };
|
||||
// Bisection on the size's steps: `hi` doesn't fit, `lo` does (or is 0).
|
||||
let asked = size.value();
|
||||
let q = step(asked);
|
||||
let (mut lo, mut hi) = (0u64, (asked / q).ceil() as u64);
|
||||
for _ in 0..20 {
|
||||
if hi.saturating_sub(lo) <= 1 {
|
||||
break;
|
||||
}
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if first_clash(sel, size.scaled(mid as f64 * q / asked)).is_none() {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
Ok(Some((clash, (lo > 0).then_some(lo as f64 * q))))
|
||||
}
|
||||
|
||||
/// "; the largest radius that fits is 5 mm", or nothing.
|
||||
fn fits_note(size: Size, largest: Option<f64>) -> String {
|
||||
let unit = if matches!(size, Size::Round(_)) { "radius" } else { "size" };
|
||||
largest.map(|v| format!("; the largest {unit} that fits is {} mm", show(v))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Refuse a blend of `edges` at `size` that runs into other faces of `body` or into another of
|
||||
/// the blends, with [`KernelError::TooLarge`] naming the largest size that fits.
|
||||
pub(crate) fn check(body: &Body, edges: &[Vec3], size: Size) -> Result<()> {
|
||||
if body.is_mesh() || edges.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
check_in(&select(body, edges)?, size)
|
||||
}
|
||||
|
||||
/// [`check`] on the selection.
|
||||
fn check_in(sel: &Selection, size: Size) -> Result<()> {
|
||||
let Some((clash, largest)) = misfit_in(sel, size)? else { return Ok(()) };
|
||||
let what = size.what();
|
||||
let into = match clash {
|
||||
Clash::Face => "it runs past the faces beside the edge into other geometry".to_string(),
|
||||
Clash::Blend => format!("the {what}s on these edges would cross each other"),
|
||||
};
|
||||
Err(KernelError::TooLarge(format!("{} mm is too large for this {what}: {into}{}", show(size.value()), fits_note(size, largest))))
|
||||
}
|
||||
|
||||
/// A blend's refusal `e`, with the largest size that fits added when the size is why.
|
||||
pub(crate) fn explain(body: &Body, edges: &[Vec3], size: Size, e: KernelError) -> KernelError {
|
||||
let note = match misfit(body, edges, size) {
|
||||
Ok(Some((_, largest @ Some(_)))) => fits_note(size, largest),
|
||||
_ => return e,
|
||||
};
|
||||
match e {
|
||||
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m}{note}")),
|
||||
KernelError::Failed(m) => KernelError::Failed(format!("{m}{note}")),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A blend of `edges` at `size` that turned `input` into `out`, if it fits ([`check`]) and
|
||||
/// changes no more than it can ([`check_volume`]).
|
||||
pub(crate) fn accept(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Result<Body> {
|
||||
if input.is_mesh() || out.is_mesh() || edges.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let sel = select(input, edges)?;
|
||||
check_in(&sel, size)?;
|
||||
volume_in(&sel, input, out, size)
|
||||
}
|
||||
|
||||
/// After a blend of `edges` at `size` turned `input` into `out`: refused when the volume taken
|
||||
/// away (or added) is more than the blends' wedges hold, which only a result that ran into
|
||||
/// other geometry does (#871's shelled box lost 240 mm³ more than any round could take).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn check_volume(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Result<Body> {
|
||||
if input.is_mesh() || out.is_mesh() || edges.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let Ok(sel) = select(input, edges) else { return Ok(out) };
|
||||
volume_in(&sel, input, out, size)
|
||||
}
|
||||
|
||||
/// [`check_volume`] on the selection.
|
||||
fn volume_in(sel: &Selection, input: &Body, out: Body, size: Size) -> Result<Body> {
|
||||
if sel.frames.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let mut bound = 0.0;
|
||||
for f in &sel.frames {
|
||||
// An edge the wedges can't describe: no bound.
|
||||
let Some(w) = f.as_ref().and_then(|f| f.wedge(size)) else { return Ok(out) };
|
||||
let Some(f) = f.as_ref().filter(|f| !f.chained) else { return Ok(out) };
|
||||
// Past an edge's ends a chamfer may still cut under a sloping end face.
|
||||
let (sa, sb) = f.setbacks(size).unwrap_or((0.0, 0.0));
|
||||
let len = f.p0.dist(f.p1);
|
||||
bound += w.volume * (len + 2.0 * sa.max(sb)) / len.max(1e-12);
|
||||
}
|
||||
// Well within the bound on the meshes the check made anyway: accepted. Otherwise measured
|
||||
// finely, as a coarse mesh of a cone or a blend is off by more than a small blend.
|
||||
if let Ok(m) = out.tessellate(sel.chord).map(|m| m.measure()) {
|
||||
let (va, aa) = sel.coarse;
|
||||
// A chord's sag over every face bounds what the meshes miss.
|
||||
let slack = (aa + m.area) * sel.chord;
|
||||
if (va - m.volume).abs() + slack <= bound * 1.02 {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
let (Ok(ma), Ok(mb)) = (crate::measure(input), crate::measure(&out)) else { return Ok(out) };
|
||||
let slack = ma.volume.abs() * 1e-4 + (ma.area + mb.area) * sel.size * 1e-5;
|
||||
let changed = (ma.volume - mb.volume).abs();
|
||||
if changed > bound * 1.02 + slack {
|
||||
return Err(KernelError::TooLarge(format!(
|
||||
"{} mm is too large for this {}: the result changes {} mm³ of the part, more than the {} can (at most {} mm³)",
|
||||
show(size.value()),
|
||||
size.what(),
|
||||
show_rounded(changed),
|
||||
size.what(),
|
||||
show_rounded(bound)
|
||||
)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A length in mm, without trailing zeros.
|
||||
fn show(v: f64) -> String {
|
||||
let s = format!("{v:.4}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
fn show_rounded(v: f64) -> String {
|
||||
format!("{v:.0}")
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! #1080: fillets and chamfers that would run past the faces beside their edge (into a pocket,
|
||||
//! a hole, another blend) are refused with the largest size that fits, not built through it.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::{Body, BoolOp, ChamferSide, KernelError, build::box_solid, ops::boolean};
|
||||
|
||||
fn cut(a: &Body, lo: [f64; 3], hi: [f64; 3]) -> Body {
|
||||
let t = box_solid(Vec3::new(lo[0], lo[1], lo[2]), Vec3::new(hi[0], hi[1], hi[2])).unwrap();
|
||||
boolean(a, &t, BoolOp::Cut).unwrap().unwrap()
|
||||
}
|
||||
|
||||
/// A 40 × 20 × 20 block with a 10 × 10 pocket, 10 deep, 4 mm in from its top edge at x = 40.
|
||||
fn pocketed() -> Body {
|
||||
let b = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 20.0, 20.0)).unwrap();
|
||||
cut(&b, [26.0, 5.0, 10.0], [36.0, 15.0, 20.0])
|
||||
}
|
||||
|
||||
const EDGE: Vec3 = Vec3 { x: 40.0, y: 10.0, z: 20.0 };
|
||||
|
||||
fn too_large(r: crate::Result<Body>) -> String {
|
||||
match r {
|
||||
Err(KernelError::TooLarge(m)) => m,
|
||||
Err(e) => panic!("refused, but not as too large: {e}"),
|
||||
Ok(_) => panic!("a blend through the pocket was accepted"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_into_a_pocket_is_refused_with_the_size_that_fits() {
|
||||
let p = pocketed();
|
||||
let m = too_large(crate::chamfer(&p, &[EDGE], 6.0));
|
||||
assert!(m.contains("runs past the faces beside the edge") && m.contains("the largest size that fits is 4 mm"), "{m}");
|
||||
// Up to the pocket's rim it builds: the triangle along the 20 mm edge.
|
||||
for s in [2.0, 4.0] {
|
||||
let b = crate::chamfer(&p, &[EDGE], s).unwrap();
|
||||
let v = b.volume_at(0.01).unwrap();
|
||||
assert!((p.volume_at(0.01).unwrap() - v - 0.5 * s * s * 20.0).abs() < 1e-6, "{s}: {v}");
|
||||
}
|
||||
// Unequal sides: along the top 6 is past the pocket, along the side it is not.
|
||||
let m = too_large(crate::chamfer_sides(&p, &[EDGE], 6.0, ChamferSide::Distance(2.0), false));
|
||||
assert!(m.contains("runs past"), "{m}");
|
||||
assert!(crate::chamfer_sides(&p, &[EDGE], 2.0, ChamferSide::Distance(6.0), false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_into_a_pocket_is_refused_and_one_that_fits_builds() {
|
||||
let p = pocketed();
|
||||
// The round build refuses it itself; the largest radius that fits is added.
|
||||
let m = crate::fillet(&p, &[EDGE], 5.0).unwrap_err().to_string();
|
||||
assert!(m.contains("the largest radius that fits is 4 mm"), "{m}");
|
||||
let b = crate::fillet(&p, &[EDGE], 3.0).unwrap();
|
||||
assert!(b.validity().is_empty(), "{:?}", b.validity());
|
||||
assert_eq!(b.face_count(), p.face_count() + 1);
|
||||
}
|
||||
|
||||
/// A rib 4 mm wide: chamfers of its two top edges meet in the middle of its top face at 2 mm;
|
||||
/// past that they would cross each other. Neither reaches the far side's face below 4 mm,
|
||||
/// so only the two blends clash.
|
||||
#[test]
|
||||
fn chamfers_crossing_each_other_are_refused() {
|
||||
let rib = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 4.0, 20.0)).unwrap();
|
||||
let edges = [Vec3::new(20.0, 0.0, 20.0), Vec3::new(20.0, 4.0, 20.0)];
|
||||
let m = too_large(crate::chamfer(&rib, &edges, 3.0));
|
||||
assert!(m.contains("would cross each other") && m.contains("the largest size that fits is 2 mm"), "{m}");
|
||||
let m = crate::fillet(&rib, &edges, 3.0).unwrap_err().to_string();
|
||||
assert!(m.contains("the largest radius that fits is 2 mm"), "{m}");
|
||||
// Below half the width both build.
|
||||
let b = crate::chamfer(&rib, &edges, 1.5).unwrap();
|
||||
assert!((b.volume_at(0.01).unwrap() - (3200.0 - 2.0 * 0.5 * 1.5 * 1.5 * 40.0)).abs() < 1e-6);
|
||||
assert!(crate::fillet(&rib, &edges, 1.5).is_ok());
|
||||
}
|
||||
|
||||
/// Edges meeting at a corner mitre: their chamfers overlap there and are not refused for it.
|
||||
#[test]
|
||||
fn chamfers_meeting_at_a_corner_still_build() {
|
||||
let b = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
|
||||
let edges = [Vec3::new(10.0, 0.0, 20.0), Vec3::new(20.0, 10.0, 20.0), Vec3::new(10.0, 20.0, 20.0), Vec3::new(0.0, 10.0, 20.0)];
|
||||
assert!(crate::chamfer(&b, &edges, 5.0).is_ok());
|
||||
assert!(crate::fillet(&b, &edges, 5.0).is_ok());
|
||||
}
|
||||
|
||||
/// A result that changes more of the part than the blends can is refused (#871's shelled box
|
||||
/// lost 240 mm³ more than any round of its edge could take).
|
||||
#[test]
|
||||
fn a_result_that_removes_more_than_the_blend_is_refused() {
|
||||
let b = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
|
||||
let e = [Vec3::new(10.0, 0.0, 20.0)];
|
||||
let size = crate::blend_fit::Size::Chamfer(2.0);
|
||||
let good = crate::chamfer(&b, &e, 2.0).unwrap();
|
||||
assert!(crate::blend_fit::check_volume(&b, good, &e, size).is_ok());
|
||||
// A chamfer and a 1 mm slab off the far side as well.
|
||||
let worse = cut(&crate::chamfer(&b, &e, 2.0).unwrap(), [0.0, 19.0, 0.0], [20.0, 20.0, 20.0]);
|
||||
let m = too_large(crate::blend_fit::check_volume(&b, worse, &e, size));
|
||||
assert!(m.contains("more than the chamfer can"), "{m}");
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
mod blend;
|
||||
mod blend_corner;
|
||||
mod blend_fit;
|
||||
mod blend_variable;
|
||||
mod blend_vertex;
|
||||
mod body;
|
||||
@@ -40,6 +41,8 @@ mod iges_out;
|
||||
#[cfg(test)]
|
||||
mod intersection_curve_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_1080_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_616_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_674_tests;
|
||||
|
||||
Reference in new issue
Block a user