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

+5
View File
@@ -314,6 +314,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## 3D sketch bridges
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
construction-plane selection of 3D wires and loft guide rails remain separate work.
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
+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)) {
+4
View File
@@ -81,6 +81,10 @@ it smoothly. Right-click finishes an ordinary natural fit. Offset accepts both e
handles and dense fitted curves; the second click sets the side and distance from the curve.
Enable **3D Sketch** in the Sketch Palette to draw lines, fit splines and points off the plane.
While placing these points, you can snap to points on other visible sketches. For a bridge
line, start Line and click an endpoint on each source sketch; each endpoint uses its source
plane, even when that plane differs from the active sketch. These snaps capture positions;
they do not create associative links to the source points.
New 3D geometry follows its sketch's component placement, including the selected occurrence
and pending position changes. Click a drawn fit point to show its move triad; its arrows move
along world X, Y and Z. Scripts also supply world coordinates to `sketch.line3d`,