Merge pull request #131 from 0xlillith/fix/sketch-constraint-preselection
fix: apply curve-pair constraints to sketch preselection
This commit is contained in:
3 files changed
+174
No files matched your search
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user