diff --git a/ROADMAP.md b/ROADMAP.md index ab001dbd..ad930f54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f5d09ced..df8a8319 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 = 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" { diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 14d2aaae..012c38ba 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -326,7 +326,25 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result, String> { /// Only consume a complete, unambiguous selection; command validation owns geometry. pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option { use solvecraft_engine::Sel::{SketchCurve, SketchPoint}; + let selected_ids = |curves_only: bool, minimum: usize| -> Option> { + 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::>()?; + 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]) => { diff --git a/crates/ui-egui/tests/constraint_preselection.rs b/crates/ui-egui/tests/constraint_preselection.rs index 6787aee7..891240e5 100644 --- a/crates/ui-egui/tests/constraint_preselection.rs +++ b/crates/ui-egui/tests/constraint_preselection.rs @@ -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); +}