mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
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:
@@ -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)> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user