diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index 7bfcebca..9f90fd03 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -54,6 +54,55 @@ fn shell_with_a_hole_through_a_curved_side() { ])); } +/// A cut whose end only touches the plate's arc side (#897, robustness seed 14): the end lies +/// tangent to the arc at its apex, the direct boolean fails, and the last-resort nudge used to +/// push the end into the arc, leaving a sliver of it a tenth of a millimetre wide that a shell +/// then couldn't offset. The nudge the other way parts them: the same faces as a cut clear of +/// the apex, and seed 14 shells and fillets again. +#[test] +fn cut_whose_end_touches_an_arc_side_leaves_no_sliver() { + let plate = |x: f64| { + script(json!([ + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [42.5, 0], [42.5, 20.0], [0, 20.0]]}}, + {"command": "sketch.arc.three_point", "params": {"end": [0, 0], "start": [0, 20.0], "through": [-10.0, 10.0]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 27.5}}, + {"command": "solid.cylinder", "params": {"axis": [1.0, 0.0, 0.0], "base": [x, 12.5, 12.5], "height": 17.5, "operation": "cut", "radius": 5.0}} + ])) + }; + let (touching, clear) = (plate(-10.0), plate(-10.1)); + let faces = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.face_count()).sum::(); + assert_eq!(faces(&touching), faces(&clear), "a sliver face was left where the cut's end touches the arc"); + let r = fuzz::run(14, 8); + let kinds: Vec<&str> = r.steps.iter().map(|s| s.kind).collect(); + assert!(r.outcome.kind == "ok" && kinds.contains(&"shell"), "seed 14: {:?} {kinds:?}\n{}", r.outcome, r.script()); +} + +/// #827: a hinge barrel joined to a laptop lid through its mounting leaf. The barrel's top lies +/// tangent to the lid's underside where the leaf ends, so the direct boolean fails, and the +/// last-resort nudge pushed the barrel into the lid and failed too; the nudge the other way +/// parts them and the join builds. The barrel adds three quarters of a cylinder (the quarter +/// inside the leaf was there already). +#[test] +fn hinge_barrel_joins_a_filleted_lid() { + let all = json!([ + {"command": "solid.box", "params": {"length": 320, "width": 220, "height": 6, "corner": [-160, 4, 21], "body_name": "Lid Housing"}}, + {"command": "solid.fillet", "params": {"body": "Lid Housing", "edges": [[-160, 4, 24], [160, 4, 24], [-160, 224, 24], [160, 224, 24]], "radius": 5}}, + {"command": "solid.fillet", "params": {"body": "Lid Housing", "edges": [[0, 4, 27], [0, 224, 27], [-160, 114, 27], [160, 114, 27]], "radius": 1}}, + {"command": "solid.box", "params": {"length": 296, "width": 190, "height": 1.1, "corner": [-148, 17, 20.9], "operation": "cut", "targets": ["Lid Housing"]}}, + {"command": "solid.box", "params": {"length": 14, "width": 10, "height": 6, "corner": [-123.5, 214, 16], "operation": "join", "targets": ["Lid Housing"]}}, + {"command": "solid.cylinder", "params": {"radius": 5, "height": 14, "axis": "X", "base": [-123.5, 224, 16], "operation": "join", "targets": ["Lid Housing"]}} + ]); + let steps = all.as_array().unwrap(); + let before = total_volume(&script(Value::Array(steps[..5].to_vec()))); + let s = script(all.clone()); + assert_eq!(s.model.state().bodies.len(), 1); + let added = total_volume(&s) - before; + let want = 0.75 * PI * 5.0 * 5.0 * 14.0; + assert!((added - want).abs() < want * 1e-3, "the barrel added {added} mm³, not {want}"); +} + /// #871: the fillet from the report (its feature as saved) on a plate with a block on top and /// a window through both. Radius 1 on the window's edges and on the block's sides overlaps in /// the 1.26 mm wall between them; the result had openings in its end faces and was accepted. diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 635413e8..af75dc6e 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -785,9 +785,33 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { APART.with(|c| c.set(true)); let c = b.solid.vertex_iter().fold(Vec3::ZERO, |acc, v| acc + from_p3(v.point())) * (1.0 / b.solid.vertex_iter().count().max(1) as f64); let translation = Vec3::new(0.61, -0.37, 0.71) * (size * 2e-6); - let (r, diagnostics) = capture_boolean_diagnostics(|| { - transform(b, translation, c, Vec3::new(0.27, 0.83, -0.49), 2e-6).and_then(|nb| Ok(boolean(a, &nb, op)?.map(|r| nudged(r, &nb, &counts)))) - }); + let nudge = |sign: f64| { + capture_boolean_diagnostics(|| { + transform(b, translation * sign, c, Vec3::new(0.27, 0.83, -0.49), 2e-6 * sign) + .and_then(|nb| Ok(boolean(a, &nb, op)?.map(|r| nudged(r, &nb, &counts)))) + }) + }; + let (mut r, mut diagnostics) = nudge(1.0); + // A nudge that pushes a face into a curved face it only touched (a cut whose end lies + // tangent to a round side) leaves a sliver of that face behind, a fraction of a + // millimetre wide, which later features (a shell, a fillet) can't work with. The + // opposite nudge parts them instead. It is tried when this one failed or left more + // slivers than the operands brought (a thin wall or a small round is no reason to run + // another boolean), and taken when it works with fewer; an empty result stays as it is. + let slivers = match &r { + Ok(Some(body)) if check(body).0 => Some(sliver_faces(body, size)), + _ => None, + }; + let made = slivers.is_none_or(|n| n > 0 && n > sliver_faces(a, size).saturating_add(sliver_faces(b, size))); + if !matches!(r, Ok(None)) && made { + let (r2, diagnostics2) = nudge(-1.0); + if let Ok(Some(body)) = &r2 + && slivers.is_none_or(|n| sliver_faces(body, size) < n) + && check(body).0 + { + (r, diagnostics) = (r2, diagnostics2); + } + } APART.with(|c| c.set(false)); match r { Ok(Some(body)) => { @@ -829,6 +853,34 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { } } +/// Faces of `b` narrower than 1/200 of `size` (twice their area over their boundary's +/// length): the slivers a nudged tool leaves where it was pushed into a face it touched. +fn sliver_faces(b: &Body, size: f64) -> usize { + use std::collections::HashMap; + let Ok(m) = b.tessellate((size * 1e-3).max(1e-4)) else { return usize::MAX }; + let q = (size * 1e-7).max(1e-12); + let key = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64); + // Per face: its area, and how many of its triangles use each mesh edge (its boundary once). + let mut area: HashMap = HashMap::new(); + let mut sides: HashMap<(u32, (i64, i64, i64), (i64, i64, i64)), (u32, f64)> = HashMap::new(); + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let Some([a, b, c]) = m.tri(t) else { continue }; + *area.entry(*f).or_default() += (b - a).cross(c - a).len() * 0.5; + for (p, r) in [(a, b), (b, c), (c, a)] { + let (kp, kr) = (key(p), key(r)); + let k = if kp <= kr { (*f, kp, kr) } else { (*f, kr, kp) }; + sides.entry(k).or_insert((0, p.dist(r))).0 += 1; + } + } + let mut boundary: HashMap = HashMap::new(); + for ((f, _, _), (n, len)) in &sides { + if *n == 1 { + *boundary.entry(*f).or_default() += len; + } + } + area.iter().filter(|(f, a)| boundary.get(f).is_some_and(|l| *l > 0.0 && 2.0 * **a / l < size * 5e-3)).count() +} + /// `r`, a boolean of the target and `nb` (the tool nudged), with its history over the target and /// the tool itself (the nudge keeps every face and edge). pub(crate) fn nudged(r: Body, nb: &Body, counts: &[(usize, usize)]) -> Body { diff --git a/docs/robustness.md b/docs/robustness.md index 31ebc181..f775f153 100644 --- a/docs/robustness.md +++ b/docs/robustness.md @@ -68,7 +68,11 @@ where the space it moves through holds what the Boolean leaves there anyway (nothing of the target for a cut's face, the target's material for a join's); a face with no such room stays, and the exact planar fallback is tried first. A tool nudge changes the relative operand -placement and reports both its translation magnitude and rotation angle. +placement and reports both its translation magnitude and rotation angle. When it +fails, or leaves a sliver face (narrower than 1/200 of the part), it is tried the +other way too: one way can push a face into a curved face it only touched (a cut +whose end lies tangent to a round side) and leave a sliver of it, where the other +way parts them (#897, #827). Exact planar and piecewise fallbacks report zero displacement. The existing volume and spatial acceptance checks still apply to all results. diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 03a63c54..0e810365 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -32,9 +32,9 @@ "28": {"area":2394.691384,"bodies":1,"edges":15,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","join cylinder"],"unsupported":0,"volume":5902.121375}, "29": {"area":6965.599057,"bodies":1,"edges":74,"error":null,"faces":30,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","shell","join cylinder"],"unsupported":0,"volume":3977.885339}, "30": {"area":853.525389,"bodies":1,"edges":23,"error":null,"faces":10,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer"],"unsupported":0,"volume":1337.14821}, - "31": {"area":10195.468237,"bodies":3,"edges":75,"error":null,"faces":31,"outcome":"ok","rejected":1,"shells":3,"steps":["box","cut box","fillet","join box","circular pattern","cut box","circular pattern","hole"],"unsupported":1,"volume":56416.134686}, + "31": {"area":10195.46137,"bodies":3,"edges":69,"error":null,"faces":30,"outcome":"ok","rejected":1,"shells":3,"steps":["box","cut box","fillet","join box","circular pattern","cut box","circular pattern","hole"],"unsupported":1,"volume":56416.127527}, "32": {"area":3220.802769,"bodies":1,"edges":51,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","cut box","cut box","join box"],"unsupported":0,"volume":7565.955897}, - "33": {"area":5269.06481,"bodies":2,"edges":64,"error":null,"faces":29,"outcome":"ok","rejected":0,"shells":3,"steps":["revolved section","fillet","join box","cut cylinder","join box","chamfer","fillet","fillet"],"unsupported":2,"volume":13840.242661}, + "33": {"area":5285.817318,"bodies":2,"edges":59,"error":null,"faces":27,"outcome":"ok","rejected":0,"shells":3,"steps":["revolved section","fillet","join box","cut cylinder","chamfer","join box","chamfer","fillet"],"unsupported":2,"volume":13859.24945}, "34": {"area":1086.303472,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.365275}, "35": {"area":4605.630216,"bodies":1,"edges":26,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["box","fillet","join box"],"unsupported":0,"volume":13965.526982}, "36": {"area":10408.825748,"bodies":1,"edges":60,"error":null,"faces":26,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","cut cylinder","join box","fillet","join cylinder"],"unsupported":0,"volume":55729.313103}, @@ -44,7 +44,7 @@ "40": {"area":15548.516294,"bodies":1,"edges":49,"error":"offset: a corner whose faces don't meet the same way after t","faces":22,"outcome":"failed","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","cut cylinder","join cylinder","shell"],"unsupported":0,"volume":16543.777532}, "41": {"area":7611.393446,"bodies":1,"edges":99,"error":null,"faces":35,"outcome":"ok","rejected":1,"shells":1,"steps":["box","cut sphere","shell","fillet","cut cylinder","rectangular pattern","cut sphere","join cylinder"],"unsupported":1,"volume":5973.93328}, "42": {"area":5486.881252,"bodies":1,"edges":45,"error":null,"faces":17,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut box","cut cylinder","rectangular pattern"],"unsupported":0,"volume":19095.110963}, - "43": {"area":2623.031137,"bodies":1,"edges":72,"error":"boolean Union: the operation failed: no result","faces":26,"outcome":"failed","rejected":2,"shells":1,"steps":["extruded profile","cut cylinder","rectangular pattern","fillet","hole","rectangular pattern","join cylinder"],"unsupported":1,"volume":4325.21806}, + "43": {"area":3492.988343,"bodies":1,"edges":72,"error":null,"faces":26,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","cut cylinder","rectangular pattern","fillet","hole","rectangular pattern","join cylinder"],"unsupported":1,"volume":7528.548742}, "44": {"area":7972.435565,"bodies":1,"edges":36,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","shell","cut sphere"],"unsupported":0,"volume":7622.184369}, "45": {"area":14416.876392,"bodies":2,"edges":62,"error":"boolean Union: implausible result volume ","faces":29,"outcome":"failed","rejected":0,"shells":3,"steps":["revolved section","join cylinder","fillet","shell","cut box","join box"],"unsupported":1,"volume":14286.791174}, "46": {"area":3445.162934,"bodies":1,"edges":99,"error":null,"faces":31,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","join cylinder","hole","cut cylinder","rectangular pattern","cut box","cut cylinder"],"unsupported":0,"volume":6232.317935}, @@ -79,7 +79,7 @@ "75": {"area":7893.117931,"bodies":1,"edges":27,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut cylinder","chamfer","rectangular pattern","rectangular pattern","cut box"],"unsupported":0,"volume":38757.62083}, "76": {"area":899.010133,"bodies":1,"edges":29,"error":null,"faces":13,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","fillet","cut cylinder"],"unsupported":0,"volume":1745.3677}, "77": {"area":12157.059928,"bodies":1,"edges":126,"error":null,"faces":54,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","hole","circular pattern","rectangular pattern","circular pattern","join cylinder"],"unsupported":0,"volume":64306.616746}, - "78": {"area":2423.343796,"bodies":1,"edges":39,"error":"offset: a corner whose faces don't meet the same way after t","faces":15,"outcome":"failed","rejected":0,"shells":1,"steps":["revolved section","cut cylinder","cut box","shell"],"unsupported":0,"volume":3707.45435}, + "78": {"area":2510.733756,"bodies":1,"edges":36,"error":"offset: a corner whose faces don't meet the same way after t","faces":14,"outcome":"failed","rejected":0,"shells":1,"steps":["revolved section","cut cylinder","cut box","shell"],"unsupported":0,"volume":3707.416641}, "79": {"area":13585.252616,"bodies":1,"edges":27,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut cylinder","join cylinder"],"unsupported":0,"volume":104810.763072}, "80": {"area":8652.533342,"bodies":1,"edges":20,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":1,"steps":["box","fillet","fillet"],"unsupported":0,"volume":37387.449469}, "81": {"area":4001.811796,"bodies":2,"edges":75,"error":null,"faces":29,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut sphere","circular pattern","rectangular pattern","hole","join cylinder","join box","cut sphere"],"unsupported":2,"volume":13139.562065}, @@ -92,7 +92,7 @@ "88": {"area":3947.799648,"bodies":1,"edges":18,"error":null,"faces":8,"outcome":"ok","rejected":0,"shells":1,"steps":["box","hole"],"unsupported":0,"volume":12788.04706}, "89": {"area":2512.5,"bodies":1,"edges":12,"error":null,"faces":6,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut box"],"unsupported":0,"volume":5906.25}, "90": {"area":9703.747183,"bodies":1,"edges":30,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","hole"],"unsupported":0,"volume":46947.022876}, - "91": {"area":4061.429233,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","cut cylinder","chamfer"],"unsupported":1,"volume":9885.651549}, + "91": {"area":3989.788869,"bodies":1,"edges":46,"error":null,"faces":18,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","fillet"],"unsupported":0,"volume":9777.743569}, "92": {"area":2855.139217,"bodies":1,"edges":30,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer","cut sphere"],"unsupported":0,"volume":9814.30292}, "93": {"area":7266.404017,"bodies":7,"edges":115,"error":null,"faces":51,"outcome":"ok","rejected":0,"shells":7,"steps":["extruded profile","fillet","chamfer","join box","circular pattern","rectangular pattern","cut cylinder"],"unsupported":0,"volume":12734.393171}, "94": {"area":3504.020666,"bodies":1,"edges":73,"error":null,"faces":27,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","hole","rectangular pattern","cut box","cut box","join box"],"unsupported":1,"volume":5899.986066}, @@ -108,6 +108,8 @@ {"reason":"#936: fillets and chamfers of smooth seams are refused as invalid picks (rejected) instead of unsupported; nothing else changes","seeds":[10,15,20,21,37,41,50,53,68,81,84,91]}, {"reason":"#946: small tools in large bodies are traced at their own scale; seeds 26 and 85 now build, 18, 43, 61, 63 and 71 fail with a different message, 19 builds two fewer faces (its fillet pick is also refused as in #936)","seeds":[18,19,26,43,61,63,71,85]}, {"reason":"#921 with its grid lines kept to display and export meshes (Booleans mesh as before #921): measured volume and area move by about 1e-6 (seed 94 also as in #936)","seeds":[22,78,94]}, - {"reason":"a hole placed on a face without a direction drills into that face (it took the direction from any face whose plane passed through the point, the last one found, such as a side of a patterned box standing on the point's plane): the holes on these seeds now go straight in (#950)","seeds":[7,21,85]} + {"reason":"a hole placed on a face without a direction drills into that face (it took the direction from any face whose plane passed through the point, the last one found, such as a side of a patterned box standing on the point's plane): the holes on these seeds now go straight in (#950)","seeds":[7,21,85]}, + {"reason":"the last-resort tool nudge is tried the other way when it fails or leaves sliver faces the operands didn't have: seed 43's join builds and the design runs to the end, 31 and 78 come out with fewer faces (#827, #897)","seeds":[31,43,78]}, + {"reason":"main's fillet changes since the last record (#926, #949) move seeds 33 and 91: a fillet step builds differently or is picked differently","seeds":[33,91]} ] }