Apply remaining sketch constraints to complete preselection

This commit is contained in:
lillith authored and bherbruck committed 2026-10-12 08:48:05 -04:00
1 parent ed102b5393
commit db0a2a007a
4 files changed
+124 -16

No files matched your search

+3
View File
@@ -67,6 +67,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements.
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
consumes complete point/curve selections consistently; mixed or ambiguous selections
retain the picking workflow. Rejected constraints preserve selection and undo history.
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
useful reproduction context. The draft is editable, with optional capture of the actual
-16
View File
@@ -519,22 +519,6 @@ impl SolveApp {
let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. }));
return self.start(if edges { "solid.fillet" } else { "solid.extrude" });
}
// Fix/Unfix: the selected sketch points and curves change at once, otherwise pick them.
if id == "sketch.constraint.fix" {
let ents: Vec<String> = self
.session
.selection
.iter()
.filter_map(|s| match s {
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
_ => None,
})
.collect();
if !ents.is_empty() {
let _ = self.run(id, json!({ "entities": ents }));
return;
}
}
// Construction toggle: selected sketch curves switch at once, otherwise pick them.
// Construction (X): convert the selection, or switch construction mode; no pick tool.
if id == "sketch.construction" {
+18
View File
@@ -326,7 +326,25 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
/// 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};
let selected_ids = |curves_only: bool, minimum: usize| -> Option<Vec<&str>> {
if !(minimum..=200).contains(&selection.len()) {
return None;
}
let ids: Vec<&str> = selection
.iter()
.map(|s| match s {
SketchCurve { id } => Some(id.as_str()),
SketchPoint { id } if !curves_only => Some(id.as_str()),
_ => None,
})
.collect::<Option<_>>()?;
let unique: std::collections::BTreeSet<_> = ids.iter().copied().collect();
(unique.len() == ids.len()).then_some(ids)
};
match (command, selection) {
("sketch.constraint.fix", _) => selected_ids(false, 1).map(|ids| json!({"entities":ids})),
("sketch.constraint.polygon", _) => selected_ids(true, 3).map(|ids| json!({"lines":ids})),
("sketch.constraint.curvature", [SketchCurve { id: a }, SketchCurve { id: b }]) if a != b => Some(json!({"a":a,"b":b})),
("sketch.constraint.horizontal_vertical", [SketchCurve { id }]) => Some(json!({"line":id})),
("sketch.constraint.horizontal_vertical", [SketchPoint { id: a }, SketchPoint { id: b }]) if a != b => Some(json!({"points":[a,b]})),
("sketch.constraint.coincident", [a, b]) => {
@@ -146,3 +146,106 @@ fn rejected_preselection_preserves_document_and_error() {
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
assert!(h.app.session.selection.is_empty());
}
#[test]
fn curvature_polygon_and_fix_consume_clicked_operands_atomically() {
for command in ["fix", "curvature", "polygon"] {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let picks = match command {
"curvature" => {
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
h.app.run("sketch.spline.control_point", json!({"points":["l1.end",[40,12],[50,16],[60,25]]})).unwrap();
vec![[20., 10.], [45., 14.875]]
}
"polygon" => {
h.app.run("sketch.line", json!({"points":[[10,10],[40,10],[40,30],[10,30]],"closed":true,"infer":false})).unwrap();
vec![[25., 10.], [40., 20.], [25., 30.], [10., 20.]]
}
_ => {
h.app.run("sketch.line", json!({"points":[[10,10],[40,10]],"infer":false})).unwrap();
h.app.run("sketch.point", json!({"point":[20,25],"id":"p"})).unwrap();
vec![[25., 10.], [20., 25.]]
}
};
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 80.0;
h.app.cam.target = solvecraft_engine::geom::Vec3::new(30.0, 15.0, 0.0);
h.settle();
for (i, p) in picks.iter().enumerate() {
h.step(&json!({"click":{"sketch":p},"shift":i>0})).unwrap();
}
assert_eq!(h.app.session.selection.len(), picks.len(), "{command}: {:?}", h.app.session.selection);
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 st = h.app.session.world_state();
let sk = &st.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
assert!(sk.constraints.iter().any(|c| match command {
"fix" => matches!(c.kind, ConstraintKind::Fix { .. }),
"curvature" => matches!(c.kind, ConstraintKind::Smooth { .. }),
_ => matches!(c.kind, ConstraintKind::Equal { .. }),
}));
match command {
"fix" => {
assert!(sk.curve_locked(sk.curve_index("l1").unwrap()));
assert!(sk.point_locked(sk.resolve_point("p").unwrap()));
}
"curvature" => {
let spline = sk.curves.iter().find(|c| c.id != "l1").unwrap();
let pts: Vec<_> = spline.kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
let line = sk.polyline(sk.curve_index("l1").unwrap());
let direction = (line[1] - line[0]).normalized().unwrap();
assert!((pts[1] - pts[0]).cross(direction).abs() < 1e-5);
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3);
}
_ => {
let lengths: Vec<_> = ["l1", "l2", "l3", "l4"]
.iter()
.map(|id| {
let pts = sk.polyline(sk.curve_index(id).unwrap());
pts[0].dist(pts[1])
})
.collect();
assert!(lengths[0] > 1.0);
assert!(lengths.iter().all(|l| (l - lengths[0]).abs() < 1e-6));
}
}
assert!(st.sketch(h.app.session.active_sketch.unwrap()).unwrap().report.ok());
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 remaining_constraints_preserve_mixed_selections_and_rejected_geometry() {
for command in ["fix", "curvature", "polygon"] {
let mut h = lines();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let mixed = vec![Sel::SketchCurve { id: "l1".into() }, Sel::Body { name: "missing".into() }];
h.app.session.selection = mixed.clone();
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
assert!(h.app.tool.is_some());
assert_eq!(h.app.session.selection, mixed);
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
}
let mut h = lines();
let selection = vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "missing".into() }];
h.app.session.selection = selection.clone();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.constraint.curvature"})).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);
}