From 316b49cd8475cf06e6ba06e5facb868e53e11aff Mon Sep 17 00:00:00 2001 From: Yeonghun Lee <33945246+asd417@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:41:41 +0000 Subject: [PATCH] Extrude from a whole face rounded by fillets: join, and cuts whose walls only touch (#2040) (#2041) * Extrude: a join from a whole face rounded by fillets sweeps the face itself (#2040) A face's sketch fits an edge that is no line or arc (the quarter-ellipses where fillets meet a slanted face) as splines a hair off the body's edges, so a join from the face touched the body along a face it did not match and failed with "boolean Union: no result" after minutes. When the profile is a whole planar face of the one target, the join now sweeps the body's own face, which shares its edges and is sewn on along it. Co-Authored-By: Claude Opus 5.5 * Extrude: a cut into a whole face keeps the walls that cut and moves the others off (#2040) A cut from a whole planar face sweeps walls from each of its edges. Where the face beside an edge turns away from the sweep (n_N . -n_P >= 0) the wall only touches the body along that edge, or lies in the face beside it, and the boolean failed on it with "no result". The cut now keeps the walls that go into the body, exactly (the body's own edges), runs the outline round the others well outside the body, and starts a little out from the face. A cut whose walls all only touch takes a slab beyond its depth plane. Not covered: a cutting wall tangent to the face beside a touching one (a round's wall meeting an end face, as in the issue's part); the boolean still fails there. Co-Authored-By: Claude Opus 5.5 * Extrude: leave a whole face outlined by lines and arcs to the profile's own tool Only a face whose outline the projection had to fit needs the body's own face as the tool. A square drawn over a box's top already meets the body exactly, and the swept face lost the names a sketch on the new top attaches to (stacked_face_tests, #489). Co-Authored-By: Claude Opus 5.5 * Run the whole cut tool from a face under the kernel guard (#2040) Review: `builder::vertex`, `builder::line`, `Face::try_new` and `builder::translated` ran outside `crate::guard`; only the sweep was guarded. `cut_tool` now runs all of it under one guard. Adds an end-to-end test of a cut into a face the body stands out past below it (the block's top strip between its rounds): it takes only what lies over the face. Co-Authored-By: Claude Opus 5.5 * test: the slab cut of whole_face_tool, on a spline prism (#2040) A prism on a closed spline standing on a wider plate, cut 3 into its whole top face: the slab takes the face's area times the depth and leaves the plate alone. A test-only counter asserts the slab was the tool, so the test cannot pass through the profile's own tool. Shortens the name of the rounded block's top-face cut test. Co-Authored-By: Claude Opus 5.5 * Kernel: the face's outline as a Vec now that polyline returns a closed chain (#2040) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/doc/src/eval.rs | 91 +++++ crates/doc/src/eval_whole_face_tests.rs | 85 ++++ crates/engine/src/boolean_regression_tests.rs | 73 ++++ crates/kernel/src/lib.rs | 2 + crates/kernel/src/polyhedron.rs | 2 +- crates/kernel/src/whole_face.rs | 364 ++++++++++++++++++ crates/kernel/src/whole_face_tests.rs | 110 ++++++ .../extrude_face_rounded_by_fillets.json | 10 + 8 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 crates/doc/src/eval_whole_face_tests.rs create mode 100644 crates/kernel/src/whole_face.rs create mode 100644 crates/kernel/src/whole_face_tests.rs create mode 100644 examples/regressions/extrude_face_rounded_by_fillets.json 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}} + ] +}