From 09bf9cafdfd0ab0709445bb81d943e0cc98dcdb8 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:51:33 +0200 Subject: [PATCH] fix: apply horizontal and vertical constraints to preselection --- crates/ui-egui/src/lib.rs | 15 ++++ crates/ui-egui/tests/point_alignment.rs | 91 +++++++++++++++++++++++++ docs/book/src/guide/sketching.md | 3 + 3 files changed, 109 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e726eb8..9441e80a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -545,6 +545,21 @@ 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 id == "sketch.constraint.horizontal_vertical" { + use solvecraft_engine::Sel; + let params = match self.session.selection.as_slice() { + [Sel::SketchCurve { id }] => Some(json!({ "line": id })), + [Sel::SketchPoint { id: a }, Sel::SketchPoint { id: b }] if a != b => Some(json!({ "points": [a, b] })), + _ => None, + }; + if let Some(params) = params { + 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/tests/point_alignment.rs b/crates/ui-egui/tests/point_alignment.rs index 45c6e126..034dcc59 100644 --- a/crates/ui-egui/tests/point_alignment.rs +++ b/crates/ui-egui/tests/point_alignment.rs @@ -4,6 +4,97 @@ use serde_json::json; use solvecraft_engine::sketch::ConstraintKind; use solvecraft_ui_egui::scenario::Harness; +#[test] +fn horizontal_vertical_uses_preselected_line_and_preserves_rejection() { + for fixed in [false, true] { + 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,13]],"infer":false}}}, + {"call":"ui.view","params":{"view":"top"}} + ])) + .unwrap(); + if fixed { + h.app.session.execute("sketch.constraint.fix", &json!({"entity":"l1"})).unwrap(); + } + h.run(&json!([{"click":{"sketch":[25,11.5]}}])).unwrap(); + assert_eq!(h.app.session.selection.len(), 1); + let selection = h.app.session.selection.clone(); + let before = serde_json::to_value(&h.app.session.doc).unwrap(); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":"sketch.constraint.horizontal_vertical"}])).unwrap(); + assert_eq!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error), fixed); + if fixed { + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before); + assert_eq!(h.app.session.selection, selection); + assert_eq!(h.app.session.undo.len(), undo); + } else { + let sk = &h.app.session.model.state().sketches[0].sketch; + let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap(); + let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap(); + assert!((a.y - b.y).abs() < 1e-6); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.session.selection.is_empty()); + } + assert!(h.app.tool.is_none()); + } +} + +#[test] +fn horizontal_vertical_leaves_ambiguous_preselection_to_pick_tool() { + use solvecraft_engine::Sel; + for selection in [ + vec![Sel::SketchPoint { id: "origin".into() }], + vec![Sel::SketchPoint { id: "origin".into() }, Sel::SketchCurve { id: "c1".into() }], + vec![Sel::SketchPoint { id: "origin".into() }, Sel::SketchPoint { id: "c1.center".into() }, Sel::SketchCurve { id: "c1".into() }], + ] { + let mut h = Harness::new(); + h.app.session.execute("sketch.create", &json!({"plane":"XY"})).unwrap(); + h.app.session.execute("sketch.circle.center", &json!({"center":[20,3],"radius":5})).unwrap(); + h.app.session.selection = selection.clone(); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":"sketch.constraint.horizontal_vertical"}])).unwrap(); + assert!(h.app.tool.as_ref().unwrap().picks.is_empty()); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.selection, selection); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + } +} + +#[test] +fn horizontal_vertical_uses_preselected_points() { + for center in [[20.0, 3.0], [3.0, 20.0]] { + for reverse in [false, true] { + let mut h = Harness::new(); + let (first, second) = if reverse { (center, [0.0, 0.0]) } else { ([0.0, 0.0], center) }; + h.run(&json!([ + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":center,"radius":5}}}, + {"call":"ui.view","params":{"view":"top"}}, + {"click":{"sketch":first}}, + {"click":{"sketch":second},"shift":true} + ])) + .unwrap(); + assert_eq!(h.app.session.selection.len(), 2); + let before = serde_json::to_value(&h.app.session.doc).unwrap(); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":"sketch.constraint.horizontal_vertical"}])).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + let sk = &h.app.session.model.state().sketches[0].sketch; + let p = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap(); + assert!((if center[0] > center[1] { p.y } else { p.x }).abs() < 1e-6); + assert!(h.app.tool.is_none()); + assert!(h.app.session.selection.is_empty()); + assert_eq!(h.app.session.undo.len(), undo + 1); + let after = serde_json::to_value(&h.app.session.doc).unwrap(); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before); + h.app.session.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), after); + } + } +} + #[test] fn horizontal_vertical_aligns_origin_and_circle_center() { for (center, horizontal) in [([20.0, 3.0], true), ([3.0, 20.0], false)] { diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 096a3bfb..009f15ce 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -91,6 +91,9 @@ 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. +Horizontal/Vertical also applies immediately to one selected line or two distinct selected +points. Shift-click to add the second point before choosing the constraint. + 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