fix: apply point constraints to sketch preselection

This commit is contained in:
lillith committed 2026-10-11 20:52:48 +02:00
1 parent 9d7be3c292
commit cc2ee6a0f9
4 files changed
+136 -1

No files matched your search

+7
View File
@@ -545,6 +545,13 @@ 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 let Some(params) = tools::constraint_preselection(id, &self.session.selection) {
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!(
+35
View File
@@ -323,6 +323,41 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
Ok(result["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect())
}
/// Only consume a complete, unambiguous selection; command validation owns geometry.
pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> {
use solvecraft_engine::Sel::{SketchCurve, SketchPoint};
match (command, selection) {
("sketch.constraint.coincident", [a, b]) => {
let entity = |s: &solvecraft_engine::Sel| match s {
SketchPoint { id } | SketchCurve { id } => Some(id.clone()),
_ => None,
};
let (a, b) = (entity(a)?, entity(b)?);
(a != b).then(|| json!({"a":a,"b":b}))
}
(
"sketch.constraint.midpoint",
[SketchPoint { id: point }, SketchCurve { id: line }] | [SketchCurve { id: line }, SketchPoint { id: point }],
) => Some(json!({"point":point,"line":line})),
("sketch.constraint.symmetry", [a, b, c]) => {
let mut points = Vec::new();
let mut lines = Vec::new();
for entity in [a, b, c] {
match entity {
SketchPoint { id } => points.push(id),
SketchCurve { id } => lines.push(id),
_ => return None,
}
}
match (points.as_slice(), lines.as_slice()) {
([a, b], [line]) if a != b => Some(json!({"a":a,"b":b,"line":line})),
_ => None,
}
}
_ => None,
}
}
fn run_pick(app: &mut SolveApp, t: &Tool) {
let k = &t.picks;
let params = match t.cmd {
@@ -0,0 +1,88 @@
use serde_json::json;
use solvecraft_engine::{Sel, sketch::ConstraintKind};
use solvecraft_ui_egui::scenario::Harness;
fn setup() -> Harness {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false,"connect":false})).unwrap();
h.app.run("sketch.point", json!({"point":[20,20],"id":"a"})).unwrap();
h.app.run("sketch.point", json!({"point":[25,30],"id":"b"})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h
}
#[test]
fn clicked_point_constraint_preselection_applies_and_undoes_once() {
for (command, picks) in [
("coincident", vec![[20, 20], [25, 30]]),
("coincident", vec![[20, 20], [20, 10]]),
("coincident", vec![[20, 10], [20, 20]]),
("midpoint", vec![[20, 20], [20, 10]]),
("midpoint", vec![[20, 10], [20, 20]]),
("symmetry", vec![[20, 10], [20, 20], [25, 30]]),
("symmetry", vec![[20, 20], [20, 10], [25, 30]]),
] {
let mut h = setup();
for (index, pick) in picks.iter().enumerate() {
h.step(&json!({"click":{"sketch":pick},"shift":index>0})).unwrap();
}
assert_eq!(h.app.session.selection.len(), picks.len(), "{command}");
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}: {:?}", h.app.status);
assert!(h.app.tool.is_none());
assert!(h.app.session.selection.is_empty());
assert_eq!(h.app.session.undo.len(), undo + 1);
let state = h.app.session.world_state();
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
assert!(sk.constraints.iter().any(|c| match command {
"coincident" => matches!(c.kind, ConstraintKind::Coincident { .. } | ConstraintKind::PointOnCurve { .. }),
"midpoint" => matches!(c.kind, ConstraintKind::Midpoint { .. }),
_ => matches!(c.kind, ConstraintKind::Symmetric { .. }),
}));
let point = |id| sk.point(sk.resolve_point(id).unwrap()).unwrap();
let a = point("a");
let b = point("b");
let start = point("l1.start");
let end = point("l1.end");
let axis = (end - start).normalized().unwrap();
match command {
"midpoint" => assert!(a.dist((start + end) * 0.5) < 1e-6),
"symmetry" => {
assert!(((a + b) * 0.5 - start).cross(axis).abs() < 1e-6);
assert!((b - a).dot(axis).abs() < 1e-6);
}
_ if picks.contains(&[20, 10]) => assert!((a - start).cross(axis).abs() < 1e-6),
_ => assert!(a.dist(b) < 1e-6),
}
let after = h.app.session.doc.to_json();
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
h.app.run("edit.redo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), after);
}
}
#[test]
fn rejected_and_ambiguous_preselection_preserve_document_and_selection() {
let mut h = setup();
h.app.run("sketch.constraint.fix", json!({"entities":["a","b"]})).unwrap();
h.app.session.selection = vec![Sel::SketchPoint { id: "a".into() }, Sel::SketchPoint { id: "b".into() }];
let selection = h.app.session.selection.clone();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.constraint.coincident"})).unwrap();
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
assert_eq!(h.app.session.selection, selection);
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
for command in ["coincident", "midpoint", "symmetry"] {
h.app.session.selection = vec![Sel::SketchPoint { id: "a".into() }];
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
assert!(h.app.tool.as_ref().unwrap().picks.is_empty());
assert_eq!(h.app.session.selection.len(), 1);
assert_eq!(h.app.session.doc.to_json(), before);
}
}
+6 -1
View File
@@ -94,7 +94,12 @@ aligned, horizontal, vertical, radius, diameter and angle dimensions. Each is a
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.
selection leaves the tool ready for ordinary picking.
Coincident accepts an exact selection of two points or a point and a curve. Midpoint
accepts one point and one line, in either selection order. Symmetry accepts two distinct
points and one symmetry line, regardless of where the line occurs in the selection.
Successful constraints clear the selection; rejected constraints preserve it.
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