From 2683b8dfed9d3eb6bd00640aafedf74862115b26 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:58:49 +0200 Subject: [PATCH] fix: allow horizontal and vertical alignment of sketch points --- crates/ui-egui/src/tools.rs | 29 +++++++- crates/ui-egui/tests/point_alignment.rs | 90 +++++++++++++++++++++++++ docs/book/src/guide/sketching.md | 4 ++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/point_alignment.rs diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index adbefac1..a2a68433 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -14,6 +14,8 @@ pub enum Kind { Draw(usize), /// Pick sketch entities; `n` picks run the command. Pick(usize), + /// Pick one straight line, or two distinct points to align. + HorizontalVertical, Dimension, /// A tool from `sketch_tools` (it handles its own clicks). Ext, @@ -56,7 +58,7 @@ impl Tool { "sketch.slot.overall" => ("sketch.slot.overall", Kind::Draw(3)), "sketch.point" => ("sketch.point", Kind::Draw(1)), "sketch.dimension" => ("sketch.dimension", Kind::Dimension), - "sketch.constraint.horizontal_vertical" => ("sketch.constraint.horizontal_vertical", Kind::Pick(1)), + "sketch.constraint.horizontal_vertical" => ("sketch.constraint.horizontal_vertical", Kind::HorizontalVertical), "sketch.constraint.fix" => ("sketch.constraint.fix", Kind::Pick(1)), "sketch.constraint.coincident" => ("sketch.constraint.coincident", Kind::Pick(2)), "sketch.constraint.tangent" => ("sketch.constraint.tangent", Kind::Pick(2)), @@ -93,6 +95,7 @@ pub fn hint(id: &str) -> String { "sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(), "sketch.arc.center_point" => "Arc: click centre and start, then click end or type a signed sweep".into(), "sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(), + "sketch.constraint.horizontal_vertical" => "Horizontal/Vertical: pick a line or two points to align".into(), "sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(), _ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(), _ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()), @@ -143,6 +146,29 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { return; } } + Kind::HorizontalVertical => { + let hits = pick(app, proj, pos); + let hit = hits.iter().find(|hit| match hit { + Hit::SketchPoint { sketch, .. } | Hit::SketchCurve { sketch, .. } => Some(*sketch) == app.session.active_sketch, + _ => false, + }); + match hit { + Some(Hit::SketchPoint { id, .. }) => { + if let Some(first) = tool.picks.first() { + if first != id { + let _ = app.run(tool.cmd, json!({"points": [first, id]})); + tool.picks.clear(); + } + } else { + tool.picks.push(id.clone()); + } + } + Some(Hit::SketchCurve { id, straight: true, .. }) if tool.picks.is_empty() => { + let _ = app.run(tool.cmd, json!({"line": id})); + } + _ => {} + } + } Kind::Pick(n) => { let hits = pick(app, proj, pos); if let Some(id) = hits.iter().find_map(|h| match h { @@ -292,7 +318,6 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result, String> { fn run_pick(app: &mut SolveApp, t: &Tool) { let k = &t.picks; let params = match t.cmd { - "sketch.constraint.horizontal_vertical" => json!({"line": k[0]}), "sketch.constraint.fix" => json!({"entity": k[0]}), "sketch.constraint.coincident" => json!({"a": k[0], "b": k[1]}), "sketch.constraint.midpoint" => json!({"point": k[0], "line": k[1]}), diff --git a/crates/ui-egui/tests/point_alignment.rs b/crates/ui-egui/tests/point_alignment.rs new file mode 100644 index 00000000..45c6e126 --- /dev/null +++ b/crates/ui-egui/tests/point_alignment.rs @@ -0,0 +1,90 @@ +//! Horizontal/Vertical accepts either one line or two distinct sketch points. + +use serde_json::json; +use solvecraft_engine::sketch::ConstraintKind; +use solvecraft_ui_egui::scenario::Harness; + +#[test] +fn horizontal_vertical_aligns_origin_and_circle_center() { + for (center, horizontal) in [([20.0, 3.0], true), ([3.0, 20.0], false)] { + for origin_first in [true, false] { + 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":center,"radius":5}}}, + {"call":"ui.view","params":{"view":"top"}}, + {"start":"sketch.constraint.horizontal_vertical"} + ])) + .unwrap(); + let (first, second) = if origin_first { ([0.0, 0.0], center) } else { (center, [0.0, 0.0]) }; + let undo = h.app.session.undo.len(); + h.run(&json!([{"click":{"sketch":first}}, {"expect":{"errors":0}}])).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().picks.len(), 1); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + assert_eq!(h.app.session.undo.len(), undo); + // Repeating the first point must not complete a self-constraint. + h.run(&json!([{"click":{"sketch":first}}, {"click":{"sketch":second}}, {"expect":{"errors":0}}])).unwrap(); + let aligned = |h: &Harness| { + let sk = &h.app.session.model.state().sketches[0].sketch; + let p = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap(); + if horizontal { p.y } else { p.x } + }; + assert!(aligned(&h).abs() < 1e-6); + let sk = &h.app.session.model.state().sketches[0].sketch; + assert!(sk.constraints.iter().any(|c| if horizontal { + matches!(c.kind, ConstraintKind::HorizontalPoints { .. }) + } else { + matches!(c.kind, ConstraintKind::VerticalPoints { .. }) + })); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.tool.as_ref().unwrap().picks.is_empty()); + h.run(&json!([{"call":"engine.execute","params":{"command":"edit.undo","params":{}}}])).unwrap(); + assert!((aligned(&h) - 3.0).abs() < 1e-6); + h.run(&json!([{"call":"engine.execute","params":{"command":"edit.redo","params":{}}}])).unwrap(); + assert!(aligned(&h).abs() < 1e-6); + } + } +} + +#[test] +fn horizontal_vertical_still_accepts_one_line() { + 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"}}, + {"start":"sketch.constraint.horizontal_vertical"}, + {"click":{"sketch":[25,11.5]}}, + {"expect":{"errors":0}} + ])) + .unwrap(); + let sk = &h.app.session.model.state().sketches[0].sketch; + assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Horizontal { .. }))); + 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); +} + +#[test] +fn horizontal_vertical_ignores_curves_and_cancels_a_pending_point() { + 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":[20,3],"radius":5}}}, + {"call":"ui.view","params":{"view":"top"}}, + {"start":"sketch.constraint.horizontal_vertical"}, + {"click":{"sketch":[25,3]}} + ])) + .unwrap(); + assert!(h.app.tool.as_ref().unwrap().picks.is_empty()); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + let undo = h.app.session.undo.len(); + h.run(&json!([{"click":{"sketch":[0,0]}}, {"click":{"sketch":[25,3]}}])).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().picks.len(), 1); + h.run(&json!([{"key":"Escape"}])).unwrap(); + assert!(h.app.tool.is_none()); + assert_eq!(h.app.session.undo.len(), undo); + let sk = &h.app.session.model.state().sketches[0].sketch; + let p = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap(); + assert!((p.y - 3.0).abs() < 1e-6); +} diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index de24f0d4..38b908ab 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -54,6 +54,10 @@ fresh link once the source is available; their stored point values are preserved ## Constraints and dimensions +**Horizontal/Vertical** accepts a click on a line or clicks on two distinct sketch points, +including the origin, endpoints and circle centres. For two points, it chooses horizontal +or vertical alignment from their current positions. Esc cancels a pending first point. + The solver is SolveCraft's own (damped Gauss–Newton with degree-of-freedom analysis). Constraints: coincident, horizontal/vertical, parallel, perpendicular, tangent, equal, concentric, collinear, midpoint, symmetry, fix, curvature (G2) and polygon. **Sketch Dimension** (D) adds linear,