Merge pull request #129 from 0xlillith/fix/sketch-point-alignment

fix: allow horizontal and vertical alignment of sketch points
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-11 08:16:27 -04:00
commit 180c655ded
3 files changed
+121 -2

No files matched your search

+27 -2
View File
@@ -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<Vec<String>, 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]}),
+90
View File
@@ -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);
}
+4
View File
@@ -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,