diff --git a/ROADMAP.md b/ROADMAP.md index 40fb20cc..be98a932 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,6 +240,10 @@ headline counts in-scope tabs only and lists the deferred ones separately. existing geometry before assigning positions, retaining saved curve IDs and constraints across enumeration changes and resize, including concentric circles. Ambiguous identities retain the last geometry and report a lost reference; permutation matching is bounded. +- Project accepts complete supported preselection in one undo step. Visible external sketch + curves and points retain their source sketch identity; model geometry, axes and planes use + the existing linked projection commands. Rejection preserves the document and selection; + mixed selections retain ordinary tool-first picking. - Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes, selecting visited values for replacement. Expression completion retains forward-Tab priority; real keyboard regressions cover rectangles, lines and single-value circles. diff --git a/crates/engine/src/cmd/delete.rs b/crates/engine/src/cmd/delete.rs index 8b35169c..ed5d8242 100644 --- a/crates/engine/src/cmd/delete.rs +++ b/crates/engine/src/cmd/delete.rs @@ -90,6 +90,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { }, Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()), Sel::Profile { .. } => skip("a profile goes with its sketch".into()), + Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => skip("edit the source sketch to delete its geometry".into()), } } features.dedup(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 894baea8..7a75cdf6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -107,6 +107,15 @@ pub enum Sel { SketchPoint { id: String, }, + /// Geometry of a different sketch, retained for projection preselection. + ExternalSketchCurve { + sketch: u64, + id: String, + }, + ExternalSketchPoint { + sketch: u64, + id: String, + }, /// A constraint or dimension (by id) of the active sketch. SketchConstraint { id: String, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0cbe7c62..63f19907 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -290,7 +290,11 @@ fn fits(a: Accept, s: &Sel) -> bool { Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0, Sel::Vertex { .. } => a & selection::VERTICES != 0, Sel::Feature { .. } => a & selection::FEATURES != 0, - Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } | Sel::Occurrence { .. } => false, + Sel::SketchPoint { .. } + | Sel::SketchConstraint { .. } + | Sel::Occurrence { .. } + | Sel::ExternalSketchCurve { .. } + | Sel::ExternalSketchPoint { .. } => false, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index df8a8319..4d44f018 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -539,6 +539,15 @@ impl SolveApp { } return; } + if id == "sketch.project" + && let Some(refs) = tools::projection_preselection(&self.session.selection) + { + if self.run(id, json!({"refs":refs})).is_ok() { + let _ = self.session.execute("select.clear", &json!({})); + self.set_status("Projection applied", false); + } + return; + } // Curve-pair constraints can consume an exact preselection. Leave ambiguous // selections to the ordinary pick tool; never silently constrain a subset. if matches!( diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 6165ef6e..5d56ea97 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -388,6 +388,30 @@ pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_eng } } +pub(crate) fn projection_preselection(selection: &[solvecraft_engine::Sel]) -> Option> { + use solvecraft_engine::Sel; + // The projection command processes at most 1000 references; never consume a subset. + if selection.is_empty() || selection.len() > 1000 { + return None; + } + selection + .iter() + .map(|item| { + Some(match item { + Sel::Body { name } => json!({"body":name}), + Sel::ExternalSketchCurve { sketch, id } => json!({"sketch":sketch,"curve":id}), + Sel::ExternalSketchPoint { sketch, id } => json!({"sketch":sketch,"point":id}), + Sel::Vertex { body, point } => json!({"body":body,"vertex":[point.x,point.y,point.z]}), + Sel::Edge { body, point, .. } => json!({"body":body,"edge":[point.x,point.y,point.z]}), + Sel::Face { body, point, .. } => json!({"body":body,"face":[point.x,point.y,point.z]}), + Sel::Axis { name } => json!({"axis":name}), + Sel::Plane { name } => json!({"plane":name}), + _ => return None, + }) + }) + .collect() +} + fn run_pick(app: &mut SolveApp, t: &Tool) { let k = &t.picks; let params = match t.cmd { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 1bfa4e48..591ad73c 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -705,13 +705,14 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { let (o, d) = proj.ray(pos); // Sketch points of the active sketch (of every shown sketch while a command takes points). let wants_points = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::POINTS != 0); - let point_sketches: Vec = if wants_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() }; + let external_points = wants_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)) { let sid = ss.feature; for p in &ss.sketch.points { if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) { - let dd = sp.distance(pos); + let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 }; if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) { best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos })); } @@ -995,6 +996,22 @@ pub fn selection_bounds(app: &SolveApp) -> Option b.add(*point), + Sel::ExternalSketchPoint { sketch, id } => { + if let Some(ss) = st.sketch(*sketch) + && let Some(p) = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.point(i)) + { + b.add(ss.plane.to_world(p)); + } + } + Sel::ExternalSketchCurve { sketch, id } => { + if let Some(ss) = st.sketch(*sketch) + && let Some(i) = ss.sketch.curve_index(id) + { + for p in ss.sketch.polyline(i) { + b.add(ss.plane.to_world(p)); + } + } + } Sel::SketchCurve { id } => { for ss in &st.sketches { if let Some(i) = ss.sketch.curve_index(id) { @@ -1063,7 +1080,7 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> { let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); hits.iter() .filter(|h| match h { - Hit::SketchCurve { sketch, .. } => app.session.active_sketch == Some(*sketch) && !off("sketch") && !off("sketch_curves"), + Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"), Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"), Hit::Profile { .. } => !off("sketch"), Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies, @@ -1072,6 +1089,12 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> { Hit::Plane { .. } | Hit::Axis { .. } => true, }) .find_map(|h| match h { + Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => { + Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() })) + } + Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => { + Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() })) + } Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })), _ => hit_sel(h).map(|s| (h.clone(), s)), }) @@ -1292,6 +1315,17 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } } Sel::Profile { sketch, index } => profile_fill(&mut sc, app, *sketch, *index, t.sel_profile, t.sel_profile_edge), + Sel::ExternalSketchCurve { sketch, id } => { + if let Some(ss) = st.sketch(*sketch) + && let Some(ci) = ss.sketch.curve_index(id) + { + for pts in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, &display_proj(app)).parts { + for w in pts.windows(2) { + sc.line(w[0].to_f32(), w[1].to_f32(), c4(t.sel_edge), 3.5, true); + } + } + } + } // A 3D sketch curve, in whichever sketch has it. Sel::SketchCurve { id } if st.sketches.iter().any(|ss| ss.sketch.wire_index(id).is_some()) => { for ss in st.sketches.iter() { @@ -1447,7 +1481,9 @@ pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, let st = app.session.world_state(); let ss = st.sketch(sid)?; for h in pick(app, proj, pos) { - if let Hit::SketchPoint { id, at, .. } = h { + if let Hit::SketchPoint { sketch, id, at } = h + && sketch == sid + { return Some((at, Some(id))); } } @@ -1832,7 +1868,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { { let active = app.session.active_sketch; match pick(app, &proj, a).into_iter().next() { - Some(Hit::SketchPoint { id, .. }) => app.viewport.point_drag = Some((id, app.session.undo.len(), None)), + Some(Hit::SketchPoint { sketch, id, .. }) if Some(sketch) == active => { + app.viewport.point_drag = Some((id, app.session.undo.len(), None)) + } Some(Hit::SketchCurve { sketch, id, .. }) if Some(sketch) == active => { app.viewport.point_drag = on_plane(app, a).map(|q| (id, app.session.undo.len(), Some(q))); } @@ -2239,6 +2277,15 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let t = Tokens::get(); let st = app.session.world_state(); + for item in &app.session.selection { + if let Sel::ExternalSketchPoint { sketch, id } = item + && let Some(ss) = st.sketch(*sketch) + && let Some(point) = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.point(i)) + && let Some(pos) = proj.to_screen(ss.plane.to_world(point)) + { + painter.circle_stroke(pos, 6.0, Stroke::new(2.0, t.sel_edge)); + } + } let Some(sid) = app.session.active_sketch else { return }; let Some(ss) = st.sketch(sid) else { return }; let sk = &ss.sketch; diff --git a/crates/ui-egui/tests/projection_preselection.rs b/crates/ui-egui/tests/projection_preselection.rs new file mode 100644 index 00000000..714c6d05 --- /dev/null +++ b/crates/ui-egui/tests/projection_preselection.rs @@ -0,0 +1,122 @@ +use serde_json::json; +use solvecraft_engine::Sel; +use solvecraft_ui_egui::scenario::Harness; + +fn setup() -> (Harness, u64) { + let mut h = Harness::new(); + let source = h.app.run("sketch.create", json!({"plane":"XY","offset":10})).unwrap()["sketch"].as_u64().unwrap(); + h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap(); + h.app.run("sketch.point", json!({"point":[20,20],"id":"p"})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[40,40],[50,40]],"infer":false})).unwrap(); + h.app.run("sketch.point", json!({"point":[45,50],"id":"p"})).unwrap(); + h.call("ui.set", json!({"shownSketches":[source]})); + h.call("ui.view", json!({"view":"top"})); + (h, source) +} + +#[test] +fn clicked_external_curve_and_point_project_with_source_identity_and_one_undo() { + let (mut h, source) = setup(); + h.step(&json!({"click":{"world":[20,10,10]}})).unwrap(); + h.step(&json!({"click":{"world":[20,20,10]},"shift":true})).unwrap(); + assert_eq!( + h.app.session.selection, + vec![Sel::ExternalSketchCurve { sketch: source, id: "l1".into() }, Sel::ExternalSketchPoint { sketch: source, id: "p".into() }] + ); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.step(&json!({"key":"P"})).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{:?}", h.app.status); + assert!(h.app.session.selection.is_empty()); + assert!(h.app.tool.is_none()); + assert_eq!(h.app.session.undo.len(), undo + 1); + let state = h.app.session.world_state(); + let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert_eq!(sk.curves.len(), 2); + assert!(sk.curves.iter().any(|curve| curve.link.is_some())); + assert!(sk.points.iter().any(|point| point.link.is_some() && (point.pos.x - 20.).abs() < 1e-6 && (point.pos.y - 20.).abs() < 1e-6)); + let local = sk.point(sk.resolve_point("p").unwrap()).unwrap(); + assert!((local.x - 45.).abs() < 1e-6 && (local.y - 50.).abs() < 1e-6); + let after = h.app.session.doc.to_json(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + h.app.run("edit.redo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), after); +} + +#[test] +fn bad_reference_preserves_selection_and_document_atomically() { + let (mut h, source) = setup(); + h.app.session.selection = + vec![Sel::ExternalSketchCurve { sketch: source, id: "l1".into() }, Sel::ExternalSketchPoint { sketch: source, id: "missing".into() }]; + let selection = h.app.session.selection.clone(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.step(&json!({"start":"sketch.project"})).unwrap(); + assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.selection, selection); +} + +#[test] +fn external_point_drag_and_delete_cannot_edit_same_named_local_point() { + let (mut h, source) = setup(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.step(&json!({"drag":[{"world":[20,20,10]},{"world":[25,25,10]}]})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + h.app.session.selection = + vec![Sel::ExternalSketchPoint { sketch: source, id: "p".into() }, Sel::ExternalSketchCurve { sketch: source, id: "l1".into() }]; + let error = h.app.run("selection.delete", json!({"dry_run":true})).unwrap_err(); + assert!(error.contains("nothing to delete"), "{error}"); + assert_eq!(h.app.session.doc.to_json(), before); +} + +#[test] +fn axis_and_plane_preselection_projects_as_one_command() { + let (mut h, _) = setup(); + h.app.session.selection = vec![Sel::Axis { name: "X".into() }, Sel::Plane { name: "YZ".into() }]; + let undo = h.app.session.undo.len(); + h.step(&json!({"start":"sketch.project"})).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{:?}", h.app.status); + assert_eq!(h.app.session.undo.len(), undo + 1); + let state = h.app.session.world_state(); + let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert_eq!(sk.curves.iter().filter(|c| c.link.is_some()).count(), 2); + assert!(h.app.session.selection.is_empty()); +} + +#[test] +fn mixed_local_and_external_selection_keeps_ordinary_project_picking() { + let (mut h, source) = setup(); + h.app.session.selection = vec![Sel::SketchCurve { id: "l1".into() }, Sel::ExternalSketchCurve { sketch: source, id: "l1".into() }]; + let before = h.app.session.doc.to_json(); + let selection = h.app.session.selection.clone(); + let undo = h.app.session.undo.len(); + h.step(&json!({"key":"P"})).unwrap(); + assert_eq!(h.app.tool.as_ref().map(|tool| tool.cmd), Some("sketch.project")); + assert_eq!(h.app.session.selection, selection); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + h.step(&json!({"click":{"world":[20,10,10]}})).unwrap(); + assert_eq!(h.app.session.undo.len(), undo + 1); + let state = h.app.session.world_state(); + assert!(state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.iter().any(|curve| curve.link.is_some())); +} + +#[test] +fn oversized_preselection_does_not_silently_project_a_subset() { + let (mut h, _) = setup(); + h.app.session.selection = vec![Sel::Axis { name: "X".into() }; 1001]; + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.step(&json!({"start":"sketch.project"})).unwrap(); + assert_eq!(h.app.tool.as_ref().map(|tool| tool.cmd), Some("sketch.project")); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.selection.len(), 1001); +} diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 08b0d0a1..3f83cc45 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -21,6 +21,11 @@ fillet, chamfer, offset (O), mirror, patterns, move/copy and scale. **Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that follows later changes. Sketching on a face projects its edges automatically. +For Project, select visible source curves or points from another sketch, model edges, +faces, vertices or bodies, or axes and planes, then press P. The entire supported selection +is projected in one undo step. A rejected projection keeps the selection for correction; +an empty or mixed selection starts the ordinary picking tool. External sketch selections +retain their source sketch, even when the active sketch uses the same point or curve names. Run from a script or the control channel, every command that adds sketch geometry returns the ids of what it added (`created: {points, curves, constraints, dimensions}`). Ids are stable and