Snap 3D bridge lines to points on other sketch planes (#229)

* Snap 3D bridge drawing to points on visible sketch planes

* Keep cross-sketch point picks out of sketch tools

`pick()` returned every visible sketch's points while 3D drawing was on,
so `sketch_point_at` and the sketch tools could take another sketch's
point as an active-sketch local point plus id (flattening it or making a
bad coincident reference). `pick()` is local again; the bridge snap uses
`pick_3d` explicitly and hover mirrors it for feedback. `sketch_point_at`
also ignores foreign points defensively.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(sketch3d): check local point scope with a sketch tool active

Main's Project preselection fix now offers other sketches' points to idle
selection, so assert the local scope where it still applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 11:25:39 -04:00
1 parent cb8aa38cc9
commit e6e0a7ebb5
5 files changed
+163 -6

No files matched your search

+12 -3
View File
@@ -14,7 +14,7 @@ use solvecraft_engine::geom::{Vec2, Vec3};
use crate::SolveApp;
use crate::theme::Tokens;
use crate::tools::Tool;
use crate::viewport::{Hit, Proj, pick};
use crate::viewport::{Hit, Proj, pick_3d};
thread_local! {
/// Points of the curve being drawn in 3D (with the tool they belong to).
@@ -40,6 +40,11 @@ fn takes(cmd: &str) -> bool {
matches!(cmd, "sketch.line" | "sketch.spline.fit_point" | "sketch.point")
}
/// Only 3D point-placing tools broaden sketch-point picking; ordinary selection stays local.
pub fn drawing(app: &SolveApp) -> bool {
active(app) && app.tool.as_ref().is_some_and(|tool| takes(tool.cmd))
}
/// Points drawn in 3D: (wire id, fit index, point).
fn drawn_points(app: &SolveApp) -> Vec<(String, usize, Vec3)> {
let st = app.session.world_state();
@@ -69,9 +74,13 @@ pub fn point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec3, &'stati
let st = app.session.world_state();
let ss = app.session.active_sketch.and_then(|s| st.sketch(s))?;
let (o, d) = proj.ray(pos);
for h in pick(app, proj, pos) {
for h in pick_3d(app, proj, pos) {
match h {
Hit::SketchPoint { at, .. } => return Some((ss.plane.to_world(at), "Pt")),
Hit::SketchPoint { sketch, at, .. } => {
if let Some(source) = st.sketch(sketch) {
return Some((source.plane.to_world(at), "Pt"));
}
}
Hit::Vertex { point, .. } => return Some((point, "Vertex")),
Hit::Edge { body, index, .. } => {
let ws = app.session.world_state();
@@ -3,6 +3,128 @@
use serde_json::json;
use solvecraft_engine::geom::Vec3;
#[test]
fn bridge_line_snaps_to_points_on_two_visible_sketch_planes() {
let mut h = crate::scenario::Harness::new();
for (name, offset, point) in [("Lower", 10, [10, 20]), ("Upper", 40, [30, 35])] {
h.app.run("sketch.create", json!({"plane":{"origin":[0,0,offset],"x_dir":[1,0,0],"y_dir":[0,1,0]},"name":name})).unwrap();
h.app.run("sketch.point", json!({"point":point})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
}
let bridge = h.app.run("sketch.create", json!({"plane":"XY","name":"Bridge"})).unwrap()["sketch"].as_u64().unwrap();
h.app.run("sketch.options", json!({"sketch_3d":true})).unwrap();
h.app.ui.show_origin = false;
h.call("ui.view", json!({"view":"home"}));
h.call("ui.view", json!({"view":"fit"}));
h.frames(3);
h.app.start("sketch.line");
h.frames(3);
// Idle selection offers other sketches' points for Project preselection; a sketch tool's
// ordinary pick stays local, and only the 3D bridge snap (`pick_3d`) reaches across.
let [x, y] = h.at(&json!({"world":[10,20,10]})).unwrap();
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
assert!(
crate::viewport::pick(&h.app, &proj, egui::pos2(x as f32, y as f32))
.iter()
.all(|hit| { !matches!(hit, crate::viewport::Hit::SketchPoint { sketch, .. } if *sketch != bridge) }),
"a sketch tool's ordinary pick must keep its local sketch-point scope"
);
for p in [[10, 20, 10], [30, 35, 40]] {
let [x, y] = h.at(&json!({"world":p})).unwrap();
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
let picked = crate::sketch3d::point_at(&h.app, &proj, egui::pos2(x as f32, y as f32)).unwrap().0;
assert!(picked.dist(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)) < 1e-6, "{picked:?}");
h.step(&json!({"click":{"world":p}})).unwrap();
}
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
let state = h.app.session.world_state();
let wire = state.sketch(bridge).unwrap().sketch.wires.first().unwrap();
assert!(wire.fit[0].dist(Vec3::new(10.0, 20.0, 10.0)) < 1e-6);
assert!(wire.fit[1].dist(Vec3::new(30.0, 35.0, 40.0)) < 1e-6);
h.app.run("edit.undo", json!({})).unwrap();
assert!(h.app.session.world_state().sketch(bridge).unwrap().sketch.wires.is_empty());
h.app.run("edit.redo", json!({})).unwrap();
assert_eq!(h.app.session.world_state().sketch(bridge).unwrap().sketch.wires[0].fit, wire.fit);
}
/// Another sketch's point and an active sketch on the same XY plane, so both lie under one cursor.
fn coplanar_foreign_point() -> (crate::scenario::Harness, u64, u64) {
let mut h = crate::scenario::Harness::new();
let other = h.app.run("sketch.create", json!({"plane":"XY","name":"Other"})).unwrap()["sketch"].as_u64().unwrap();
h.app.run("sketch.point", json!({"point":[10,20]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let active = h.app.run("sketch.create", json!({"plane":"XY","name":"Active"})).unwrap()["sketch"].as_u64().unwrap();
h.app.ui.show_origin = false;
h.call("ui.view", json!({"view":"home"}));
h.call("ui.view", json!({"view":"fit"}));
h.frames(3);
(h, other, active)
}
#[test]
fn sketch_tools_never_pick_another_sketchs_point() {
// Both in plain 2D sketching and while 3D drawing is on (the bridge snap's mode), the
// sketch-tool pick path must not return a foreign point as a local point plus id.
for sketch_3d in [false, true] {
let (mut h, other, active) = coplanar_foreign_point();
h.app.run("sketch.options", json!({"sketch_3d":sketch_3d})).unwrap();
h.app.start("sketch.line");
h.frames(3);
assert_eq!(crate::sketch3d::drawing(&h.app), sketch_3d);
let [x, y] = h.at(&json!({"world":[10,20,0]})).unwrap();
let pos = egui::pos2(x as f32, y as f32);
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
let hits = crate::viewport::pick(&h.app, &proj, pos);
assert!(
!hits.iter().any(|hit| matches!(hit, crate::viewport::Hit::SketchPoint { sketch, .. } if *sketch == other)),
"sketch_3d={sketch_3d}: pick leaked a point of sketch {other}: {hits:?}"
);
let (at, id) = crate::viewport::sketch_point_at(&h.app, &proj, pos).unwrap();
assert!(id.is_none(), "sketch_3d={sketch_3d}: snapped to foreign point id {id:?}");
assert!(at.dist(solvecraft_engine::geom::Vec2::new(10.0, 20.0)) < 0.5, "{at:?}");
if !sketch_3d {
let before = h.app.session.world_state().sketch(other).unwrap().sketch.clone();
// A real 2D line click there creates nothing tied to the other sketch's point.
h.step(&json!({"click":{"world":[10,20,0]}})).unwrap();
h.step(&json!({"click":{"world":[40,5,0]}})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
let state = h.app.session.world_state();
let own = &state.sketch(active).unwrap().sketch;
assert!(!own.curves.is_empty(), "line was drawn");
assert_eq!(state.sketch(other).unwrap().sketch, before, "other sketch untouched");
}
}
}
#[test]
fn bridge_snap_still_sees_other_sketch_points() {
let (mut h, other, _) = coplanar_foreign_point();
h.app.run("sketch.options", json!({"sketch_3d":true})).unwrap();
h.app.start("sketch.line");
h.frames(3);
let [x, y] = h.at(&json!({"world":[10,20,0]})).unwrap();
let pos = egui::pos2(x as f32, y as f32);
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
assert!(
crate::viewport::pick_3d(&h.app, &proj, pos)
.iter()
.any(|hit| matches!(hit, crate::viewport::Hit::SketchPoint { sketch, .. } if *sketch == other)),
"the 3D bridge pick offers the other sketch's point"
);
let (snapped, kind) = crate::sketch3d::point_at(&h.app, &proj, pos).unwrap();
assert_eq!(kind, "Pt");
assert!(snapped.dist(Vec3::new(10.0, 20.0, 0.0)) < 1e-6, "{snapped:?}");
// Hover highlights the snap target the click will use.
h.step(&json!({"move":{"world":[10,20,0]}})).unwrap();
h.frames(2);
assert!(
matches!(&h.app.viewport.hover, Some(crate::viewport::Hit::SketchPoint { sketch, .. }) if *sketch == other),
"{:?}",
h.app.viewport.hover
);
}
#[test]
fn second_occurrence_drawn_wire_fit_picks_and_world_triad_drag_stay_aligned() {
let mut h = crate::scenario::Harness::new();
+20 -3
View File
@@ -682,6 +682,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
app.session.revision.hash(&mut h);
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
app.session.active_sketch.hash(&mut h);
crate::sketch3d::drawing(app).hash(&mut h);
app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h);
app.session.visibility.hidden_bodies.hash(&mut h);
app.session.visibility.hidden_occurrences.hash(&mut h);
@@ -694,21 +695,37 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
{
return hits.clone();
}
let hits = pick(app, proj, pos);
// Hover feedback mirrors what a 3D bridge click will snap to; clicks still go through `pick`.
let hits = pick_with_sketch_points(app, proj, pos, crate::sketch3d::drawing(app));
app.viewport.last_pick = Some((key, hits.clone()));
hits
}
/// What is under `pos`. Sketch points come from the active sketch only (from every shown sketch
/// while a dialog input takes points, while idle, or while projecting), so sketch tools never get
/// another sketch's local point and id. The 3D bridge snap asks for cross-sketch points explicitly with [`pick_3d`].
pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
pick_with_sketch_points(app, proj, pos, false)
}
/// Like [`pick`], but also returns points of every visible sketch: only for the 3D drawing snap,
/// which converts them to world space through their own sketch's plane.
pub fn pick_3d(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
pick_with_sketch_points(app, proj, pos, true)
}
fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec<Hit> {
let s = &app.session;
let st = s.world_state();
let bounds = body_bounds(app, &st);
let bound = |name: &str| bounds.iter().find(|(n, _)| n == name).map(|(_, b)| *b);
let mut hits = Vec::new();
let (o, d) = proj.ray(pos);
// Sketch points of the active sketch (of every shown sketch while a command takes points).
// Sketch points of the active sketch (of every shown sketch while a command takes points,
// while idle or projecting, and while 3D drawing snaps to points on other visible sketches).
let wants_points = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::POINTS != 0);
let external_points = wants_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
let external_points =
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 mut best: Option<(f32, Hit)> = None;
for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) {