diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 30a7599a4c..0e4c48bf9e 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3387,6 +3387,84 @@ struct ExtrudeConstructor { interval: (f64, f64), } +/// The tool of an Extrude whose profile is a whole planar face of its one target (the face's +/// outline projected into the sketch), started at the face (#2040). The projection fits an edge +/// that is no line or arc, so the profile's tool lies a hair off the body all round its outline, +/// and the boolean fails there. A join takes the body's own face swept instead, which is sewn on +/// along it. A cut whose walls take nothing from the body (all of it within the depth lies over +/// the face) takes a slab beyond the depth plane instead, which meets the body only across that +/// plane; one whose walls cut into the body at some edges keeps those walls, exactly, and moves +/// the others off it (`kernel::cut_tool`). `None` (the profile's own tool) otherwise. With the +/// interval along the normal it spans. +fn whole_face_tool( + st: &ModelState, + targets: &[String], + ss: &SolvedSketch, + r: &Region2, + operation: Operation, + lo: f64, + hi: f64, +) -> Option<(Traced, (f64, f64))> { + if !matches!(operation, Operation::Join | Operation::Cut) || hi - lo <= 0.0 { + return None; + } + // An outline of lines and arcs is the face's own: the profile's tool already meets the body + // exactly, and keeps the names later features attach to (#489). + if r.outer.segs.iter().all(|s| matches!(s, solidcraft_geom::Seg2::Line { .. } | solidcraft_geom::Seg2::Arc { .. })) { + return None; + } + let mut candidates = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)); + let target = candidates.next()?; + if candidates.next().is_some() { + return None; + } + let face = kernel::matching_face(&target.body, &ss.plane, r, &[lo, hi])?; + let n = ss.plane.normal(); + let tol = target.body.size() * 1e-6 + 1e-9; + let up = face.normal.dot(n) > 0.0; + let (at_lo, at_hi) = ((face.height - lo).abs() <= tol, (face.height - hi).abs() <= tol); + let roles = |region| crate::lineage::ProfileRoles { + sketch: Some(ss), + region: Some(region), + caps: [Some(crate::lineage::extrude_cap_role(lo)), Some(crate::lineage::extrude_cap_role(hi))], + }; + match operation { + // Out of the body from the face. + Operation::Join if (up && at_lo) || (!up && at_hi) => { + let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss)).ok()?; + let [built] = built.as_slice() else { return None }; + let exact = kernel::face_prism(&target.body, face, hi - lo, true, &built.body).ok()?; + kernel::can_join_touching_faces(&target.body, &exact).then(|| (roles(r).tool(exact, Some(&built.body)), (lo, hi))) + } + // Into the body from the face, its walls touching it at most. + Operation::Cut if ((up && at_hi) || (!up && at_lo)) && !kernel::cut_takes_only_the_face(&target.body, face, hi - lo) => { + // Some walls go into the body: those that only touch it are moved off. + let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss)).ok()?; + let [built] = built.as_slice() else { return None }; + let (tool, off) = kernel::cut_tool(&target.body, face, hi - lo, Some(&built.body))?; + let span = if up { (lo, hi + off) } else { (lo - off, hi) }; + Some((roles(r).tool(tool, Some(&built.body)), span)) + } + Operation::Cut if (up && at_hi) || (!up && at_lo) => { + // Over the whole body, from the depth plane out past it. + let reach = target.body.size() * 2.0 + (hi - lo); + let c = r.outer.centroid(); + let p = |x: f64, y: f64| Vec2::new(c.x + x * reach, c.y + y * reach); + let line = |a: Vec2, b: Vec2| solidcraft_geom::Seg2::Line { a, b }; + let corners = [p(-1.0, -1.0), p(1.0, -1.0), p(1.0, 1.0), p(-1.0, 1.0)]; + let segs = (0..4).filter_map(|i| Some(line(*corners.get(i)?, *corners.get((i + 1) % 4)?))).collect(); + let slab = Region2 { outer: solidcraft_geom::Loop2 { segs }, holes: vec![] }; + let (l, h) = if up { (lo, hi + reach) } else { (lo - reach, hi) }; + let built = centered_extrude(&ss.plane, &slab, l, h, None).ok()?; + let [built] = built.as_slice() else { return None }; + #[cfg(test)] + whole_face_tests::slab_taken(); + Some((roles(&slab).tool(built.body.clone(), None), (l, h))) + } + _ => None, + } +} + /// Construct planar caps locally before placing the completed tool. fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketch: Option<&SolvedSketch>) -> Result> { if !plane.origin.is_finite() @@ -3696,6 +3774,15 @@ fn feature_tools_with_constructor( None }; for r in regions.iter().filter(|_| !touching_batch) { + if regions.len() == 1 + && let Some((tool, (l2, h2))) = whole_face_tool(st, &plain_targets, &ss, r, *operation, lo + off, hi + off) + { + untrimmed &= l2 == lo + off && h2 == hi + off; + tools.push(tool); + footprints.push(r.clone()); + extents.push((l2, h2)); + continue; + } if *operation == Operation::Join { let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?; if exact.iter().all(|tool| { @@ -6314,6 +6401,10 @@ mod chain_tests { #[path = "eval_cost_tests.rs"] mod cost_tests; +#[cfg(test)] +#[path = "eval_whole_face_tests.rs"] +mod whole_face_tests; + #[cfg(test)] mod body_name_tests { use super::*; diff --git a/crates/doc/src/eval_whole_face_tests.rs b/crates/doc/src/eval_whole_face_tests.rs new file mode 100644 index 0000000000..348a9480e0 --- /dev/null +++ b/crates/doc/src/eval_whole_face_tests.rs @@ -0,0 +1,85 @@ +//! Extrudes from a whole face whose outline is no lines and arcs (#2040), checked through the +//! tool `whole_face_tool` picks. + +use std::cell::Cell; + +use serde_json::json; +use solidcraft_geom::{Plane, Vec2, Vec3}; +use solidcraft_kernel as kernel; +use solidcraft_sketch::{CurveKind, Sketch}; + +use crate::*; + +thread_local! { + static SLABS: Cell = const { Cell::new(0) }; +} + +/// A cut took a slab over the whole body as its tool, on this thread. +pub(super) fn slab_taken() { + SLABS.with(|n| n.set(n.get() + 1)); +} + +/// A closed spline through six points round the origin, on the plane at height `z`. +fn spline_sketch(z: f64) -> FeatureKind { + let mut sk = Sketch::new(); + let fit = [(8.0, 0.0), (4.0, 6.0), (-3.0, 7.0), (-7.0, 1.0), (-4.0, -6.0), (3.0, -5.0)]; + let mut pts: Vec = fit.iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect(); + pts.push(pts[0]); + sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap(); + let plane = Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY }; + FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: sk } +} + +fn extrude(doc: &mut Document, sketch: u64, extent: serde_json::Value, operation: Operation) { + let kind = FeatureKind::Extrude { + thin: None, + sketch: SketchId(sketch), + profiles: ProfileSel::All, + extent: serde_json::from_value(extent).unwrap(), + operation, + targets: vec![], + }; + doc.add_feature(kind, None).unwrap(); +} + +fn volume(model: &Model) -> f64 { + let state = model.state(); + assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some())); + assert_eq!(state.bodies.len(), 1); + assert!(state.bodies[0].body.validity().is_empty(), "{:?}", state.bodies[0].body.validity()); + kernel::measure(&state.bodies[0].body).unwrap().volume +} + +/// A prism 10 tall on a closed spline, standing on a 40 x 40 x 5 plate, cut 3 into its whole +/// top face. The walls only touch the prism, so the cut takes a slab over the whole body from 3 +/// below the face up: it must take the face's area times 3 and leave the plate alone. +#[test] +fn slab_cut_into_a_spline_prism() { + let mut doc = Document::new("spline prism"); + let plate = FeatureKind::Box { + corner: point_expr(Vec3::new(-20.0, -20.0, 0.0)), + length: "40 mm".into(), + width: "40 mm".into(), + height: "5 mm".into(), + operation: Operation::NewBody, + }; + doc.add_feature(plate, None).unwrap(); + let base = doc.add_feature(spline_sketch(5.0), None).unwrap(); + extrude(&mut doc, base, json!({"distance": "10 mm"}), Operation::Join); + let mut model = Model::new(); + model.evaluate(&doc); + let joined = volume(&model); + let area = (joined - 40.0 * 40.0 * 5.0) / 10.0; + assert!(area > 100.0, "the prism's area {area}"); + + let top = doc.add_feature(spline_sketch(15.0), None).unwrap(); + extrude(&mut doc, top, json!({"distance": "3 mm", "direction": "negative"}), Operation::Cut); + SLABS.with(|n| n.set(0)); + model.evaluate(&doc); + let cut = volume(&model); + assert_eq!(SLABS.with(Cell::get), 1, "the cut took the slab"); + assert!((joined - cut - area * 3.0).abs() < 1e-3 * area, "took {}, expected {}", joined - cut, area * 3.0); + let bounds = model.state().bodies[0].mesh().bounds(); + assert!((bounds.max.z - 12.0).abs() < 1e-6, "top at {}", bounds.max.z); + assert!(bounds.min.dist(Vec3::new(-20.0, -20.0, 0.0)) < 1e-6, "plate from {:?}", bounds.min); +} diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 2ed8cf4d5d..862f929de4 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -1150,3 +1150,76 @@ fn joined_loft_frames_reference_mesh_covers_each_face_once() { } } } + +/// The block of #2040 with `extra` run after it. +fn rounded_block_and(extra: Value) -> Session { + let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap(); + script["commands"].as_array_mut().unwrap().push(extra); + replay(&script.to_string()) +} + +/// #2040: the slanted face of a block whose ends fillets round, extruded as a join. Its outline's +/// quarter-ellipses come into the face's sketch as splines a hair off the body's edges, and the +/// join failed with "boolean Union: no result"; it now sweeps the body's own face. It adds the +/// face's area times its height: a 10 x 12 face less four corners of (1 - pi/4) r * r*sqrt(2). +#[test] +fn extrude_join_from_a_face_rounded_by_fillets() { + let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]); + let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1"); + let r = 3.8_f64; + let area = 10.0 * 12.0 - 4.0 * (1.0 - std::f64::consts::FRAC_PI_4) * r * r * 2f64.sqrt(); + let joined = rounded_block_and(json!({"command": "solid.extrude", "params": {"face": at, "distance": 5, "operation": "join"}})); + let bodies = &joined.model.state().bodies; + assert_eq!(bodies.len(), 1, "the join makes no second body"); + assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity()); + let v = volume(&joined, "Body1"); + assert!((v - (before + area * 5.0)).abs() < 0.05, "join {v} expected {}", before + area * 5.0); +} + +/// #2040: a cut 3 mm into the slanted face of the block rounded along its top only (its bottom +/// edge left sharp). The walls from the bottom edge go into the body and the others only touch +/// it, along edges with curved faces beside them: the cut failed with "boolean Cut: no result". +/// In the face's plane the body's section at depth t is the block's section (10 wide, h high, its +/// top corners rounded) moved down the face by t: the cut takes +/// √2 ∫₀³ (10 (h − t/√2) − 2 (1 − π/4) r²) dt. +#[test] +fn extrude_cut_into_a_face_beside_rounds() { + let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]); + let top_only = |extra: Option| { + let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap(); + let commands = script["commands"].as_array_mut().unwrap(); + let fillet = commands.last_mut().unwrap(); + fillet["params"]["edges"] = json!([[10, -10, 8.485281374238571], [10, 0, 8.485281374238571]]); + commands.extend(extra); + replay(&script.to_string()) + }; + let before = volume(&top_only(None), "Body1"); + let cut = top_only(Some(json!({"command": "solid.extrude", "params": {"face": at, "distance": 3, "direction": "negative", "operation": "cut"}}))); + let bodies = &cut.model.state().bodies; + assert_eq!(bodies.len(), 1); + assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity()); + let (h, r, d, w) = (8.485281374238571_f64, 3.8_f64, 3.0_f64, 10.0_f64); + let corner = (1.0 - std::f64::consts::FRAC_PI_4) * r * r; + let taken = 2f64.sqrt() * w * h * d - w * d * d / 2.0 - 2.0 * 2f64.sqrt() * corner * d; + let v = before - volume(&cut, "Body1"); + assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}"); +} + +/// #2040: a cut 2 mm into the top face of the block rounded at its ends, the strip 10 − 2r wide +/// between the rounds. Below the face the rounds stand out past its outline, so a slab over the +/// whole body would take them too: the cut takes only what lies over the face, at height z from +/// x = 0 to the slanted face at x = x0 + z. +#[test] +fn extrude_cut_into_a_rounded_block_top() { + let (h, r, x0, d) = (8.485281374238571_f64, 3.8_f64, 11.514718625761429_f64, 2.0_f64); + let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1"); + let cut = rounded_block_and( + json!({"command": "solid.extrude", "params": {"face": [5, -5, h], "distance": d, "direction": "negative", "operation": "cut"}}), + ); + let bodies = &cut.model.state().bodies; + assert_eq!(bodies.len(), 1); + assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity()); + let taken = (10.0 - 2.0 * r) * (x0 * d + (h * h - (h - d) * (h - d)) / 2.0); + let v = before - volume(&cut, "Body1"); + assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}"); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 239e719c17..25e8013bd5 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -153,6 +153,7 @@ mod tspline; #[cfg(test)] mod untouched_faces_tests; pub mod watch; +mod whole_face; pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each}; pub use blend_corner::fillet_radii; @@ -220,6 +221,7 @@ pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_ pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at}; pub use topo::{TopoCounts, merged_topology, seam_flags}; pub use tspline::tspline_sheet; +pub use whole_face::{WholeFace, cut_takes_only_the_face, cut_tool, face_prism, matching_face}; #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum KernelError { diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index 94c52b1530..95f1885fa9 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -796,7 +796,7 @@ fn unsupported_shell(m: &str) -> KernelError { KernelError::Failed(format!("not supported yet: {m}")) } -fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { +pub(crate) fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { let n = (b - a).cross(c - a); let Some(nn) = n.normalized() else { return f64::INFINITY }; let h = (p - a).dot(nn); diff --git a/crates/kernel/src/whole_face.rs b/crates/kernel/src/whole_face.rs new file mode 100644 index 0000000000..4d172fdb6d --- /dev/null +++ b/crates/kernel/src/whole_face.rs @@ -0,0 +1,364 @@ +//! Extrudes from a whole planar face of a body (#2040). The profile of a face is its outline +//! projected into a sketch, and an edge that is no line or arc comes in fitted: the elliptic +//! arcs where a fillet meets a slanted face are cubic splines a hair off the body's edges. A +//! join's tool then touches the body along a face it does not match, and a cut's walls lie a +//! hair inside or outside the body all round: the boolean finds pieces of no area and gives up. +//! +//! When the profile is the face's outline (to within the fit), the face itself is used: a join +//! sweeps the body's own face, which shares its edges with the body and is sewn on along it, and +//! a cut whose walls take nothing from the body (all of it within the cut's depth lies over the +//! face) is the body cut by a slab beyond the depth plane, which meets it only across that plane. + +use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3}; +use truck_modeling as mt; + +use crate::body::{Body, v3}; +use crate::history::{Change, History}; +use crate::{KernelError, Result}; + +/// Most boundary samples compared per outline. +const MAX_SAMPLES: usize = 20_000; + +/// A planar face of `target` and where it lies. +#[derive(Clone, Copy, Debug)] +pub struct WholeFace { + /// The face's index in `target`. + pub face: usize, + /// Its outward normal. + pub normal: Vec3, + /// Its height along the sketch normal, from the sketch plane's origin. + pub height: f64, +} + +/// How far a fitted outline may lie from the face's edges: the projection fits a smooth chain to +/// within 1e-4 of its extent (`doc::project`), and the mesh the edges are sampled from sags. +fn outline_tol(target: &Body) -> f64 { + (target.size() * 5e-4).max(1e-4) +} + +/// The planar face of `target` at one of `heights` along `plane`'s normal (from its origin) +/// whose outline is `region`'s outer loop, to within the projection's fit, with no holes on +/// either side. +pub fn matching_face(target: &Body, plane: &Plane, region: &Region2, heights: &[f64]) -> Option { + if !region.holes.is_empty() { + return None; + } + let n = plane.normal(); + let tol = outline_tol(target); + let mesh = target.tessellate(tol * 0.2).ok()?; + let ring: Vec = region.outer.polyline(tol * 0.1).into_vec(); + if ring.len() < 3 || ring.len() > MAX_SAMPLES { + return None; + } + let faces: Vec<&mt::Face> = target.solid.face_iter().collect(); + for (i, f) in faces.iter().enumerate() { + let Some(m) = crate::blend::plane_normal(f) else { continue }; + if m.dot(n).abs() < 1.0 - 1e-9 || f.boundaries().len() != 1 { + continue; + } + let edges = face_outline(&mesh, i); + let Some(height) = edges.first().and_then(|e| e.first()).map(|p| (*p - plane.origin).dot(n)) else { continue }; + if !heights.iter().any(|h| (h - height).abs() <= tol) { + continue; + } + // The outline in the sketch's plane, at the face's height. + let at = |p: Vec2| plane.to_world(p) + n * height; + let ring3: Vec = ring.iter().map(|p| at(*p)).collect(); + let segs: Vec<(Vec3, Vec3)> = edges.iter().flat_map(|e| e.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?)))).collect(); + if segs.is_empty() || segs.len() > MAX_SAMPLES { + continue; + } + let near_edges = |p: Vec3| segs.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol); + let ring_segs: Vec<(Vec3, Vec3)> = (0..ring3.len()).filter_map(|k| Some((*ring3.get(k)?, *ring3.get((k + 1) % ring3.len())?))).collect(); + let near_ring = |p: Vec3| ring_segs.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol); + let off_plane = segs.iter().any(|(a, _)| ((*a - plane.origin).dot(n) - height).abs() > tol); + if !off_plane && ring3.iter().all(|p| near_edges(*p)) && segs.iter().all(|(a, _)| near_ring(*a)) { + return Some(WholeFace { face: i, normal: m, height }); + } + } + None +} + +/// The boundary polylines of face `face` of `mesh`, seams left out. +fn face_outline(mesh: &Mesh, face: usize) -> Vec> { + let Ok(f) = u32::try_from(face) else { return Vec::new() }; + mesh.face_edges(f).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).filter_map(|e| mesh.edges.get(e).cloned()).collect() +} + +/// The prism `built` (made from a profile that [`matching_face`] matched to `face`) made again +/// from the face itself: the face swept `length` along its outward normal, `outward` or back +/// into the body. Each of its faces comes from the face of `built` it lies on, so the feature +/// names its faces as it named `built`'s. +pub fn face_prism(target: &Body, face: WholeFace, length: f64, outward: bool, built: &Body) -> Result { + if !length.is_finite() || length <= 0.0 { + return Err(KernelError::Invalid("a prism from a face needs a positive length".into())); + } + let f = target.solid.face_iter().nth(face.face).cloned().ok_or_else(|| KernelError::Invalid("no such face".into()))?; + let along = if outward { face.normal * length } else { face.normal * -length }; + let swept = crate::guard("prism from a face", || Body::new(mt::builder::tsweep(&f, v3(along))))?; + traced_onto(swept, built, outline_tol(target)) +} + +/// `swept`, made in place of `built`, with each of its faces coming from the face of `built` +/// nearest it (so a feature names its faces as it named `built`'s). +fn traced_onto(swept: Body, built: &Body, tol: f64) -> Result { + let (mine, theirs) = (swept.tessellate(tol)?, built.tessellate(tol)?); + let mut faces = Vec::with_capacity(swept.face_count()); + for i in 0..swept.face_count() { + let probe = face_probe(&mine, i).ok_or_else(|| KernelError::Failed("tool from a face: a face with no mesh".into()))?; + let from = nearest_face(&theirs, probe).ok_or_else(|| KernelError::Failed("tool from a face: no face under it".into()))?; + faces.push(crate::record::from_face(0, from, Change::Modified)); + } + let history: History = crate::record::from_faces(&swept.solid, 1, faces); + swept.with_history_counts(history, &crate::record::counts(&[built])) +} + +/// The centroid of face `face`'s largest triangle. +fn face_probe(mesh: &Mesh, face: usize) -> Option { + let f = u32::try_from(face).ok()?; + mesh.triangles + .iter() + .zip(&mesh.tri_face) + .filter(|(_, tf)| **tf == f) + .filter_map(|(t, _)| mesh.tri(t)) + .max_by(|x, y| (x[1] - x[0]).cross(x[2] - x[0]).len().total_cmp(&(y[1] - y[0]).cross(y[2] - y[0]).len())) + .map(|[a, b, c]| (a + b + c) / 3.0) +} + +/// The face of `mesh` nearest `p`. +fn nearest_face(mesh: &Mesh, p: Vec3) -> Option { + mesh.triangles + .iter() + .zip(&mesh.tri_face) + .filter_map(|(t, f)| mesh.tri(t).map(|[a, b, c]| (crate::polyhedron::point_tri(p, a, b, c), *f))) + .min_by(|x, y| x.0.total_cmp(&y.0)) + .and_then(|(_, f)| usize::try_from(f).ok()) +} + +/// Whether a cut `depth` into `target` from `face` takes only what lies over the face: nothing of +/// the body stands beyond the face's plane, and everything of it less than `depth` below lies +/// over the face (on its outline at most). Then the prism over the face and a slab over the whole +/// body take the same material, and the slab meets the body only across its depth plane. +pub fn cut_takes_only_the_face(target: &Body, face: WholeFace, depth: f64) -> bool { + if !depth.is_finite() || depth <= 0.0 { + return false; + } + let tol = outline_tol(target); + let Ok(mesh) = target.tessellate(tol * 0.2) else { return false }; + let Some(origin) = face_outline(&mesh, face.face).first().and_then(|e| e.first().copied()) else { return false }; + let n = face.normal; + // The face's plane with the outline in it, flattened. + let Some(x) = (if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).cross(n).normalized() else { return false }; + let y = n.cross(x); + let flat = |p: Vec3| Vec2::new((p - origin).dot(x), (p - origin).dot(y)); + let segs: Vec<(Vec2, Vec2)> = face_outline(&mesh, face.face) + .iter() + .flat_map(|e| e.windows(2).filter_map(|w| Some((flat(*w.first()?), flat(*w.get(1)?)))).collect::>()) + .collect(); + if segs.len() < 3 || segs.len() > MAX_SAMPLES { + return false; + } + let mut points = mesh.positions.clone(); + points.extend(mesh.triangles.iter().filter_map(|t| mesh.tri(t)).map(|[a, b, c]| (a + b + c) / 3.0)); + points.iter().all(|p| { + let h = (*p - origin).dot(n); + h <= tol && (h <= -depth + tol || over(&segs, flat(*p), tol)) + }) +} + +/// How a wall swept into the body from an edge of the face meets the body along that edge, from +/// the face beside it (outward normal `n_N`) and the sweep's direction `−n_P`: `n_N · (−n_P)` +/// above zero leaves the body (the wall only touches it along the edge), zero lies in the face +/// beside it, below zero goes into the material (a real cut). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Wall { + Touches, + Cuts, +} + +/// The walls of each edge of face `face` of `mesh` (by mesh edge), `None` where an edge's wall +/// changes from one to the other along it. +fn walls(mesh: &Mesh, face: usize, n: Vec3, tol: f64) -> Option> { + let f = u32::try_from(face).ok()?; + let mut out = Vec::new(); + for e in mesh.face_edges(f) { + if mesh.seams.get(e).copied().unwrap_or(false) { + continue; + } + let other = mesh.edge_faces.get(e)?.iter().copied().find(|g| *g != f)?; + let pts = mesh.edges.get(e)?; + let tris: Vec<[Vec3; 3]> = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g == other).filter_map(|(t, _)| mesh.tri(t)).collect(); + let dist = |p: Vec3, t: &[Vec3; 3]| crate::polyhedron::point_tri(p, t[0], t[1], t[2]); + // Each point's nearest triangle of the face beside, by its normal. + let mut signs = pts.iter().filter_map(|p| { + let [a, b, c] = *tris.iter().min_by(|x, y| dist(*p, x).total_cmp(&dist(*p, y)))?; + let m = (b - a).cross(c - a).normalized()?; + Some(if -m.dot(n) >= -tol { Wall::Touches } else { Wall::Cuts }) + }); + let first = signs.next()?; + if signs.any(|s| s != first) { + return None; + } + out.push((e, first)); + } + Some(out) +} + +/// Whether `q` lies inside the outline made of `segs` (in any order and direction, by even-odd +/// crossings) or within `tol` of it. +fn over(segs: &[(Vec2, Vec2)], q: Vec2, tol: f64) -> bool { + let on = segs.iter().any(|(a, b)| { + let ab = *b - *a; + let t = if ab.len2() > 0.0 { ((q - *a).dot(ab) / ab.len2()).clamp(0.0, 1.0) } else { 0.0 }; + q.dist(*a + ab * t) <= tol + }); + on || segs.iter().filter(|(a, b)| (a.y > q.y) != (b.y > q.y) && q.x < a.x + (q.y - a.y) / (b.y - a.y) * (b.x - a.x)).count() % 2 == 1 +} + +/// Even-odd containment of `q` in the closed polygon `poly`. +fn inside(poly: &[Vec2], q: Vec2) -> bool { + let m = poly.len(); + (0..m) + .filter(|i| match (poly.get(*i), poly.get((i + 1) % m)) { + (Some(a), Some(b)) => (a.y > q.y) != (b.y > q.y) && q.x < a.x + (q.y - a.y) / (b.y - a.y) * (b.x - a.x), + _ => false, + }) + .count() + % 2 + == 1 +} + +/// A tool cutting `depth` into `target` from its planar face `face`, as the face swept would, with +/// the walls that only touch the body moved off it: the face's outline keeps its edges whose walls +/// cut into the body, exactly (the body's own edges), and runs round the others well outside it. +/// The tool starts a little out from the face, so its cap does not lie on it. `None` when no wall +/// cuts (the face's prism takes only what is over it, see [`cut_takes_only_the_face`]), when an +/// edge's wall changes from touching to cutting along it, or when anything of the body within the +/// depth lies where the outline runs round. Its faces come from those of `built` (the profile's +/// own tool) they lie nearest, when given. With how far out from the face it starts. +pub fn cut_tool(target: &Body, face: WholeFace, depth: f64, built: Option<&Body>) -> Option<(Body, f64)> { + // Every step reads or builds truck's shapes, so all of it runs under the guard. + crate::guard("cut tool from a face", || Ok(build_cut_tool(target, face, depth, built))).ok().flatten() +} + +fn build_cut_tool(target: &Body, face: WholeFace, depth: f64, built: Option<&Body>) -> Option<(Body, f64)> { + use mt::{BoundedCurve, ParametricCurve}; + use std::f64::consts::{PI, TAU}; + if !depth.is_finite() || depth <= 0.0 { + return None; + } + let size = target.size(); + let tol = outline_tol(target); + let mesh = target.tessellate(tol * 0.2).ok()?; + let n = face.normal; + let walls = walls(&mesh, face.face, n, 1e-6)?; + if !walls.iter().any(|(_, w)| *w == Wall::Cuts) || !walls.iter().any(|(_, w)| *w == Wall::Touches) { + return None; + } + let f = target.solid.face_iter().nth(face.face)?.clone(); + let wire = f.absolute_boundaries().first()?.clone(); + // Each edge of the face's outline (in order) with its wall, matched to the mesh's edges by + // their middles. + let mid = |e: &mt::Edge| { + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + crate::body::from_p3(c.subs((t0 + t1) * 0.5)) + }; + let wall_of = |e: &mt::Edge| { + let m = mid(e); + walls + .iter() + .filter_map(|(i, w)| { + let pts = mesh.edges.get(*i)?; + Some((pts.windows(2).filter_map(|s| Some(m.dist_to_segment(*s.first()?, *s.get(1)?))).fold(f64::INFINITY, f64::min), *w)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .filter(|(d, _)| *d <= tol) + .map(|(_, w)| w) + }; + let edges: Vec<(mt::Edge, Wall)> = wire.edge_iter().map(|e| wall_of(e).map(|w| (e.clone(), w))).collect::>()?; + // Start at a cutting edge that follows a touching one. + let k = edges.len(); + let start = + (0..k).find(|i| edges.get(*i).is_some_and(|e| e.1 == Wall::Cuts) && edges.get((i + k - 1) % k).is_some_and(|e| e.1 == Wall::Touches))?; + let edges: Vec<(mt::Edge, Wall)> = edges.iter().cycle().skip(start).take(k).cloned().collect(); + // The plane's frame, round the face's middle. + let origin = face_probe(&mesh, face.face)?; + let x = (if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).cross(n).normalized()?; + let y = n.cross(x); + let flat = |p: Vec3| Vec2::new((p - origin).dot(x), (p - origin).dot(y)); + let lift = |q: Vec2| origin + x * q.x + y * q.y; + let outline: Vec<(Vec2, Vec2)> = face_outline(&mesh, face.face) + .iter() + .flat_map(|e| e.windows(2).filter_map(|w| Some((flat(*w.first()?), flat(*w.get(1)?)))).collect::>()) + .collect(); + let centre = outline.iter().fold(Vec2::ZERO, |a, (p, _)| a + *p) / outline.len().max(1) as f64; + let far = size * 2.0; + let at = |v: &mt::Vertex| flat(crate::body::from_p3(v.point())); + // Which way the outline runs round its middle. + let turn: f64 = edges.iter().map(|(e, _)| (at(e.front()) - centre).cross(at(e.back()) - centre)).sum::().signum(); + let mut out: Vec = Vec::new(); + let mut detours: Vec> = Vec::new(); + let mut i = 0; + while let Some((e, w)) = edges.get(i) { + if *w == Wall::Cuts { + out.push(e.clone()); + i += 1; + continue; + } + // A run of touching edges from vertex `a` to vertex `b`: round it, far out. + let a = e.front().clone(); + let mut j = i; + while edges.get(j).is_some_and(|e| e.1 == Wall::Touches) { + j += 1; + } + let b = edges.get(j - 1)?.0.back().clone(); + let (pa, pb) = (at(&a), at(&b)); + let (da, db) = ((pa - centre).normalized()?, (pb - centre).normalized()?); + let (t0, mut t1) = (da.y.atan2(da.x), db.y.atan2(db.x)); + // Round the same way as the run, which goes from a to b with the outline. + while (t1 - t0) * turn <= 0.0 { + t1 += TAU * turn; + } + let steps = (((t1 - t0).abs() / (PI / 8.0)).ceil() as usize).clamp(2, 64); + // A polygon round a circle `far` out (its corners 1/cos(π/16) further), clear of it. + let r = far / (PI / 16.0).cos(); + let mut path = vec![pa]; + path.extend((0..=steps).map(|s| { + let t = t0 + (t1 - t0) * s as f64 / steps as f64; + centre + Vec2::new(t.cos(), t.sin()) * if s == 0 || s == steps { far } else { r } + })); + path.push(pb); + let mut verts = vec![a]; + verts.extend(path.iter().skip(1).take(path.len().saturating_sub(2)).map(|q| mt::builder::vertex(crate::body::p3(lift(*q))))); + verts.push(b); + out.extend(verts.windows(2).filter_map(|v| Some(mt::builder::line(v.first()?, v.get(1)?)))); + detours.push(path); + i = j; + } + // Nothing of the body within the depth may lie where the outline runs round. + let clear = mesh.positions.iter().all(|p| { + let h = (*p - origin).dot(n); + let q = flat(*p); + h >= tol || h <= -depth + tol || !detours.iter().any(|d| inside(d, q)) || over(&outline, q, tol) + }); + if !clear { + return None; + } + let mut base = mt::Face::try_new(vec![mt::Wire::from(out)], f.surface()).ok()?; + if !f.orientation() { + base.invert(); + } + // Out from the face, then swept back through it and the depth. + let off = (size * 1e-2).max(tol * 10.0); + let base = mt::builder::translated(&base, v3(n * off)); + let tool = Body::new(mt::builder::tsweep(&base, v3(n * -(off + depth)))).ok()?; + match built { + Some(built) => traced_onto(tool, built, tol).ok().map(|t| (t, off)), + None => Some((tool, off)), + } +} + +#[cfg(test)] +#[path = "whole_face_tests.rs"] +mod tests; diff --git a/crates/kernel/src/whole_face_tests.rs b/crates/kernel/src/whole_face_tests.rs new file mode 100644 index 0000000000..28c0d902ae --- /dev/null +++ b/crates/kernel/src/whole_face_tests.rs @@ -0,0 +1,110 @@ +use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +use super::*; +use crate::{BoolOp, boolean, box_solid, measure}; + +fn square(a: Vec2, b: Vec2) -> Region2 { + let p = Vec2::new; + let line = |a: Vec2, b: Vec2| Seg2::Line { a, b }; + Region2 { outer: Loop2 { segs: vec![line(a, p(b.x, a.y)), line(p(b.x, a.y), b), line(b, p(a.x, b.y)), line(p(a.x, b.y), a)] }, holes: vec![] } +} + +#[test] +fn a_profile_matches_the_face_it_outlines_and_no_other() { + let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap(); + let top = Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY }; + let whole = matching_face(&block, &top, &square(Vec2::new(0.0, 0.0), Vec2::new(20.0, 10.0)), &[0.0]).unwrap(); + assert!((whole.normal - Vec3::Z).len() < 1e-9 && whole.height.abs() < 1e-9, "{whole:?}"); + // A smaller rectangle on the same face is not the face. + assert!(matching_face(&block, &top, &square(Vec2::new(1.0, 1.0), Vec2::new(19.0, 9.0)), &[0.0]).is_none()); + // Nor is the bottom face, though its outline is the same. + assert!(matching_face(&block, &top, &square(Vec2::ZERO, Vec2::new(20.0, 10.0)), &[-3.0]).is_none()); +} + +#[test] +fn a_cut_over_the_whole_top_takes_only_the_face_and_a_step_beside_it_does_not() { + let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap(); + let top = + matching_face(&block, &Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY }, &square(Vec2::ZERO, Vec2::new(20.0, 10.0)), &[0.0]).unwrap(); + assert!(cut_takes_only_the_face(&block, top, 2.0)); + // A step standing beside the top face: a slab would take it too. + let step = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 8.0)).unwrap(); + let stepped = boolean(&block, &step, BoolOp::Union).unwrap().unwrap(); + let face = matching_face(&stepped, &Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY }, &square(Vec2::ZERO, Vec2::new(20.0, 10.0)), &[0.0]) + .unwrap(); + assert!(!cut_takes_only_the_face(&stepped, face, 2.0)); +} + +#[test] +fn a_prism_from_the_face_joins_by_its_shared_edges() { + let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap(); + let plane = Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY }; + let region = square(Vec2::ZERO, Vec2::new(20.0, 10.0)); + let face = matching_face(&block, &plane, ®ion, &[0.0]).unwrap(); + let built = crate::extrude(&plane, std::slice::from_ref(®ion), 0.0, 3.0).unwrap().remove(0); + let tool = face_prism(&block, face, 3.0, true, &built).unwrap(); + assert!(tool.history().is_some_and(|h| h.operands == 1)); + let joined = boolean(&block, &tool, BoolOp::Union).unwrap().unwrap(); + assert!((measure(&joined).unwrap().volume - 20.0 * 10.0 * 8.0).abs() < 1e-6); +} + +/// The block of #2040: one side at 45°, its top (and, with `bottom`, its bottom) rounded 3.8 mm +/// at both ends. +fn rounded_block(bottom: bool) -> Body { + let p = Vec2::new; + let line = |a: Vec2, b: Vec2| Seg2::Line { a, b }; + let h = 8.485281374238571; + let segs = vec![ + line(p(0.0, 0.0), p(11.514718625761429, 0.0)), + line(p(11.514718625761429, 0.0), p(20.0, h)), + line(p(20.0, h), p(0.0, h)), + line(p(0.0, h), p(0.0, 0.0)), + ]; + let plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z }; + let block = crate::extrude(&plane, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 10.0).unwrap().remove(0); + let mut edges = vec![Vec3::new(10.0, -10.0, h), Vec3::new(10.0, 0.0, h)]; + if bottom { + edges.extend([Vec3::new(5.757359312880714, 0.0, 0.0), Vec3::new(5.757359312880714, -10.0, 0.0)]); + } + crate::fillet(&block, &edges, 3.8).unwrap() +} + +fn slanted_face(b: &Body) -> WholeFace { + let n = Vec3::new(1.0, 0.0, -1.0).normalized().unwrap(); + let face = b.solid.face_iter().position(|f| crate::blend::plane_normal(f).is_some_and(|m| m.dot(n) > 1.0 - 1e-9)).unwrap(); + WholeFace { face, normal: n, height: 0.0 } +} + +/// Into the slanted face, the walls from its top edge and the rounds beside it leave the body +/// (n_N · −n_P = +0.707 down to 0), those from the end faces lie in them (0), and those from the +/// bottom edge and its rounds go into it (−0.707 up to 0): the bottom ones are kept, the others +/// moved off. +#[test] +fn the_walls_into_a_slanted_face_touch_at_the_top_and_cut_at_the_bottom() { + let b = rounded_block(true); + let face = slanted_face(&b); + let mesh = b.tessellate(outline_tol(&b) * 0.2).unwrap(); + let walls = walls(&mesh, face.face, face.normal, 1e-6).unwrap(); + assert_eq!(walls.iter().filter(|(_, w)| *w == Wall::Cuts).count(), 3, "{walls:?}"); + assert_eq!(walls.iter().filter(|(_, w)| *w == Wall::Touches).count(), 5, "{walls:?}"); + assert!(!cut_takes_only_the_face(&b, face, 3.0)); + assert!(cut_tool(&b, face, 3.0, None).is_some()); +} + +/// A cut 3 mm into the slanted face of the block rounded along its top: the walls from the top +/// are moved off it, the one from the bottom edge kept. What it takes: in the face's plane, the +/// body's section at depth t is the block's section (10 wide, h high, its top corners rounded) +/// moved down the face by t, so the cut takes √2 ∫₀³ (10 (h − t/√2) − 2 (1 − π/4) r²) dt. +#[test] +fn a_cut_into_a_slanted_face_beside_rounds_takes_what_lies_over_it() { + let b = rounded_block(false); + let face = slanted_face(&b); + let (tool, _) = cut_tool(&b, face, 3.0, None).unwrap(); + let cut = boolean(&b, &tool, BoolOp::Cut).unwrap().unwrap(); + assert!(cut.validity().is_empty(), "{:?}", cut.validity()); + let (h, r, d, w) = (8.485281374238571_f64, 3.8_f64, 3.0_f64, 10.0_f64); + let corner = (1.0 - std::f64::consts::FRAC_PI_4) * r * r; + let taken = 2f64.sqrt() * w * h * d - w * d * d / 2.0 - 2.0 * 2f64.sqrt() * corner * d; + let v = measure(&b).unwrap().volume - measure(&cut).unwrap().volume; + assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}"); +} diff --git a/examples/regressions/extrude_face_rounded_by_fillets.json b/examples/regressions/extrude_face_rounded_by_fillets.json new file mode 100644 index 0000000000..92b826eeb1 --- /dev/null +++ b/examples/regressions/extrude_face_rounded_by_fillets.json @@ -0,0 +1,10 @@ +{ + "description": "Issue #2040: a block with one side at 45 degrees, rounded by 3.8 mm fillets along the ends of its top and bottom. The slanted face's outline has four quarter-ellipses, which its sketch fits as splines; extruding the face as a cut or a join failed with 'boolean ... no result'. The extrude is added by the test.", + "commands": [ + {"command": "sketch.create", "params": {"plane": "XZ", "flip": true}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [11.514718625761429, 0], [20, 8.485281374238571], [0, 8.485281374238571]], "closed": true}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 10}}, + {"command": "solid.fillet", "params": {"edges": [[10, -10, 8.485281374238571], [10, 0, 8.485281374238571], [5.757359312880714, 0, 0], [5.757359312880714, -10, 0]], "radius": 3.8}} + ] +}