From 5cc65fe46bfbac10b1210e1416f4b8b5126b9c31 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:09:01 +0200 Subject: [PATCH 1/2] fix: apply curve-pair constraints to sketch preselection --- crates/ui-egui/src/lib.rs | 21 +++ .../ui-egui/tests/constraint_preselection.rs | 123 ++++++++++++++++++ docs/book/src/guide/sketching.md | 5 + 3 files changed, 149 insertions(+) create mode 100644 crates/ui-egui/tests/constraint_preselection.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 11577212..ff1a316c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -517,6 +517,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..215d5fb5 --- /dev/null +++ b/crates/ui-egui/tests/constraint_preselection.rs @@ -0,0 +1,123 @@ +//! 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() }]); +} + +#[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}" + ); + 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()); + } +} + +#[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 From 7c667eda536e1bbaa0d42a374955c310d0748b4b Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:24:42 +0200 Subject: [PATCH 2/2] test: verify constrained line geometry after preselection and redo --- .../ui-egui/tests/constraint_preselection.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/ui-egui/tests/constraint_preselection.rs b/crates/ui-egui/tests/constraint_preselection.rs index 215d5fb5..6787aee7 100644 --- a/crates/ui-egui/tests/constraint_preselection.rs +++ b/crates/ui-egui/tests/constraint_preselection.rs @@ -25,6 +25,29 @@ fn select_lines(h: &mut Harness) { 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"] { @@ -44,6 +67,7 @@ fn selected_lines_apply_constraints_with_one_undo_step() { }), "{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()); @@ -52,6 +76,7 @@ fn selected_lines_apply_constraints_with_one_undo_step() { 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); } }