diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 02a33843..f614d5ec 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -274,6 +274,7 @@ fn join_of_a_lobed_disc_pattern_is_not_refused() { assert!(rel(got, want) < 1e-3, "{got} vs {want}"); } +mod blend_fit; mod boolean_diagnostics; mod component_frames; mod large_step_import; diff --git a/crates/engine/src/tests/blend_fit.rs b/crates/engine/src/tests/blend_fit.rs new file mode 100644 index 00000000..e25275b5 --- /dev/null +++ b/crates/engine/src/tests/blend_fit.rs @@ -0,0 +1,46 @@ +//! #1080: a fillet or chamfer too large for the geometry beside its edge is refused, naming the +//! largest size that fits, and one that fits still builds. + +use std::path::Path; + +use serde_json::json; + +use super::{run, volume}; +use crate::Session; + +fn open_fixture() -> Session { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tests/fixtures/cubeish-chamfer-too-large.solidcraft"); + let mut s = Session::default(); + run(&mut s, "doc.open", json!({"path": path.to_string_lossy()})); + s +} + +fn chamfer_error(s: &Session) -> Option { + s.model.results.iter().find(|r| r.name == "Chamfer1").and_then(|r| r.error.clone()) +} + +/// The report's design: a 20 mm cube with a 10 mm square opening into each of three faces, +/// then a 9 mm chamfer on the top edge over one side opening. The openings are 5 mm from the +/// edge, so the chamfer would cut through their rims. It is refused, naming 5 mm, and the part +/// is left as it was. +#[test] +fn chamfer_through_the_openings_is_refused() { + let mut s = open_fixture(); + let err = chamfer_error(&s).expect("the 9 mm chamfer cuts through two openings: it must be refused"); + assert!(err.contains("runs past the faces beside the edge"), "{err}"); + assert!(err.contains("the largest size that fits is 5 mm"), "{err}"); + assert!((volume(&mut s) - 4000.0).abs() < 1e-6, "the part stays as it was without the chamfer"); +} + +/// Sizes that stop short of the openings build, and take away exactly the chamfer's triangle +/// along the 20 mm edge. +#[test] +fn chamfer_that_fits_still_builds() { + let mut s = open_fixture(); + for d in [4.0, 5.0] { + run(&mut s, "timeline.edit", json!({"feature": "Chamfer1", "set": {"distance": format!("{d} mm")}})); + assert_eq!(chamfer_error(&s), None, "{d} mm fits"); + let want = 4000.0 - 0.5 * d * d * 20.0; + assert!((volume(&mut s) - want).abs() < 1e-6, "{d} mm: {} (want {want})", volume(&mut s)); + } +} diff --git a/crates/engine/src/tests/fixtures/cubeish-chamfer-too-large.solidcraft b/crates/engine/src/tests/fixtures/cubeish-chamfer-too-large.solidcraft new file mode 100644 index 00000000..f7578fd7 Binary files /dev/null and b/crates/engine/src/tests/fixtures/cubeish-chamfer-too-large.solidcraft differ diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index dbcb09ca..d5e71fcd 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -1814,6 +1814,17 @@ fn closed(b: Body) -> Result { /// largest that works). pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { 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 { // 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 { 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 { + 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)> = Vec::new(); @@ -2019,6 +2038,17 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { 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 { // 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 diff --git a/crates/kernel/src/blend_fit.rs b/crates/kernel/src/blend_fit.rs new file mode 100644 index 00000000..5cf57293 --- /dev/null +++ b/crates/kernel/src/blend_fit.rs @@ -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, + 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>, + /// 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 { + 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 = 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>, + /// The body's triangles, with their face and whether that face is planar. + tris: Vec<([Vec3; 3], usize)>, + planar: Vec, + size: f64, + chord: f64, + /// The body's volume and area from that mesh. + coarse: (f64, f64), +} + +fn select(body: &Body, pts: &[Vec3]) -> Result { + // 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 { + 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 = faces.iter().map(|f| plane_normal(f).is_some()).collect(); + let all = Body::unique_edges(solid); + let mut taken: Vec = 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 { + 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 = 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 { + 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 { + let wedges: Vec> = 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)>> { + 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)>> { + 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) -> 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 { + 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 { + 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 { + 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}") +} diff --git a/crates/kernel/src/issue_1080_tests.rs b/crates/kernel/src/issue_1080_tests.rs new file mode 100644 index 00000000..914ffa3f --- /dev/null +++ b/crates/kernel/src/issue_1080_tests.rs @@ -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) -> 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}"); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index fa8b0cf8..adae29be 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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; diff --git a/tests/action_matrix/baseline.json b/tests/action_matrix/baseline.json index abbb9eea..43b6c6b6 100644 --- a/tests/action_matrix/baseline.json +++ b/tests/action_matrix/baseline.json @@ -839,16 +839,16 @@ "corpus/modern-plastic": { "Body1/F1:+x": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Body1/F1:+x|F1:+y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F1:+x|F1:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F1:+x|F1:+z": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:+x|F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:+x|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:+y": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F1:+y|F1:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F1:+y|F1:+z": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:+y|F1:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:+y|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:+z": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F1:+z|F1:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F1:+z|F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F1:+z|F1:-x": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F1:+z|F1:-y": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:+z|F3:blend:F1:+z|F2:inner:F1:+x#[F1:+z]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:+z|F3:blend:F1:+z|F2:inner:F1:+y#[F1:+z]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:+z|F3:blend:F1:+z|F2:inner:F1:-x#[F1:+z]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -860,21 +860,21 @@ "Body1/F1:-y|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:-z": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Body1/F2:inner:F1:+x": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F2:inner:F1:+x|F2:inner:F1:+y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F2:inner:F1:+x|F2:inner:F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F2:inner:F1:+x|F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F2:inner:F1:+x|F3:inner:F1:+z": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:+x|F2:inner:F1:+y": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:+x|F2:inner:F1:-y": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:+x|F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:+x|F3:inner:F1:+z": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F2:inner:F1:+y": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F2:inner:F1:+y|F2:inner:F1:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F2:inner:F1:+y|F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F2:inner:F1:+y|F3:inner:F1:+z": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:+y|F2:inner:F1:-x": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:+y|F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:+y|F3:inner:F1:+z": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F2:inner:F1:-x": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F2:inner:F1:-x|F2:inner:F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F2:inner:F1:-x|F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F2:inner:F1:-x|F3:inner:F1:+z": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:-x|F2:inner:F1:-y": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:-x|F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:-x|F3:inner:F1:+z": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F2:inner:F1:-y": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F2:inner:F1:-y|F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F2:inner:F1:-y|F3:inner:F1:+z": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:-y|F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F2:inner:F1:-y|F3:inner:F1:+z": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", "Body1/F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]|F4:post:side:0": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:inner:F1:-z#[F2:inner:F1:+x,F2:inner:F1:+y,F2:inner:F1:-x,F2:inner:F1:-y,F4:post:side:0,F4:post:side:1,F5:rest:side:0,F5:rest:side:1,F5:rest:side:2,F5:rest:side:3]|F4:post:side:1": "edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -3666,38 +3666,38 @@ "Box/F2:side:l1|F2:side:l4~1": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F2:side:l1|F2:side:l4~2": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F2:side:l1|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Box/F2:side:l1|F2:top": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Box/F2:side:l1|F2:top": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Box/F2:side:l2": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Box/F2:side:l2|F2:side:l3~1": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F2:side:l2|F2:side:l3~2": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F2:side:l2|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Box/F2:side:l2|F2:top": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Box/F2:side:l2|F2:top": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Box/F2:side:l3": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Box/F2:side:l3|F2:side:l4~1": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F2:side:l3|F2:side:l4~2": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F2:side:l3|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Box/F2:side:l3|F2:top": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Box/F2:side:l3|F2:top": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Box/F2:side:l4": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Box/F2:side:l4|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Box/F2:side:l4|F2:top": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Box/F2:side:l4|F2:top": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Box/F2:start": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Box/F2:top": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Box/F2:top|F3:inner:F2:side:l1": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Box/F2:top|F3:inner:F2:side:l2": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Box/F2:top|F3:inner:F2:side:l3": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Box/F2:top|F3:inner:F2:side:l4": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Box/F2:top|F3:inner:F2:side:l1": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Box/F2:top|F3:inner:F2:side:l2": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Box/F2:top|F3:inner:F2:side:l3": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Box/F2:top|F3:inner:F2:side:l4": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Box/F3:inner:F2:side:l1": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Box/F3:inner:F2:side:l1|F3:inner:F2:side:l2": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Box/F3:inner:F2:side:l1|F3:inner:F2:side:l4": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Box/F3:inner:F2:side:l1|F3:inner:F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Box/F3:inner:F2:side:l1|F3:inner:F2:side:l2": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Box/F3:inner:F2:side:l1|F3:inner:F2:side:l4": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Box/F3:inner:F2:side:l1|F3:inner:F2:start": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F3:inner:F2:side:l2": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Box/F3:inner:F2:side:l2|F3:inner:F2:side:l3": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Box/F3:inner:F2:side:l2|F3:inner:F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Box/F3:inner:F2:side:l2|F3:inner:F2:side:l3": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Box/F3:inner:F2:side:l2|F3:inner:F2:start": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F3:inner:F2:side:l3": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Box/F3:inner:F2:side:l3|F3:inner:F2:side:l4": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Box/F3:inner:F2:side:l3|F3:inner:F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Box/F3:inner:F2:side:l3|F3:inner:F2:side:l4": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Box/F3:inner:F2:side:l3|F3:inner:F2:start": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F3:inner:F2:side:l4": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Box/F3:inner:F2:side:l4|F3:inner:F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Box/F3:inner:F2:side:l4|F3:inner:F2:start": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Box/F3:inner:F2:start": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", "Box/body": "body.copy body.mirror body.move body.pattern", "Box/v[F2:side:l1 & F2:side:l2 & F2:start]": "vertex.plane_three_points vertex.point", @@ -9631,10 +9631,10 @@ }, "regressions/join_struts_on_bore_wall": { "Frame/F1:+x": "face.angle_plane face.draft_neutral !face.extrude face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", - "Frame/F1:+x|F1:+y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Frame/F1:+x|F1:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Frame/F1:+x|F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Frame/F1:+x|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Frame/F1:+x|F1:+y": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Frame/F1:+x|F1:+z": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Frame/F1:+x|F1:-y": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Frame/F1:+x|F1:-z": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Frame/F1:+x|F3:+z#[F4:+y,F5:face:11]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Frame/F1:+x|F3:+z#[F4:-y,F5:face:9]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Frame/F1:+x|F3:-z#[F4:+y,F5:face:4]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -10112,10 +10112,10 @@ "Body1/F2:top|F6:side:c1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:top|F6:side:c1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F4:bottom": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F4:bottom|F4:side:l1": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F4:bottom|F4:side:l2": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F4:bottom|F4:side:l3": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F4:bottom|F4:side:l4": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F4:bottom|F4:side:l1": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F4:bottom|F4:side:l2": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F4:bottom|F4:side:l3": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F4:bottom|F4:side:l4": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F4:bottom|F9:side:l1": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F4:bottom|F9:side:l2": "edge.axis edge.normal_plane edge.project", "Body1/F4:bottom|F9:side:l3": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", @@ -10139,12 +10139,12 @@ "Body1/F9:end|F9:side:l3": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F9:end|F9:side:l4": "edge.axis edge.normal_plane edge.project", "Body1/F9:side:l1": "face.angle_plane face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F9:side:l1|F9:side:l2": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", - "Body1/F9:side:l1|F9:side:l4": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F9:side:l1|F9:side:l2": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F9:side:l1|F9:side:l4": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F9:side:l2": "face.angle_plane face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F9:side:l2|F9:side:l3": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F9:side:l2|F9:side:l3": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F9:side:l3": "face.angle_plane face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F9:side:l3|F9:side:l4": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F9:side:l3|F9:side:l4": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F9:side:l4": "face.angle_plane face.extrude face.hole face.offset_plane face.press_pull !face.shell face.sketch", "Body1/body": "body.copy body.mirror body.move body.pattern", "Body1/v[F2:side:l1 & F2:side:l2 & F2:start]": "vertex.plane_three_points vertex.point",