diff --git a/crates/engine/src/cmd/face.rs b/crates/engine/src/cmd/face.rs index 59af605a..bb486a2c 100644 --- a/crates/engine/src/cmd/face.rs +++ b/crates/engine/src/cmd/face.rs @@ -20,7 +20,12 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { /// The outward normal of the planar body face at `p`. pub(super) fn face_normal(s: &Session, p: Vec3) -> Option { - let (m, f, d, _) = face_at(s, p)?; + face_normal_in(s, p, None) +} + +/// The outward normal of the planar face at `p` of `body` (any body when `None`). +pub(super) fn face_normal_in(s: &Session, p: Vec3, body: Option<&str>) -> Option { + let (m, f, d, _) = face_at_in(s, p, body).ok().flatten()?; let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); if d > tol * 10.0 { return None; @@ -52,12 +57,20 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option { Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6)) } -/// The body face under a point: (mesh, face index, distance). +/// The body face under a point: (mesh, face index, distance, body). fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64, String)> { + face_at_in(s, p, None).ok().flatten() +} + +type FacePick = (std::sync::Arc, u32, f64, String); + +/// The face under a point of `only` (any body when `None`). Where faces of several bodies are +/// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies. +fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result, Vec> { let st = s.model.state(); - let mut best: Option<(std::sync::Arc, u32, f64, String)> = None; - let mut ambiguous = false; - for b in &st.bodies { + let mut best: Option = None; + let mut tied: Vec = Vec::new(); + for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) { let m = b.mesh(); let mut near: Option<(u32, f64)> = None; for (t, f) in m.triangles.iter().zip(&m.tri_face) { @@ -70,13 +83,16 @@ fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64, Stri if let Some((f, d)) = near { if best.as_ref().is_none_or(|x| d < x.2 - 1e-8) { best = Some((m.clone(), f, d, b.name.clone())); - ambiguous = false; + tied.clear(); } else if best.as_ref().is_some_and(|x| (d - x.2).abs() <= 1e-8) { - ambiguous = true; + tied.push(b.name.clone()); } } } - if ambiguous { None } else { best } + match best { + Some(b) if !tied.is_empty() => Err(std::iter::once(b.3).chain(tied).collect()), + b => Ok(b), + } } /// Circle through three points. @@ -97,11 +113,17 @@ fn xy(v: Vec2) -> Value { /// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns /// the sketch id and a point inside the face (sketch coordinates). The sketch is left active. -pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> { - let (m, f, d, body) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?; +pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2)> { + let none = || match face_body { + Some(b) => bad(cmd, format!("no face of `{b}` at `face`")), + None => bad(cmd, "no body face at `face`"), + }; + let (m, f, d, body) = face_at_in(s, point, face_body) + .map_err(|names| bad(cmd, format!("faces of several bodies meet at `face` ({}): name one with `face_body`", names.join(", "))))? + .ok_or_else(none)?; let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); if d > tol * 10.0 { - return Err(bad(cmd, "no body face at `face`")); + return Err(none()); } let tris: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf == f).filter_map(|(t, _)| m.tri(t)).collect(); let normal = tris.iter().find_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).ok_or_else(|| bad(cmd, "degenerate face"))?; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 1d805eaf..5b74a94a 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -13,11 +13,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("extrude") .key("E") - .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"), + .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"), CommandSpec::new("solid.revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve") - .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"), + .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"), CommandSpec::new("solid.sweep", "Sweep", sweep) .at("SOLID", "CREATE") .icon("sweep") @@ -114,7 +114,7 @@ pub static COMMANDS: &[CommandSpec] = &[ /// face, and the unit normal. fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> { if let Some(fp) = p.get("face").and_then(vec3) { - return Some((fp, super::face::face_normal(s, fp)?)); + return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?)); } let sid = feature_sketch(s, p, "Extrude").ok()?; let st = s.model.state(); @@ -495,7 +495,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result { // A planar body face extrudes like a profile: its boundary is copied into a sketch on it. let (sketch, face_profile) = match p.get("face").and_then(vec3) { Some(fp) => { - let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?; + let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?; (id, Some(ProfileSel::Points { points: vec![inside] })) } None => (feature_sketch(s, p, cmd)?, None), @@ -559,7 +559,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result { // A planar body face revolves like a profile (its edges projected into a sketch on it). let (sketch, face_profile) = match p.get("face").and_then(vec3) { Some(fp) => { - let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?; + let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?; s.active_sketch = None; (id, Some(ProfileSel::Points { points: vec![inside] })) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f5a50b3d..250191d6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -111,6 +111,22 @@ fn a_body_cut_in_pieces_twice_gets_pieces_of_their_own_names() { } /// A join that bridges two bodies makes them one body, not an overlap of the first and the second. +#[test] +fn extrude_a_face_where_another_body_lies_flush_on_it() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 4, "body_name": "A"})); + run(&mut s, "solid.box", json!({"corner": [10, 0, 4], "length": 10, "width": 20, "height": 1, "body_name": "B"})); + // A's top and B's bottom both lie at the point: which one is meant has to be said. + let e = s.execute("solid.extrude", &json!({"face": [15, 5, 4], "distance": "2 mm", "operation": "new"})).unwrap_err().to_string(); + assert!(e.contains("A") && e.contains("B") && e.contains("face_body"), "{e}"); + let r = run(&mut s, "solid.extrude", json!({"face": [15, 5, 4], "face_body": "A", "distance": "2 mm", "operation": "new", "body_name": "C"})); + let m = run(&mut s, "inspect.measure", json!({"bodies": ["C"]})); + let v = m["total"]["volume_mm3"].as_f64().unwrap(); + assert!((v - 20.0 * 20.0 * 2.0).abs() < 1e-6, "{v} {r}"); + let e = s.execute("solid.extrude", &json!({"face": [15, 5, 4], "face_body": "Nope", "distance": "2 mm"})).unwrap_err().to_string(); + assert!(e.contains("Nope"), "{e}"); +} + #[test] fn join_bridging_two_bodies_merges_them() { let mut s = Session::default();