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 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 cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements. 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 - Contextual error reporting: a **Create Issue** action beside feature errors opens a
reviewable GitHub issue draft with the error, operation settings, build diagnostics and 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 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 { .. })); let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. }));
return self.start(if edges { "solid.fillet" } else { "solid.extrude" }); 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 toggle: selected sketch curves switch at once, otherwise pick them.
// Construction (X): convert the selection, or switch construction mode; no pick tool. // Construction (X): convert the selection, or switch construction mode; no pick tool.
if id == "sketch.construction" { 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. /// Only consume a complete, unambiguous selection; command validation owns geometry.
pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> { pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> {
use solvecraft_engine::Sel::{SketchCurve, SketchPoint}; 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) { 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", [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.horizontal_vertical", [SketchPoint { id: a }, SketchPoint { id: b }]) if a != b => Some(json!({"points":[a,b]})),
("sketch.constraint.coincident", [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.status.as_ref().is_some_and(|(_, _, error)| *error));
assert!(h.app.session.selection.is_empty()); 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);
}