Tapered join on a face: sew a whole face, keep the run-on inside the body (#1534) (#1763)

Thanks @HiroKenEz.
This commit is contained in:
HiroKenEz authored and GitHub committed 2026-10-16 13:26:15 -04:00
1 parent 31b2ddc0cf
commit 19be7d27e7
2 files changed
+113 -2

No files matched your search

+59 -2
View File
@@ -1942,6 +1942,39 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
(lo2, hi2)
}
/// 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`
/// all round; that grown outline is probed at two depths.
#[allow(clippy::too_many_arguments)]
fn taper_run_on_fits(st: &ModelState, start: &Plane, dir: Vec3, r: &Region2, depth: f64, widen: f64, op: Operation, targets: &[String]) -> bool {
const MAX_SEGMENTS: usize = 2_000;
let meshes: Vec<Arc<Mesh>> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).map(|b| b.mesh()).collect();
if meshes.is_empty() || !depth.is_finite() || !widen.is_finite() {
return false;
}
let want = op == Operation::Join;
// Each loop counterclockwise with the side away from the material: right of the outer loop,
// left of a hole.
let loops: Vec<(solidcraft_geom::Loop2, f64)> = std::iter::once((r.outer.ccw(), 1.0)).chain(r.holes.iter().map(|h| (h.ccw(), -1.0))).collect();
let count: usize = loops.iter().map(|(l, _)| l.segs.len()).sum();
if count > MAX_SEGMENTS {
return false;
}
loops.iter().all(|(lp, side)| {
lp.segs.iter().all(|seg| {
[0.0, 0.25, 0.5, 0.75].iter().all(|t| {
let Some(d) = seg.derivative(*t).normalized() else { return true };
let out = Vec2::new(d.y, -d.x) * *side;
[0.5, 1.0].iter().all(|k| {
let p = start.to_world(seg.point_at(*t) + out * (widen * k)) - dir * (depth * k);
meshes.iter().any(|m| m.contains(p)) == want
})
})
})
})
}
/// How high (mm) a tapered tool's sides stand straight where it starts on a face it can't run
/// on into.
const TAPER_FOOT: f64 = 0.01;
@@ -2840,16 +2873,40 @@ fn feature_tools_with_constructor(
// sides standing on a face are curved where they meet it (along text, say),
// and the boolean finds no result there. The sides keep their slope, so
// the profile stays the section at the start.
let roles = crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() };
// A start that is a whole face of the target (a foot under a part's base) is sewn on
// along it, as it lies: run on, the sides would leave the face through its edges.
if on_face
&& *operation == Operation::Join
&& let Ok(exact) = kernel::extrude_tapered_between(&start_plane, r, 0.0, len, 0.0, sign, taper)
&& st
.bodies
.iter()
.filter(|t| targets.is_empty() || targets.iter().any(|x| *x == t.name))
.any(|t| kernel::can_join_touching_faces(&t.body, &exact))
{
growth = growth.max(len * taper.tan().abs());
return Ok(roles.tool(exact, None));
}
let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) };
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo, hi, *operation, &crate::plain(targets));
let (a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
let (mut a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
// A taper that narrows the tool away from the face widens it as it runs on into it, past
// the profile (where the profile is the whole face, out through the body's sides, #1534):
// then it starts on the face instead.
if a < 0.0 && a * taper.tan() > 0.0 {
let dir = n * sign;
if !taper_run_on_fits(st, &start_plane, dir, r, -a, a * taper.tan(), *operation, &crate::plain(targets)) {
a = 0.0;
}
}
// A start that can't run on (the profile hangs over the face's edge):
// the sides stand straight for a hair first, so the boolean can push the
// face the tool stands on into the body.
let foot = if a >= 0.0 && on_face { TAPER_FOOT.min((b - a) * 0.01) } else { 0.0 };
let body = kernel::extrude_tapered_between(&start_plane, r, a, b, foot, sign, taper)?;
growth = growth.max(a.abs().max(b.abs()) * taper.tan().abs());
Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None))
Ok(roles.tool(body, None))
})
.collect::<Result<_>>()?;
// Profiles apart by more than the tools grow (the letters of a text): the tools
@@ -782,6 +782,60 @@ fn a_tapered_text_joins_a_block_on_its_face_or_hanging_over_its_edge() {
}
}
/// A tapered foot extruded 3 mm down from a part's bottom face and joined (#1534). Run on into
/// the part with their slope, sides that narrow the foot away from the face widened it past the
/// face: a box got a lip round its walls (0.16 mm wide, and nothing said), a cylinder's join found
/// no result, and sides that widen the foot met the face's edge there. A foot on the whole face is
/// sewn on along it; one drawn just inside the face's edge starts on it. Either way: the part plus
/// the frustum, and nothing beside the part's walls.
#[test]
fn a_tapered_foot_under_a_part_joins_as_drawn() {
// (round, the foot's half width: the whole face, or just inside its edge)
for (round, half) in [(false, 15.0), (true, 15.0), (false, 14.9)] {
for taper in [-20.0f64, 20.0] {
let t = taper.to_radians().tan();
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
if round {
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":15}));
} else {
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-15,-15],"p1":[15,15]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"B"}));
let taper_deg = format!("{taper} deg");
if half < 15.0 {
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-half,-half],"p1":[half,half]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":3,"direction":"negative","operation":"join","taper":taper_deg}));
} else {
let foot = json!({"face":[5,1,0],"face_body":"B","distance":3,"direction":"positive","operation":"join","taper":taper_deg});
run(&mut s, "solid.extrude", foot);
}
let (part, frustum) = if round {
let r = 15.0 + 3.0 * t;
(std::f64::consts::PI * 225.0 * 10.0, std::f64::consts::PI * (225.0 + 15.0 * r + r * r))
} else {
let side = 2.0 * half + 6.0 * t;
(9000.0, 4.0 * half * half + 2.0 * half * side + side * side)
};
let v = volume(&mut s);
// The cylinder is measured on its mesh; a foot that starts on the face stands straight
// for a hair first.
let tol = if round || half < 15.0 { 2e-4 } else { 1e-9 } * (part + frustum);
assert!((v - (part + frustum)).abs() < tol, "round {round}, half {half}, taper {taper}: {v} for {}", part + frustum);
assert_eq!(s.world_state().bodies.len(), 1);
let state = s.model.state();
let mesh = state.body("B").unwrap().mesh();
// Beside the part, above the foot's start: nothing.
let out = |x: f64, y: f64| if round { x.hypot(y) } else { x.abs().max(y.abs()) } > 15.0 + 1e-6;
let beside = mesh.positions.iter().filter(|p| p.z > 1e-6 && out(p.x, p.y)).count();
assert_eq!(beside, 0, "round {round}, half {half}, taper {taper}: {:?}", mesh.bounds());
}
}
}
/// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top
/// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front
/// face and in a knife-edge tip where it runs into the back face.