From 80f5a5880603ad69bb59cbd38cca396c6429e500 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 21:42:28 +0000 Subject: [PATCH] Re-land the robustness batch on main (#6): fillets running into an earlier round go to blend_corner (any radius) instead of the same-radius undo; the lip and drilled-hole regression tests move into tests/robustness.rs with the new command ids; measuring 5000 faces is checked for its counts, its time in an ignored perf test run alone Part of #6 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests/robustness.rs | 98 +++++++++++++++++++++++---- crates/kernel/src/blend.rs | 57 ---------------- crates/kernel/src/tests.rs | 24 +++++-- docs/robustness.md | 6 +- 5 files changed, 108 insertions(+), 79 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 860c43f0..ebe9aa8b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,9 +9,9 @@ pub mod cmd; pub mod frames; +pub mod fuzz; pub mod legacy_ids; pub mod licences; -pub mod fuzz; pub mod params; pub mod recovery; pub mod sample; diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index b020062d..41d3070d 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -1,8 +1,11 @@ //! Cases found by the robustness runs (`solvecraft-cli fuzz`, docs/robustness.md), cut down to //! the steps that matter, and a fixed set of seeds that must keep running clean. +use std::f64::consts::PI; + use serde_json::{Value, json}; +use super::{run, volume}; use crate::Session; use crate::fuzz; @@ -180,22 +183,23 @@ fn shell_after_a_drilled_hole() { } } -/// Rounding an edge that runs into an earlier round of the same radius (filleting a box's -/// edges one at a time): the same body as picking both at once. Another radius says so. +/// Rounding a box's edges one at a time: an edge running into an earlier round (of the same +/// radius or another) takes off about what each round alone would (the corner where they meet +/// is small). #[test] fn fillet_into_an_earlier_round() { let box_ = json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}}); - let both = script(json!([box_, {"command": "solid.fillet", "params": {"edges": [[30, 0, 15], [60, 0, 7]], "radius": 2}}])); - let one_by_one = script(json!([ - box_, - {"command": "solid.fillet", "params": {"edges": [[30, 0, 15]], "radius": 2}}, - {"command": "solid.fillet", "params": {"edges": [[60, 0, 7]], "radius": 2}} - ])); - let (a, b) = (total_volume(&both), total_volume(&one_by_one)); - assert!((a - b).abs() < 1e-6 * a, "{a} vs {b}"); - let mut s = script(json!([box_, {"command": "solid.fillet", "params": {"edges": [[30, 0, 15]], "radius": 2}}])); - let e = s.execute("solid.fillet", &json!({"edges": [[60, 0, 7]], "radius": 3})).unwrap_err(); - assert!(e.to_string().contains("not supported yet: an edge running into an earlier round of another radius"), "{e}"); + for r2 in [2.0, 3.0] { + let s = script(json!([ + box_, + {"command": "solid.fillet", "params": {"edges": [[30, 0, 15]], "radius": 2}}, + {"command": "solid.fillet", "params": {"edges": [[60, 0, 7]], "radius": r2}} + ])); + let lost = |r: f64, len: f64| (1.0 - PI / 4.0) * r * r * len; + let want = 36000.0 - lost(2.0, 60.0) - lost(r2, 15.0); + let v = total_volume(&s); + assert!((v - want).abs() < 2.0, "r2 {r2}: {v} vs {want}"); + } } /// Seeds that run clean: no failure, no invalid body, no volume going the wrong way. @@ -208,3 +212,71 @@ fn fixed_seeds_run_clean() { } const FIXED_SEEDS: &[u64] = &[3, 5, 11, 16, 21, 29, 33, 35]; + +/// A drilled hole as deep as the plate: the drill point breaks through the bottom. +#[test] +fn hole_as_deep_as_the_plate() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 10})); + let v0 = volume(&mut s); + run(&mut s, "solid.hole", json!({"position": [25, 12.5, 10], "diameter": 6, "depth": 10, "type": "drilled"})); + let v = volume(&mut s); + assert!(v < v0 - PI * 9.0 * 9.0 && v > v0 - PI * 9.0 * 10.0 - 1.0, "{v0} {v}"); + // Other sizes and places, the defaults, and a tapped hole. + for (i, p) in [ + json!({"position": [10, 10, 10], "diameter": 5, "depth": 10, "type": "drilled"}), + json!({"position": [40, 15, 10], "diameter": 5, "depth": 10}), + json!({"position": [12, 6, 10], "diameter": 8.5, "depth": 10}), + json!({"position": [30, 7, 10], "thread": "M6", "depth": 10}), + json!({"position": [20, 18, 10], "diameter": 4, "depth": 10, "tip_angle": 90}), + ] + .into_iter() + .enumerate() + { + let before = volume(&mut s); + let r = s.execute("solid.hole", &p); + assert!(r.is_ok(), "{i} {p}: {r:?}"); + assert!(volume(&mut s) < before - 1.0, "{i} {p}"); + } +} + +/// An enclosure built in the usual order: shell, bosses, then the lip on the rim. +#[test] +fn lip_after_boss() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30})); + run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2})); + run(&mut s, "plastic.boss", json!({"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5})); + run(&mut s, "plastic.boss", json!({"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5})); + let v0 = volume(&mut s); + run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2})); + let v = volume(&mut s); + assert!(v > v0 + 1.0, "{v0} {v}"); + // Bosses up to the rim, with ribs, against a wall; lips, grooves and outside rims. + let bosses = [ + json!({"position": [15, 15, 2], "diameter": 8, "height": 28, "hole_diameter": 3}), + // Ribs out to the inner walls (15 − 4 − 9 = 2), and short of them. + json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 9}), + json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6}), + json!({"position": [5.5, 25, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}), + json!({"position": [40, 25, 2], "diameter": 8, "height": 20, "draft": "1 deg", "fillet": 1}), + ]; + let lips = [ + json!({"face": [1, 25, 30], "width": 1, "height": 2}), + json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove"}), + json!({"face": [1, 25, 30], "width": 1, "height": 2, "side": "outside"}), + ]; + for (i, b) in bosses.iter().enumerate() { + for (j, l) in lips.iter().enumerate() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30})); + run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2})); + let r = s.execute("plastic.boss", b); + assert!(r.is_ok(), "boss {i}: {r:?}"); + let v0 = volume(&mut s); + let r = s.execute("plastic.lip", l); + assert!(r.is_ok(), "boss {i} lip {j}: {r:?}"); + assert!((volume(&mut s) - v0).abs() > 1.0, "boss {i} lip {j}"); + } + } +} diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 102f67d4..8472bd09 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -361,15 +361,6 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul let attempt = match guard(what, || blend_one(&solid, &edge, size, r, shape)) { Ok(o) => Ok((o, vec![j])), // A curved edge, or one between curved faces: roll a ball along its smooth chain. - // A straight edge running into an earlier round of the same radius: that round - // undone and both edges rounded together (they meet in a mitre, as when picked - // at once). - Err(e) if !curved_case(&solid, &edge) && e.to_string().contains("edge ends must be planar") && shape == Shape::Round => { - unround_and_blend(&cur, &edge, r, what).map(|b| ((*b.solid).clone(), vec![j])).map_err(|e2| match &e2 { - KernelError::Failed(m) if m.starts_with("not supported yet: an edge running into") => e2, - _ => KernelError::Failed(format!("{e} ({e2})")), - }) - } Err(e) if !curved_case(&solid, &edge) => Err(e), Err(e) => match smooth_chain(&solid, &edge) { Some(ids) => { @@ -808,54 +799,6 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl Ok(cur) } -thread_local! { - static UNROUNDING: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// Round `edge` of `cur` where an end runs into an earlier round of radius `r`: the round is -/// deleted (its faces healed back to the sharp edge) and both edges rounded in one go. -fn unround_and_blend(cur: &Body, edge: &mt::Edge, r: f64, what: &str) -> Result { - if UNROUNDING.with(|c| c.get()) { - return Err(unsupported("rounds meeting rounds")); - } - let size = cur.size(); - let mesh = cur.tessellate((size * 1e-3).max(1e-3))?; - let info = cur.faces((size * 1e-3).max(1e-3))?; - let curved = |f: usize| info.get(f).is_some_and(|x| x.plane_normal.is_none()); - let ends = [from_p3(edge.absolute_front().point()), from_p3(edge.absolute_back().point())]; - let mut round: Option<(usize, Vec3)> = None; - for q in ends { - let near = mesh - .triangles - .iter() - .zip(&mesh.tri_face) - .filter(|(_, f)| curved(**f as usize)) - .filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (q.dist((x + y + z) / 3.0), *f as usize, (x + y + z) / 3.0))) - .min_by(|a, b| a.0.total_cmp(&b.0)); - if let Some((d, f, c)) = near - && d < r * 2.0 - { - round = Some((f, c)); - break; - } - } - let (g, on_g) = round.ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?; - let cyl = crate::topo::cylinder_face_at(cur, on_g).ok_or_else(|| unsupported("only rounds (cylinders) at the edge ends"))?; - if (cyl.radius - r).abs() > 1e-4 * r.max(1.0) || cyl.internal { - return Err(unsupported("an edge running into an earlier round of another radius (round both with one radius, or pick them together)")); - } - let faces = crate::offset::with_same_surface(cur, &[g]); - let sharp = crate::delete_face::delete_faces(cur, &faces)?; - let mid = (ends[0] + ends[1]) * 0.5; - // The restored edge: nearest the middle of the round (its centroid), not its ends (where - // the new edge is near too). - let old = info.get(g).map(|x| x.centroid).unwrap_or(on_g); - UNROUNDING.with(|c| c.set(true)); - let r2 = blend(&sharp, &[old, mid], r, Shape::Round, what); - UNROUNDING.with(|c| c.set(false)); - r2 -} - pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result { chamfer_tool_sides(cur, p, s, None, k) } diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 68f9f73a..f2e23857 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1361,8 +1361,8 @@ fn boolean_of_a_body_in_pieces() { assert!((v - (2000.0 - 2.0 * PI * 4.0 * 10.0)).abs() < 1.0, "{v}"); } -/// Measuring a body of 5000 faces (a modelled thread is like that) stays fast and counts -/// right: 0.8 s here, it took 13 s (a per-face recomputation of the body's size). +/// Measuring a body of 5000 faces (a modelled thread is like that) counts right (it took 13 s, +/// a per-face recomputation of the body's size; now under a second). #[test] fn measure_of_a_body_with_many_faces() { let n = 5000; @@ -1373,9 +1373,23 @@ fn measure_of_a_body_with_many_faces() { }) .collect(); let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap(); - let t = std::time::Instant::now(); let m = measure(&b).unwrap(); - // (A loaded build machine runs slower; the bound is loose.) - assert!(t.elapsed().as_secs_f64() < 5.0, "{:?}", t.elapsed()); assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (n + 2, 3 * n, 2 * n), "{:?}", m.merged); } + +/// The time it takes (run alone, the suite's other tests share the machine): +/// `cargo test --release -p solvecraft-kernel perf_measure_5000_faces -- --ignored`. +#[test] +#[ignore] +fn perf_measure_5000_faces() { + let pts: Vec = (0..5000) + .map(|i| { + let a = std::f64::consts::TAU * i as f64 / 5000.0; + Vec2::new(20.0 * a.cos(), 20.0 * a.sin()) + }) + .collect(); + let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap(); + let t = std::time::Instant::now(); + measure(&b).unwrap(); + assert!(t.elapsed().as_secs_f64() < 1.5, "{:?}", t.elapsed()); +} diff --git a/docs/robustness.md b/docs/robustness.md index 1a5b2dc8..440b96bd 100644 --- a/docs/robustness.md +++ b/docs/robustness.md @@ -94,9 +94,9 @@ Found by QA on new users' first steps, each with an engine test against the anal - **Shell thinner than a round tangent to the open face** (1.5 mm): every face moves in (tangencies keep) and the opening is the open face's copy swept out through the body. - **Shell after a drilled hole** (its tip a cone). -- **Filleting a box's edges one at a time**: an edge that runs into an earlier round of the - same radius undoes that round and rounds both together, the same body as picking both at once. - Another radius says what to do; Fusion's corner patch for it is still missing. +- **Filleting a box's edges one at a time**: an edge that runs into an earlier round takes + that round off and rounds both together, with the corner where they meet (`blend_corner.rs`, + any radii). ### Speed