Sheet metal: a cut names the pieces it parts from the base (#1338)

A sheet-metal cut that parts pieces from the base names them, and the guide explains it.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-15 08:34:01 -04:00
1 parent 6fa9e0d2dd
commit e95e32d72a
5 files changed
+341 -15

No files matched your search

+17 -7
View File
@@ -2276,17 +2276,24 @@ fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Featu
/// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out /// 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 /// 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 /// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then
/// refuses it). /// 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<more::PrimitivePrism>)]) -> Result<Vec<String>> { fn sheet_hole_cut(
st: &mut ModelState,
f: &Feature,
tools: &[(Traced, Option<more::PrimitivePrism>)],
warning: &mut Option<String>,
) -> Result<Vec<String>> {
if st.sheets.is_empty() { if st.sheets.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let saved = (st.sheets.clone(), st.bodies.clone()); let saved = (st.sheets.clone(), st.bodies.clone());
let mut handled: Vec<String> = Vec::new(); let mut handled: Vec<String> = Vec::new();
// Noted only when every tool was machined here.
let mut parted = None;
for (tool, prism) in tools { for (tool, prism) in tools {
let tool = &tool.body; let tool = &tool.body;
let done = match prism { 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, Ok(done) => done,
// Drilled along a panel (into a rim) the hole is no flat cut-out: a bore of // Drilled along a panel (into a rim) the hole is no flat cut-out: a bore of
// that panel, when it stays inside it. // that panel, when it stays inside it.
@@ -2322,6 +2329,9 @@ fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<mor
} }
} }
} }
if let Some(note) = parted {
append_warning(warning, note);
}
Ok(handled) Ok(handled)
} }
@@ -4045,7 +4055,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
// Cuts through sheet metal become cut-outs of its flat pattern. // 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() { if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect(); let bodies: Vec<Body> = 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() { if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled); return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
} }
@@ -4055,7 +4065,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Hole { thread, diameter, .. } => { FeatureKind::Hole { thread, diameter, .. } => {
let (spots, dir) = hole_spots(st, f)?; let (spots, dir) = hole_spots(st, f)?;
let tools = hole_feature_tools(vals, 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<Traced> = tools.into_iter().map(|(tool, _)| tool).collect(); let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
if handled.is_empty() { if handled.is_empty() {
apply_traced(st, f, tools, Operation::Cut, &[])?; apply_traced(st, f, tools, Operation::Cut, &[])?;
@@ -4445,7 +4455,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let corner = point(vals, corner)?; let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?; 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 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)]; let tools = vec![crate::lineage::box_tool(b)];
if !handled.is_empty() { if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled); return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
@@ -4457,7 +4467,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let b = kernel::cylinder(base, *axis, radius, height)?; 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 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 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)]; let tools = vec![crate::lineage::cylinder_tool(b)];
if !handled.is_empty() { if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled); return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
+49 -8
View File
@@ -764,6 +764,7 @@ pub(super) fn sheet_cut(
tools: &[Body], tools: &[Body],
targets: &[String], targets: &[String],
constructor: Option<&ExtrudeConstructor>, constructor: Option<&ExtrudeConstructor>,
warning: &mut Option<String>,
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) }; 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) { 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(|l| ss.sketch.link_curves(&l.id))
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci))) .flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
.collect(); .collect();
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some(), &text) sheet_prism_cut(f, st, &ss.plane, &regions, *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 /// 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, prism: PrimitivePrism,
operation: Operation, operation: Operation,
drill: bool, drill: bool,
warning: &mut Option<String>,
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) { if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
return Ok(Vec::new()); 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(normal) = normal else { return Ok(Vec::new()) };
let Some((plane, region, interval)) = prism.along(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(&region), interval.0, interval.1)?; let tools = kernel::extrude(&plane, std::slice::from_ref(&region), interval.0, interval.1)?;
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false, &[]) sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), 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 /// 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>, constructor: Option<&ExtrudeConstructor>,
footprints_bound: bool, footprints_bound: bool,
text: &[Seg2], text: &[Seg2],
warning: &mut Option<String>,
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
let n = plane.normal(); let n = plane.normal();
let mut work = sheet::FlatWork::new(); let mut work = sheet::FlatWork::new();
@@ -986,6 +989,7 @@ fn sheet_prism_cut(
Vec::new() Vec::new()
}; };
let mut plans = Vec::new(); let mut plans = Vec::new();
let mut notes = Vec::new();
'sheets: for original in &st.sheets { 'sheets: for original in &st.sheets {
if !sheet_eligible(f, st, targets, original) { if !sheet_eligible(f, st, targets, original) {
continue; continue;
@@ -1133,16 +1137,24 @@ fn sheet_prism_cut(
if !modeled { 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())))); 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 { } else {
let body = sh.solid()?; let material = sh.flat_material()?;
plans.push((sh.body.clone(), Some((sh, body)))); 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(); let handled = plans.iter().map(|(name, _)| name.clone()).collect();
if !notes.is_empty() {
append_warning(warning, notes.join("; "));
}
for (name, result) in plans { for (name, result) in plans {
match result { match result {
Some((sh, body)) => { Some((sh, body)) => {
@@ -1163,6 +1175,35 @@ fn sheet_prism_cut(
Ok(handled) 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<String> {
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<String> = 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 /// 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. /// `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<Vec<Vec2>> { fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
+125
View File
@@ -31,6 +31,8 @@ const MAX_FLAT_POINTS: usize = 16_384;
const MAX_FLAT_SAMPLES: usize = 65_536; const MAX_FLAT_SAMPLES: usize = 65_536;
const MAX_FLAT_PIECES: usize = 4096; const MAX_FLAT_PIECES: usize = 4096;
const MAX_FLAT_WORK: usize = 500_000_000; 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 /// 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). /// 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(&regions, &cuts, kernel::BoolOp::Cut, &mut work)? }; let regions = if cuts.is_empty() { regions } else { material_boolean(&regions, &cuts, kernel::BoolOp::Cut, &mut work)? };
Ok(regions) 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<Vec<Region2>> {
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<bool> {
let common = material_boolean(std::slice::from_ref(piece), &base, kernel::BoolOp::Intersect, work)?;
Ok(common.iter().map(Region2::area).sum::<f64>() > TOUCH_AREA)
};
// The cheap test first: a piece inside another's outline is an island, whatever it holds.
let outlines: Vec<(Vec<Vec2>, 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, 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. /// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> { pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES { 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); 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());
}
} }
+146
View File
@@ -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"); 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<f64> {
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(&note), "{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::<f64>() * 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(&note), 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);
}
+4
View File
@@ -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 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. 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 Large profiles that exceed the geometry processing budget report an error instead of
exporting a partial outline. exporting a partial outline.