From e6e0a7ebb51d533b38248af3cf7a2fb6b9bfea3d Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:25:39 +0200 Subject: [PATCH] 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 * 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 * style: rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: bherbruck Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 5 + crates/ui-egui/src/sketch3d.rs | 15 ++- crates/ui-egui/src/sketch3d_frame_ui_tests.rs | 122 ++++++++++++++++++ crates/ui-egui/src/viewport.rs | 23 +++- docs/book/src/guide/sketching.md | 4 + 5 files changed, 163 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f9518ddd..553f95e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/ui-egui/src/sketch3d.rs b/crates/ui-egui/src/sketch3d.rs index 11496880..f2014fe2 100644 --- a/crates/ui-egui/src/sketch3d.rs +++ b/crates/ui-egui/src/sketch3d.rs @@ -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(); diff --git a/crates/ui-egui/src/sketch3d_frame_ui_tests.rs b/crates/ui-egui/src/sketch3d_frame_ui_tests.rs index d1a9cb10..cc314e50 100644 --- a/crates/ui-egui/src/sketch3d_frame_ui_tests.rs +++ b/crates/ui-egui/src/sketch3d_frame_ui_tests.rs @@ -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(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 2ad98017..4e050db4 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -682,6 +682,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { 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 { { 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 { + 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 { + pick_with_sketch_points(app, proj, pos, true) +} + +fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec { 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 = 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)) { diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index e215b17a..fba5b9bc 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -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`,