diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aa759b47..4293e258 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -531,6 +531,27 @@ impl SolveApp { self.last_command = Some((spec.id.to_string(), spec.label.to_string())); } if let Some(mut t) = tools::Tool::for_command(id) { + // Curve-pair constraints can consume an exact preselection. Leave ambiguous + // selections to the ordinary pick tool; never silently constrain a subset. + if matches!( + id, + "sketch.constraint.parallel" + | "sketch.constraint.perpendicular" + | "sketch.constraint.equal" + | "sketch.constraint.tangent" + | "sketch.constraint.concentric" + | "sketch.constraint.collinear" + ) && let [solvecraft_engine::Sel::SketchCurve { id: a }, solvecraft_engine::Sel::SketchCurve { id: b }] = + self.session.selection.as_slice() + && a != b + { + let params = json!({ "a": a, "b": b }); + if self.run(id, params).is_ok() { + let _ = self.session.execute("select.clear", &json!({})); + self.set_status("Constraint applied", false); + } + return; + } if id == "sketch.dimension" { t.picks = self .session diff --git a/crates/ui-egui/tests/constraint_preselection.rs b/crates/ui-egui/tests/constraint_preselection.rs new file mode 100644 index 00000000..6787aee7 --- /dev/null +++ b/crates/ui-egui/tests/constraint_preselection.rs @@ -0,0 +1,148 @@ +//! Constraint tools accept a selected curve pair as well as tool-first clicks. + +use serde_json::json; +use solvecraft_engine::{Sel, sketch::ConstraintKind}; +use solvecraft_ui_egui::scenario::Harness; + +fn lines() -> Harness { + let mut h = Harness::new(); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,10],[40,10]],"infer":false}}}, + {"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,30],[40,35]],"infer":false}}}, + {"call":"ui.view","params":{"view":"top"}} + ])) + .unwrap(); + h +} + +fn select_lines(h: &mut Harness) { + h.run(&json!([ + {"click":{"sketch":[25,10]}}, + {"click":{"sketch":[25,32.5]},"shift":true} + ])) + .unwrap(); + assert_eq!(h.app.session.selection, vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }]); +} + +fn assert_line_geometry(h: &Harness, command: &str) { + let sk = &h.app.session.model.state().sketches[0].sketch; + let point = |name| sk.point(sk.resolve_point(name).unwrap()).unwrap(); + let a = point("l1.start"); + let b = point("l2.start"); + let u = point("l1.end") - a; + let v = point("l2.end") - b; + // Normalization also rejects a collapsed line; zero-length vectors must not + // accidentally satisfy the parallel/perpendicular checks. + let du = u.normalized().unwrap(); + let dv = v.normalized().unwrap(); + match command { + "parallel" => assert!(du.cross(dv).abs() < 1e-6), + "perpendicular" => assert!(du.dot(dv).abs() < 1e-6), + "equal" => assert!((u.len() - v.len()).abs() < 1e-6), + "collinear" => { + assert!(du.cross(dv).abs() < 1e-6); + assert!((b - a).cross(du).abs() < 1e-6); + } + _ => panic!("unexpected line constraint: {command}"), + } +} + +#[test] +fn selected_lines_apply_constraints_with_one_undo_step() { + for command in ["parallel", "perpendicular", "equal", "collinear"] { + let mut h = lines(); + select_lines(&mut h); + let before = h.app.session.doc.clone(); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":format!("sketch.constraint.{command}")}])).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}"); + let sk = &h.app.session.model.state().sketches[0].sketch; + assert!( + sk.constraints.iter().any(|c| match command { + "parallel" => matches!(c.kind, ConstraintKind::Parallel { .. }), + "perpendicular" => matches!(c.kind, ConstraintKind::Perpendicular { .. }), + "equal" => matches!(c.kind, ConstraintKind::Equal { .. }), + _ => matches!(c.kind, ConstraintKind::Collinear { .. }), + }), + "{command}" + ); + assert_line_geometry(&h, command); + let after = h.app.session.doc.clone(); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.session.selection.is_empty()); + assert!(h.app.tool.is_none()); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(before).unwrap()); + h.app.session.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(after).unwrap()); + assert_line_geometry(&h, command); + } +} + +#[test] +fn selected_circles_apply_constraints() { + for command in ["tangent", "concentric", "equal"] { + let mut h = Harness::new(); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[10,10],"radius":5}}}, + {"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[25,12],"radius":7}}}, + {"call":"ui.view","params":{"view":"top"}}, + {"click":{"sketch":[10,15]}}, + {"click":{"sketch":[25,19]},"shift":true} + ])) + .unwrap(); + assert_eq!(h.app.session.selection.len(), 2); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":format!("sketch.constraint.{command}")}])).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}"); + assert_eq!(h.app.session.undo.len(), undo + 1, "{command}"); + let sk = &h.app.session.model.state().sketches[0].sketch; + assert!(sk.constraints.iter().any(|c| match command { + "tangent" => matches!(c.kind, ConstraintKind::Tangent { .. }), + "concentric" => matches!(c.kind, ConstraintKind::Concentric { .. }), + _ => matches!(c.kind, ConstraintKind::Equal { .. }), + })); + assert!(h.app.session.selection.is_empty()); + } +} + +#[test] +fn ambiguous_selection_preserves_tool_first_workflow() { + for selection in [ + vec![], + vec![Sel::SketchCurve { id: "l1".into() }], + vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }, Sel::SketchPoint { id: "origin".into() }], + ] { + let mut h = lines(); + h.app.session.selection = selection; + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap(); + assert_eq!(h.app.session.undo.len(), undo); + assert!(h.app.tool.as_ref().unwrap().picks.is_empty()); + h.run(&json!([{"click":{"sketch":[25,10]}}, {"click":{"sketch":[25,32.5]}}])).unwrap(); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + } +} + +#[test] +fn rejected_preselection_preserves_document_and_error() { + let mut h = lines(); + h.app.session.execute("sketch.constraint.fix", &json!({"entities":["l1","l2"]})).unwrap(); + select_lines(&mut h); + let before = serde_json::to_value(&h.app.session.doc).unwrap(); + let undo = h.app.session.undo.len(); + let selection = h.app.session.selection.clone(); + h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap(); + assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.selection, selection); + // Correct the conflict and retry without reselecting; the old error must disappear. + h.app.session.execute("sketch.constraint.fix", &json!({"entities":["l1","l2"]})).unwrap(); + h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + assert!(h.app.session.selection.is_empty()); +} diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index de24f0d4..7bb00f1b 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -60,6 +60,11 @@ midpoint, symmetry, fix, curvature (G2) and polygon. **Sketch Dimension** (D) ad aligned, horizontal, vertical, radius, diameter and angle dimensions. Each is a parameter (`d1`, `d2`, …), so it can hold an expression. +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. + 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 sketch is refused rather than applied. Drag a point to move it; the solver keeps every