From 5c98c1e24a1cb38c12a020a352400b8e1d2655e0 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:46:11 +0200 Subject: [PATCH] A hole square to a folded sheet metal panel is flat machining (#656) A hole drilled square to a flange's panel, not only the base's, is now a flat cut-out of the sheet: it unfolds with its panel to where the bend allowance puts it, shows in the flat pattern and DXF as a circle, and survives unfold and refold. A pocket tool spans the cut-outs already inside it (a counterbore's hole), so the order they are made in no longer matters. Fixes #560. --- crates/doc/src/eval.rs | 6 +- crates/doc/src/eval_more.rs | 21 ++++- crates/doc/src/sheet.rs | 17 +++- crates/engine/src/cmd/sheet_tests.rs | 111 +++++++++++++++++++++++++++ docs/book/src/guide/sheet-metal.md | 6 ++ 5 files changed, 152 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 956d1f20..c09d6253 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2010,7 +2010,7 @@ fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut)?, + Some(prism) => more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true)?, None => Vec::new(), }; if done.is_empty() { @@ -4017,7 +4017,7 @@ 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 }; - let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation)?; + let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false)?; let tools = vec![crate::lineage::box_tool(b)]; if !handled.is_empty() { return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled); @@ -4029,7 +4029,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: 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 }; let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height }; - let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation)?; + let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false)?; let tools = vec![crate::lineage::cylinder_tool(b)]; if !handled.is_empty() { return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled); diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index ae72d409..61380bc5 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -726,9 +726,17 @@ fn any_eligible_sheet(f: &Feature, st: &ModelState, targets: &[String]) -> bool /// 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 +/// keeps it as a flat cut-out or pocket. A hole's drill (`drill`) may also be square to a +/// folded panel of the sheet, a flange's. 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> { +pub(super) fn sheet_primitive_cut( + f: &Feature, + st: &mut ModelState, + tool: &Body, + prism: PrimitivePrism, + operation: Operation, + drill: bool, +) -> Result> { if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) { return Ok(Vec::new()); } @@ -738,7 +746,14 @@ pub(super) fn sheet_primitive_cut(f: &Feature, st: &mut ModelState, tool: &Body, if let Some(mb) = st.body(&sh.body) && kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some() { - normal = Some(sh.frame.normal()); + normal = Some(match prism { + PrimitivePrism::Cylinder { axis, .. } + if drill && !sh.flat && Plane::from_normal(Vec3::ZERO, axis).is_some_and(|p| !sh.parallel_panels(&p).is_empty()) => + { + axis + } + _ => sh.frame.normal(), + }); break; } } diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 185f4e98..a16b40db 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -1267,11 +1267,22 @@ impl SheetBody { } } for (pi, panel) in &panels { - let clipped = material_boolean(panel, std::slice::from_ref(outline), kernel::BoolOp::Intersect, work)?; - if clipped.is_empty() { + if material_boolean(panel, std::slice::from_ref(outline), kernel::BoolOp::Intersect, work)?.is_empty() { continue; } - // What the tool takes over from a free-form cut-out of the panel follows its + // The tool spans the cut-outs inside it (a counterbore's hole, whichever was made + // first): a wall of its own along a cut-out's wall would only touch it, which the + // cut cannot resolve. An island of the pocket that holds material stays. + let whole: Vec = panel.iter().map(|r| Region2 { outer: r.outer.clone(), holes: Vec::new() }).collect(); + let mut spanning = Region2 { outer: outline.outer.clone(), holes: Vec::new() }; + for island in &outline.holes { + let inside = Region2 { outer: island.ccw(), holes: Vec::new() }; + if !material_boolean(panel, std::slice::from_ref(&inside), kernel::BoolOp::Intersect, work)?.is_empty() { + spanning.holes.push(island.clone()); + } + } + let clipped = material_boolean(&whole, std::slice::from_ref(&spanning), kernel::BoolOp::Intersect, work)?; + // What the tool takes over from a free-form outline of the panel follows its // fitted arcs: walls of planes and cylinders, which the prism booleans take. let clipped = clipped.iter().map(fitted_region).collect::>>()?; let w = if self.flat { self.frame_mat() } else { self.world(*pi) }; diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index eb46b666..9f1178e7 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -2375,6 +2375,117 @@ fn hole_beside_a_bend_with_a_round_cut_out_cuts_and_the_bend_stays_guarded() { assert!(error.contains("bend"), "{error}"); } +/// Whether the flat pattern has a cut-out whose points all lie on the circle of radius `r` about `c`. +fn has_round_cutout(f: &Value, c: [f64; 2], r: f64) -> bool { + f["cutouts"].as_array().into_iter().flatten().any(|cut| { + let on_circle = |p: &Value| ((p[0].as_f64().unwrap_or(f64::NAN) - c[0]).hypot(p[1].as_f64().unwrap_or(f64::NAN) - c[1]) - r).abs() < 1e-3; + cut.as_array().is_some_and(|points| !points.is_empty() && points.iter().all(on_circle)) + }) +} + +/// Whether the flat DXF holds the circle of radius `r` about `c` as a `CIRCLE`. +fn dxf_has_circle(s: &mut Session, c: [f64; 2], r: f64) -> bool { + let dxf = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + let centre = solidcraft_geom::Vec2::new(c[0], c[1]); + solidcraft_io::read_dxf(&dxf) + .unwrap_or_default() + .iter() + .any(|g| matches!(g, solidcraft_io::Geom2::Circle(at, radius) if at.dist(centre) < 1e-5 && (radius - r).abs() < 1e-5)) +} + +/// A hole on a flange lands in the flat pattern where the bend allowance puts its panel. +#[test] +fn a_hole_on_a_flange_panel_unfolds_with_the_flange() { + // The flange's panel begins 5 mm (R + T) above the base's underside, and at y = 5 − BA in + // the flat pattern: a hole 7 mm up the panel is at y = 5 − BA − 7 there, one 10 mm up at + // y = 5 − BA − 10. + let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5); + let mut s = Session::default(); + plate_with_flange(&mut s); + let v0 = volume(&mut s); + // Ø4 from the flange's inner face (y = 2.5), Ø3 from its outer face (y = 0), Ø6 on the base. + run(&mut s, "solid.hole", json!({"position": [30, 2.5, 12], "diameter": 4})); + run(&mut s, "solid.hole", json!({"position": [70, 0, 15], "diameter": 3})); + run(&mut s, "solid.hole", json!({"position": [50, 30, 2.5], "diameter": 6})); + let removed = PI * 2.5 * (4.0 + 2.25 + 9.0); + assert!((v0 - volume(&mut s) - removed).abs() < 0.05, "{}", v0 - volume(&mut s)); + assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [33.0, 1.25, 12.0])); + assert!(!contains(&s, [70.0, 1.25, 15.0]) && !contains(&s, [50.0, 30.0, 1.25])); + let holes = [([30.0, 5.0 - ba - 7.0], 2.0), ([70.0, 5.0 - ba - 10.0], 1.5), ([50.0, 30.0], 3.0)]; + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(3)); + for (centre, radius) in holes { + assert!(has_round_cutout(&f, centre, radius), "{centre:?}: {}", f["cutouts"]); + assert!(dxf_has_circle(&mut s, centre, radius), "{centre:?}"); + } + // Unfolded, the sheet is its flat pattern 2.5 thick: (55 + BA + 15) x 100, less the holes. + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - ((70.0 + ba) * 250.0 - removed)).abs() < 0.05, "{}", volume(&mut s)); + run(&mut s, "sheet.refold", json!({})); + assert!((v0 - volume(&mut s) - removed).abs() < 0.05, "{}", v0 - volume(&mut s)); + // A hole made on the unfolded sheet where the flange lies is on the flange once refolded. + let mut flat_first = Session::default(); + plate_with_flange(&mut flat_first); + run(&mut flat_first, "sheet.unfold", json!({})); + run(&mut flat_first, "solid.hole", json!({"position": [30, 5.0 - ba - 7.0, 2.5], "diameter": 4})); + run(&mut flat_first, "sheet.refold", json!({})); + assert!((v0 - volume(&mut flat_first) - PI * 4.0 * 2.5).abs() < 0.05, "{}", v0 - volume(&mut flat_first)); + assert!(!contains(&flat_first, [30.0, 1.25, 12.0]) && contains(&flat_first, [33.0, 1.25, 12.0])); +} + +/// On a folded sheet a counterbore is the same pocket around the same hole, on the base and on a +/// flange, and stays through unfold and refold. +#[test] +fn a_counterbore_on_a_folded_sheet_is_a_pocket_around_its_cut_out() { + let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5); + let mut s = Session::default(); + plate_with_flange(&mut s); + let v0 = volume(&mut s); + // Ø4 with Ø8 x 1 on the base: 22π mm³. Ø3 with Ø6 x 1 from the flange's inner face, 7 mm up + // its panel: π · (1.5² · 2.5 + (3² − 1.5²) · 1) = 12.375π mm³. + run(&mut s, "solid.hole", json!({"position": [80, 40, 2.5], "diameter": 4, "type": "counterbore", "cb_diameter": 8, "cb_depth": 1})); + run(&mut s, "solid.hole", json!({"position": [30, 2.5, 12], "diameter": 3, "type": "counterbore", "cb_diameter": 6, "cb_depth": 1})); + let removed = (22.0 + 12.375) * PI; + assert!((v0 - volume(&mut s) - removed).abs() < 0.05, "{}", v0 - volume(&mut s)); + assert!(!contains(&s, [83.0, 40.0, 2.0]) && contains(&s, [83.0, 40.0, 1.0]) && !contains(&s, [80.0, 40.0, 1.0])); + // The flange's pocket is in its inner face (y from 1.5 to 2.5), around the hole. + assert!(!contains(&s, [32.0, 2.0, 12.0]) && contains(&s, [32.0, 1.0, 12.0]) && !contains(&s, [30.0, 1.0, 12.0])); + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(2)); + assert!(has_round_cutout(&f, [80.0, 40.0], 2.0) && has_round_cutout(&f, [30.0, 5.0 - ba - 7.0], 1.5), "{}", f["cutouts"]); + assert_eq!(f["pockets"].as_array().map(Vec::len), Some(2)); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - ((70.0 + ba) * 250.0 - removed)).abs() < 0.05, "{}", volume(&mut s)); + run(&mut s, "sheet.refold", json!({})); + assert!((v0 - volume(&mut s) - removed).abs() < 0.05, "{}", v0 - volume(&mut s)); +} + +/// The pocket's tool spans a cut-out inside it: extrude cuts of a pocket and of a hole in its +/// floor, in either order, rebuild on the base of a folded sheet, where the pocket's own wall +/// along the hole's used to make the cut fail. +#[test] +fn a_pocket_around_a_cut_out_rebuilds_on_a_folded_sheet() { + for pocket_first in [true, false] { + let mut s = Session::default(); + plate_with_flange(&mut s); + let v0 = volume(&mut s); + let cuts = [("Pocket", 6.0, 1.0), ("Bore", 3.0, 2.5)]; + for (name, diameter, depth) in if pocket_first { cuts } else { [cuts[1], cuts[0]] } { + circle_sketch(&mut s, name, json!({"face": [50, 30, 2.5]}), [40.0, 30.0], diameter); + run(&mut s, "solid.extrude", json!({"sketch": name, "distance": depth, "direction": "negative", "operation": "cut"})); + } + // π · (1.5² · 2.5 + (3² − 1.5²) · 1) = 12.375π mm³. + assert!((v0 - volume(&mut s) - 12.375 * PI).abs() < 0.05, "{pocket_first}: {}", v0 - volume(&mut s)); + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1)); + assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1)); + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({})); + } + assert!((v0 - volume(&mut s) - 12.375 * PI).abs() < 0.05, "{pocket_first}: {}", v0 - volume(&mut s)); + } +} + /// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working: /// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263). #[test] diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 028bd117..db46e806 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -179,6 +179,12 @@ A Box cut, or a Cylinder cut whose axis is the sheet's normal, machines the shee 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. +Hole machines the sheet the same way from a flat face of any panel, the base or a flange, on +either side, folded or unfolded: square to that panel, a through hole (plain or tapped) is a +round cut-out of the flat pattern and a `CIRCLE` in the DXF, and a counterbore or a blind +flat-bottomed hole is a round pocket. A hole slanted to its panel or drilled into the edge of +the sheet, a countersink and a drill point are 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