diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index b7eb89d9..98754470 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2141,6 +2141,46 @@ fn extend_for_coplanar(sur: &Surroundings, plane: &Plane, r: &Region2, lo: f64, (lo2, hi2) } +/// Remove ends of a join tool already wholly inside its targets. A tab extending +/// a rib otherwise shares the rib's side faces through its buried portion; on a +/// chamfered base those coincident faces can defeat every boolean retry (#2059). +/// Only straight profiles are trimmed, after checking the entire swept material +/// against the targets' faces, with the same clearance used when extending ends. +fn trim_embedded_join_ends(sur: &Surroundings, plane: &Plane, r: &Region2, lo: f64, hi: f64) -> (f64, f64) { + if sur.op != Operation::Join + || !std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).all(|s| matches!(s, solidcraft_geom::Seg2::Line { .. })) + { + return (lo, hi); + } + let pieces = r.triangulate(1e-6); + let covered: f64 = pieces.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).abs() * 0.5).sum(); + if pieces.is_empty() || (covered - r.area()).abs() > 1e-7 * r.area() { + return (lo, hi); + } + let n = plane.normal(); + let mut levels: Vec = sur + .bodies + .iter() + .flat_map(|(_, mesh, _)| { + mesh.triangles.iter().filter_map(|t| { + let [a, b, c] = mesh.tri(t)?; + let normal = (b - a).cross(c - a).normalized()?; + (normal.dot(n).abs() > 1.0 - 1e-9).then(|| (a - plane.origin).dot(n)) + }) + }) + .filter(|h| *h > lo + sur.skin && *h < hi - sur.skin) + .collect(); + levels.sort_by(f64::total_cmp); + levels.dedup_by(|a, b| (*a - *b).abs() < sur.skin); + if levels.len() > 256 { + return (lo, hi); + } + let start = levels.iter().rev().copied().find(|h| sur.harmless(plane, &pieces, -sur.skin, lo + sur.skin, h - sur.skin)).unwrap_or(lo); + let end = + levels.iter().copied().find(|h| *h > start + sur.skin && sur.harmless(plane, &pieces, -sur.skin, h + sur.skin, hi - sur.skin)).unwrap_or(hi); + (start, end) +} + /// Whether a tapered tool, run on by `depth` behind the plane it starts in (`dir` the way it /// extrudes), stays where running on changes nothing: in the targets for a join, out of them for /// a cut. Its sides keep their slope, so the section there is the profile grown by up to `widen` @@ -3180,6 +3220,23 @@ fn feature_tools_with_constructor( let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len()); let plain_targets: Vec = crate::plain(targets); let surroundings = Surroundings::of(st, *operation, &plain_targets); + // Trimming may discard a tool's material, so only bodies this feature + // actually joins can establish that the discarded end is already filled. + let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets }; + let trim_targets: Vec = st + .bodies + .iter() + .filter(|b| { + if participants.is_empty() { + b.component == f.component || b.feature == f.id + } else { + participants.iter().any(|t| t == "*" || t == &b.name) + } + }) + .map(|b| b.name.clone()) + .collect(); + let join_surroundings = + if *operation == Operation::Join && !trim_targets.is_empty() { Surroundings::of(st, *operation, &trim_targets) } else { None }; for r in ®ions { if *operation == Operation::Join { let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?; @@ -3198,6 +3255,12 @@ fn feature_tools_with_constructor( } } let (l2, h2) = surroundings.as_ref().map_or((lo + off, hi + off), |sur| extend_for_coplanar(sur, &ss.plane, r, lo + off, hi + off)); + let (l2, h2) = join_surroundings.as_ref().map_or((l2, h2), |sur| { + // Trim only material in the requested extent. A run-on added + // above to resolve a touching cap must keep doing its job. + let (l, h) = trim_embedded_join_ends(sur, &ss.plane, r, lo + off, hi + off); + (if l > lo + off { l } else { l2 }, if h < hi + off { h } else { h2 }) + }); let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::>(); let original = r; let grown = |gains: &dyn Fn(&[[Vec2; 3]]) -> bool| match &surroundings { @@ -5734,6 +5797,27 @@ mod body_name_tests { mod coplanar_growth_tests { use super::*; + #[test] + fn buried_join_ends_stop_at_material_boundaries() { + let block = kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 8.0)).unwrap(); + let profile = rect(2.0, 2.0, 4.0, 4.0); + let st = state(vec![block.clone()]); + let sur = Surroundings::of(&st, Operation::Join, &[]).unwrap(); + assert_eq!(trim_embedded_join_ends(&sur, &Plane::XY, &profile, 0.0, 16.0), (8.0, 16.0)); + assert_eq!(trim_embedded_join_ends(&sur, &Plane::XY, &profile, -8.0, 8.0), (-8.0, 0.0)); + // A thin sealed void between the old start and the rib's top must still + // be filled by the join. The remaining tool must start before that void. + let void = kernel::box_solid(Vec3::new(1.0, 1.0, 3.01), Vec3::new(5.0, 5.0, 3.02)).unwrap(); + let hollow = kernel::boolean(&block, &void, BoolOp::Cut).unwrap().unwrap(); + let st = state(vec![hollow]); + let sur = Surroundings::of(&st, Operation::Join, &[]).unwrap(); + let (lo, hi) = trim_embedded_join_ends(&sur, &Plane::XY, &profile, 0.0, 16.0); + assert!(lo <= 3.01, "trimming past {lo} would discard material the join adds in the void"); + assert_eq!(hi, 16.0); + let cut = Surroundings::of(&st, Operation::Cut, &[]).unwrap(); + assert_eq!(trim_embedded_join_ends(&cut, &Plane::XY, &profile, 0.0, 16.0), (0.0, 16.0)); + } + fn state(bodies: Vec) -> ModelState { ModelState { bodies: bodies.into_iter().enumerate().map(|(i, b)| ModelBody::new(format!("Body{i}"), b, 0)).collect(), ..Default::default() } } diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 7e35708d..911c9220 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -45,6 +45,64 @@ fn volume(s: &Session, body: &str) -> f64 { s.model.state().body(body).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0) } +/// #2059: tabs exactly as wide as their supporting ribs share the ribs' side +/// planes. The base's remote conical chamfer must not prevent their join. +#[test] +fn eighteen_tabs_join_the_ribs_of_a_chamfered_base() { + let s = replay(include_str!("../../../examples/regressions/tabs_on_chamfered_ribbed_base.json")); + let check = |s: &Session| { + assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results); + assert_eq!(s.model.state().bodies.len(), 1); + let state = s.model.state(); + let body = &state.body("Base").unwrap().body; + assert_eq!(body.shell_count(), 1); + assert!(body.validity().is_empty(), "{:?}", body.validity()); + let mesh = body.tessellate(1e-4).unwrap(); + // R40 x 4 disc less its 1 x 1 chamfer, six 32 x 2 ribs + // standing 4 above it, and eighteen 1 x 2 tabs another 8 higher. + let expected = std::f64::consts::PI * (6400.0 - 40.0 + 1.0 / 3.0) + 1536.0 + 288.0; + let got = mesh.measure().volume; + assert!((got - expected).abs() < 0.1, "{got} vs {expected}"); + println!("#2059 volume {got:.6} / {expected:.6} mm³; one valid shell, eighteen tabs"); + for k in 0..6 { + let angle = k as f64 * std::f64::consts::TAU / 6.0; + let p = |x: f64, y: f64, z| solidcraft_geom::Vec3::new(x * angle.cos() - y * angle.sin(), x * angle.sin() + y * angle.cos(), z); + for x in [15.5, 25.5, 35.5] { + assert!(mesh.contains(p(x, 0.0, 12.0)), "tab {k} at {x}"); + assert!(!mesh.contains(p(x, 1.2, 12.0)), "tab {k} grew sideways at {x}"); + } + assert!(mesh.contains(p(20.0, 0.0, 6.0)), "rib {k}"); + assert!(mesh.contains(p(39.5, 0.0, 2.0)), "rim {k}"); + assert!(!mesh.contains(p(39.75, 0.0, 3.75)), "chamfer {k}"); + } + assert!((mesh.bounds().max.z - 16.0).abs() < 1e-8); + }; + check(&s); + let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + check(&reopened); +} + +#[test] +fn buried_join_end_ignores_bodies_in_other_components() { + for own_body in [false, true] { + let mut s = Session::default(); + let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 8, "body_name": "Other"})); + run(&mut s, "component.create", json!({"name": "Child"})); + if own_body { + run(&mut s, "solid.box", json!({"corner": [50, 0, 0], "length": 10, "width": 10, "height": 8, "body_name": "Child"})); + } + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [4, 4]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 16, "operation": "join", "body_name": "Tab"})); + let got = volume(&s, if own_body { "Child" } else { "Tab" }); + let expected = 64.0 + if own_body { 800.0 } else { 0.0 }; + assert!((got - expected).abs() < 1e-6, "{got}"); + assert!((volume(&s, "Other") - 800.0).abs() < 1e-6); + } +} + /// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to /// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)). fn slab_pocket_loft() -> (f64, f64) { diff --git a/examples/regressions/tabs_on_chamfered_ribbed_base.json b/examples/regressions/tabs_on_chamfered_ribbed_base.json new file mode 100644 index 00000000..e8786b7d --- /dev/null +++ b/examples/regressions/tabs_on_chamfered_ribbed_base.json @@ -0,0 +1,37 @@ +{ + "description": "Reduced reproduction for #2059, constructed from the public SolidCraft screenshot: eighteen 1 x 2 mm tabs extend six ribs on a chamfered round base. The tabs share the ribs' side planes; the remote conical chamfer made the join fail with Union: no result. This is an original analytic example, not the reporter's unavailable design.", + "commands": [ + {"command": "solid.cylinder", "params": {"radius": 40, "height": 4, "body_name": "Base"}}, + {"command": "solid.chamfer", "params": {"edges": [[40, 0, 4]], "distance": 1}}, + {"command": "sketch.create", "params": {"plane": "XY", "name": "Ribs"}}, + {"command": "sketch.line", "params": {"points": [[5.0, -1.0], [37.0, -1.0], [37.0, 1.0], [5.0, 1.0]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[3.366025404, 3.830127019], [19.366025404, 31.54293994], [17.633974596, 32.54293994], [1.633974596, 4.830127019]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-1.633974596, 4.830127019], [-17.633974596, 32.54293994], [-19.366025404, 31.54293994], [-3.366025404, 3.830127019]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-5.0, 1.0], [-37.0, 1.0], [-37.0, -1.0], [-5.0, -1.0]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-3.366025404, -3.830127019], [-19.366025404, -31.54293994], [-17.633974596, -32.54293994], [-1.633974596, -4.830127019]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[1.633974596, -4.830127019], [17.633974596, -32.54293994], [19.366025404, -31.54293994], [3.366025404, -3.830127019]], "closed": true}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Ribs", "distance": 8, "operation": "join"}}, + {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Tabs"}}, + {"command": "sketch.line", "params": {"points": [[15.0, -1.0], [16.0, -1.0], [16.0, 1.0], [15.0, 1.0]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[25.0, -1.0], [26.0, -1.0], [26.0, 1.0], [25.0, 1.0]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[35.0, -1.0], [36.0, -1.0], [36.0, 1.0], [35.0, 1.0]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[8.366025404, 12.490381057], [8.866025404, 13.356406461], [7.133974596, 14.356406461], [6.633974596, 13.490381057]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[13.366025404, 21.150635095], [13.866025404, 22.016660498], [12.133974596, 23.016660498], [11.633974596, 22.150635095]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[18.366025404, 29.810889132], [18.866025404, 30.676914536], [17.133974596, 31.676914536], [16.633974596, 30.810889132]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-6.633974596, 13.490381057], [-7.133974596, 14.356406461], [-8.866025404, 13.356406461], [-8.366025404, 12.490381057]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-11.633974596, 22.150635095], [-12.133974596, 23.016660498], [-13.866025404, 22.016660498], [-13.366025404, 21.150635095]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-16.633974596, 30.810889132], [-17.133974596, 31.676914536], [-18.866025404, 30.676914536], [-18.366025404, 29.810889132]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-15.0, 1.0], [-16.0, 1.0], [-16.0, -1.0], [-15.0, -1.0]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-25.0, 1.0], [-26.0, 1.0], [-26.0, -1.0], [-25.0, -1.0]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-35.0, 1.0], [-36.0, 1.0], [-36.0, -1.0], [-35.0, -1.0]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-8.366025404, -12.490381057], [-8.866025404, -13.356406461], [-7.133974596, -14.356406461], [-6.633974596, -13.490381057]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-13.366025404, -21.150635095], [-13.866025404, -22.016660498], [-12.133974596, -23.016660498], [-11.633974596, -22.150635095]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[-18.366025404, -29.810889132], [-18.866025404, -30.676914536], [-17.133974596, -31.676914536], [-16.633974596, -30.810889132]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[6.633974596, -13.490381057], [7.133974596, -14.356406461], [8.866025404, -13.356406461], [8.366025404, -12.490381057]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[11.633974596, -22.150635095], [12.133974596, -23.016660498], [13.866025404, -22.016660498], [13.366025404, -21.150635095]], "closed": true}}, + {"command": "sketch.line", "params": {"points": [[16.633974596, -30.810889132], [17.133974596, -31.676914536], [18.866025404, -30.676914536], [18.366025404, -29.810889132]], "closed": true}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Tabs", "distance": 16, "operation": "join"}} + ] +}