diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 170f1419..923ac485 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -1992,6 +1992,10 @@ fn offset_face(s: &mut Session, p: &Value) -> Result { #[path = "patterns_tests.rs"] mod patterns_tests; +#[cfg(test)] +#[path = "prism_local_tests.rs"] +mod prism_local_tests; + #[cfg(test)] #[path = "revolve_convention_tests.rs"] mod revolve_convention_tests; diff --git a/crates/engine/src/cmd/prism_local_tests.rs b/crates/engine/src/cmd/prism_local_tests.rs new file mode 100644 index 00000000..eff63392 --- /dev/null +++ b/crates/engine/src/cmd/prism_local_tests.rs @@ -0,0 +1,94 @@ +//! A cut whose walls lie on the target's, in a target that is a prism along the cut only round +//! it (a chamfer or a round elsewhere): the 2D route in a column cut round the tool +//! (`solidcraft_kernel`'s `prism_local`). The B-rep boolean finds no result on these. + +use serde_json::{Value, json}; +use std::f64::consts::PI; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn volume(s: &mut Session) -> f64 { + run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) +} + +/// A cut of `params`: the volume it takes, after checking it built. +fn cut(s: &mut Session, params: Value) -> f64 { + let before = volume(s); + let id = run(s, "solid.extrude", params)["feature"].as_u64().unwrap(); + let r = s.model.result(id).unwrap(); + assert!(r.error.is_none(), "{r:?}"); + before - volume(s) +} + +/// An 80 x 50 x 10 plate with a 10 mm round pocket 2 mm deep in its top; `edge` dressed first. +fn plate(edge: impl FnOnce(&mut Session)) -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[80,50]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10})); + edge(&mut s); + pocket(&mut s, 2.0); + s +} + +/// The round pocket cut `depth` down from the plate's top. +fn pocket(s: &mut Session, depth: f64) -> f64 { + run(s, "sketch.create", json!({"plane":"XY","offset":10})); + run(s, "sketch.circle.center", json!({"center":[40,25],"radius":5})); + run(s, "sketch.finish", json!({})); + cut(s, json!({"distance":-depth,"operation":"cut"})) +} + +/// The pocket made deeper (2 to 5 mm): its wall lies on the pocket's. The plate is a prism +/// along the cut but for its chamfered rim, or its rounded bottom edges. +#[test] +fn a_round_pocket_made_deeper_beside_a_chamfer_or_a_round() { + let chamfered = |s: &mut Session| { + run(s, "solid.chamfer", json!({"edges":[[40,0,10],[40,50,10],[0,25,10],[80,25,10]],"distance":1})); + }; + let rounded = |s: &mut Session| { + run(s, "solid.fillet", json!({"edges":[[40,0,0],[40,50,0],[0,25,0],[80,25,0]],"radius":2})); + }; + let want = PI * 25.0 * 3.0; + for (what, mut s) in [("chamfered", plate(chamfered)), ("rounded", plate(rounded))] { + let taken = pocket(&mut s, 5.0); + assert!((taken - want).abs() < want * 1e-3, "{what}: {taken} taken, want {want}"); + } +} + +/// #1360's ring cut round a hole, the hole counterbored: the ring's inner wall lies on the +/// hole's, in a disc whose top rim is chamfered. Without the chamfer the disc is a prism and +/// the 2D route takes it whole. +#[test] +fn a_ring_cut_round_a_counterbored_hole_in_a_chamfered_disc() { + let ring = |chamfer: bool| { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":40})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10})); + run(&mut s, "solid.hole", json!({"position":[20,0,10],"diameter":6,"type":"counterbore","cb_diameter":9,"cb_depth":1.5})); + if chamfer { + run(&mut s, "solid.chamfer", json!({"edges":[[40,0,10]],"distance":1})); + } + run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,10]}})); + run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":3})); + run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":6})); + run(&mut s, "sketch.finish", json!({})); + // The ring between the counterbore's rim (projected from the face) and the 6 mm circle. + cut(&mut s, json!({"direction":"negative","distance":3,"operation":"cut","profiles":[{"point":[25.25,0]}]})) + }; + let want = PI * (36.0 - 20.25) * 3.0; + for chamfer in [false, true] { + let taken = ring(chamfer); + assert!((taken - want).abs() < want * 1e-3, "chamfer {chamfer}: {taken} taken, want {want}"); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index a152328c..0376c6a7 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -90,6 +90,7 @@ mod polybool; mod polyhedron; mod prism; mod prism_curve; +mod prism_local; mod prism_measure; pub mod provenance; mod record; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 6bb9a983..5cfc713c 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -817,6 +817,19 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { return Ok(Some(body)); } } + // A prism tool whose target is a prism along its direction only round it (a pocket made + // deeper in a plate with a chamfered rim, a ring cut round a counterbored hole in a + // chamfered disc): the exact 2D route in a column cut round the tool. Its walls lying on + // the target's are what the tries below fail on. + if attempt == 1 && matches!(op, BoolOp::Cut | BoolOp::Intersect) { + let (local, diagnostics) = capture_boolean_diagnostics(|| crate::prism_local::local_prism_boolean(a, b, op)); + if let Some(Ok(Some(body))) = local + && check(&body).0 + { + merge(diagnostics); + return Ok(Some(body)); + } + } let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01); for k in [2e-3, 5e-4] { let tol = (size * k).max(1e-5); diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index 970205dc..7d03a82d 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -527,79 +527,98 @@ pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option = match r { - Some(m) => { - let healed = recorded::heal_body(&m, m.deep_copy(), size, a.split_keep(), &record::counts(&[&middle.0, b]))?; - let h = recorded::of_pair(&healed, &middle.1, &h_b); - vec![(healed, h)] - } - None => Vec::new(), - }; - // Each piece in its lumps: a cut plane crossing a frame or a ring leaves the middle (or - // a piece outside) in several parts, which meet the others along several cut faces. - let lumps = |(whole, h): Traced| -> Result> { - if whole.solid.boundaries().len() == 1 { - return Ok(vec![(whole, h)]); - } - let parts = whole.lumps()?; - let histories = recorded::parts_of(&parts, &whole); - Ok(parts - .into_iter() - .zip(histories) - .map(|(p, hp)| { - let h = match (hp, h.as_ref()) { - (Some(hp), Some(h)) => recorded::composed(hp.substituted(0, h)), - _ => None, - }; - (p, h) - }) - .collect()) - }; - let mut all: Vec = Vec::new(); - for piece in pieces.into_iter().chain(outside) { - all.extend(lumps(piece)?); - } - // Glue pieces that meet along their cut faces until one is left; every cut face pair - // goes (a pair left behind would be a slit through the result). - let mut joined: Vec = Vec::new(); - while let Some(mut cur) = all.pop() { - loop { - let found = all.iter().enumerate().find_map(|(i, (o, _))| Some((i, crate::coplanar::glue(&cur.0, o)?.ok()?))); - let Some((i, j)) = found else { break }; - let other = all.swap_remove(i); - let h = recorded::of_pair(&j, &cur.1, &other.1); - cur = (j, h); - } - joined.push(cur); - } - if joined.iter().any(|(x, _)| x.solid.boundaries().len() != 1) { - return Err(fail("a piece in several parts")); - } - // A join onto one piece is one piece: anything apart is a cut face that did not glue. - if op == BoolOp::Union && a.solid.boundaries().len() == 1 && joined.len() != 1 { - return Err(fail("a piece does not join back")); - } - let pieces = joined; - recorded::join(pieces.into_iter().map(|(p, h)| (p, dropped(h))).collect(), &counts) + reassembled(a, b, op, &middle, r, outside, n, &fail) }; Some(run()) } +/// The middle piece's boolean with `b` (`r`, over `[middle, b]`) and the pieces of `a` split +/// away from the middle (`outside`) glued back into one result over `[a, b]`: the operands past +/// the second (`n` in all, the boxes a piece was cut with) are dropped. `fail` names the route +/// in its errors. Shared with [`crate::prism_local`]. +#[allow(clippy::too_many_arguments)] +pub(crate) fn reassembled( + a: &Body, + b: &Body, + op: BoolOp, + middle: &Traced, + r: Option, + outside: Vec, + n: usize, + fail: &dyn Fn(&str) -> KernelError, +) -> Result> { + let size = a.size().max(b.size()); + let h_b = recorded::kept(b, 1, n); + let counts = record::counts(&[a, b]); + if op == BoolOp::Intersect { + return Ok(r.map(|r| { + let h = dropped(recorded::of_pair(&r, &middle.1, &h_b)); + r.with_checked_history(h, &counts) + })); + } + // The rebuild leaves the cut faces' edges split where other polygons were cut; healed, + // they match the pieces outside edge for edge. + let pieces: Vec = match r { + Some(m) => { + let healed = recorded::heal_body(&m, m.deep_copy(), size, a.split_keep(), &record::counts(&[&middle.0, b]))?; + let h = recorded::of_pair(&healed, &middle.1, &h_b); + vec![(healed, h)] + } + None => Vec::new(), + }; + // Each piece in its lumps: a cut plane crossing a frame or a ring leaves the middle (or + // a piece outside) in several parts, which meet the others along several cut faces. + let lumps = |(whole, h): Traced| -> Result> { + if whole.solid.boundaries().len() == 1 { + return Ok(vec![(whole, h)]); + } + let parts = whole.lumps()?; + let histories = recorded::parts_of(&parts, &whole); + Ok(parts + .into_iter() + .zip(histories) + .map(|(p, hp)| { + let h = match (hp, h.as_ref()) { + (Some(hp), Some(h)) => recorded::composed(hp.substituted(0, h)), + _ => None, + }; + (p, h) + }) + .collect()) + }; + let mut all: Vec = Vec::new(); + for piece in pieces.into_iter().chain(outside) { + all.extend(lumps(piece)?); + } + // Glue pieces that meet along their cut faces until one is left; every cut face pair + // goes (a pair left behind would be a slit through the result). + let mut joined: Vec = Vec::new(); + while let Some(mut cur) = all.pop() { + loop { + let found = all.iter().enumerate().find_map(|(i, (o, _))| Some((i, crate::coplanar::glue(&cur.0, o)?.ok()?))); + let Some((i, j)) = found else { break }; + let other = all.swap_remove(i); + let h = recorded::of_pair(&j, &cur.1, &other.1); + cur = (j, h); + } + joined.push(cur); + } + if joined.iter().any(|(x, _)| x.solid.boundaries().len() != 1) { + return Err(fail("a piece in several parts")); + } + // A join onto one piece is one piece: anything apart is a cut face that did not glue. + if op == BoolOp::Union && a.solid.boundaries().len() == 1 && joined.len() != 1 { + return Err(fail("a piece does not join back")); + } + let pieces = joined; + recorded::join(pieces.into_iter().map(|(p, h)| (p, dropped(h))).collect(), &counts) +} + /// `piece` split by `plane` (at `x` along `axis`): the parts on the side away from the tool /// and the one part on its side (positive along `axis` when `keep_positive`), each with its /// history over the operands `piece`'s is over. `None` when the split leaves no part on either /// side, or several on the tool's. -fn split_beside(piece: &Traced, plane: &solidcraft_geom::Plane, axis: Vec3, x: f64, keep_positive: bool) -> Option<(Vec, Traced)> { +pub(crate) fn split_beside(piece: &Traced, plane: &solidcraft_geom::Plane, axis: Vec3, x: f64, keep_positive: bool) -> Option<(Vec, Traced)> { let parts = crate::splitface::split_body(&piece.0, &crate::splitface::SplitTool::Plane(*plane)).ok()?; if parts.len() < 2 { return None; diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 7e5642c7..58b9cd80 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -2217,6 +2217,34 @@ fn directions(a: &Body, b: &Body) -> Option> { Some(dirs) } +/// The direction `b` is a prism (or a stack of prisms) along: the first of its planar faces' +/// normals along which it is one. `None` when there is none. +pub(crate) fn prism_axis(b: &Body) -> Option { + if b.face_count() > MAX_PLANAR_SEGMENTS { + return None; + } + let tol = (b.size() * 1e-7).max(1e-9) * 10.0; + let index = LazyIndex::new(b, 1); + let mut tried: Vec = Vec::new(); + for f in b.solid.face_iter() { + let mt::Surface::Plane(p) = f.oriented_surface() else { continue }; + let n = p.normal(); + let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() else { continue }; + if tried.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) { + continue; + } + tried.push(n); + if tried.len() > 16 { + return None; + } + let plane = Plane::from_normal(Vec3::ZERO, n)?; + if stack_tagged(b, n, &plane, tol, &index).is_some() { + return Some(n); + } + } + None +} + /// Whether a boolean of sections left one of area `v` as it was (area `u` after): the same to /// within rounding, as the very same outline gives (a change is at least a sliver `tol` wide). fn same_area(u: f64, v: f64, tol: f64) -> bool { diff --git a/crates/kernel/src/prism_local.rs b/crates/kernel/src/prism_local.rs new file mode 100644 index 00000000..4bbb4d44 --- /dev/null +++ b/crates/kernel/src/prism_local.rs @@ -0,0 +1,352 @@ +//! Booleans of a prism tool with a target that is a prism along the tool's direction only round +//! the tool: a pocket made deeper in a plate whose rim is chamfered, a ring cut round a +//! counterbored hole in a chamfered disc. The tool's walls lie on the target's there, which the +//! B-rep boolean can't intersect, and the 2D route ([`crate::prism::prism_boolean`]) declines the +//! target for a face far away (the chamfer) that is neither a wall along the direction nor a cap +//! square to it. +//! +//! The target is split by planes into a column round the tool that holds none of those faces; +//! the column takes the exact 2D boolean, and the pieces are glued back along the cut faces, as +//! [`crate::polybool::sliced_planar_boolean`] does for an all-planar tool beside curved faces. +//! The cuts are imprints (no boolean), each placed beside the tool on one of three axes: the +//! tool's direction and two square to it. A cut that leaves every offending face of the middle +//! piece beyond it goes first; otherwise one close beside the tool, which splits them (a +//! chamfer all round a disc is split into two pieces apart, which the next cut leaves out). + +use solidcraft_geom::{Plane, Vec3}; +use truck_modeling as mt; + +use crate::body::Body; +use crate::boolean_record::{self as recorded, Traced}; +use crate::{BoolOp, KernelError, Result}; + +/// Cuts at most, each splitting the middle piece once. +const MAX_CUTS: usize = 6; + +/// Cuts made with half boxes at most (two booleans each, with their retries): an imprint traced +/// wrong across a curved face is rare, and the booleans cost seconds on curved parts. +const MAX_BOX_CUTS: usize = 2; + +/// Operands a piece's history is over: `a`, `b`, then the two half boxes each cut may be made +/// with (dropped from the result, whose faces are none of theirs). +const OPERANDS: usize = 2 + 2 * MAX_CUTS; + +thread_local! { + /// Set while [`local_prism_boolean`] runs: the booleans it makes a cut with (a piece and a + /// half box) take the general path, not this one again. + static RUNNING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// An extent along each of three axes. +type Extent = [(f64, f64); 3]; + +/// [`crate::prism::prism_boolean`] of `a` and the prism `b`, taken in a column of `a` round `b` +/// (see the module notes). `None` when `b` is not a prism, `a` is one along `b`'s direction +/// already, or no column round `b` is clear of `a`'s other faces: the caller goes on as before. +pub(crate) fn local_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { + // A cut or an intersection: what a tool meeting the target's walls fails on. A union (a + // shell's cavity joined with its opening) is left to the general path. + if !matches!(op, BoolOp::Cut | BoolOp::Intersect) || a.face_count() > 4096 || RUNNING.with(std::cell::Cell::get) { + return None; + } + /// Clears the flag however the boolean ends, a caught panic included. + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + RUNNING.with(|r| r.set(false)); + } + } + RUNNING.with(|r| r.set(true)); + let _reset = Reset; + let d = crate::prism::prism_axis(b)?; + let size = a.size().max(b.size()); + let tol = (size * 1e-7).max(1e-9) * 10.0; + let frame = Plane::from_normal(Vec3::ZERO, d)?; + let axes = [frame.x, frame.y, d]; + // Extents come from meshes this close to the faces, widened by as much. + let mesh_tol = (size * 1e-3).max(1e-4); + let tool = widened(extent(&b.tessellate(mesh_tol).ok()?.positions, axes)?, mesh_tol); + let gap = size * 1e-4 + 1e-6; + let pieces = survey(a, d, tol, mesh_tol, axes)?.pieces; + if pieces.is_empty() { + return None; + } + // A piece of an offending face within a hair of the tool's box: no column round the tool + // can leave it out, so nothing is cut. + let reach = size * 2e-3 + 2.0 * gap; + if pieces.iter().any(|(_, e)| e.iter().zip(&tool).all(|((c0, c1), (t0, t1))| *c1 > t0 - reach && *c0 < t1 + reach)) { + return None; + } + let run = || -> Result> { + let fail = |m: &str| KernelError::Failed(format!("prism boolean in a column round the tool: {m}")); + let mut middle: Traced = (a.clone(), recorded::kept(a, 0, OPERANDS)); + let mut outside: Vec = Vec::new(); + // The sides already cut (axis, positive side): each is cut once. + let mut cut: Vec<(usize, bool)> = Vec::new(); + let mut box_cuts = 0; + for _ in 0..MAX_CUTS { + let survey = survey(&middle.0, d, tol, mesh_tol, axes).ok_or_else(|| fail("the middle piece"))?; + let off = survey.faces(); + if off.is_empty() { + break; + } + let (k, places, keep_positive) = + next_cut(&off, &tool, &survey.here, &survey.flat, &cut, axes, size, gap).ok_or_else(|| fail("no cut leaves the other faces out"))?; + cut.push((k, !keep_positive)); + let axis = axes[k]; + let (u, w) = (axes[(k + 1) % 3], axes[(k + 2) % 3]); + // The first of the places where the piece splits into parts that add up to it (a + // plane whose section through the piece doesn't close is passed over). The cut's + // half boxes, if it takes them, are operands `slot` and `slot + 1`. + let slot = 2 * cut.len(); + let (away, kept) = places + .iter() + .enumerate() + .find_map(|(i, x)| { + let plane = Plane::new(axis * *x, u, w)?; + let with_boxes = i == 0 && box_cuts < MAX_BOX_CUTS; + // Only a plane across an offending face can be traced wrong (a cone's piece + // meshed past its bounds): the others are taken as they are. + let verify = survey.pieces.iter().any(|(_, e)| e[k].0 < *x && e[k].1 > *x); + let r = split(&middle, &plane, axis, *x, keep_positive, slot, with_boxes, verify)?; + box_cuts += usize::from(r.2); + Some((r.0, r.1)) + }) + .ok_or_else(|| fail("a cut beside the tool"))?; + outside.extend(away); + middle = kept; + } + if !survey(&middle.0, d, tol, mesh_tol, axes).is_some_and(|s| s.pieces.is_empty()) { + return Err(fail("other faces are left round the tool")); + } + let r = match crate::prism::prism_boolean(&middle.0, b, op) { + Some(r) => r?, + None => return Err(fail("the column is not a prism along the tool")), + }; + crate::polybool::reassembled(a, b, op, &middle, r, outside, OPERANDS, &fail) + }; + Some(run()) +} + +/// `piece` split by `plane` (at `x` along `axis`, square to it): the parts away from the tool +/// and the one on its side (positive along `axis` when `keep_positive`), with their histories. +/// An imprint of the plane, taken as it is unless `verify`; else where its parts don't add up to +/// the piece (a plane across a cone can be traced wrong) and `with_boxes`, the piece's intersections with the half boxes +/// either side (the cut's operands `slot` and `slot + 1`), and then whether it took them. `None` +/// when neither adds up. +#[allow(clippy::too_many_arguments)] +fn split( + piece: &Traced, + plane: &Plane, + axis: Vec3, + x: f64, + keep_positive: bool, + slot: usize, + with_boxes: bool, + verify: bool, +) -> Option<(Vec, Traced, bool)> { + if !verify { + let (away, kept) = crate::polybool::split_beside(piece, plane, axis, x, keep_positive)?; + return Some((away, kept, false)); + } + let whole = crate::measure(&piece.0).ok()?.volume; + let adds_up = |away: &[Traced], kept: &Traced| { + let sum: Option = away.iter().chain([kept]).map(|(p, _)| crate::measure(p).ok().map(|m| m.volume)).sum(); + sum.is_some_and(|sum| (sum - whole).abs() <= whole.abs() * 5e-5 + 1e-9) + }; + if let Some((away, kept)) = crate::polybool::split_beside(piece, plane, axis, x, keep_positive) + && adds_up(&away, &kept) + { + return Some((away, kept, false)); + } + if !with_boxes { + return None; + } + let boxes = (crate::ops::half_box(&piece.0, plane, !keep_positive).ok()?, crate::ops::half_box(&piece.0, plane, keep_positive).ok()?); + let (h_away, h_kept) = (recorded::kept(&boxes.0, slot, OPERANDS), recorded::kept(&boxes.1, slot + 1, OPERANDS)); + let away = crate::ops::boolean(&piece.0, &boxes.0, BoolOp::Intersect).ok()?; + let kept = crate::ops::boolean(&piece.0, &boxes.1, BoolOp::Intersect).ok()??; + let away: Vec = away + .into_iter() + .map(|o| { + let h = recorded::of_pair(&o, &piece.1, &h_away); + (o, h) + }) + .collect(); + let kept: Traced = { + let h = recorded::of_pair(&kept, &piece.1, &h_kept); + (kept, h) + }; + adds_up(&away, &kept).then_some((away, kept, true)) +} + +/// The extent of `points` along each of `axes`; `None` for no points. +fn extent(points: &[Vec3], axes: [Vec3; 3]) -> Option { + if points.is_empty() { + return None; + } + let mut e = [(f64::INFINITY, f64::NEG_INFINITY); 3]; + for p in points { + for (slot, axis) in e.iter_mut().zip(axes) { + let t = p.dot(axis); + *slot = (slot.0.min(t), slot.1.max(t)); + } + } + e.iter().all(|(lo, hi)| lo.is_finite() && hi.is_finite()).then_some(e) +} + +/// What a cut is chosen from: a piece's extent (`here`), the mesh triangles of its faces that +/// are neither a wall along the direction nor a plane square to it (the faces that keep it from +/// being a prism along it: each one's index and extent), and its planar faces (unit normal, +/// offset along it) for a cut to keep out of. Extents are widened by the mesh's tolerance. +struct Survey { + here: Extent, + pieces: Vec<(usize, Extent)>, + flat: Vec<(Vec3, f64)>, +} + +impl Survey { + /// The extent of each offending face. + fn faces(&self) -> Vec { + let mut faces: Vec<(usize, Extent)> = Vec::new(); + for (i, e) in &self.pieces { + match faces.iter_mut().find(|(j, _)| j == i) { + Some((_, acc)) => { + for (x, y) in acc.iter_mut().zip(e) { + *x = (x.0.min(y.0), x.1.max(y.1)); + } + } + None => faces.push((*i, *e)), + } + } + faces.into_iter().map(|(_, e)| e).collect() + } +} + +/// [`Survey`] of `body` along `d`, from one mesh of it. `None` when it can't be meshed. +fn survey(body: &Body, d: Vec3, tol: f64, mesh_tol: f64, axes: [Vec3; 3]) -> Option { + let mut work = 262_144usize; + let mut flat = Vec::new(); + let off: Vec = body + .solid + .face_iter() + .map(|f| { + let surface = f.oriented_surface(); + match &surface { + mt::Surface::Plane(p) => { + let n = p.normal(); + let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() else { return true }; + let o = p.origin(); + flat.push((n, n.dot(Vec3::new(o.x, o.y, o.z)))); + let along = n.dot(d).abs(); + !(along > 1.0 - 1e-9 || along < 1e-9) + } + _ => !crate::prism::wall_along(&surface, d, tol, &mut work), + } + }) + .collect(); + let mesh = body.tessellate(mesh_tol).ok()?; + let here = widened(extent(&mesh.positions, axes)?, mesh_tol); + let mut pieces = Vec::new(); + if off.iter().any(|x| *x) { + for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { + let i = *fi as usize; + if !off.get(i).copied().unwrap_or(false) { + continue; + } + let Some(tri) = mesh.tri(t) else { continue }; + if let Some(e) = extent(&tri, axes) { + pieces.push((i, widened(e, mesh_tol))); + } + } + } + Some(Survey { here, pieces, flat }) +} + +/// `e` grown by `by` each way. +fn widened(mut e: Extent, by: f64) -> Extent { + for (lo, hi) in e.iter_mut() { + *lo -= by; + *hi += by; + } + e +} + +/// The next cut of the middle piece (its extent `here`, its planar faces `flat`): the axis, the +/// place along it and whether the tool's side is the positive one. A cut leaving the most +/// offending faces (`off`) wholly beyond it, on a side not `cut` yet, placed halfway to the nearest of them; failing +/// that, one beside the tool across the most of them (on an axis square to the direction +/// first). `None` when no cut beside the tool has any offending face beyond it. +#[allow(clippy::too_many_arguments)] +fn next_cut( + off: &[Extent], + tool: &Extent, + here: &Extent, + flat: &[(Vec3, f64)], + cut: &[(usize, bool)], + axes: [Vec3; 3], + size: f64, + gap: f64, +) -> Option<(usize, Vec, bool)> { + // Places between `near` (beside the tool) and `far`, best first, out of the planes of the + // piece's faces square to the axis. + let place = |k: usize, near: f64, far: f64| -> Option> { + let axis = axes[k]; + let places: Vec = [0.5, 0.37, 0.63, 0.25, 0.75, 0.1, 0.9] + .iter() + .map(|t| near + (far - near) * t) + .filter(|x| !flat.iter().any(|(n, o)| n.dot(axis).abs() > 1.0 - 1e-9 && (o * n.dot(axis).signum() - x).abs() < gap)) + .collect(); + (!places.is_empty()).then_some(places) + }; + let mut whole: Option<(usize, usize, Vec, bool)> = None; + let mut across: Option<(usize, usize, Vec, bool)> = None; + let beside = size * 2e-3; + for k in [0usize, 1, 2] { + let (t0, t1) = tool[k]; + let (h0, h1) = here[k]; + for positive_side in [true, false] { + if cut.contains(&(k, positive_side)) { + continue; + } + // Past the tool on this side: is there any of the piece to cut away? + let (edge, room) = if positive_side { (t1 + gap, h1 - t1) } else { (t0 - gap, t0 - h0) }; + if room <= 2.0 * gap { + continue; + } + let beyond: Vec = off + .iter() + .filter_map(|e| { + let (c0, c1) = e[k]; + if positive_side { (c0 > edge).then_some(c0) } else { (c1 < edge).then_some(c1) } + }) + .collect(); + if let Some(nearest) = beyond.iter().copied().reduce(if positive_side { f64::min } else { f64::max }) { + let Some(x) = place(k, edge, nearest) else { continue }; + if whole.as_ref().is_none_or(|(_, n, ..)| beyond.len() > *n) { + whole = Some((k, beyond.len(), x, !positive_side)); + } + continue; + } + if k == 2 && across.is_some() { + continue; + } + let near = edge + if positive_side { beside } else { -beside }; + let crossing = off.iter().filter(|e| if positive_side { e[k].1 > near } else { e[k].0 < near }).count(); + let limit = if positive_side { h1 } else { h0 }; + if crossing == 0 { + continue; + } + let Some(x) = place(k, edge, near + (near - edge)) + .map(|p| p.into_iter().filter(|x| if positive_side { *x < limit - gap } else { *x > limit + gap }).collect::>()) + .filter(|p| !p.is_empty()) + else { + continue; + }; + if across.as_ref().is_none_or(|(_, n, ..)| crossing > *n) { + across = Some((k, crossing, x, !positive_side)); + } + } + } + whole.or(across).map(|(k, _, x, keep_positive)| (k, x, keep_positive)) +} diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index b6253d6f..12d35e35 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -66,7 +66,7 @@ "62": {"area":8771.003732,"bodies":1,"edges":60,"error":null,"faces":23,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","circular pattern","cut box","cut box","join cylinder"],"unsupported":2,"volume":40107.001728}, "63": {"area":3198.797259,"bodies":1,"edges":84,"error":null,"faces":30,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","join box","cut cylinder","hole","cut cylinder","join box","fillet"],"unsupported":0,"volume":7075.892751}, "64": {"area":4088.335645,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","fillet","join cylinder"],"unsupported":0,"volume":8495.220295}, - "65": {"area":8442.175249,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut cylinder","fillet","rectangular pattern","circular pattern","circular pattern","fillet","cut box"],"unsupported":0,"volume":45117.305023}, + "65": {"area":8436.255548,"bodies":1,"edges":37,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut cylinder","fillet","rectangular pattern","circular pattern","circular pattern","fillet","cut box"],"unsupported":0,"volume":45143.475817}, "66": {"area":9222.76638,"bodies":1,"edges":99,"error":null,"faces":37,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","join box","join box","join box","cut sphere","cut box"],"unsupported":1,"volume":35217.203139}, "67": {"area":7943.46222,"bodies":2,"edges":88,"error":null,"faces":37,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","cut cylinder","hole","join cylinder","join box","hole","chamfer","rectangular pattern"],"unsupported":2,"volume":27607.962186}, "68": {"area":7338.675126,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","hole","hole","rectangular pattern","rectangular pattern","fillet","circular pattern"],"unsupported":0,"volume":29780.741772}, @@ -124,6 +124,7 @@ {"reason":"#1390: a chamfer of a convex edge that would grow the part is refused (TooLarge). Seed 57's 0.5 mm chamfer on the sphere cut's rim, which grew the part since #391 reordered its steps, is now refused, so the run goes on to its later steps and ends ok (16017.21 mm³, 24 faces)","seeds":[57]}, {"reason":"Seed 83 builds: a shell splits a saddle corner (convex and concave edges in turn) into two corners","seeds":[83]}, {"reason":"seed 15 now cuts around a sealed void","seeds":[15]}, - {"reason":"#1490: when the last-resort nudge fails both ways, two more screw axes are tried; seed 63's final box join (its face 0.00005 mm off the half-ring's cut face) now builds instead of being refused with an implausible volume, and the design goes on to its fillet","seeds":[63]} + {"reason":"#1490: when the last-resort nudge fails both ways, two more screw axes are tried; seed 63's final box join (its face 0.00005 mm off the half-ring's cut face) now builds instead of being refused with an implausible volume, and the design goes on to its fillet","seeds":[63]}, + {"reason":"a prism cut in a column round the tool (prism_local): seed 65's rectangular pattern (step 4, a second half-cylinder groove overlapping the first, in a block with a rounded vertical edge) is now built by the exact 2D route in a column clear of the round, where the B-rep tries left it with split edges. The step's part has the 10 faces, 24 edges and 16 vertices it should (28 edges and 18 vertices before), and its volume is 45145.383 mm³ against 45145.214 (exactly 45145.103, both within the mesh measure); the generator then picks other edges for its step 7 fillet, so the run ends on another design (45143.48 mm³, 15 faces)","seeds":[65]} ] }