Extrude: join extensions of ribs on curved bases (#2059) (#2082)

Thanks @fkirill.
This commit is contained in:
fkirill authored and GitHub committed 2026-10-17 13:33:21 -04:00
1 parent 9be8bb5e6b
commit f480898158
3 files changed
+179

No files matched your search

+84
View File
@@ -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<f64> = 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<String> = 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<String> = 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 &regions {
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::<Vec<_>>();
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<Body>) -> ModelState {
ModelState { bodies: bodies.into_iter().enumerate().map(|(i, b)| ModelBody::new(format!("Body{i}"), b, 0)).collect(), ..Default::default() }
}
@@ -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, &params).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) {
@@ -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"}}
]
}