diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index e7a33a91..73c42203 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2276,17 +2276,24 @@ fn hole_feature_tools(vals: &BTreeMap, st: &ModelState, f: &Featu /// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out /// or pocket of its flat pattern (#560). Returns the sheet bodies it machined; none, with the /// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then -/// refuses it). -fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option)]) -> Result> { +/// refuses it). Pieces the hole parts from a sheet's base are named in `warning`. +fn sheet_hole_cut( + st: &mut ModelState, + f: &Feature, + tools: &[(Traced, Option)], + warning: &mut Option, +) -> Result> { if st.sheets.is_empty() { return Ok(Vec::new()); } let saved = (st.sheets.clone(), st.bodies.clone()); let mut handled: Vec = Vec::new(); + // Noted only when every tool was machined here. + let mut parted = None; for (tool, prism) in tools { let tool = &tool.body; let done = match prism { - Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true) { + Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true, &mut parted) { Ok(done) => done, // Drilled along a panel (into a rim) the hole is no flat cut-out: a bore of // that panel, when it stays inside it. @@ -2322,6 +2329,9 @@ fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option, f: &Feature, st: // Cuts through sheet metal become cut-outs of its flat pattern. if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { let bodies: Vec = tools.iter().map(|t| t.body.clone()).collect(); - let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?; + let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref(), warning)?; if !handled.is_empty() { return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled); } @@ -4055,7 +4065,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: FeatureKind::Hole { thread, diameter, .. } => { let (spots, dir) = hole_spots(st, f)?; let tools = hole_feature_tools(vals, st, f)?; - let handled = sheet_hole_cut(st, f, &tools)?; + let handled = sheet_hole_cut(st, f, &tools, warning)?; let tools: Vec = tools.into_iter().map(|(tool, _)| tool).collect(); if handled.is_empty() { apply_traced(st, f, tools, Operation::Cut, &[])?; @@ -4445,7 +4455,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, false)?; + let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false, warning)?; let tools = vec![crate::lineage::box_tool(b)]; if !handled.is_empty() { return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled); @@ -4457,7 +4467,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, false)?; + let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false, warning)?; 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 21377a4b..6cebb3ee 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -764,6 +764,7 @@ pub(super) fn sheet_cut( tools: &[Body], targets: &[String], constructor: Option<&ExtrudeConstructor>, + warning: &mut Option, ) -> Result> { let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) }; if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) { @@ -780,7 +781,7 @@ pub(super) fn sheet_cut( .flat_map(|l| ss.sketch.link_curves(&l.id)) .flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci))) .collect(); - sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some(), &text) + sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning) } /// Whether a cut of `f` with these targets (else its participants, else its component's @@ -812,6 +813,7 @@ pub(super) fn sheet_primitive_cut( prism: PrimitivePrism, operation: Operation, drill: bool, + warning: &mut Option, ) -> Result> { if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) { return Ok(Vec::new()); @@ -836,7 +838,7 @@ pub(super) fn sheet_primitive_cut( 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, &[]) + sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false, &[], warning) } /// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each @@ -941,6 +943,7 @@ fn sheet_prism_cut( constructor: Option<&ExtrudeConstructor>, footprints_bound: bool, text: &[Seg2], + warning: &mut Option, ) -> Result> { let n = plane.normal(); let mut work = sheet::FlatWork::new(); @@ -986,6 +989,7 @@ fn sheet_prism_cut( Vec::new() }; let mut plans = Vec::new(); + let mut notes = Vec::new(); 'sheets: for original in &st.sheets { if !sheet_eligible(f, st, targets, original) { continue; @@ -1133,16 +1137,24 @@ fn sheet_prism_cut( if !modeled { 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 - // topology errors propagate; they are never mistaken for an empty blank. - plans.push((sh.body.clone(), None)); } else { - let body = sh.solid()?; - plans.push((sh.body.clone(), Some((sh, body)))); + let material = sh.flat_material()?; + if material.is_empty() { + // Empty exact material removes geometry and metadata together. Budget or + // topology errors propagate; they are never mistaken for an empty blank. + plans.push((sh.body.clone(), None)); + } else { + // Every piece the cut leaves stays; the ones it parts from the base are named. + notes.extend(parted_note(&sh, &material, original)); + let body = sh.solid()?; + plans.push((sh.body.clone(), Some((sh, body)))); + } } } let handled = plans.iter().map(|(name, _)| name.clone()).collect(); + if !notes.is_empty() { + append_warning(warning, notes.join("; ")); + } for (name, result) in plans { match result { Some((sh, body)) => { @@ -1163,6 +1175,35 @@ fn sheet_prism_cut( Ok(handled) } +/// A cut's note on the pieces it parted from the base of `sh` (see +/// [`sheet::SheetBody::parted_from_base`]; `material` is the sheet's flat material after the cut, +/// `before` the sheet before it): how many, their area and where they lie in the flat pattern. +/// The pieces stay, as every piece a cut leaves does. None when nothing is parted, and when the +/// pieces can't be worked out: the note is no reason for a cut to fail. +fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::SheetBody) -> Option { + let pieces = sh.parted_from_base(material, before).ok().filter(|p| !p.is_empty())?; + let area: f64 = pieces.iter().map(Region2::area).sum(); + let at: Vec = pieces + .iter() + .take(4) + .map(|r| { + let c = r.interior_point(); + format!("({:.1}, {:.1})", c.x, c.y) + }) + .collect(); + let at = at.join(", "); + let body = &sh.body; + Some(match pieces.len() { + 1 => format!("1 piece of `{body}` is no longer joined to its base ({area:.2} mm², near {at} in the flat pattern); it is kept"), + n => { + let more = if n > 4 { format!(" and {} more", n - 4) } else { String::new() }; + format!( + "{n} pieces of `{body}` are no longer joined to its base ({area:.2} mm² in all, near {at}{more} in the flat pattern); they are kept" + ) + } + }) +} + /// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance /// `level` from `at` along `n`: the edges used by one of them only, chained. fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec> { diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index ab6764a8..18d647f7 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -31,6 +31,8 @@ const MAX_FLAT_POINTS: usize = 16_384; const MAX_FLAT_SAMPLES: usize = 65_536; const MAX_FLAT_PIECES: usize = 4096; const MAX_FLAT_WORK: usize = 500_000_000; +/// Pieces of flat material sharing less area than this (mm²) only touch. +const TOUCH_AREA: f64 = 1e-6; /// Starts the warning a fold gives when its `fixed` point touches no material: a recompute keeps /// the side the point lies on, and the `sheet.fold` command refuses such a point (#263). @@ -1696,6 +1698,84 @@ impl SheetBody { let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? }; Ok(regions) } + + /// The pieces of flat material that a cut parted from the base: `after` is this sheet's + /// [`Self::flat_material`] after the cut, `before` the sheet before it. A piece is parted when + /// it holds none of the base panel, lies in a piece that held some before the cut, and no + /// other piece surrounds it: a flange cut off along its bend (with any flange on it), the + /// part of a ring's inside that lies on a flange's edge, the tip of a flange beyond a cut + /// through a closed corner. A ring's island and a letter's counter, inside other material, + /// are not counted; nor are pieces an earlier cut parted, nor anything once no piece holds + /// the base. + pub(crate) fn parted_from_base(&self, after: &[Region2], before: &SheetBody) -> Result> { + if after.len() < 2 { + return Ok(Vec::new()); + } + let mut work = FlatWork::new(); + work.charge(after.len().saturating_mul(after.len()))?; + let base = [planar_region(&self.base)?]; + let holds_base = |piece: &Region2, work: &mut FlatWork| -> Result { + let common = material_boolean(std::slice::from_ref(piece), &base, kernel::BoolOp::Intersect, work)?; + Ok(common.iter().map(Region2::area).sum::() > TOUCH_AREA) + }; + // The cheap test first: a piece inside another's outline is an island, whatever it holds. + let outlines: Vec<(Vec, Vec2, Vec2)> = after + .iter() + .map(|r| { + let pts = r.outer.polyline(1e-3); + let lo = pts.iter().fold(Vec2::new(f64::MAX, f64::MAX), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y))); + let hi = pts.iter().fold(Vec2::new(f64::MIN, f64::MIN), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y))); + (pts, lo, hi) + }) + .collect(); + let (mut open, mut islands) = (Vec::new(), Vec::new()); + for (i, piece) in after.iter().enumerate() { + let p = piece.interior_point(); + let inside = |(pts, lo, hi): &(Vec, Vec2, Vec2)| (lo.x..=hi.x).contains(&p.x) && (lo.y..=hi.y).contains(&p.y) && point_in(pts, p); + if outlines.iter().enumerate().any(|(j, o)| j != i && inside(o)) { + islands.push(piece); + } else { + open.push(piece); + } + } + let (mut joined, mut loose) = (false, Vec::new()); + for piece in open { + if holds_base(piece, &mut work)? { + joined = true; + } else { + loose.push(piece); + } + } + if loose.is_empty() { + return Ok(Vec::new()); + } + // The base may be an island itself (a cut all round it). + for piece in islands { + if joined { + break; + } + joined = holds_base(piece, &mut work)?; + } + if !joined { + return Ok(Vec::new()); + } + // A cut only takes material away: each piece lies in the piece it was part of. + let mut held = Vec::new(); + for piece in before.flat_material()? { + if holds_base(&piece, &mut work)? { + held.push(piece); + } + } + Ok(loose + .into_iter() + .filter(|piece| { + let p = piece.interior_point(); + held.iter().any(|h| h.contains(p)) + }) + .cloned() + .collect()) + } + /// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out. pub fn outline(&self) -> Result>> { if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES { @@ -7389,4 +7469,49 @@ mod tests { holds_the_flat_material(&s, &what); } } + + /// The 100 x 60 plate with a 20 mm flange on its y = 0 edge (2.5 mm, R 2.5, K 0.44): in the + /// flat the base starts at y = t + R = 5, the bend's zone is (π/2)·(R + K·t) long and the + /// flange's panel 20 − 5, so its far edge lies at y = 5 − (π/2)·3.6 − 15. + #[test] + fn a_cut_names_the_pieces_it_parts_from_the_base_and_no_others() { + use std::f64::consts::{FRAC_PI_2, PI}; + let far = 5.0 - FRAC_PI_2 * (2.5 + 0.44 * 2.5) - 15.0; + let mut s = plate(100.0, 60.0); + s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap(); + let r = |x0: f64, y0: f64, x1: f64, y1: f64| rect(Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)); + let ring = |c: Vec2, ro: f64, ri: f64| Region2 { outer: Loop2::circle(c, ro), holes: vec![Loop2::circle(c, ri)] }; + let parted = |before: &SheetBody, holes: &[Region2]| { + let mut after = before.clone(); + after.holes.extend_from_slice(holes); + after.parted_from_base(&after.flat_material().unwrap(), before).unwrap() + }; + // A slot across the flange beside its bend parts the rest of the flange: 100 × (−3 − far). + let slot = r(-5.0, -3.0, 105.0, -1.0); + let p = parted(&s, std::slice::from_ref(&slot)); + assert!(p.len() == 1 && (p[0].area() - 100.0 * (-3.0 - far)).abs() < 1e-6, "{p:?}"); + // So does a ring on the flange's far edge: the half of its inside on the flange, π·2²/2. + let p = parted(&s, &[ring(Vec2::new(50.0, far), 4.0, 2.0)]); + assert!(p.len() == 1 && (p[0].area() - 2.0 * PI).abs() < 1e-6, "{p:?}"); + // A ring's island, on the base or on the flange, has material round it; a strip across the + // whole sheet leaves two halves that each keep their part of the base. None is named. + for holes in [ring(Vec2::new(50.0, 30.0), 10.0, 5.0), ring(Vec2::new(50.0, -9.0), 4.0, 2.0), r(45.0, -30.0, 55.0, 70.0)] { + assert!(parted(&s, std::slice::from_ref(&holes)).is_empty(), "{holes:?}"); + } + // Pieces an earlier cut parted are not named again, nor when a later cut splits them. + let mut cut = s.clone(); + cut.holes.push(slot); + for holes in [Region2 { outer: Loop2::circle(Vec2::new(30.0, 30.0), 4.0), holes: vec![] }, r(45.0, -30.0, 55.0, -2.0)] { + assert!(parted(&cut, std::slice::from_ref(&holes)).is_empty(), "{holes:?}"); + } + // Nothing is named once no piece holds the base: a cut that takes the base and both bends + // of a sheet with flanges on two sides leaves the two flanges. + let mut two = plate(100.0, 60.0); + two.add_flanges(&[Vec3::new(50.0, 0.0, 2.5), Vec3::new(50.0, 60.0, 2.5)], 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap(); + let mut after = two.clone(); + after.holes.push(r(-5.0, -3.0, 105.0, 63.0)); + let material = after.flat_material().unwrap(); + assert_eq!(material.len(), 2); + assert!(after.parted_from_base(&material, &two).unwrap().is_empty()); + } } diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index ccf19dbe..a48f4254 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -4147,3 +4147,149 @@ fn perforate_supports_a_square_pattern_and_refuses_impossible_values() { assert!(s.execute("sheet.perforate", &bad).is_err(), "{bad} must be refused"); } } + +/// A 100 x 60 tray with one 45° flange 40 high on its y = 0 edge (2.5 mm, R 2.5, K 0.44), and on +/// it a return 10 high when asked. Returns the far edge of the last panel in the flat pattern: +/// the base starts at the setback s = (t + R)·tan 22.5°, each bend's zone is (π/4)·(R + K·t) long, +/// and a panel h high is h − s long, or h − 2s with a return on its far edge. +fn tray_with_a_45_degree_flange(s: &mut Session, with_return: bool) -> f64 { + base(s, 100.0, 60.0); + run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 40, "angle": 45})); + let (setback, ba) = (5.0 * (PI / 8.0).tan(), PI / 4.0 * (2.5 + 0.44 * 2.5)); + if with_return { + run(s, "sheet.flange", json!({"edges": [[50, -27.5, 26.5]], "height": 10, "angle": 45})); + return setback - ba - (40.0 - 2.0 * setback) - ba - (10.0 - setback); + } + setback - ba - (40.0 - setback) +} + +/// A point on that flange's inside face, the plane y + z = 2.5·√2 (tangent to the bend's inside +/// radius, 2.5 about y = 5·tan 22.5°, z = 5). +const FLANGE_FACE: [f64; 3] = [50.0, 2.5 * std::f64::consts::SQRT_2 - 15.0, 15.0]; + +fn last_warning(s: &Session) -> String { + s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default() +} + +fn parted_note(area: f64, at: [f64; 2]) -> String { + format!("1 piece of `Body1` is no longer joined to its base ({area:.2} mm², near ({:.1}, {:.1}) in the flat pattern); it is kept", at[0], at[1]) +} + +fn flat_pieces(s: &Session) -> Vec { + s.model.state().sheets[0].flat_regions().unwrap().iter().map(solidcraft_geom::Region2::area).collect() +} + +#[test] +fn a_cut_that_parts_a_flange_from_the_base_keeps_it_and_says_so_folded_or_flat() { + // A slot across the flange beside its bend (flat y −3 to −1.5) leaves the flange's panel past + // it, with any return on it, joined to nothing: 100 × (−3 − far). Cut on the folded flange's + // face or in the unfolded sheet, the piece stays, and the cut names it in its reply and on the + // timeline, with the middle of the piece in the flat pattern. + for (with_return, unfolded) in [(false, false), (false, true), (true, false), (true, true)] { + let what = format!("return {with_return}, unfolded {unfolded}"); + let mut s = Session::default(); + let far = tray_with_a_45_degree_flange(&mut s, with_return); + let out = if unfolded { + run(&mut s, "sheet.unfold", json!({})); + rectangle_tool(&mut s, "Slot", [-5.0, -3.0], [105.0, -1.5]); + run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"})) + } else { + run(&mut s, "sketch.create", json!({"plane": {"face": FLANGE_FACE}, "name": "Slot"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, -3], "p1": [105, -1.5]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "negative", "operation": "cut"})) + }; + let note = parted_note(100.0 * (-3.0 - far), [50.0, (far - 3.0) / 2.0]); + assert!(last_warning(&s).contains(¬e), "{what}: {}", last_warning(&s)); + assert_eq!(out["warning"], json!(last_warning(&s)), "{what}: {out}"); + // Both pieces stay, folded and flat: the base with its bend left, and the flange. + let pieces = flat_pieces(&s); + assert!(pieces.len() == 2 && pieces.iter().any(|a| (a - 100.0 * (-3.0 - far)).abs() < 1e-6), "{what}: {pieces:?}"); + for command in if unfolded { ["sheet.refold", "sheet.unfold"] } else { ["sheet.unfold", "sheet.refold"] } { + run(&mut s, command, json!({})); + assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2), "{what} {command}"); + assert_eq!(flat_pieces(&s), pieces, "{what} {command}"); + if command == "sheet.unfold" { + assert!((volume(&mut s) - pieces.iter().sum::() * 2.5).abs() < 1e-3, "{what}: {}", volume(&mut s)); + } + } + } +} + +#[test] +fn a_ring_over_a_flanges_edge_names_the_piece_of_its_inside_on_the_flange_but_not_an_island() { + // A ring Ø16 / Ø10 cut on the folded flange's face, centred on its far edge: the half of its + // inside on the flange (π·5²/2, its centroid 4·5/3π inside the edge) has nothing round it, + // and is named. Centred on the flange, its inside is an island, and nothing is named. + for (y, named) in [(None, true), (Some(-20.0), false)] { + let mut s = Session::default(); + let far = tray_with_a_45_degree_flange(&mut s, false); + let y = y.unwrap_or(far); + run(&mut s, "sketch.create", json!({"plane": {"face": FLANGE_FACE}, "name": "Ring"})); + run(&mut s, "sketch.circle.center", json!({"center": [50, y], "diameter": 16})); + run(&mut s, "sketch.circle.center", json!({"center": [50, y], "diameter": 10})); + run(&mut s, "sketch.finish", json!({})); + let out = run( + &mut s, + "solid.extrude", + json!({"sketch": "Ring", "profiles": [{"point": [56.5, y]}], "through_all": true, "direction": "negative", "operation": "cut"}), + ); + let warning = out["warning"].as_str().unwrap_or_default().to_string(); + let note = parted_note(12.5 * PI, [50.0, far + 20.0 / (3.0 * PI)]); + assert_eq!(warning.contains(¬e), named, "{y}: {warning}"); + assert!(named || !warning.contains("no longer joined"), "{y}: {warning}"); + assert_eq!(flat_pieces(&s).len(), 2, "{y}"); + } +} + +#[test] +fn cuts_that_split_a_sheet_or_cut_it_beside_the_base_name_only_the_pieces_they_part() { + // Unfolded, a strip across tray and flange leaves two pieces that each keep their part of + // the base: nothing to name. A box over the smaller piece's base and bend then leaves its + // flange panel, 26 × (−3 − far), named; a hole in the base after it names nothing again. + let mut s = Session::default(); + let far = tray_with_a_45_degree_flange(&mut s, false); + run(&mut s, "sheet.unfold", json!({})); + rectangle_tool(&mut s, "Strip", [70.0, -60.0], [74.0, 70.0]); + let out = run(&mut s, "solid.extrude", json!({"sketch": "Strip", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert!(out["warning"].is_null() && last_warning(&s).is_empty(), "{out}"); + rectangle_tool(&mut s, "Box", [72.0, -3.0], [101.0, 61.0]); + let out = run(&mut s, "solid.extrude", json!({"sketch": "Box", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert_eq!(out["warning"], json!(parted_note(26.0 * (-3.0 - far), [87.0, (far - 3.0) / 2.0])), "{out}"); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [30, 30], "diameter": 8})); + run(&mut s, "sketch.finish", json!({})); + let out = run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert!(out["warning"].is_null(), "{out}"); + run(&mut s, "sheet.refold", json!({})); + assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); + // Folded, a slot across the base beside the bend leaves the flange with a strip of the base; + // a plain plate cut in two halves. Neither names anything. + let mut s = Session::default(); + tray_with_a_45_degree_flange(&mut s, false); + rectangle_tool(&mut s, "Slot", [-5.0, 3.0], [105.0, 6.0]); + let out = run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert!(out["warning"].is_null() && flat_pieces(&s).len() == 2, "{out}"); + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Strip", [45.0, -5.0], [55.0, 65.0]); + let out = run(&mut s, "solid.extrude", json!({"sketch": "Strip", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert!(out["warning"].is_null() && flat_pieces(&s).len() == 2, "{out}"); +} + +#[test] +fn a_hole_that_parts_a_flange_from_the_base_names_it() { + // A 10 x 60 plate with a 90° flange 40 high on its y = 0 edge: the flange's panel runs from + // flat y = 5 − BA (world z = 5) for 35 mm. A Ø12 hole drilled into it at z = 15 (flat + // y = 5 − BA − 10) takes its whole width, and leaves the 25 mm below the hole's centre less + // the half disc there: 10 × 25 − ∫ √(36 − u²) du over |u| ≤ 5 = 250 − (5√11 + 36·asin(5/6)). + let mut s = Session::default(); + base(&mut s, 10.0, 60.0); + run(&mut s, "sheet.flange", json!({"edges": [[5, 0, 2.5]], "height": 40})); + let out = run(&mut s, "solid.hole", json!({"position": [5, 0, 15], "diameter": 12})); + let area = 250.0 - (5.0 * 11f64.sqrt() + 36.0 * (5.0f64 / 6.0).asin()); + let warning = out["warning"].as_str().unwrap_or_default(); + assert!(warning.starts_with(&format!("1 piece of `Body1` is no longer joined to its base ({area:.2} mm², near (5.0, ")), "{warning}"); + assert_eq!(out["warning"], json!(last_warning(&s))); + assert_eq!(flat_pieces(&s).len(), 2); +} diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 45f2480e..098a3784 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -244,6 +244,10 @@ exactly (`cutout_shapes`): a round hole as `{"circle": {"center", "radius"}}`, a Cuts can leave separate material pieces, including the centre of a ring cut. These pieces remain one logical sheet group through Unfold / Refold and export as separate flat outlines. +A cut that parts a piece from the base (a flange cut off along its bend, the part of a ring's +inside on a flange's edge) keeps it too, folded or flat, and its warning says how many pieces +are no longer joined to the base, their area and where they lie in the flat pattern. Islands +inside other material (a ring's centre, a letter's counter) are not counted. Large profiles that exceed the geometry processing budget report an error instead of exporting a partial outline.