From c7f0139ad4e989c13dafa21b74ee9aa547854793 Mon Sep 17 00:00:00 2001 From: Jonas Wrede Date: Mon, 12 Oct 2026 17:18:28 +0200 Subject: [PATCH] test: failing regression test for #261: Rectangular pattern of a hole cut scales super-linearly (#276) * test: reproduce #261 (Rectangular pattern of a hole cut scales super-linearly) * Patterns of many hole cuts scale near-linearly (#261) Two quadratic costs: - Meshing a face tested every point against every segment of every boundary loop, so a plate face with hundreds of holes meshed in quadratic time. The vendored truck-meshalgo inside test now skips loops whose parameter box is clear of the point (they wind zero times around it). - A pattern cut each copy in turn, rebuilding and re-checking an ever more complex target per copy. Copies whose boxes are clear of each other are now one tool of many pieces (kernel::union_apart, no intersection work), cut once. 512 holes now take ~7 s instead of not finishing. The test's volume tolerance is widened from 1e-6 relative to 0.1 % of each hole: the measure meshes the bores and reads each hole ~0.06 % small, on main too. Co-Authored-By: Claude Opus 5.5 * Hole-pattern test: a budget CI can meet; coarser apart-box meshes (#261) CI runs every test binary at once and is about ten times slower than a machine running this test alone (15 tests passed 60 s in the last run), so the 512-hole pattern (about 6 s alone) hit the 60 s timeout there. The budget now only has to tell linear from the quadratic hang: 300 s timeout, 120 s budget. union_apart meshes each copy coarsely (2 % of its size, box grown by twice that). Co-Authored-By: Claude Opus 5.5 * Hole-pattern test checks the scaling, not a wall-clock time (#261) CI runs every test binary at once: the 512-hole pattern (about 6 s alone, 10 s CPU single-threaded) took 177 s there, so no fixed time is reliable. The test now patterns 128 and then 512 holes and requires the second to take under 8x the first (linear is 4x, measured 3.5-4.2x here; the old quadratic cost was 16x or more), each with the volume check and a 900 s hang guard. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: bherbruck Co-authored-by: Claude Opus 5.5 --- crates/doc/src/eval.rs | 8 ++++ crates/engine/src/cmd/patterns_tests.rs | 45 +++++++++++++++++++ crates/kernel/src/lib.rs | 2 +- crates/kernel/src/ops.rs | 37 +++++++++++++++ vendor/README.md | 3 ++ .../src/tessellation/triangulation.rs | 18 ++++++-- 6 files changed, 109 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index e67e2db2..3b034d9c 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2535,6 +2535,14 @@ fn replay( } } } + // Many cut copies clear of each other: one tool of many pieces, so the target is cut + // once instead of rebuilding an ever more complex body for every copy (#261). + if op == Operation::Cut + && copies.len() >= 8 + && let Ok(Some(one)) = kernel::union_apart(&copies) + { + copies = vec![one]; + } // New bodies from a pattern join their source when they touch it (as one feature would). if f.body_names.is_empty() { apply_op(st, f, copies, op, &targets)?; diff --git a/crates/engine/src/cmd/patterns_tests.rs b/crates/engine/src/cmd/patterns_tests.rs index 895b2581..f4728ed9 100644 --- a/crates/engine/src/cmd/patterns_tests.rs +++ b/crates/engine/src/cmd/patterns_tests.rs @@ -207,3 +207,48 @@ fn patterns_of_a_blind_hole_drill_every_copy() { )); assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}"); } + +/// A plate with a `count1` × `count2` feature pattern of one through-hole cut: its volume and how +/// long the pattern took. It runs on its own thread so a hang fails the test instead of CI. +fn hole_pattern(count1: usize, count2: usize) -> (f64, std::time::Duration) { + use std::time::{Duration, Instant}; + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 200, "width": 100, "height": 1, "operation": "new_body", "body_name": "Plate"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "2", "name": "H"})); + run(&mut s, "sketch.circle.center", json!({"center": [4, 4], "diameter": 2})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "H", "distance": "4", "direction": "negative", "operation": "cut", "targets": ["Plate"], "name": "Hole"}), + ); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let t = Instant::now(); + let r = s.execute( + "solid.pattern.rectangular", + &json!({"features": ["Hole"], "dir1": [1, 0, 0], "count1": count1, "spacing1": 6, "dir2": [0, 1, 0], "count2": count2, "spacing2": 6}), + ); + let took = t.elapsed(); + let v = r.map(|_| volume(&mut s)).map_err(|e| e.to_string()); + let _ = tx.send((v, took)); + }); + let (v, took) = rx.recv_timeout(Duration::from_secs(900)).expect("the hole pattern did not finish within 900 s"); + let n = (count1 * count2) as f64; + let v = v.expect("pattern"); + // The measure meshes the bores: each hole reads about 0.06 % small, so allow 0.1 % a hole. + assert!((v - (200.0 * 100.0 - n * PI)).abs() < n * PI * 1e-3, "{n} holes: {v}"); + (v, took) +} + +/// 512 identical through-holes from a feature pattern of one cut (#261). 128 took 30 s and 512 did +/// not finish in 150 s: each copy was cut into an ever more complex plate. The time must grow about +/// linearly with the count. A ratio, not a time: CI runs every test binary at once and is many +/// times slower than a machine running this alone (about 6 s for 512 holes there). +#[test] +fn pattern_of_512_hole_cuts_finishes_in_reasonable_time() { + let (_, t128) = hole_pattern(16, 8); + let (_, t512) = hole_pattern(32, 16); + // Linear is 4×, the old quadratic cost 16× or more. + assert!(t512.as_secs_f64() < 8.0 * t128.as_secs_f64() + 2.0, "128 holes took {t128:?}, 512 took {t512:?}"); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index d900e4bf..317d414c 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -75,7 +75,7 @@ pub use measure::{BodyMeasure, measure}; pub use mesh_convert::convert_prismatic_mesh; pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use offset::with_same_surface; -pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix}; +pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix, union_apart}; pub use polybool::{faceted_boolean, planar_boolean}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use prism::{extrude_planar_regions, planar_regions_boolean}; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index d0ebb211..e1d37818 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -588,6 +588,43 @@ fn by_lumps(la: Vec, lb: Vec, op: BoolOp) -> Result> { join_pieces(out) } +/// Bodies whose bounding boxes are clearly apart, as one body of several pieces, without any +/// intersection work (many identical pattern tools, #261). `None` when any two may meet, or a +/// body is a mesh: the caller unions those the usual way. +pub fn union_apart(bodies: &[Body]) -> Result> { + if bodies.len() < 2 || bodies.len() > 10_000 || bodies.iter().any(Body::is_mesh) { + return Ok(None); + } + let mut boxes = Vec::with_capacity(bodies.len()); + for b in bodies { + // A coarse mesh is enough: the chord tolerance bounds how far the true surface lies + // outside it, and the box grows by twice that. + let tol = (b.size() * 0.02).max(1e-3); + let bb = b.tessellate(tol)?.bounds(); + if bb.is_empty() { + return Ok(None); + } + let pad = Vec3::new(1.0, 1.0, 1.0) * (2.0 * tol); + boxes.push(solvecraft_geom::Aabb3 { min: bb.min - pad, max: bb.max + pad }); + } + // Sweep along x: only boxes whose x ranges overlap are compared. + let mut order: Vec = (0..boxes.len()).collect(); + order.sort_by(|&i, &j| boxes.get(i).map(|b| b.min.x).unwrap_or(0.0).total_cmp(&boxes.get(j).map(|b| b.min.x).unwrap_or(0.0))); + for (k, &i) in order.iter().enumerate() { + let Some(a) = boxes.get(i) else { continue }; + for &j in order.iter().skip(k + 1) { + let Some(b) = boxes.get(j) else { continue }; + if b.min.x > a.max.x { + break; + } + if a.min.y <= b.max.y && b.min.y <= a.max.y && a.min.z <= b.max.z && b.min.z <= a.max.z { + return Ok(None); + } + } + } + join_pieces(bodies.to_vec()) +} + /// Bodies (apart from each other) as one body of several pieces. pub(crate) fn join_pieces(pieces: Vec) -> Result> { match pieces.len() { diff --git a/vendor/README.md b/vendor/README.md index b468b9fe..d9068532 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,6 +37,9 @@ Changes: ## truck-meshalgo 0.4.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck) Changes: +- `tessellation/triangulation.rs`: the inside test of a face's boundary skips loops whose + parameter box is clear of the point (it tested every segment of every loop, so a face with + hundreds of holes meshed in quadratic time). - `tessellation/triangulation.rs`: meshed faces and edges are built unchecked: faces whose loop runs along a seam edge both ways and closed edges (a full circle on one vertex) are valid, but the debug-build checks rejected them (debug builds could not mesh them). diff --git a/vendor/truck-meshalgo/src/tessellation/triangulation.rs b/vendor/truck-meshalgo/src/tessellation/triangulation.rs index 13fe3a43..7a09c452 100644 --- a/vendor/truck-meshalgo/src/tessellation/triangulation.rs +++ b/vendor/truck-meshalgo/src/tessellation/triangulation.rs @@ -344,8 +344,10 @@ fn get_mindiff(u: f64, u0: f64, up: f64) -> f64 { (-2..=2).map(closure).min_by(abs_diff(u0)).unwrap() } +/// SolveCraft: the second field holds each loop's parameter box, so `include` skips loops that +/// cannot hold the point (a face with hundreds of holes meshed in quadratic time). #[derive(Debug, Default, Clone)] -struct PolyBoundary(Vec>); +struct PolyBoundary(Vec>, Vec>); fn normalize_range(curve: &mut Vec, compidx: usize, (u0, u1): (f64, f64)) { let p = curve[0]; @@ -485,16 +487,26 @@ impl PolyBoundary { closed.push(connect_edges([vec0, vec1, vec2, vec3])); } } - Self(closed) + let boxes = closed.iter().map(|curve| curve.iter().collect()).collect(); + Self(closed, boxes) } /// whether `c` is included in the domain with boundary = `self`. fn include(&self, c: Point2) -> bool { let t = 2.0 * std::f64::consts::PI * HashGen::hash1(c); let r = Vector2::new(f64::cos(t), f64::sin(t)); + // SolveCraft: a closed loop whose box is clear of `c` winds zero times around it, and + // no crossing with it lies within the tolerance of `c`: it changes nothing here. + let margin = 2.0 * TOLERANCE; + let near = |bdb: &BoundingBox| { + let (min, max) = (bdb.min(), bdb.max()); + !(c.x < min.x - margin || c.x > max.x + margin || c.y < min.y - margin || c.y > max.y + margin) + }; self.0 .iter() - .flat_map(|vec| vec.iter().circular_tuple_windows()) + .zip(&self.1) + .filter(|(_, bdb)| near(bdb)) + .flat_map(|(vec, _)| vec.iter().circular_tuple_windows()) .try_fold(0_i32, move |counter, (p0, p1)| { let a = p0 - c; let b = p1 - c;