Thread: a shoulder thinner than the overrun's reach is still a shoulder

The free-end test looked for material 1 to 1.5 pitches past the face's end, beyond the first
pitch so that a chamfer the groove runs out through counts as free. A plate thinner than that
(a boss on a 1 mm plate under M10) had none there, so its top was taken for a free end and
the groove's one-pitch overrun cut into the plate (15 mm³ of a 1 mm plate).

An end is now closed when, over the overrun's whole reach, material lies outside the face's
cylinder (inside it, for a hole) at the radii the groove cuts beyond the face: a shoulder,
plate, head or blind bottom, however thin, steps out past the face, while a chamfer, neck,
countersink or counterbore steps the other way. The band test beyond the first pitch stays
for a cylinder that goes on past a split.

The regression test builds the boss as the app does (a rounded plate, a circle sketched on its
top face, extruded with Join) on 6 and 1 mm plates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-27 03:26:36 -04:00
committed by Irakli Kourtis
parent 75deb73aa7
commit 361b35afee
2 changed files with 55 additions and 28 deletions
+20 -7
View File
@@ -118,12 +118,20 @@ pub(super) struct ThreadOn {
}
/// The free-end test of a face's two ends (`side` 0 at its start, 1 at its end), each made
/// once: whether any material lies past the end, over the axial span the groove's one-pitch
/// overrun reaches beyond its first pitch, within the radii the groove cuts.
/// once. The groove's one-pitch overrun past a free end reaches 1.5 pitches past it. An end is
/// closed when, within that reach, material lies outside the face's cylinder (inside it, for a
/// hole) at the radii the groove cuts beyond the face: a shoulder, a plate, a head, a blind
/// hole's bottom, however thin, the material stepping out past the face. It is closed too when
/// material lies in the groove's band of radii beyond its first pitch: the face's cylinder going
/// on past a split. A chamfer, a neck, a countersink or a counterbore steps the other way and
/// leaves the end free: the groove runs out through it.
struct FaceEnds<'a> {
body: &'a Body,
cyl: &'a kernel::CylinderFace,
band: (f64, f64),
/// The radii the groove cuts beyond the face's own: from it out to the groove's outer edge
/// (external), or in to its inner edge (internal).
beyond: (f64, f64),
pitch: f64,
free: [Option<bool>; 2],
}
@@ -134,11 +142,13 @@ impl FaceEnds<'_> {
return Ok(*f);
}
let p = self.pitch;
let along = if side == 0 { (self.cyl.start - 1.5 * p, self.cyl.start - p) } else { (self.cyl.end + p, self.cyl.end + 1.5 * p) };
// Material that only touches the band's rims (a counterbore as wide as the thread) is
// not in the groove's way.
let past =
|from: f64, to: f64| if side == 0 { (self.cyl.start - to, self.cyl.start - from) } else { (self.cyl.end + from, self.cyl.end + to) };
// Material that only touches the band's rims (a counterbore as wide as the thread, the
// face's own end) is not in the groove's way.
let e = p * 1e-3;
let free = !kernel::material_in_ring(self.body, self.cyl, self.band.0 + e, self.band.1 - e, along)?;
let stepped_out = kernel::material_in_ring(self.body, self.cyl, self.beyond.0 + e, self.beyond.1 - e, past(e, 1.5 * p))?;
let free = !stepped_out && !kernel::material_in_ring(self.body, self.cyl, self.band.0 + e, self.band.1 - e, past(p, 1.5 * p))?;
if let Some(slot) = self.free.get_mut(side) {
*slot = Some(free);
}
@@ -240,7 +250,10 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
(form.groove(major, pitch, false).root_r, major / 2.0)
};
let band = (cyl.radius.min(r_lo), cyl.radius.max(r_hi));
let mut ends = FaceEnds { body: &mb.body, cyl: &cyl, band, pitch, free: [None; 2] };
// As far beyond the face as the groove's section reaches ([`thread_grooves`]).
let beyond =
if cyl.internal { ((cyl.radius.min(r_lo) - 0.1 * pitch).max(0.0), cyl.radius) } else { (cyl.radius, cyl.radius.max(r_hi) + 0.05 * pitch) };
let mut ends = FaceEnds { body: &mb.body, cyl: &cyl, band, beyond, pitch, free: [None; 2] };
let (lo, hi) = (cyl.start, cyl.end);
let along = |q: Vec3| (q - cyl.axis_point).dot(cyl.axis);
let span = |side: usize, to: f64| -> Result<(f64, f64)> {
+35 -21
View File
@@ -521,43 +521,57 @@ fn a_thread_stops_clear_of_a_shoulder() {
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
}
/// A d10 boss (z 6..22) joined onto a 40x40x6 plate, threaded M10: the plate's top is the
/// shoulder. The groove, with or without its runout, stays on the boss: the plate keeps all of
/// its 40 40 6 (what lies below z = 6 after the boss is cut away), and without a runout the
/// boss loses (16 - w)/P turns, as at a collar.
/// A d10 boss (z t..t+16) on a plate t thick, threaded M10: the plate's top is the shoulder.
/// The groove, with or without its runout, stays on the boss: the plate keeps all of itself
/// (what lies below z = t after the boss is cut away), and without a runout the boss loses
/// (16 - w)/P turns, as at a collar. The owner's bug: built as the app builds it (a rounded
/// plate, a circle sketched on its top face and extruded with Join), a plate thinner than 1.5 P
/// had no material where the free-end test looked (1 to 1.5 pitches past the face), so its top
/// was taken for a free end and the overrun cut a pitch into it.
#[test]
fn a_thread_on_a_boss_never_cuts_into_the_plate_below() {
let boss = |extra: Value| {
let boss = |t: f64, extra: Value| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [-20, -20, 0], "length": 40, "width": 40, "height": 6, "body_name": "Part"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 6], "radius": 5, "height": 16, "operation": "join", "targets": ["Part"]}));
let mut p = json!({"face": [5, 0, 14], "designation": "M10", "modeled": true});
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.rounded", json!({"p0": [-20, -20], "p1": [20, 20], "radius": 5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": t}));
run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, t]}}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 16, "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
let mut p = json!({"face": [5, 0, t + 8.0], "modeled": true});
if let (Some(p), Value::Object(e)) = (p.as_object_mut(), extra) {
p.extend(e);
}
run(&mut s, "solid.thread", p);
s
};
let plate_left = |mut s: Session| {
let plate_left = |mut s: Session, t: f64| {
run(
&mut s,
"solid.box",
json!({"corner": [-30, -30, 6], "length": 60, "width": 60, "height": 30, "body_name": "Above", "operation": "new_body"}),
json!({"corner": [-30, -30, t], "length": 60, "width": 60, "height": 30, "body_name": "Above", "operation": "new_body"}),
);
run(&mut s, "solid.combine", json!({"target": "Part", "tools": ["Above"], "operation": "cut"}));
exact_volume(&s, "Part")
run(&mut s, "solid.combine", json!({"target": "Body1", "tools": ["Above"], "operation": "cut"}));
exact_volume(&s, "Body1")
};
let s = boss(json!({}));
let plate = |t: f64| (40.0 * 40.0 - (4.0 - PI) * 25.0) * t;
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
let want = 40.0 * 40.0 * 6.0 + PI * 25.0 * 16.0 - (16.0 - w) / p * ext_turn(10.0, p, 5.0);
let got = exact_volume(&s, "Part");
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
assert!((plate_left(s) - 9600.0).abs() < 1e-6);
for t in [6.0, 1.0] {
let s = boss(t, json!({}));
let want = plate(t) + PI * 25.0 * 16.0 - (16.0 - w) / p * ext_turn(10.0, p, 5.0);
let got = exact_volume(&s, "Body1");
assert!((got - want).abs() < want * 2e-4, "plate {t}: {got} vs {want}");
let left = plate_left(s, t);
assert!((left - plate(t)).abs() < 1e-6 * plate(t), "plate {t}: lost {} mm³", plate(t) - left);
}
for extra in [json!({"ease_out": "standard"}), json!({"ease_out": "short", "tip": "both"})] {
let s = boss(extra.clone());
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty(), "{extra}");
let left = plate_left(s);
assert!((left - 9600.0).abs() < 1e-6, "{extra}: the plate below the boss lost {} mm³", 9600.0 - left);
let s = boss(1.0, extra.clone());
assert!(s.model.state().body("Body1").unwrap().body.validity().is_empty(), "{extra}");
let left = plate_left(s, 1.0);
assert!((left - plate(1.0)).abs() < 1e-6 * plate(1.0), "{extra}: the plate below the boss lost {} mm³", plate(1.0) - left);
}
}