diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index cfb4eb10..13821e01 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -239,6 +239,12 @@ impl Document { /// The B-rep face of a body nearest a point (within 5% of the body's size). pub fn face_index_at(b: &crate::ModelBody, p: Vec3) -> Option { + let (d, f) = nearest_face(b, p)?; + (d <= pick_tolerance(b)).then_some(f) +} + +/// The face of `b` nearest `p` and how far it is, however far. +pub fn nearest_face(b: &crate::ModelBody, p: Vec3) -> Option<(f64, usize)> { let m = b.mesh(); let mut best: Option<(f64, u32)> = None; for (t, f) in m.triangles.iter().zip(&m.tri_face) { @@ -248,8 +254,12 @@ pub fn face_index_at(b: &crate::ModelBody, p: Vec3) -> Option { best = Some((d, *f)); } } - let (d, f) = best?; - (d <= b.body.size() * 0.05 + 1e-6).then_some(f as usize) + best.map(|(d, f)| (d, f as usize)) +} + +/// How far from a face of `b` a picked point may lie and still pick it (`face_index_at`). +pub fn pick_tolerance(b: &crate::ModelBody) -> f64 { + b.body.size() * 0.05 + 1e-6 } /// The point of triangle abc nearest p. diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 3b5cb980..5f361a8f 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2901,52 +2901,62 @@ mod surface; pub fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 { let n = plane.normal(); let h = |q: Vec3| (q - plane.origin).dot(n); - for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) { - let tol = (b.body.size() * 1e-3).max(1e-3); - let Ok(faces) = b.body.faces(tol) else { continue }; - let m = b.mesh(); - let on: Vec = faces.iter().filter(|f| crate::appearance::face_index_at(b, p) == Some(f.index)).map(|f| f.index).collect(); - let Some(&fi) = on.first() else { continue }; - if let Some(f) = faces.iter().find(|f| f.index == fi) - && f.plane_normal.is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6) + let Some((bi, fi)) = face_at(st, p) else { return h(p) }; + let Some(b) = st.bodies.get(bi) else { return h(p) }; + let tol = (b.body.size() * 1e-3).max(1e-3); + let Ok(faces) = b.body.faces(tol) else { return h(p) }; + if let Some(f) = faces.iter().find(|f| f.index == fi) + && f.plane_normal.is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6) + { + return h(f.centroid); + } + // A curved or slanted face: where the profile's middle meets it. + let m = b.mesh(); + for dir in [n, n * -1.0] { + if let Some((t, tri)) = m.raycast(centre, dir) + && m.tri_face.get(tri).is_some_and(|x| *x as usize == fi) { - return h(f.centroid); + return h(centre + dir * t); } - // A curved or slanted face: where the profile's middle meets it. - for dir in [n, n * -1.0] { - if let Some((t, tri)) = m.raycast(centre, dir) - && m.tri_face.get(tri).is_some_and(|x| *x as usize == fi) - { - return h(centre + dir * t); - } - } - return h(p); } h(p) } +/// The face a To Object pick is on: the nearest face over every solid body, `(body index, +/// face index)`, within that body's pick tolerance. Not the first body with any face within +/// its tolerance: that is 5 % of the body's size, so a large body beside a small target (a +/// plate under a post, a frame around a pin) would otherwise take a pick made on the target. +fn face_at(st: &ModelState, p: Vec3) -> Option<(usize, usize)> { + st.bodies + .iter() + .enumerate() + .filter(|(_, b)| !b.body.is_mesh()) + .filter_map(|(bi, b)| { + let (d, fi) = crate::appearance::nearest_face(b, p)?; + (d <= crate::appearance::pick_tolerance(b)).then_some((d, bi, fi)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, bi, fi)| (bi, fi)) +} + /// The body at `p` when the face there is not a plane square to `n` (To Object must then /// follow the face's shape). fn curved_target(st: &ModelState, n: Vec3, p: Vec3) -> Option { - for (bi, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()) { - let Some(fi) = crate::appearance::face_index_at(b, p) else { continue }; - let tol = (b.body.size() * 1e-3).max(1e-3); - let planar = - b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).and_then(|f| f.plane_normal).is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6); - return (!planar).then_some(bi); - } - None + let (bi, fi) = face_at(st, p)?; + let b = st.bodies.get(bi)?; + let tol = (b.body.size() * 1e-3).max(1e-3); + let planar = + b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).and_then(|f| f.plane_normal).is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6); + (!planar).then_some(bi) } /// The body at `p` when the face there is not planar. fn curved_target_any(st: &ModelState, p: Vec3) -> Option { - for (bi, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()) { - let Some(fi) = crate::appearance::face_index_at(b, p) else { continue }; - let tol = (b.body.size() * 1e-3).max(1e-3); - let planar = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).is_some_and(|f| f.plane_normal.is_some()); - return (!planar).then_some(bi); - } - None + let (bi, fi) = face_at(st, p)?; + let b = st.bodies.get(bi)?; + let tol = (b.body.size() * 1e-3).max(1e-3); + let planar = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).is_some_and(|f| f.plane_normal.is_some()); + (!planar).then_some(bi) } /// Tools run through `target`, cut back to its surface: each tool less the body, keeping the diff --git a/crates/engine/src/extent_tests.rs b/crates/engine/src/extent_tests.rs index 1396c6a3..961beccc 100644 --- a/crates/engine/src/extent_tests.rs +++ b/crates/engine/src/extent_tests.rs @@ -104,3 +104,24 @@ fn revolve_to_a_cylinder_follows_it() { } panic!("neither post was reached"); } + +/// To Object picks the face that is at the picked point, not the first body with any face +/// near it. Here the pick is on a post (r 2.75) only 2 mm above a plate: the ridge must end +/// on the post. Before, the plate (within 5 % of its size of the pick) was taken as the +/// target, nothing trimmed the tool, and the ridge ran past the post. +#[test] +fn extrude_to_a_cylinder_beside_another_body() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner": [-98, -5, 0], "length": 101, "width": 10, "height": 3, "body_name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [0, 0, 1], "radius": 2.75, "height": 10, "body_name": "Post"})); + // A ridge above the plate, y -1.5..0, z 4..7, from the plate's far end towards the post. + run(&mut s, "sketch.create", json!({"plane": {"origin": [-98, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "End"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-1.5, 4], "p1": [0, 7]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "End", "to": [-2.75, 0, 5], "operation": "new", "body_name": "Ridge"})); + let b = body(&mut s, "Ridge"); + // Its end follows the post: farthest at the ridge's edge y = -1.5, x = -√(2.75² − 1.5²). + let end = b["bbox"]["max"][0].as_f64().unwrap_or(f64::MAX); + assert!((end + (2.75f64 * 2.75 - 1.5 * 1.5).sqrt()).abs() < 1e-3, "{b}"); + assert!((b["bbox"]["min"][0].as_f64().unwrap_or(0.0) + 98.0).abs() < 1e-6, "{b}"); +}