Single-hole snapping takes only shown points and checks the face once per pick (#798)

Follow-up to #718: a face place snaps only to sketch points that are shown, and the face under the cursor is looked up once per pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
semmelrindeandClaude Opus 5.5 authored and GitHub committed 2026-10-13 14:28:54 -04:00
1 parent 17b4b9a32c
commit dbfb689f6c
4 files changed
+582 -39

No files matched your search

+276 -26
View File
@@ -10,7 +10,7 @@ use solidcraft_engine::Sel;
use solidcraft_engine::Session; use solidcraft_engine::Session;
use solidcraft_engine::doc::expr::Kind as ValueKind; use solidcraft_engine::doc::expr::Kind as ValueKind;
use solidcraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel}; use solidcraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel};
use solidcraft_engine::geom::Vec3; use solidcraft_engine::geom::{Aabb3, Vec3};
use crate::SolveApp; use crate::SolveApp;
use crate::selection::{self, AXES, Accept, BODIES, CURVES, EDGES, FACES, PLANAR_FACES, PLANES, PROFILES, SelInput}; use crate::selection::{self, AXES, Accept, BODIES, CURVES, EDGES, FACES, PLANAR_FACES, PLANES, PROFILES, SelInput};
@@ -747,10 +747,32 @@ impl Dialog {
self.candidate_on(s, h, None) self.candidate_on(s, h, None)
} }
/// The selection a hit would add to the active input, knowing the face under the cursor (the /// The face under the cursor (the nearest face the pick ray meets, first among `hits`), looked
/// nearest face the pick ray meets). A face place picked on or near a sketch point that lies /// up once for all the sketch points among `hits` when the active input snaps face places to
/// on that face (a circle's centre) is taken at the point, as a points input picks it. /// them (the Single hole's); `None` otherwise.
pub fn candidate_on(&self, s: &Session, h: &Hit, face: Option<&Hit>) -> Option<Sel> { pub(crate) fn face_snap(&self, s: &Session, hits: &[Hit]) -> Option<FaceSnap> {
if !self.active_input().is_some_and(|i| snaps_to_points(i.accept)) {
return None;
}
let Some(Hit::Face { body, index, .. }) = hits.iter().find(|h| matches!(h, Hit::Face { .. })) else { return None };
let st = s.world_state();
let near: Vec<Vec3> = hits
.iter()
.filter_map(|h| match h {
Hit::SketchPoint { sketch, at, .. } => st.sketch(*sketch).map(|ss| ss.plane.to_world(*at)),
_ => None,
})
.collect();
if near.is_empty() {
return None;
}
Some(FaceSnap::new(s, body, *index, &near))
}
/// The selection a hit would add to the active input, given the face under the cursor
/// ([`Dialog::face_snap`] of the same hits). A face place picked on or near a sketch point that
/// lies on that face (a circle's centre) is taken at the point, as a points input picks it.
pub(crate) fn candidate_on(&self, s: &Session, h: &Hit, snap: Option<&FaceSnap>) -> Option<Sel> {
let inp = self.active_input()?; let inp = self.active_input()?;
// Features are also picked on the canvas: a face stands for the feature that made it. // Features are also picked on the canvas: a face stands for the feature that made it.
if inp.accept == selection::FEATURES { if inp.accept == selection::FEATURES {
@@ -759,16 +781,14 @@ impl Dialog {
_ => None, _ => None,
}; };
} }
if inp.accept & selection::POINTS != 0 if snaps_to_points(inp.accept)
&& inp.accept & PLANAR_FACES != 0
&& let Hit::SketchPoint { sketch, at, .. } = h && let Hit::SketchPoint { sketch, at, .. } = h
{ {
return match face { let snap = snap?;
Some(Hit::Face { body, index, .. }) => face_place_at_sketch_point(s, *sketch, *at, body, *index), return snap.place_at(s.world_state().sketch(*sketch)?.plane.to_world(*at));
_ => None,
};
} }
inp.accepts(h, |body, face| planar_face(s, body, face).is_some()) // The snap looked the face under the cursor up already, planar or not.
inp.accepts(h, |body, face| snap.and_then(|f| f.planar(body, face)).unwrap_or_else(|| planar_face(s, body, face).is_some()))
} }
/// A pick in the viewport (already accepted by the active input). /// A pick in the viewport (already accepted by the active input).
@@ -855,20 +875,248 @@ fn default_profiles(s: &Session) -> Vec<Sel> {
} }
} }
/// A place on planar face `face` of `body` at a sketch point (a circle's centre or a point), when /// Does an input take face places that snap to sketch points on the face (the Single hole's)?
/// the point lies on that face (within a micrometre); `None` otherwise. pub(crate) fn snaps_to_points(accept: Accept) -> bool {
fn face_place_at_sketch_point(s: &Session, sketch: u64, at: solidcraft_engine::geom::Vec2, body: &str, face: usize) -> Option<Sel> { accept & selection::POINTS != 0 && accept & PLANAR_FACES != 0
let st = s.world_state(); }
let p = st.sketch(sketch)?.plane.to_world(at);
let m = st.body(body)?.mesh(); /// Which of a sketch's points a face place snaps to (the Single hole's): the points of geometry
// The same wherever the part is: a planar face's triangles lie in its plane. /// the sketch shows. Not a text outline's points, which are never drawn or picked on their own
let tol = 1e-3; /// (as `view::sketch_point_visible` has it), nor projected points while the sketch hides
let on = m /// projected geometry (Show Projected off), whose curves aren't picked either. Hidden sketches
.triangles /// offer no points at all. Show Points doesn't count: it only hides the markers of the sketch
.iter() /// being edited, and a finished sketch's circle centres are snapped to without markers.
.zip(&m.tri_face) pub(crate) struct SnapPoints<'a> {
.any(|(t, f)| *f as usize == face && m.tri(t).is_some_and(|[a, b, c]| crate::preview::point_triangle_dist(p, a, b, c) < tol)); /// Show Projected is off: no linked point is taken.
(p.is_finite() && on && planar_face(s, body, face).is_some()).then(|| Sel::Face { body: body.to_string(), index: face, point: p }) hide_linked: bool,
/// The text sources' link ids (looked up once, however many points and links there are).
text: std::collections::HashSet<&'a str>,
}
impl<'a> SnapPoints<'a> {
pub(crate) fn of(sk: &'a solidcraft_engine::sketch::Sketch) -> SnapPoints<'a> {
let hide_linked = sk.view.hide_projected;
let text = if hide_linked {
Default::default()
} else {
// An id names its first link, as `Sketch::link` finds it.
let mut seen = std::collections::HashSet::new();
sk.links
.iter()
.filter(|l| seen.insert(l.id.as_str()))
.filter(|l| l.kind == solidcraft_engine::sketch::LinkKind::Text)
.map(|l| l.id.as_str())
.collect()
};
SnapPoints { hide_linked, text }
}
/// Does a face place snap to point `p` of the sketch?
pub(crate) fn takes(&self, p: &solidcraft_engine::sketch::SPoint) -> bool {
match p.link.as_deref() {
None => true,
Some(_) if self.hide_linked => false,
Some(l) => !self.text.contains(l),
}
}
}
/// How near a sketch point must be to a face to lie on it: a micrometre, wherever the part is (a
/// planar face's triangles lie in its plane), by the true distance ([`triangle_dist`]).
const ON_FACE: f64 = 1e-3;
/// The distance from `p` to the triangle `abc`, exactly: from its plane when `p` is over it,
/// otherwise from its nearest side. (`preview::point_triangle_dist` takes a point just past a
/// side for one over the triangle, with an absolute tolerance that is wide for a sliver.)
fn triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
let sides = [(a, b), (b, c), (c, a)];
let to_sides = || sides.iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min);
// However small the triangle: only one without area (or not finite) has no plane.
let n = (b - a).cross(c - a);
let len = n.len();
if !(len > 0.0 && len.is_finite()) {
return to_sides();
}
let unit = n / len;
let h = (p - a).dot(unit);
let q = p - unit * h;
if sides.iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(unit) >= 0.0) { h.abs() } else { to_sides() }
}
/// The most point-in-triangle tests one pick makes for the snap. Points past it aren't snapped
/// to (the face as picked still is), so no sketch, however dense, can stall the viewport.
const SNAP_BUDGET: usize = 1 << 18;
#[cfg(test)]
thread_local! {
/// A smaller [`SNAP_BUDGET`] for a test that runs it out.
static TEST_BUDGET: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}
/// Run the snap with a budget of `n` tests per pick on this thread (`None`: [`SNAP_BUDGET`]).
#[cfg(test)]
pub(crate) fn set_snap_budget(n: Option<usize>) {
TEST_BUDGET.with(|b| b.set(n));
}
fn snap_budget() -> usize {
#[cfg(test)]
if let Some(n) = TEST_BUDGET.with(std::cell::Cell::get) {
return n;
}
SNAP_BUDGET
}
/// The face under the cursor, looked up once for all the sketch points one pick offers a face
/// place to (the Single hole's snap): whether it is planar, its plane, and those of its
/// triangles near the points on that plane, with their bounding boxes. A dense sketch over a
/// finely tessellated part costs one planarity lookup and one pass over the body's triangles per
/// pick; then a point off the plane is turned down at once, and a point on it is tested against
/// the face's triangles near the cursor, within [`SNAP_BUDGET`] tests in all.
pub(crate) struct FaceSnap {
body: String,
face: usize,
/// A point of the plane, its unit normal, and how far a point on the face may be from it
/// (the triangles' own distance plus [`ON_FACE`]); `None` when the face isn't planar.
plane: Option<(Vec3, Vec3, f64)>,
tris: Vec<([Vec3; 3], Aabb3)>,
/// Point-in-triangle tests left for this pick.
budget: std::cell::Cell<usize>,
}
impl FaceSnap {
/// Face `face` of `body`, for sketch points at `near` (in the world).
fn new(s: &Session, body: &str, face: usize, near: &[Vec3]) -> FaceSnap {
let mut snap = FaceSnap { body: body.to_string(), face, plane: None, tris: Vec::new(), budget: std::cell::Cell::new(snap_budget()) };
let st = s.world_state();
let (Some((origin, normal)), Some(b)) = (planar_face(s, body, face), st.body(body)) else { return snap };
let m = b.mesh();
#[cfg(test)]
SNAP_WORK.with(|w| w.set((w.get().0, w.get().1 + m.triangles.len())));
let tris: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face).filter_map(|(t, _)| m.tri(t)).collect();
// Anything within ON_FACE of a triangle is within this of the plane.
let stray = tris.iter().flatten().map(|v| (*v - origin).dot(normal).abs()).fold(0.0, f64::max);
let plane = (origin, normal, stray + ON_FACE);
let mut region = Aabb3::EMPTY;
for p in near.iter().filter(|p| near_plane(plane, **p)) {
region.add(*p);
}
snap.tris = tris
.into_iter()
.map(|t| {
let mut b = Aabb3::EMPTY;
t.iter().for_each(|v| b.add(*v));
(t, b)
})
.filter(|(_, b)| boxes_meet(b, &region, ON_FACE))
.collect();
snap.plane = Some(plane);
snap
}
/// Is face `face` of `body` planar, when it is this snap's face?
fn planar(&self, body: &str, face: usize) -> Option<bool> {
(self.face == face && self.body == body).then_some(self.plane.is_some())
}
/// The face place at `p` (one of the points the snap was made for), when `p` lies on the face.
fn place_at(&self, p: Vec3) -> Option<Sel> {
let plane = self.plane?;
if !(p.is_finite() && near_plane(plane, p)) {
return None;
}
let at = Aabb3 { min: p, max: p };
for ([a, b, c], bb) in &self.tris {
let left = self.budget.get();
if left == 0 {
return None;
}
self.budget.set(left - 1);
#[cfg(test)]
SNAP_WORK.with(|w| w.set((w.get().0, w.get().1 + 1)));
if boxes_meet(bb, &at, ON_FACE) && triangle_dist(p, *a, *b, *c) < ON_FACE {
return Some(Sel::Face { body: self.body.clone(), index: self.face, point: p });
}
}
None
}
}
/// Is `p` within reach of the plane (a point of it, unit normal, reach)?
fn near_plane((origin, normal, reach): (Vec3, Vec3, f64), p: Vec3) -> bool {
(p - origin).dot(normal).abs() <= reach
}
/// Do boxes `a` and `b` meet once `b` is grown by `pad` on every side?
fn boxes_meet(a: &Aabb3, b: &Aabb3, pad: f64) -> bool {
a.min.x <= b.max.x + pad
&& b.min.x - pad <= a.max.x
&& a.min.y <= b.max.y + pad
&& b.min.y - pad <= a.max.y
&& a.min.z <= b.max.z + pad
&& b.min.z - pad <= a.max.z
}
#[cfg(test)]
thread_local! {
/// Planar face lookups (each tessellates the body) and triangles the snap looked at, on this
/// thread: tests bound the work of a pick with them.
static SNAP_WORK: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
}
/// (planar face lookups, triangles the snap looked at) on this thread since the last call.
#[cfg(test)]
pub(crate) fn take_snap_work() -> (usize, usize) {
SNAP_WORK.with(|w| w.replace((0, 0)))
}
#[cfg(test)]
mod face_snap_tests {
use super::*;
#[test]
fn a_point_more_than_a_micrometre_off_a_sliver_is_off_the_face() {
// A triangle with half-micrometre legs. Points 2 µm below its lower side and 1.6 µm past
// its long side get past `preview::point_triangle_dist`'s inside test (an absolute
// tolerance); the snap goes by the true distance and turns them down. Points on a side,
// or half a micrometre beyond one, are on the face.
let (a, b, c) = (Vec3::ZERO, Vec3::new(5e-4, 0.0, 0.0), Vec3::new(0.0, 5e-4, 0.0));
let out = Vec3::new(1.0, 1.0, 0.0) * std::f64::consts::FRAC_1_SQRT_2;
let mid = Vec3::new(2.5e-4, 2.5e-4, 0.0);
let off = [Vec3::new(2.5e-4, -2e-3, 0.0), Vec3::new(1.4e-3, 1.4e-3, 0.0)];
let on = [mid, mid + out * 5e-4, Vec3::new(1e-4, -5e-4, 0.0), Vec3::new(1e-4, 1e-4, 5e-4)];
let mut bb = Aabb3::EMPTY;
[a, b, c].into_iter().for_each(|v| bb.add(v));
let snap = FaceSnap {
body: "Body1".into(),
face: 0,
plane: Some((Vec3::ZERO, Vec3::Z, ON_FACE)),
tris: vec![([a, b, c], bb)],
budget: std::cell::Cell::new(SNAP_BUDGET),
};
for p in off {
assert!(crate::preview::point_triangle_dist(p, a, b, c) < ON_FACE, "the inside test alone takes {p:?}");
assert!(triangle_dist(p, a, b, c) > 1.5e-3, "{p:?}");
assert_eq!(snap.place_at(p), None, "{p:?}");
}
for p in on {
assert_eq!(snap.place_at(p), Some(Sel::Face { body: "Body1".into(), index: 0, point: p }), "{p:?}");
}
}
#[test]
fn the_distance_to_a_tiny_triangle_is_from_its_plane_over_it() {
// Legs of 20 nm: the triangle still has a plane, and a point just under a micrometre
// above it is that far from it (not from its nearest side), so it is on the face. A
// triangle without area is measured from its sides; nothing here panics on non-finite input.
let (a, b, c) = (Vec3::ZERO, Vec3::new(2e-5, 0.0, 0.0), Vec3::new(0.0, 2e-5, 0.0));
let p = Vec3::new(5e-6, 5e-6, 0.99999e-3);
assert!((triangle_dist(p, a, b, c) - 0.99999e-3).abs() < 1e-12, "{}", triangle_dist(p, a, b, c));
assert!((triangle_dist(Vec3::new(0.0, 3.0, 4.0), a, b, b) - 5.0).abs() < 1e-9, "a triangle without area");
let far = |d: f64| d.is_nan() || d >= ON_FACE;
assert!(far(triangle_dist(Vec3::new(f64::NAN, 0.0, 0.0), a, b, c)), "a point that isn't finite is never near");
assert!(far(triangle_dist(p, a, b, Vec3::new(f64::INFINITY, 0.0, 0.0))), "nor is a triangle that isn't finite");
}
} }
/// Does a feature build from this sketch's profiles (extrude, revolve, sweep, loft, emboss…)? /// Does a feature build from this sketch's profiles (extrude, revolve, sweep, loft, emboss…)?
@@ -878,6 +1126,8 @@ fn profiles_used(s: &Session, sketch: u64) -> bool {
/// The plane of a planar body face: (point on it, unit normal). /// The plane of a planar body face: (point on it, unit normal).
pub fn planar_face(s: &Session, body: &str, face: usize) -> Option<(Vec3, Vec3)> { pub fn planar_face(s: &Session, body: &str, face: usize) -> Option<(Vec3, Vec3)> {
#[cfg(test)]
SNAP_WORK.with(|w| w.set((w.get().0 + 1, w.get().1)));
let st = s.world_state(); let st = s.world_state();
let b = st.body(body)?; let b = st.body(body)?;
let tol = (b.body.size() * 1e-3).max(1e-3); let tol = (b.body.size() * 1e-3).max(1e-3);
+290 -2
View File
@@ -1,12 +1,14 @@
//! Hole placement on the canvas. A Single hole picked on or near a sketch point on the face (a //! Hole placement on the canvas. A Single hole picked on or near a sketch point on the face (a
//! circle's centre) goes to that point, as a Multiple pick of the same point does (#657); a pick //! circle's centre) goes to that point, as a Multiple pick of the same point does (#657); a pick
//! away from sketch points, or near one off the face under the cursor, stays where it was clicked. //! away from sketch points, or near one off the face under the cursor, stays where it was clicked.
//! Only points the sketch shows are snapped to (not lettering's outline points), and Select Other
//! looks the face up once for every point near the cursor.
use serde_json::{Value, json}; use serde_json::{Value, json};
use solidcraft_engine::Sel; use solidcraft_engine::Sel;
use solidcraft_engine::doc::FeatureKind; use solidcraft_engine::doc::FeatureKind;
use solidcraft_engine::geom::Vec3; use solidcraft_engine::geom::{Aabb3, Vec2, Vec3};
use solidcraft_engine::sketch::CurveKind; use solidcraft_engine::sketch::{CurveKind, LinkKind, SPoint};
use crate::dialogs::Kind; use crate::dialogs::Kind;
use crate::scenario::Harness; use crate::scenario::Harness;
@@ -236,3 +238,289 @@ fn select_other_keeps_the_face_as_picked_beside_many_snapped_points() {
assert_eq!((count("Point"), count("Face")), (crate::select_other::MAX_PER_KIND, 1), "{list}"); assert_eq!((count("Point"), count("Face")), (crate::select_other::MAX_PER_KIND, 1), "{list}");
assert_eq!(list["total"], json!(many + 1), "{list}"); assert_eq!(list["total"], json!(many + 1), "{list}");
} }
/// The block, seen from the top, with an "L" 10 mm high written on its top face from (5, 5), and
/// with a circle of radius 0.5 about `circle` (millimetres right of the foot's lower right corner)
/// when given. Returns the corner, then the circle centre, in the world.
fn block_with_lettering(circle: Option<f64>) -> (Harness, Vec3, Option<Vec3>) {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
h.app.run("sketch.create", json!({"plane": {"face": [20, 15, 20]}})).unwrap();
h.app.run("sketch.text", json!({"text": "L", "at": [5, 5], "height": 10})).unwrap();
let outline = |h: &Harness| {
let st = h.app.session.world_state();
let ss = st.sketches.first().unwrap();
let sk = &ss.sketch;
let text = |p: &&SPoint| p.link.as_deref().and_then(|l| sk.link(l)).is_some_and(|l| l.kind == LinkKind::Text);
sk.points.iter().filter(text).map(|p| p.pos).collect::<Vec<Vec2>>()
};
let corner = outline(&h).into_iter().max_by(|a, b| (a.x - a.y).total_cmp(&(b.x - b.y))).unwrap();
let centre = circle.map(|dx| Vec2::new(corner.x + dx, corner.y));
if let Some(c) = centre {
h.app.run("sketch.circle.center", json!({"center": [c.x, c.y], "radius": 0.5})).unwrap();
}
h.app.run("sketch.finish", json!({})).unwrap();
h.step(&json!({"call": "ui.view", "params": {"view": "top"}})).unwrap();
h.settle();
let st = h.app.session.world_state();
let plane = st.sketches.first().unwrap().plane;
assert_eq!(outline(&h).len(), 6, "the L's outline has six corners");
(h, plane.to_world(corner), centre.map(|c| plane.to_world(c)))
}
/// Where the ray through a screen point meets the nearest face.
fn face_point_at(h: &Harness, at: [f64; 2]) -> Vec3 {
let ray = hits_at(h, at);
match ray.iter().find(|x| matches!(x, Hit::Face { .. })) {
Some(Hit::Face { point, .. }) => *point,
_ => panic!("no face under {at:?}: {ray:?}"),
}
}
#[test]
fn a_single_hole_picked_beside_lettering_stays_where_it_was_clicked() {
// Text outlines' points are not drawn and not picked on their own (#718 review): a click 5 px
// off a letter's corner keeps the face point it was clicked at.
let (mut h, corner, _) = block_with_lettering(None);
assert!((corner.z - 20.0).abs() < 1e-9, "the lettering lies on the top face: {corner:?}");
let click = near(&mut h, corner);
h.step(&json!({"start": "solid.hole"})).unwrap();
let raw = face_point_at(&h, click);
h.step(&json!({"move": click})).unwrap();
assert!(matches!(&h.app.viewport.hover, Some(Hit::Face { .. })), "the face lights up, not a letter's corner: {:?}", h.app.viewport.hover);
h.step(&json!({"click": click})).unwrap();
let places = face_places(&h);
assert!(matches!(places.as_slice(), [(_, p)] if p.dist(raw) < 1e-9), "the face is picked where clicked {raw:?}, not {places:?}");
h.step(&json!({"key": "Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "OK closed the dialog: {:?}", h.app.dialog.as_ref().map(|d| &d.error));
let (_, position, at_points) = hole(&h);
assert!(!at_points && position.dist(raw) < 1e-9 && position.dist(corner) > 0.5, "{position:?}");
}
#[test]
fn a_single_hole_picked_beside_lettering_snaps_to_a_shown_point_in_reach() {
// A circle centre 1.2 mm (8 px) right of a letter's corner. The click is nearer the corner
// (2.4 px) but within reach of the centre (5.6 px): the centre is the point the pick snaps to.
let (mut h, corner, centre) = block_with_lettering(Some(1.2));
let centre = centre.unwrap();
let (c, p) = (screen(&mut h, corner), screen(&mut h, centre));
let d = ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt();
assert!(d > 6.0 && d < 10.0, "the centre is {d} px from the corner");
let click = [c[0] + (p[0] - c[0]) * 0.3, c[1] + (p[1] - c[1]) * 0.3];
h.step(&json!({"start": "solid.hole"})).unwrap();
h.step(&json!({"click": click})).unwrap();
let places = face_places(&h);
assert!(matches!(places.as_slice(), [(_, p)] if p.dist(centre) < 1e-9), "the face is picked at the centre {centre:?}, not {places:?}");
}
#[test]
fn a_single_hole_snaps_only_to_points_its_sketch_shows() {
// A face sketch projects the face's edges, so the face's corners are points of the sketch: a
// click 5 px inside a corner snaps to it while the sketch shows projected geometry, and stays
// where it was clicked while the sketch hides it (Show Projected off).
for shown in [true, false] {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
h.app.run("sketch.create", json!({"plane": {"face": [20, 15, 20]}})).unwrap();
h.app.run("sketch.options", json!({"show_projected": shown})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.step(&json!({"call": "ui.view", "params": {"view": "top"}})).unwrap();
h.settle();
let corner = Vec3::new(40.0, 30.0, 20.0);
let c = screen(&mut h, corner);
let click = [c[0] - 4.0, c[1] + 3.0];
h.step(&json!({"start": "solid.hole"})).unwrap();
let raw = face_point_at(&h, click);
assert!(raw.dist(corner) > 0.5 && (raw.z - 20.0).abs() < 1e-9, "{raw:?}");
h.step(&json!({"click": click})).unwrap();
let want = if shown { corner } else { raw };
let places = face_places(&h);
assert!(matches!(places.as_slice(), [(_, p)] if p.dist(want) < 1e-9), "projected shown: {shown}; at {want:?}, not {places:?}");
}
// A hidden sketch offers no points: its circle's centre is not snapped to.
let (mut h, centre) = block_with_circle();
let name = h.app.session.doc.features.iter().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).unwrap().name.clone();
h.app.run("browser.visibility", json!({"items": [name], "visible": false})).unwrap();
let click = near(&mut h, centre);
h.step(&json!({"start": "solid.hole"})).unwrap();
let raw = face_point_at(&h, click);
h.step(&json!({"click": click})).unwrap();
let places = face_places(&h);
assert!(matches!(places.as_slice(), [(_, p)] if p.dist(raw) < 1e-9 && p.dist(centre) > 0.1), "{places:?}");
// Show Points off only hides the markers of the sketch being edited (a finished sketch draws
// none): its circle's centre is still snapped to.
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
h.app.run("sketch.create", json!({"plane": {"face": [20, 15, 20]}})).unwrap();
h.app.run("sketch.options", json!({"show_points": false})).unwrap();
h.app.run("sketch.circle.center", json!({"center": [12, 9], "radius": 4})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.step(&json!({"call": "ui.view", "params": {"view": "top"}})).unwrap();
h.settle();
let centre = Vec3::new(12.0, 9.0, 20.0);
let click = near(&mut h, centre);
h.step(&json!({"start": "solid.hole"})).unwrap();
h.step(&json!({"click": click})).unwrap();
let places = face_places(&h);
assert!(matches!(places.as_slice(), [(_, p)] if p.dist(centre) < 1e-9), "Show Points off: at the centre, not {places:?}");
}
/// Three Select Other lists' worth of sketch points under the cursor, with a Single hole started.
struct Cluster {
h: Harness,
/// How many points.
many: usize,
/// 5 px off them.
click: [f64; 2],
/// Triangles of the body under the click, of the face under it, and of that face's triangles
/// the ones whose bounding box comes within a micrometre of the points' bounding box.
body_tris: usize,
face_tris: usize,
near_tris: usize,
}
impl Cluster {
/// Select Other at the click: the list, then (planar face lookups, triangles the snap looked
/// at) while making it.
fn select_other(&mut self) -> (Value, (usize, usize)) {
crate::dialogs::take_snap_work();
let list = self.h.call("ui.selectOther", json!({"x": self.click[0], "y": self.click[1]}))["result"].clone();
(list, crate::dialogs::take_snap_work())
}
}
/// How many rows of a Select Other list are labelled `prefix`….
fn rows_named(list: &Value, prefix: &str) -> usize {
list["rows"].as_array().into_iter().flatten().filter(|r| r["label"].as_str().is_some_and(|l| l.starts_with(prefix))).count()
}
/// A cylinder of radius 15 and height 20 standing on the origin, seen from `view`, with the
/// points within 0.1 mm of `at` in the sketch `sketch.create` makes with `sketch`.
fn cylinder_with_cluster(sketch: Value, at: [f64; 2], view: &str) -> Cluster {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.cylinder", json!({"radius": 15, "height": 20})).unwrap();
h.app.run("sketch.create", sketch).unwrap();
let many = 3 * crate::select_other::MAX_PER_KIND;
let local: Vec<Vec2> = (0..many).map(|i| i as f64 * 0.3).map(|a| Vec2::new(at[0] + 0.1 * a.cos(), at[1] + 0.1 * a.sin())).collect();
for p in &local {
h.app.run("sketch.point", json!({"point": [p.x, p.y]})).unwrap();
}
h.app.run("sketch.finish", json!({})).unwrap();
h.step(&json!({"call": "ui.view", "params": {"view": view}})).unwrap();
h.settle();
let plane = h.app.session.world_state().sketches.first().unwrap().plane;
let centre = plane.to_world(Vec2::new(at[0], at[1]));
let mut points = Aabb3::EMPTY;
local.iter().for_each(|p| points.add(plane.to_world(*p)));
let click = near(&mut h, centre);
h.step(&json!({"start": "solid.hole"})).unwrap();
let ray = hits_at(&h, click);
let Some(Hit::Face { body, index, .. }) = ray.iter().find(|x| matches!(x, Hit::Face { .. })) else { panic!("no face under {centre:?}: {ray:?}") };
let m = h.app.session.world_state().body(body).unwrap().mesh();
let face: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == *index).filter_map(|(t, _)| m.tri(t)).collect();
let near_points = |t: &[Vec3; 3]| {
let mut b = Aabb3::EMPTY;
t.iter().for_each(|v| b.add(*v));
let gap = (b.min - points.max).max(points.min - b.max);
gap.x.max(gap.y).max(gap.z) <= 1e-3
};
let (body_tris, face_tris, near_tris) = (m.triangles.len(), face.len(), face.iter().filter(|t| near_points(t)).count());
assert!(body_tris >= 2 * face_tris, "the body has other faces: {body_tris} and {face_tris} triangles");
Cluster { h, many, click, body_tris, face_tris, near_tris }
}
#[test]
fn select_other_over_a_dense_cluster_looks_the_face_up_once() {
// The points on the cylinder's top face, a disc of a tenth of the body's triangles. Select
// Other looks the face under the cursor up once for all of them, not once per point (#718
// review): one planarity lookup, which tessellates the body, and one pass over the body's
// triangles. Each point is then tested against the face's triangles near the points only,
// not the face's or the body's; every point on the face is still counted.
let mut c = cylinder_with_cluster(json!({"plane": {"face": [0, 0, 20]}}), [5.0, 4.0], "top");
let (many, body, face, near) = (c.many, c.body_tris, c.face_tris, c.near_tris);
assert!(body >= 8 * face && 4 * near <= face, "{body} triangles in the body, {face} in the face, {near} of them near the points");
let (list, (lookups, tested)) = c.select_other();
assert_eq!((rows_named(&list, "Point"), rows_named(&list, "Face")), (crate::select_other::MAX_PER_KIND, 1), "{list}");
assert_eq!(list["total"], json!(many + 1), "every point on the face is counted: {list}");
assert_eq!(lookups, 1, "the face is looked up once for {many} points");
assert!(tested <= body + many * near, "{tested} triangles looked at for {many} points ({body} in the body, {near} near the points)");
}
#[test]
fn select_other_over_a_dense_cluster_off_the_face_tests_no_triangles() {
// The points 10 mm above the cylinder's top face, seen from the top: under the cursor, but
// off the face's plane. Each is turned down without a triangle test, and the face as picked
// is all that is offered.
let mut c = cylinder_with_cluster(json!({"plane": "XY", "offset": 30}), [5.0, 4.0], "top");
let (many, body) = (c.many, c.body_tris);
let (list, (lookups, tested)) = c.select_other();
assert_eq!((rows_named(&list, "Point"), rows_named(&list, "Face")), (0, 1), "{list}");
assert_eq!(list["total"], json!(1), "{list}");
assert_eq!(lookups, 1, "the face is looked up once for {many} points");
assert_eq!(tested, body, "one pass over the body's {body} triangles, and no test for the {many} points off the face");
}
#[test]
fn select_other_over_a_dense_cluster_by_a_curved_face_looks_it_up_once() {
// The points in a sketch through the cylinder's axis, seen from the front: the face under the
// cursor is the curved side, which takes no hole. It is looked up once, its triangles not at
// all, and nothing is offered.
let mut c = cylinder_with_cluster(json!({"plane": "XZ"}), [5.0, -10.0], "front");
let many = c.many;
let (list, (lookups, tested)) = c.select_other();
assert_eq!(list["open"], json!(false), "nothing for a Single hole here: {list}");
assert_eq!(lookups, 1, "the curved face is looked up once for {many} points");
assert_eq!(tested, 0, "no triangle of a curved face is looked at");
}
#[test]
fn select_other_snaps_no_more_points_than_the_budget_allows() {
// With the snap's budget cut to 30 point-in-triangle tests, at most 30 of the points on the
// face are offered (each takes one test at least), and the face as picked keeps its row.
let mut c = cylinder_with_cluster(json!({"plane": {"face": [0, 0, 20]}}), [5.0, 4.0], "top");
let (many, body) = (c.many, c.body_tris);
crate::dialogs::set_snap_budget(Some(30));
let (list, (_, tested)) = c.select_other();
crate::dialogs::set_snap_budget(None);
assert!(many > 30, "more points than the budget");
assert_eq!(rows_named(&list, "Face"), 1, "{list}");
let (points, total) = (rows_named(&list, "Point"), list["total"].as_u64().unwrap() as usize);
assert!(points >= 1 && total > points && total <= 30 + 1, "some points within the budget, and the face: {list}");
assert!(tested <= body + 30, "{tested} triangles looked at");
}
#[test]
fn a_face_place_snaps_to_the_points_of_shown_geometry_only() {
// Which points a Single hole snaps to, against the rule the viewport draws them by: not a
// text outline's points (`view::sketch_point_visible`), and no linked point while Show
// Projected is off. An id names its first link; a point whose link is gone counts as shown.
let (h, _, _) = block_with_lettering(None);
let mut sk = h.app.session.world_state().sketches.first().unwrap().sketch.clone();
let text = sk.links.iter().find(|l| l.kind == LinkKind::Text).unwrap().clone();
let edges = sk.links.iter().find(|l| l.kind != LinkKind::Text).unwrap().clone();
// Later links reusing the ids, with the other kind: the first link of an id is the one.
let (mut not_text, mut now_text) = (text.clone(), edges.clone());
not_text.kind = edges.kind;
now_text.kind = LinkKind::Text;
sk.links.extend([not_text, now_text]);
let mut lost = sk.points.last().unwrap().clone();
lost.id = "lost".into();
lost.link = Some("gone".into());
sk.points.push(lost);
let linked = |l: &str| sk.points.iter().filter(|p| p.link.as_deref() == Some(l)).count();
assert!(linked(&text.id) == 6 && linked(&edges.id) == 4, "the L's corners and the face's");
for hide in [false, true] {
sk.view.hide_projected = hide;
let takes = crate::dialogs::SnapPoints::of(&sk);
for (i, p) in sk.points.iter().enumerate() {
let want = solidcraft_engine::view::sketch_point_visible(&sk, i) && !(hide && p.link.is_some());
assert_eq!(takes.takes(p), want, "point {} (link {:?}), Show Projected off: {hide}", p.id, p.link);
}
let taken = sk.points.iter().filter(|p| takes.takes(p)).count();
assert_eq!(taken, if hide { 1 } else { 1 + 4 + 1 }, "the origin, and unless hidden the face's corners and the lost point");
}
}
+13 -9
View File
@@ -829,10 +829,17 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
let external_points = let external_points =
wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project")); wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
let point_sketches: Vec<u64> = if external_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() }; let point_sketches: Vec<u64> = if external_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() };
// A face place (the Single hole's) snaps only to points their sketches show, so a nearer
// point that isn't shown (a letter's outline) neither takes the pick nor hides a shown one.
let snapping = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| crate::dialogs::snaps_to_points(i.accept));
let mut best = Near::new(all); let mut best = Near::new(all);
for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) { for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) {
let sid = ss.feature; let sid = ss.feature;
let shown = snapping.then(|| crate::dialogs::SnapPoints::of(&ss.sketch));
for p in &ss.sketch.points { for p in &ss.sketch.points {
if shown.as_ref().is_some_and(|f| !f.takes(p)) {
continue;
}
if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) { if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) {
let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 }; let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
if dd < 8.0 && best.wants(dd) { if dd < 8.0 && best.wants(dd) {
@@ -1215,8 +1222,8 @@ pub fn hit_sel(h: &Hit) -> Option<Sel> {
/// dialog the first hit (sketch entities only belong to the active sketch). /// dialog the first hit (sketch entities only belong to the active sketch).
pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> { pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> {
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) { if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
let face = face_under(hits); let snap = d.face_snap(&app.session, hits);
return hits.iter().find_map(|h| d.candidate_on(&app.session, h, face).map(|s| (h.clone(), s))); return hits.iter().find_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), s)));
} }
hits.iter().filter(|h| pick_allowed(app, h)).find_map(|h| hit_candidate(app, h)) hits.iter().filter(|h| pick_allowed(app, h)).find_map(|h| hit_candidate(app, h))
} }
@@ -1224,17 +1231,14 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> {
/// Every hit a click could pick, in order, each with its selection (Select Other). /// Every hit a click could pick, in order, each with its selection (Select Other).
pub fn candidates(app: &SolveApp, hits: &[Hit]) -> Vec<(Hit, Sel)> { pub fn candidates(app: &SolveApp, hits: &[Hit]) -> Vec<(Hit, Sel)> {
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) { if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
let face = face_under(hits); // The face under the cursor is looked up once for every sketch point a face place may
return hits.iter().filter_map(|h| d.candidate_on(&app.session, h, face).map(|s| (h.clone(), s))).collect(); // snap to, before Select Other caps its rows.
let snap = d.face_snap(&app.session, hits);
return hits.iter().filter_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), s))).collect();
} }
hits.iter().filter(|h| pick_allowed(app, h)).filter_map(|h| hit_candidate(app, h)).collect() hits.iter().filter(|h| pick_allowed(app, h)).filter_map(|h| hit_candidate(app, h)).collect()
} }
/// The face under the cursor: the nearest face the pick ray meets (faces come nearest first).
fn face_under(hits: &[Hit]) -> Option<&Hit> {
hits.iter().find(|h| matches!(h, Hit::Face { .. }))
}
/// What the selection filter lets a click pick with no dialog. /// What the selection filter lets a click pick with no dialog.
fn pick_allowed(app: &SolveApp, h: &Hit) -> bool { fn pick_allowed(app: &SolveApp, h: &Hit) -> bool {
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
+3 -2
View File
@@ -55,8 +55,9 @@ planar placement.
A **Hole** with **Single** placement drills where you pick a planar face. A pick on or near a A **Hole** with **Single** placement drills where you pick a planar face. A pick on or near a
point of a shown sketch that lies on that face, such as a circle's centre, places the hole point of a shown sketch that lies on that face, such as a circle's centre, places the hole
exactly at that point, as **Multiple** placement picks it; **Select Other** (press and hold) also exactly at that point, as **Multiple** placement picks it; **Select Other** (press and hold) also
offers the face where you clicked. The hole keeps that place on the face; to have holes follow offers the face where you clicked. The corners of text outlines don't count, nor do projected
later edits of the sketch, place them with **Multiple**. points while the sketch hides projected geometry. The hole keeps that place on the face; to have
holes follow later edits of the sketch, place them with **Multiple**.
Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of
neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling