Lip: run along the rim's opening, not the foot of a boss standing on the face (#933)

A lip picked on a face that isn't a shelled body's rim, such as the Sample Plate's top
face with its two holes and the boss standing on it, ran its band round the largest inner
edge. That was the boss's foot, so the band stood against the boss's wall and the join
failed with a boolean error about curved faces.

The band now goes round the face's opening (an inner edge with no material just over the
face inside it). A rim face that has a smaller hole in it still works. A face whose inner
edges are only where other features stand on it, or whose openings are alike in size (a
plate's holes), is refused with a plain message that names the rim face it needs.

Fixes #892.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 20:26:24 -04:00
1 parent 2dcfea8df7
commit b41ad23e3f
2 files changed
+44 -2

No files matched your search

+26 -2
View File
@@ -1254,8 +1254,32 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
let mut loops = face_loops(&mesh, &plane, *face, n, 0.0, tol);
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
let ccw = |l: &[Vec2]| if poly_area(l) < 0.0 { l.iter().rev().copied().collect::<Vec<_>>() } else { l.to_vec() };
let (Some(outer), Some(inner)) = (loops.first().map(|l| ccw(l)), loops.get(1).map(|l| ccw(l))) else {
return Err(DocError::Invalid("pick the rim face of a shelled body (a face with an inner and an outer edge)".into()));
// The inner edge is an opening (the cavity under a rim): not the foot of a boss or a
// wall standing on the face, whose side the band would run against.
let opening = |l: &[Vec2]| {
let eps = (tol * 4.0).max(1e-3);
l.iter().zip(l.iter().cycle().skip(1)).filter_map(|(a, b)| Some(((*a + *b) * 0.5, (*b - *a).normalized()?))).take(3).any(|(m, t)| {
// Just over the face, inside the loop (to the left of a ccw loop's edges).
let q = plane.to_world(m + Vec2::new(-t.y, t.x) * eps) + n * eps;
!mesh.contains(q)
})
};
let outer = loops.first().map(|l| ccw(l));
let openings: Vec<Vec<Vec2>> = loops.iter().skip(1).map(|l| ccw(l)).filter(|l| opening(l)).collect();
// A rim runs round one opening, the cavity; smaller ones (a screw hole) may sit in it.
// Openings alike in size (a plate's holes) leave no edge that is the rim's.
let alike = match openings.as_slice() {
[a, b, ..] => poly_area(b).abs() > 0.5 * poly_area(a).abs(),
_ => false,
};
let (Some(outer), Some(inner), false) = (outer, openings.first().cloned(), alike) else {
let what = match (loops.len(), openings.len()) {
(0 | 1, _) => "pick the rim face of a shelled body (a face with an inner and an outer edge)".to_string(),
(_, 0) => "pick the rim face of a shelled body: this face's inner edges are where other features stand on it, not an opening"
.to_string(),
(_, k) => format!("pick the rim face of a shelled body (round one opening): this face has {k} openings alike in size"),
};
return Err(DocError::Invalid(what));
};
let bw = if *groove { w + g } else { w };
// A lip: the band raised from the rim's own faces (its walls continue the walls
+18
View File
@@ -336,6 +336,24 @@ mod tests {
assert!(g.execute("plastic.lip", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
}
#[test]
fn lip_on_a_face_that_is_not_a_rim_says_so() {
// The Sample Plate's top face (#892): two holes through it and the boss standing on it.
// The lip used to run round the boss's foot, against its wall, and the join failed in
// the boolean; it is refused as no rim instead.
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
let lip = json!({"body": "Plate", "face": [51.89, 45.2, 8.0], "width": "1.5 mm", "height": "3.5 mm", "side": "inside", "type": "lip"});
let e = s.execute("plastic.lip", &lip).unwrap_err().to_string();
assert!(e.contains("rim face") && e.contains("2 openings alike"), "{e}");
// The boss alone, no holes: its foot is not an opening either.
let mut b = Session::default();
run(&mut b, "solid.box", json!({"length": 80, "width": 50, "height": 8, "body_name": "Plate"}));
run(&mut b, "solid.cylinder", json!({"base": [40, 25, 8], "axis": [0, 0, 1], "radius": 12, "height": 14, "operation": "join"}));
let e = b.execute("plastic.lip", &lip).unwrap_err().to_string();
assert!(e.contains("stand on it"), "{e}");
}
#[test]
fn snap_fit_arm_and_catch() {
let mut s = Session::default();