Merge pull request #180 from 0xlillith/fix/sketch-point-constraint-preselection

fix: apply Coincident, Midpoint and point Symmetry to preselection
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-11 15:32:20 -04:00
commit da56d180bf
4 files changed
+136 -1

No files matched your search

+7
View File
@@ -547,6 +547,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);
}
}