diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 6f802ce6..b7eb89d9 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2660,6 +2660,23 @@ fn sheet_hole_cut( let mut handled: Vec = Vec::new(); // Noted only when every tool was machined here. let mut parted = None; + // Many plain drills along one axis (a hole on each of a pattern's points): all in one pass. + let prisms: Option> = tools.iter().map(|(_, prism)| *prism).collect(); + if let Some(prisms) = prisms { + match more::sheet_drill_cuts(f, st, &prisms, &mut parted) { + Ok(Some(done)) => { + if let Some(note) = parted { + append_warning(warning, note); + } + return Ok(done); + } + // Not all of them that way, or refused at once: one by one, from the model as it was. + Ok(None) | Err(_) => { + (st.sheets, st.bodies) = saved.clone(); + parted = None; + } + } + } for (tool, prism) in tools { let tool = &tool.body; let done = match prism { diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 2592cf2a..3ade16e1 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -5,7 +5,7 @@ use super::*; use crate::EmbossFace; use crate::apply_point; use crate::sheet; -use solidcraft_geom::{Loop2, Seg2}; +use solidcraft_geom::{Aabb3, Loop2, Seg2}; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { match &f.kind { @@ -833,11 +833,20 @@ pub(super) fn sheet_primitive_cut( 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. + // The first sheet the primitive meets decides the machining direction. Sheets clear of it + // by their boxes are passed over; the one sheet left needs no boolean to show it is met (that + // boolean came out empty against a sheet carrying a label's cut-outs, and the hole was then + // cut into the solid without a place in the flat pattern). + let tool_box = body_box(tool); + let near: Vec<&sheet::SheetBody> = st + .sheets + .iter() + .filter(|sh| sheet_eligible(f, st, &[], sh) && st.body(&sh.body).is_some_and(|mb| !boxes_apart(&mesh_box(mb), &tool_box))) + .collect(); let mut normal = None; - for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) { + for sh in &near { if let Some(mb) = st.body(&sh.body) - && kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some() + && (near.len() == 1 || kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some()) { normal = Some(match prism { PrimitivePrism::Cylinder { axis, .. } @@ -895,6 +904,70 @@ pub(super) fn sheet_bore_cut(f: &Feature, st: &mut ModelState, prism: PrimitiveP Ok(Some(handled)) } +/// The most hole tools machined in one pass by [`sheet_drill_cuts`]. +const MAX_DRILLS_AT_ONCE: usize = 1000; + +/// A hole feature's straight drills (`prisms`, all along one axis), machined into the sheets in +/// one pass for each depth they share: one rebuild of the sheet and of its flat pattern for all +/// of them, rather than one per drill, each on a sheet machined by the ones before. +/// The drills are the cut-outs and pockets [`sheet_primitive_cut`] makes of them one at a time. +/// None when they can't all be taken this way (they are not all cylinders along one axis that +/// every sheet they may machine takes square, or a sheet keeps one some other way): the caller +/// then puts the model back and drills them one by one. +pub(super) fn sheet_drill_cuts( + f: &Feature, + st: &mut ModelState, + prisms: &[PrimitivePrism], + warning: &mut Option, +) -> Result>> { + if prisms.len() < 2 || prisms.len() > MAX_DRILLS_AT_ONCE || !any_eligible_sheet(f, st, &[]) { + return Ok(None); + } + let Some(PrimitivePrism::Cylinder { base: base0, axis: axis0, .. }) = prisms.first().copied() else { return Ok(None) }; + let Some(axis) = axis0.normalized() else { return Ok(None) }; + let Some(plane) = Plane::from_normal(base0, axis) else { return Ok(None) }; + // Every sheet the drills may machine takes them square: along its normal, or (folded) square + // to one of its panels, as a single drill would be taken. + for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) { + let square = sh.frame.normal().cross(axis).len() <= 1e-12 || (!sh.flat && !sh.parallel_panels(&plane).is_empty()); + if !square { + return Ok(None); + } + } + // Each drill in the one plane: its circle, and its depth interval along the axis. + let mut groups: Vec<((f64, f64), Vec)> = Vec::new(); + for prism in prisms { + let PrimitivePrism::Cylinder { base, axis: a, radius, height } = *prism else { return Ok(None) }; + let Some(a) = a.normalized() else { return Ok(None) }; + if a.cross(axis).len() > 1e-12 || ![base.x, base.y, base.z, radius, height].iter().all(|v| v.is_finite()) || !(radius > 0.0) { + return Ok(None); + } + let (sense, at) = (a.dot(axis).signum(), (base - base0).dot(axis)); + let (e0, e1) = (at + sense * height.min(0.0), at + sense * height.max(0.0)); + let interval = (e0.min(e1), e0.max(e1)); + let circle = Region2 { outer: Loop2::circle(plane.to_local(base), radius), holes: vec![] }; + let same = |(lo, hi): (f64, f64)| (lo - interval.0).abs() <= 1e-9 * (1.0 + lo.abs()) && (hi - interval.1).abs() <= 1e-9 * (1.0 + hi.abs()); + match groups.iter_mut().find(|(i, _)| same(*i)) { + Some((_, regions)) => regions.push(circle), + None => groups.push((interval, vec![circle])), + } + } + let mut handled: Vec = Vec::new(); + for ((lo, hi), regions) in groups { + let tools = kernel::extrude(&plane, ®ions, lo, hi)?; + let done = sheet_prism_cut(f, st, &plane, ®ions, Operation::Cut, &tools, &[], None, false, &[], warning)?; + if done.is_empty() { + return Ok(None); + } + for name in done { + if !handled.contains(&name) { + handled.push(name); + } + } + } + Ok(Some(handled)) +} + /// A primitive tool's exact geometry, for [`sheet_primitive_cut`]. #[derive(Clone, Copy, Debug)] pub(super) enum PrimitivePrism { @@ -1003,6 +1076,9 @@ fn sheet_prism_cut( } else { Vec::new() }; + // Boxes around the tools: a tool clear of a bend by its box needs no boolean to show it (a + // label's letters against every bend and flange of a tray). + let tool_boxes: Vec> = tools.iter().map(body_box).collect(); let mut plans = Vec::new(); let mut notes = Vec::new(); 'sheets: for original in &st.sheets { @@ -1018,12 +1094,16 @@ fn sheet_prism_cut( 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)? { - plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); - continue; - } + // No 3D boolean of each tool with the whole folded sheet first: the flat machining below + // is exact, and a cut that machines nothing there is checked against the solid after it. + // (That boolean cost each label hundreds of milliseconds, and against a sheet already + // carrying a label's cut-outs it came out empty: the next cut was dropped.) for bends in sh.unmodeled_solids(plane)? { - for tool in tools { + let bends_box = body_box(&bends); + for (tool, tool_box) in tools.iter().zip(&tool_boxes) { + if boxes_apart(&bends_box, tool_box) { + continue; + } if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() { return Err(DocError::Invalid( "this machining intersects a folded bend or a nonparallel panel; unfold the sheet before machining that material".into(), @@ -1190,6 +1270,31 @@ fn sheet_prism_cut( Ok(handled) } +/// A box around `body`, grown past its mesh's deflection from the exact faces (and a hair +/// more): bodies whose boxes are apart do not meet. None when it can't be worked out. +fn body_box(body: &Body) -> Option { + let tol = (body.size() * 1e-3).max(1e-4); + padded_box(&body.tessellate(tol).ok()?, tol) +} + +/// [`body_box`] of a model body, from the mesh it keeps. +fn mesh_box(mb: &ModelBody) -> Option { + padded_box(&mb.mesh(), mb.display_tol()) +} + +fn padded_box(mesh: &solidcraft_geom::Mesh, tol: f64) -> Option { + let b = mesh.bounds(); + let pad = 2.0 * tol + 1e-6; + let pad = Vec3::new(pad, pad, pad); + (!b.is_empty() && b.min.is_finite() && b.max.is_finite()).then(|| Aabb3 { min: b.min - pad, max: b.max + pad }) +} + +/// Whether two boxes are known to be apart (an unknown box is never apart). +fn boxes_apart(a: &Option, b: &Option) -> bool { + let (Some(a), Some(b)) = (a, b) else { return false }; + a.max.x < b.min.x || b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z +} + /// 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. diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 8a446bcd..f72a3dee 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -1139,11 +1139,22 @@ impl SheetBody { cuts.push(fitted_region(&pocket)?); } let cuts = material_union(cuts, work)?; + let cut_boxes = cuts.iter().map(|c| loop_box(&c.outer)).collect::>>()?; let mut out = Vec::new(); let mut points = 0usize; for (piece, region) in pieces { let panel = fitted_region(&planar_region(®ion)?)?; - let material = if cuts.is_empty() { vec![panel] } else { material_boolean(&[panel], &cuts, kernel::BoolOp::Cut, work)? }; + // Each piece meets only the cut-outs near it: arranging every cut-out of the sheet (a + // label's letters on its base) against each flange and bend zone too would charge the + // budget for all of them once per piece. + let (lo, hi) = loop_box(&panel.outer)?; + let near: Vec = cuts + .iter() + .zip(&cut_boxes) + .filter(|(_, (a, b))| a.x <= hi.x && b.x >= lo.x && a.y <= hi.y && b.y >= lo.y) + .map(|(c, _)| c.clone()) + .collect(); + let material = if near.is_empty() { vec![panel] } else { material_boolean(&[panel], &near, kernel::BoolOp::Cut, work)? }; for region in material { points = points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1); if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES { @@ -1545,6 +1556,9 @@ impl SheetBody { } fn machine_pockets(&self, mut body: Body, work: &mut FlatWork) -> Result { + if self.pockets.is_empty() { + return Ok(body); + } if self.pockets.len() > 1000 { return Err(flat_budget_error()); } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 8e0653e7..423a5bd9 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -893,6 +893,10 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result { #[path = "sheet_tests.rs"] mod tests; +#[cfg(test)] +#[path = "sheet_batch_cuts_tests.rs"] +mod batch_cuts_tests; + #[cfg(test)] #[path = "sheet_convert_tests.rs"] mod convert_tests; diff --git a/crates/engine/src/cmd/sheet_batch_cuts_tests.rs b/crates/engine/src/cmd/sheet_batch_cuts_tests.rs new file mode 100644 index 00000000..621a80c6 --- /dev/null +++ b/crates/engine/src/cmd/sheet_batch_cuts_tests.rs @@ -0,0 +1,204 @@ +//! Many cuts through one sheet: a hole feature's drills machined in one pass, labels in one +//! sketch or one feature each, all keeping the geometry and flat pattern of cutting them one by one. + +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn volume(s: &mut Session) -> f64 { + run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) +} + +/// A `w` x `h` tray of 2.5 mm sheet: a base flange on XY with a 15 mm flange on each edge. +fn tray(s: &mut Session, w: f64, h: f64) { + run(s, "sketch.create", json!({"plane": "XY", "name": "Base"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]})); + run(s, "sketch.finish", json!({})); + run(s, "sheet.flange", json!({"sketch": "Base"})); + let edges = json!([[w / 2.0, h, 2.5], [w, h / 2.0, 2.5], [w / 2.0, 0, 2.5], [0, h / 2.0, 2.5]]); + run(s, "sheet.flange", json!({"edges": edges, "height": 15})); +} + +fn through_cut(s: &mut Session, sketch: &str) { + run(s, "solid.extrude", json!({"sketch": sketch, "through_all": true, "direction": "symmetric", "operation": "cut"})); +} + +/// A label cut on its own: one sketch, one cut. +fn label_cut(s: &mut Session, name: &str, text: &str, at: [f64; 2]) { + run(s, "sketch.create", json!({"plane": "XY", "name": name})); + run(s, "sketch.text", json!({"text": text, "at": at, "height": 6})); + run(s, "sketch.finish", json!({})); + through_cut(s, name); +} + +/// The labels in one sketch, cut at once. +fn labels_cut(s: &mut Session, labels: &[(String, [f64; 2])]) { + run(s, "sketch.create", json!({"plane": "XY", "name": "Labels"})); + for (text, at) in labels { + run(s, "sketch.text", json!({"text": text, "at": at, "height": 6})); + } + run(s, "sketch.finish", json!({})); + through_cut(s, "Labels"); +} + +/// One hole feature drilling at every point (one sketch of points). +fn holes_at_once(s: &mut Session, points: &[[f64; 2]]) { + run(s, "sketch.create", json!({"plane": "XY", "name": "HolePoints"})); + for p in points { + run(s, "sketch.point", json!({"point": p})); + } + run(s, "sketch.finish", json!({})); + run(s, "solid.hole", json!({"sketch": "HolePoints", "diameter": 4})); +} + +/// A hole feature for each point, drilled into the base's top face. +fn holes_one_by_one(s: &mut Session, points: &[[f64; 2]]) { + for p in points { + run(s, "solid.hole", json!({"position": [p[0], p[1], 2.5], "diameter": 4})); + } +} + +fn polyline(v: &Value) -> Vec<[f64; 2]> { + v.as_array().map(|a| a.iter().map(|p| [p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN)]).collect()).unwrap_or_default() +} + +fn area(p: &[[f64; 2]]) -> f64 { + let n = p.len(); + (0..n).map(|i| p[i][0] * p[(i + 1) % n][1] - p[(i + 1) % n][0] * p[i][1]).sum::() / 2.0 +} + +fn sorted_loops(v: &Value) -> Vec> { + let mut loops: Vec> = v.as_array().map(|a| a.iter().map(polyline).collect()).unwrap_or_default(); + loops.sort_by(|a, b| { + let (a, b) = (a.first().copied().unwrap_or_default(), b.first().copied().unwrap_or_default()); + a[0].total_cmp(&b[0]).then(a[1].total_cmp(&b[1])) + }); + loops +} + +/// The flat pattern's outline loops and cut-outs (each a polyline, ordered by where they start, +/// so loops made in another order compare alike) and the area they enclose. +fn flat(s: &mut Session) -> (Vec>, f64) { + let f = run(s, "sheet.flat_pattern", json!({})); + let mut loops = sorted_loops(&f["outline"]); + let cutouts = sorted_loops(&f["cutouts"]); + let enclosed = loops.iter().map(|l| area(l).abs()).sum::() - cutouts.iter().map(|c| area(c).abs()).sum::(); + loops.extend(cutouts); + (loops, enclosed) +} + +fn cutout_count(s: &mut Session) -> usize { + run(s, "sheet.flat_pattern", json!({}))["cutouts"].as_array().map_or(0, Vec::len) +} + +/// The same volume (within a millionth) and the same flat pattern (its material area within a +/// millionth, every point of its outline and cut-outs within 1e-6 mm). +fn assert_same(a: &mut Session, b: &mut Session) { + let (va, vb) = (volume(a), volume(b)); + assert!((va - vb).abs() <= 1e-6 * va.abs(), "volume {va} vs {vb}"); + let (la, ma) = flat(a); + let (lb, mb) = flat(b); + assert!(ma.is_finite() && (ma - mb).abs() <= 1e-6 * ma.abs().max(1.0), "flat area {ma} vs {mb}"); + let close = |p: &[[f64; 2]], q: &[[f64; 2]]| { + p.len() == q.len() && p.iter().zip(q).all(|(x, y)| (x[0] - y[0]).abs() <= 1e-6 && (x[1] - y[1]).abs() <= 1e-6) + }; + assert_eq!(la.len(), lb.len(), "flat loop count"); + for (x, y) in la.iter().zip(&lb) { + assert!(close(x, y), "flat loop differs: {:?} vs {:?}", x.first(), y.first()); + } +} + +fn hole_grid(n: usize, columns: usize, at: [f64; 2], pitch: f64) -> Vec<[f64; 2]> { + (0..n).map(|i| [at[0] + (i % columns) as f64 * pitch, at[1] + (i / columns) as f64 * pitch]).collect() +} + +#[test] +fn a_hole_feature_drills_a_sheet_in_one_pass_like_holes_one_by_one() { + let points = hole_grid(12, 6, [15.0, 20.0], 15.0); + let (mut once, mut each) = (Session::default(), Session::default()); + tray(&mut once, 120.0, 80.0); + tray(&mut each, 120.0, 80.0); + let before = volume(&mut once); + holes_at_once(&mut once, &points); + holes_one_by_one(&mut each, &points); + // Each hole takes π r² t from the base (the measure follows the walls' facets). + let want = before - 12.0 * std::f64::consts::PI * 4.0 * 2.5; + assert!((volume(&mut once) - want).abs() < 1e-5 * want, "{} {want}", volume(&mut once)); + assert_eq!(cutout_count(&mut once), 12); + assert_same(&mut once, &mut each); +} + +/// A second cut after a text cut was dropped without a word, and a hole after it missed the flat +/// pattern: the 3D test whether their tools met the sheet came out empty against the sheet +/// rebuilt with the letters' cut-outs. +#[test] +fn a_cut_after_a_text_cut_still_cuts() { + let mut s = Session::default(); + tray(&mut s, 120.0, 80.0); + label_cut(&mut s, "Label", "AB", [15.0, 50.0]); + let before = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [70, 20], "p1": [90, 30]})); + run(&mut s, "sketch.finish", json!({})); + let r = run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert_eq!(r["changed_bodies"], json!(["Body1"]), "{r}"); + let want = before - 20.0 * 10.0 * 2.5; + assert!((volume(&mut s) - want).abs() < 1e-6 * want, "{} {want}", volume(&mut s)); + assert_eq!(cutout_count(&mut s), 3, "A, B and the slot"); + // A hole drilled after it is a cut-out of the flat pattern too (it was cut into the solid + // only, so unfolding would have lost it). + run(&mut s, "solid.hole", json!({"position": [100, 60, 2.5], "diameter": 4})); + assert_eq!(cutout_count(&mut s), 4, "A, B, the slot and the hole"); +} + +#[test] +fn labels_in_one_sketch_and_holes_in_one_feature_match_cutting_them_one_by_one() { + let labels: Vec<(String, [f64; 2])> = (0..3).map(|i| (format!("A{i}"), [15.0 + 30.0 * i as f64, 55.0])).collect(); + let points = hole_grid(6, 6, [15.0, 20.0], 15.0); + let (mut once, mut each) = (Session::default(), Session::default()); + tray(&mut once, 120.0, 80.0); + tray(&mut each, 120.0, 80.0); + labels_cut(&mut once, &labels); + holes_at_once(&mut once, &points); + for (i, (text, at)) in labels.iter().enumerate() { + label_cut(&mut each, &format!("Label{i}"), text, *at); + } + holes_one_by_one(&mut each, &points); + assert_same(&mut once, &mut each); +} + +/// The full size: a flanged sheet with 10 labels and 50 holes, cut at once and one by one. +/// `cargo test --release -p solidcraft-engine --lib sheet_many_cuts_benchmark -- --ignored --nocapture` +#[test] +#[ignore = "benchmark: run with --release --ignored --nocapture"] +fn sheet_many_cuts_benchmark() { + let labels: Vec<(String, [f64; 2])> = + (0..10).map(|i| (format!("AB{i}"), [20.0 + 55.0 * (i % 5) as f64, if i < 5 { 150.0 } else { 170.0 }])).collect(); + let points = hole_grid(50, 10, [20.0, 30.0], 20.0); + let time = |f: &mut dyn FnMut()| { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }; + let (mut once, mut each) = (Session::default(), Session::default()); + tray(&mut once, 300.0, 200.0); + tray(&mut each, 300.0, 200.0); + let labels_once = time(&mut || labels_cut(&mut once, &labels)); + let holes_once = time(&mut || holes_at_once(&mut once, &points)); + let labels_each = time(&mut || { + for (i, (text, at)) in labels.iter().enumerate() { + label_cut(&mut each, &format!("Label{i}"), text, *at); + } + }); + let holes_each = time(&mut || holes_one_by_one(&mut each, &points)); + println!("labels: one sketch {labels_once:.0} ms, one feature each {labels_each:.0} ms"); + println!("holes: one feature {holes_once:.0} ms, one feature each {holes_each:.0} ms"); + assert_same(&mut once, &mut each); +}