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

+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
//! 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.
//! 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 solidcraft_engine::Sel;
use solidcraft_engine::doc::FeatureKind;
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::sketch::CurveKind;
use solidcraft_engine::geom::{Aabb3, Vec2, Vec3};
use solidcraft_engine::sketch::{CurveKind, LinkKind, SPoint};
use crate::dialogs::Kind;
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!(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");
}
}