doc: re-anchor a stored face pick strictly inside its named face

After a parameter edit, a feature's stored face point is moved onto the
face its name finds. That used the face's nearest point to the old pick,
which lies on the face's boundary whenever the pick no longer lies on the
face: a shell opened at the end of a bar picked (150,15,10), on the edge
it shares with the bottom face, so truck opened the wrong face and SMLib
refused the pick (#2531).

FaceHit::pick_point keeps the point where it still lies on the face clear
of its edges, and otherwise takes the face's nearest point among points at
least a margin inside its triangles (a few pick tolerances, capped at half
the roomiest triangle's inradius on narrow faces).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-21 12:37:59 -04:00
parent 02bfd6e10d
commit d20e91f8cf
3 changed files with 102 additions and 1 deletions
+1 -1
View File
@@ -6403,7 +6403,7 @@ fn with_named_face_points(doc: &Document, state: &ModelState, f: &Feature) -> Op
p.x, p.y, p.z
)),
},
_ => *p = hit.point,
_ => *p = hit.pick_point(&fst, *p),
}
}
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
+70
View File
@@ -241,6 +241,76 @@ impl FaceHit {
}
best.map(|t| p + d * t)
}
/// A point that picks the face found again by itself: [`point`](Self::point) when the
/// reference's point still lies on the face clear of its edges; else the point of the face
/// nearest `p` that keeps clear of every edge of it. The nearest point of a face the
/// reference's point no longer lies on is on the face's boundary (a parameter moved the face
/// away from the pick), where a pick by point finds the face beside it as well (#2531).
pub fn pick_point(&self, st: &ModelState, p: Vec3) -> Vec3 {
let Some(b) = st.bodies.get(self.body) else { return self.point };
let m = b.mesh();
// Clear of the edges by a few pick tolerances, or by half the face's roomiest triangle
// on a face narrower than that.
let Some(margin) = roomiest_inradius(&m, self.face).map(|r| (4.0 * (b.body.size() * 1e-3).max(1e-3)).min(r * 0.5)) else {
return self.point;
};
if self.on_point && edge_clearance(&m, self.face, self.point) >= margin {
return self.point;
}
inside_nearest(&m, self.face, p, margin).unwrap_or(self.point)
}
}
/// The incentre and inradius of triangle abc (`None`: degenerate).
fn incircle(a: Vec3, b: Vec3, c: Vec3) -> Option<(Vec3, f64)> {
// The incentre weighs each corner by the side opposite it; the inradius is area / half the perimeter.
let (la, lb, lc) = (b.dist(c), c.dist(a), a.dist(b));
let perimeter = la + lb + lc;
let radius = (b - a).cross(c - a).len() / perimeter;
let centre = (a * la + b * lb + c * lc) / perimeter;
(radius.is_finite() && radius > 0.0 && centre.is_finite()).then_some((centre, radius))
}
/// The triangles of face `face` of a mesh.
fn face_triangles(m: &Mesh, face: usize) -> impl Iterator<Item = [Vec3; 3]> + '_ {
m.triangles.iter().zip(&m.tri_face).filter(move |(_, f)| **f as usize == face).filter_map(|(t, _)| m.tri(t))
}
/// The greatest inradius of the triangles of face `face` of a mesh (`None`: it has none).
fn roomiest_inradius(m: &Mesh, face: usize) -> Option<f64> {
face_triangles(m, face).filter_map(|[a, b, c]| incircle(a, b, c)).map(|(_, r)| r).reduce(f64::max)
}
/// How far `q` is from the nearest edge bounding face `face` of a mesh (infinite: it has none).
fn edge_clearance(m: &Mesh, face: usize, q: Vec3) -> f64 {
m.edges
.iter()
.zip(&m.edge_faces)
.filter(|(_, fs)| fs.iter().any(|f| *f as usize == face))
.flat_map(|(e, _)| e.windows(2).map(|w| q.dist_to_segment(w[0], w[1])))
.filter(|d| d.is_finite())
.fold(f64::INFINITY, f64::min)
}
/// The point of face `face` of a mesh nearest `p` among the points at least `margin` inside one
/// of its triangles (each triangle shrunk about its incentre by `margin`): such a point is at
/// least `margin` from every edge of the face, which runs along the sides of its triangles.
/// `None`: no triangle of the face is roomier than `margin`.
fn inside_nearest(m: &Mesh, face: usize, p: Vec3, margin: f64) -> Option<Vec3> {
let mut best: Option<(f64, Vec3)> = None;
for [a, b, c] in face_triangles(m, face) {
let Some((centre, radius)) = incircle(a, b, c).filter(|(_, r)| *r > margin) else { continue };
// Shrunk about the incentre so each side moves in by `margin`.
let s = (radius - margin) / radius;
let shrink = |v: Vec3| centre + (v - centre) * s;
let q = closest(p, shrink(a), shrink(b), shrink(c));
let d = q.dist(p);
if d.is_finite() && best.is_none_or(|(old, _)| d < old) {
best = Some((d, q));
}
}
best.map(|(_, q)| q)
}
impl EdgeHit {
+31
View File
@@ -623,3 +623,34 @@ fn one_fillet_over_stacked_boxes_rounds_the_upper_box_s_edge_even_when_saved_nam
s.refresh();
check(&s, "saved by an older build");
}
/// A shell opened by a point pick on a bar's end face keeps opening that face when a parameter
/// edit moves the bar so the stored point no longer lies on it (it now lies on the bottom face).
/// The point is moved onto the named face clear of its edges: the face's nearest point to the old
/// pick is on the edge it shares with the bottom face, where a pick finds both faces (#2531).
#[test]
fn a_shell_point_pick_moved_off_its_face_reopens_only_that_face() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "L", "expression": "100 mm", "unit": "mm"}));
run(&mut s, "parameters.add", json!({"name": "Y0", "expression": "5 mm", "unit": "mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 5], "p1": [100, 25]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["origin", "l1.start"], "mode": "vertical"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": "Y0"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "type": "length", "value": "L"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l4"], "type": "length", "value": "20 mm"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": "20 mm", "operation": "new", "body_name": "Bar"}));
run(&mut s, "solid.shell", json!({"faces": [[100, 15, 10]], "thickness": "2 mm", "direction": "inside", "body": "Bar", "name": "Shell"}));
change(&mut s, "L", "150 mm");
change(&mut s, "Y0", "15 mm");
assert_eq!(error(&s, "Shell"), None);
let state = s.model.state();
let bar = state.body("Bar").expect("the shelled bar");
let volume = solidcraft_kernel::measure(&bar.body).expect("measure").volume;
// 150 x 20 x 20 open at x = 150 only: 60000 - 148 * 16 * 16.
assert!((volume - 22112.0).abs() < 1e-3, "{volume}");
assert_eq!(bar.body.face_count(), 11);
assert!(solid_at(&s, [100.0, 16.0, 10.0]), "the bottom face stays closed");
assert!(!solid_at(&s, [149.0, 25.0, 10.0]), "the end at x = 150 is open");
}