diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 61380bc5..f7215517 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -1254,8 +1254,32 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap, 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::>() } 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> = 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 diff --git a/crates/engine/src/cmd/plastic.rs b/crates/engine/src/cmd/plastic.rs index 74470aae..4423f3bd 100644 --- a/crates/engine/src/cmd/plastic.rs +++ b/crates/engine/src/cmd/plastic.rs @@ -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();