diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e726eb8..4f328acd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -545,6 +545,13 @@ impl SolveApp { self.last_command = Some((spec.id.to_string(), spec.label.to_string())); } if let Some(mut t) = tools::Tool::for_command(id) { + if let Some(params) = tools::constraint_preselection(id, &self.session.selection) { + if self.run(id, params).is_ok() { + let _ = self.session.execute("select.clear", &json!({})); + self.set_status("Constraint 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 167c5af1..5c16b364 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -323,6 +323,41 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result, String> { Ok(result["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect()) } +/// Only consume a complete, unambiguous selection; command validation owns geometry. +pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option { + use solvecraft_engine::Sel::{SketchCurve, SketchPoint}; + match (command, selection) { + ("sketch.constraint.coincident", [a, b]) => { + let entity = |s: &solvecraft_engine::Sel| match s { + SketchPoint { id } | SketchCurve { id } => Some(id.clone()), + _ => None, + }; + let (a, b) = (entity(a)?, entity(b)?); + (a != b).then(|| json!({"a":a,"b":b})) + } + ( + "sketch.constraint.midpoint", + [SketchPoint { id: point }, SketchCurve { id: line }] | [SketchCurve { id: line }, SketchPoint { id: point }], + ) => Some(json!({"point":point,"line":line})), + ("sketch.constraint.symmetry", [a, b, c]) => { + let mut points = Vec::new(); + let mut lines = Vec::new(); + for entity in [a, b, c] { + match entity { + SketchPoint { id } => points.push(id), + SketchCurve { id } => lines.push(id), + _ => return None, + } + } + match (points.as_slice(), lines.as_slice()) { + ([a, b], [line]) if a != b => Some(json!({"a":a,"b":b,"line":line})), + _ => None, + } + } + _ => None, + } +} + fn run_pick(app: &mut SolveApp, t: &Tool) { let k = &t.picks; let params = match t.cmd { diff --git a/crates/ui-egui/tests/point_constraint_preselection.rs b/crates/ui-egui/tests/point_constraint_preselection.rs new file mode 100644 index 00000000..b2b4ed8d --- /dev/null +++ b/crates/ui-egui/tests/point_constraint_preselection.rs @@ -0,0 +1,88 @@ +use serde_json::json; +use solvecraft_engine::{Sel, sketch::ConstraintKind}; +use solvecraft_ui_egui::scenario::Harness; + +fn setup() -> Harness { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false,"connect":false})).unwrap(); + h.app.run("sketch.point", json!({"point":[20,20],"id":"a"})).unwrap(); + h.app.run("sketch.point", json!({"point":[25,30],"id":"b"})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h +} + +#[test] +fn clicked_point_constraint_preselection_applies_and_undoes_once() { + for (command, picks) in [ + ("coincident", vec![[20, 20], [25, 30]]), + ("coincident", vec![[20, 20], [20, 10]]), + ("coincident", vec![[20, 10], [20, 20]]), + ("midpoint", vec![[20, 20], [20, 10]]), + ("midpoint", vec![[20, 10], [20, 20]]), + ("symmetry", vec![[20, 10], [20, 20], [25, 30]]), + ("symmetry", vec![[20, 20], [20, 10], [25, 30]]), + ] { + let mut h = setup(); + for (index, pick) in picks.iter().enumerate() { + h.step(&json!({"click":{"sketch":pick},"shift":index>0})).unwrap(); + } + assert_eq!(h.app.session.selection.len(), picks.len(), "{command}"); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}: {:?}", h.app.status); + assert!(h.app.tool.is_none()); + assert!(h.app.session.selection.is_empty()); + 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!(sk.constraints.iter().any(|c| match command { + "coincident" => matches!(c.kind, ConstraintKind::Coincident { .. } | ConstraintKind::PointOnCurve { .. }), + "midpoint" => matches!(c.kind, ConstraintKind::Midpoint { .. }), + _ => matches!(c.kind, ConstraintKind::Symmetric { .. }), + })); + let point = |id| sk.point(sk.resolve_point(id).unwrap()).unwrap(); + let a = point("a"); + let b = point("b"); + let start = point("l1.start"); + let end = point("l1.end"); + let axis = (end - start).normalized().unwrap(); + match command { + "midpoint" => assert!(a.dist((start + end) * 0.5) < 1e-6), + "symmetry" => { + assert!(((a + b) * 0.5 - start).cross(axis).abs() < 1e-6); + assert!((b - a).dot(axis).abs() < 1e-6); + } + _ if picks.contains(&[20, 10]) => assert!((a - start).cross(axis).abs() < 1e-6), + _ => assert!(a.dist(b) < 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 rejected_and_ambiguous_preselection_preserve_document_and_selection() { + let mut h = setup(); + h.app.run("sketch.constraint.fix", json!({"entities":["a","b"]})).unwrap(); + h.app.session.selection = vec![Sel::SketchPoint { id: "a".into() }, Sel::SketchPoint { id: "b".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.constraint.coincident"})).unwrap(); + assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + assert_eq!(h.app.session.selection, selection); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + for command in ["coincident", "midpoint", "symmetry"] { + h.app.session.selection = vec![Sel::SketchPoint { id: "a".into() }]; + h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap(); + assert!(h.app.tool.as_ref().unwrap().picks.is_empty()); + assert_eq!(h.app.session.selection.len(), 1); + assert_eq!(h.app.session.doc.to_json(), before); + } +} diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 096a3bfb..8d6f6d24 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -94,7 +94,12 @@ aligned, horizontal, vertical, radius, diameter and angle dimensions. Each is a For Parallel, Perpendicular, Equal, Tangent, Concentric and Collinear, select exactly two sketch curves (Shift-click to add the second) and choose the constraint to apply it immediately. You can also start the constraint first and then pick its curves. An incomplete or mixed -selection starts that pick workflow; a rejected constraint keeps the selection for correction. +selection leaves the tool ready for ordinary picking. + +Coincident accepts an exact selection of two points or a point and a curve. Midpoint +accepts one point and one line, in either selection order. Symmetry accepts two distinct +points and one symmetry line, regardless of where the line occurs in the selection. +Successful constraints clear the selection; rejected constraints preserve it. Under-constrained geometry is blue and fully constrained geometry turns dark, and the status shows the remaining degrees of freedom. A constraint or dimension that would over-constrain the