diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 1d9f0000..7b522bec 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1218,6 +1218,25 @@ fn intersection_tool_union(tools: Vec) -> Result> { Ok(groups) } +/// After a cut handled some sheet bodies as flat machining, apply it to the other targets. +fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec, op: Operation, targets: &[String], handled: &[String]) -> Result<()> { + let effective = if targets.is_empty() { &f.participants } else { targets }; + let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*"); + let rest: Vec = st + .bodies + .iter() + .filter(|b| { + !handled.contains(&b.name) + && (all || if effective.is_empty() { b.component == f.component || b.feature == f.id } else { effective.contains(&b.name) }) + }) + .map(|b| b.name.clone()) + .collect(); + if rest.is_empty() { + return Ok(()); + } + apply_traced(st, f, tools, op, &rest) +} + fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation, targets: &[String]) -> Result<()> { apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets) } @@ -3356,26 +3375,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let bodies: Vec = tools.iter().map(|t| t.body.clone()).collect(); let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?; if !handled.is_empty() { - let effective = if targets.is_empty() { &f.participants } else { targets }; - let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*"); - let rest: Vec = st - .bodies - .iter() - .filter(|b| { - !handled.contains(&b.name) - && (all - || if effective.is_empty() { - b.component == f.component || b.feature == f.id - } else { - effective.contains(&b.name) - }) - }) - .map(|b| b.name.clone()) - .collect(); - if rest.is_empty() { - return Ok(()); - } - return apply_traced(st, f, tools, *operation, &rest); + return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled); } } apply_traced(st, f, tools, *operation, targets) @@ -3752,12 +3752,24 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let corner = point(vals, corner)?; let b = kernel::box_solid(corner, corner + s)?; let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b }; - apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[]) + let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation)?; + let tools = vec![crate::lineage::box_tool(b)]; + if !handled.is_empty() { + return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled); + } + apply_traced(st, f, tools, *operation, &[]) } FeatureKind::Cylinder { base, axis, radius, height, operation } => { - let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?; + let (base, radius, height) = (point(vals, base)?, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?); + let b = kernel::cylinder(base, *axis, radius, height)?; let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b }; - apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *operation, &[]) + let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height }; + let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation)?; + let tools = vec![crate::lineage::cylinder_tool(b)]; + if !handled.is_empty() { + return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled); + } + apply_traced(st, f, tools, *operation, &[]) } FeatureKind::Sphere { center, radius, operation } => { let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?; diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index c63bb19e..36e071c1 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -668,24 +668,112 @@ pub(super) fn sheet_cut( constructor: Option<&ExtrudeConstructor>, ) -> Result> { let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) }; - if extent.taper.is_some() { - return Ok(Vec::new()); - } - let targets = if targets.is_empty() { &f.participants } else { targets }; - let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); - let eligible = |original: &crate::sheet::SheetBody| { - all || if targets.is_empty() { - st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id)) - } else { - targets.contains(&original.body) - } - }; - if !st.sheets.iter().any(&eligible) { + if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) { return Ok(Vec::new()); } let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) }; let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); - let n = ss.plane.normal(); + sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some()) +} + +/// Whether a cut of `f` with these targets (else its participants, else its component's +/// bodies) machines the sheet `original`. +fn sheet_eligible(f: &Feature, st: &ModelState, targets: &[String], original: &crate::sheet::SheetBody) -> bool { + let targets = if targets.is_empty() { &f.participants } else { targets }; + let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); + all || if targets.is_empty() { + st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id)) + } else { + targets.contains(&original.body) + } +} + +/// Whether a cut of `f` with these targets machines any sheet metal body. +fn any_eligible_sheet(f: &Feature, st: &ModelState, targets: &[String]) -> bool { + st.sheets.iter().any(|original| sheet_eligible(f, st, targets, original)) +} + +/// A Box or Cylinder cut (or intersect) whose tool is a straight prism along the normal of +/// the sheet it meets: machined like the equivalent extrude of its footprint, so the sheet +/// keeps it as a flat cut-out or pocket. Returns the sheet bodies it handled; none when the +/// primitive is not such a prism (the generic preflight then refuses real 3D machining). +pub(super) fn sheet_primitive_cut(f: &Feature, st: &mut ModelState, tool: &Body, prism: PrimitivePrism, operation: Operation) -> Result> { + if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) { + return Ok(Vec::new()); + } + // The first sheet the primitive meets decides the machining direction. + let mut normal = None; + for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) { + if let Some(mb) = st.body(&sh.body) + && kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some() + { + normal = Some(sh.frame.normal()); + break; + } + } + let Some(normal) = normal else { return Ok(Vec::new()) }; + let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) }; + let tools = kernel::extrude(&plane, std::slice::from_ref(®ion), interval.0, interval.1)?; + sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false) +} + +/// A primitive tool's exact geometry, for [`sheet_primitive_cut`]. +pub(super) enum PrimitivePrism { + /// An axis-aligned box between two corners. + Box { lo: Vec3, hi: Vec3 }, + /// A cylinder: base centre, unit axis, radius and signed height. + Cylinder { base: Vec3, axis: Vec3, radius: f64, height: f64 }, +} + +impl PrimitivePrism { + /// The sketch-like plane, footprint region and depth interval of this tool as a prism along + /// `normal` (either sense), or None when it is not one. + fn along(&self, normal: Vec3) -> Option<(Plane, Region2, (f64, f64))> { + let n = normal.normalized()?; + match *self { + PrimitivePrism::Box { lo, hi } => { + let (lo, hi) = (lo.min(hi), lo.max(hi)); + let s = hi - lo; + // Plane axes (x, y) and the depth axis, as a right-handed frame. + let (x, y, depth) = if n.cross(Vec3::Z).len() <= 1e-12 { + (Vec3::X, Vec3::Y, s.z) + } else if n.cross(Vec3::X).len() <= 1e-12 { + (Vec3::Y, Vec3::Z, s.x) + } else if n.cross(Vec3::Y).len() <= 1e-12 { + (Vec3::Z, Vec3::X, s.y) + } else { + return None; + }; + let plane = Plane::new(lo, x, y)?; + let (w, h) = (s.dot(x), s.dot(y)); + let outer = Loop2::polygon(&[Vec2::ZERO, Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]); + Some((plane, Region2 { outer, holes: vec![] }, (0.0, depth))) + } + PrimitivePrism::Cylinder { base, axis, radius, height } => { + let axis = axis.normalized()?; + if axis.cross(n).len() > 1e-12 { + return None; + } + let plane = Plane::from_normal(base, axis)?; + Some((plane, Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] }, (height.min(0.0), height.max(0.0)))) + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn sheet_prism_cut( + f: &Feature, + st: &mut ModelState, + plane: &Plane, + regions: &[Region2], + operation: Operation, + tools: &[Body], + targets: &[String], + constructor: Option<&ExtrudeConstructor>, + footprints_bound: bool, +) -> Result> { + let n = plane.normal(); let mut work = sheet::FlatWork::new(); let faces = tools.iter().fold(0usize, |count, body| count.saturating_add(body.face_count())); if tools.len() > 1000 || faces > 16_384 { @@ -696,7 +784,7 @@ pub(super) fn sheet_cut( let mut cap_roundoff: f64 = 0.0; let mut current_footprints = Vec::new(); for tool in tools { - let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, &ss.plane) else { + let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, plane) else { // Modified tools need a current geometric prism certificate. The generic // sheet preflight permits actual no-change, otherwise refuses stale metadata. return Ok(Vec::new()); @@ -716,7 +804,7 @@ pub(super) fn sheet_cut( let selected_edges = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).fold(0usize, usize::saturating_add); work.charge(faces.saturating_mul(selected_edges).saturating_mul(16))?; - let nominal = constructor.filter(|c| c.plane == ss.plane).and_then(|c| { + let nominal = constructor.filter(|c| c.plane == *plane).and_then(|c| { let placed_basis = Plane::new(c.plane.origin, c.plane.x, c.plane.y)?; (current_footprints.len() == tools.len() && current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r))) @@ -724,23 +812,23 @@ pub(super) fn sheet_cut( }); let mut plans = Vec::new(); 'sheets: for original in &st.sheets { - if !eligible(original) { + if !sheet_eligible(f, st, targets, original) { continue; } let Some(mb) = st.body(&original.body) else { continue; }; let mut sh = original.clone(); - if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) { - super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + if operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) { + super::sheet_boolean_unchanged(&mb.body, tools, operation)?; plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); continue; } - if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? { + if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, operation)? { plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); continue; } - for bends in sh.unmodeled_solids(&ss.plane)? { + for bends in sh.unmodeled_solids(plane)? { for tool in tools { if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() { return Err(DocError::Invalid( @@ -752,21 +840,21 @@ pub(super) fn sheet_cut( let candidates = if sh.flat { if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() } } else { - sh.material_panels(Some(&ss.plane), &mut work)? + sh.material_panels(Some(plane), &mut work)? }; let mut modeled = false; for (pi, panel) in candidates { let w = if sh.flat { sh.world(None) } else { sh.world(pi) }; - let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n); + let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n); let sign = crate::apply_vector(&w, Vec3::Z).dot(n); if sign.abs() < 1e-6 { continue; } let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign); let roundoff = (cap_roundoff - + 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + ss.plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0)) + + 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0)) / sign.abs(); - let nominal_depth = if apply_point(&w, Vec3::ZERO) == ss.plane.origin + let nominal_depth = if apply_point(&w, Vec3::ZERO) == plane.origin && let Some((lo, hi)) = nominal && (span.0 - lo).abs() <= roundoff && (span.1 - hi).abs() <= roundoff @@ -786,7 +874,7 @@ pub(super) fn sheet_cut( None }; if nominal_depth.is_none() && roundoff > 1e-7 { - super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + super::sheet_boolean_unchanged(&mb.body, tools, operation)?; plans.push((original.body.clone(), Some((original.clone(), mb.body.clone())))); continue 'sheets; } @@ -794,12 +882,12 @@ pub(super) fn sheet_cut( let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t)); // Sub-resolution layers must not be silently erased by endpoint snapping. let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6); - let mapped = if extent.to.is_some() && current_footprints.len() == tools.len() { + let mapped = if footprints_bound && current_footprints.len() == tools.len() { current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::>>()? } else { - regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::>>()? + regions.iter().map(|r| sh.region_to_flat(pi, plane, r)).collect::>>()? }; - if *operation == Operation::Intersect { + if operation == Operation::Intersect { if z1 <= z0 { sh.holes.push(panel); modeled = true; @@ -855,7 +943,7 @@ pub(super) fn sheet_cut( } } if !modeled { - super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + super::sheet_boolean_unchanged(&mb.body, tools, operation)?; plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); } else if sh.flat_material()?.is_empty() { // Empty exact material removes geometry and metadata together. Budget or diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index ed3c8d25..38259ed4 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -2500,7 +2500,6 @@ fn a_hole_on_a_folded_panel_keeps_the_name_of_its_sketch_circle() { } #[test] -#[ignore = "fails until #541 is fixed"] fn a_box_cut_straight_through_a_flat_sheet_is_a_cutout_like_the_extrude_cut() { // A 10 x 10 box perpendicular to the sheet and through its whole thickness removes exactly // what a sketch rectangle extruded through all removes, which is accepted today. @@ -2518,6 +2517,34 @@ fn a_box_cut_straight_through_a_flat_sheet_is_a_cutout_like_the_extrude_cut() { run(&mut s, "sheet.unfold", json!({})); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - 14750.0).abs() < 1e-6); + // A cylinder along the sheet normal is a round hole, exact in the flat pattern and DXF. + run(&mut s, "solid.cylinder", json!({"base": [70, 30, -1], "radius": 5, "height": 5, "operation": "cut"})); + assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(2)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + assert!(dxf.contains("CIRCLE"), "the hole stays a circle"); + assert!((volume(&mut s) - (14750.0 - PI * 25.0 * 2.5)).abs() < 0.5); + // A box that only dents the top face is a pocket of constant depth. + run(&mut s, "solid.box", json!({"corner": [50, 10, 1.5], "length": 10, "width": 10, "height": 5, "operation": "cut"})); + let f = flat(&mut s); + assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1)); + assert_eq!(f["pockets"][0]["depth_mm"], 1.0); + // A box across a folded bend, or a cylinder lying in the sheet, is still 3D machining + // and still refused atomically. + let mut tilted = Session::default(); + base(&mut tilted, 100.0, 60.0); + run(&mut tilted, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})); + let before = tilted.doc.to_json(); + let error = tilted + .execute("solid.box", &json!({"corner": [40, -10, -5], "length": 20, "width": 20, "height": 40, "operation": "cut"})) + .unwrap_err() + .to_string(); + assert!(error.contains("bend") || error.contains("preserv"), "{error}"); + assert_eq!(tilted.doc.to_json(), before); + let error = s + .execute("solid.cylinder", &json!({"base": [10, 50, 1.25], "axis": "X", "radius": 1, "height": 20, "operation": "cut"})) + .unwrap_err() + .to_string(); + assert!(error.contains("preserv"), "{error}"); } #[test] diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index d833f597..f6f1f007 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -89,6 +89,10 @@ remain one logical sheet group through Unfold / Refold and export as separate fl Large profiles that exceed the geometry processing budget report an error instead of exporting a partial outline. +A Box cut, or a Cylinder cut whose axis is the sheet's normal, machines the sheet like the +extrude of its footprint: through the sheet it is a cut-out (a cylinder a round one), and +part way it is a pocket. A primitive cut at any other angle, or across a folded bend, is refused. + Parallel, untapered extrude cuts also preserve shallow pockets through Unfold / Refold. The pocket floor and side walls remain solid geometry; overlapping cuts from opposite sides become a through hole wherever they remove the whole thickness. Flat-pattern JSON lists