diff --git a/crates/engine/src/cmd/delete.rs b/crates/engine/src/cmd/delete.rs index d1a53cba..c42959f5 100644 --- a/crates/engine/src/cmd/delete.rs +++ b/crates/engine/src/cmd/delete.rs @@ -65,7 +65,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let mut bodies: Vec = Vec::new(); for x in &items { match x { - Sel::SketchCurve { id } | Sel::SketchPoint { id } => entities.push(id.clone()), + Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()), Sel::Feature { id } => { if s.doc.feature(*id).is_none() { return Err(bad(cmd, format!("no feature {id}"))); diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 1d68ade2..02ca94ed 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -163,6 +163,10 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params( "sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, grid, snap, slice, sketch_3d: bool. Returns them all", ), + CommandSpec::new("sketch.chain", "Chain", chain_cmd) + .enabled(in_sketch) + .noundo() + .params("curve: id: the curves joined to it end to end (what a double-click selects)"), CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd) .enabled(in_sketch) .noundo() @@ -1660,6 +1664,14 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result { Ok(json!({"options": v, "sketch": info})) } +fn chain_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.chain"; + let id = target_sketch(s, p, cmd)?; + let sk = s.doc.sketch(id)?; + let c = curve_ref(sk, p.get("curve"), cmd, "curve")?; + Ok(json!({"curves": ids_of(sk, &sk.chain(c))})) +} + fn solve_cmd(s: &mut Session, p: &Value) -> Result { let id = target_sketch(s, p, "sketch.solve")?; let st = s.model.state(); diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index b354d4bf..d837d866 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -526,3 +526,41 @@ fn fully_constrained_feedback_is_per_entity() { assert!(lines.iter().filter(|l| l.1 == crate::view::colors::SKETCH_FIXED).count() >= 4); assert!(lines.iter().any(|l| l.1 == crate::view::colors::SKETCH)); } + +// --------------------------------------------------------------------------------------------- +// Selection inside sketches + +#[test] +fn chain_follows_shared_and_coincident_ends() { + let mut s = new_sketch(); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]})); + run(&mut s, "DrawPolyline", json!({"points": [[40, 0], [50, 0]]})); + run(&mut s, "DrawPolyline", json!({"points": [[50.5, 0], [60, 5]]})); + run(&mut s, "CircleCenterRadius", json!({"center": [80, 0], "radius": 3})); + let chain = |s: &mut Session, c: &str| -> Vec { + let v = run(s, "sketch.chain", json!({"curve": c})); + v["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() + }; + assert_eq!(chain(&mut s, "l3"), ["l3", "l1", "l2", "l4"]); + assert_eq!(chain(&mut s, "l5"), ["l5"]); + assert_eq!(chain(&mut s, "c1"), ["c1"]); + // Joined by a coincident constraint, the two lines are one chain. + run(&mut s, "ConstraintCoincident", json!({"a": "l5.end", "b": "l6.start"})); + assert_eq!(chain(&mut s, "l6"), ["l6", "l5"]); + assert!(s.execute("sketch.chain", &json!({"curve": "nope"})).is_err()); +} + +#[test] +fn constraints_and_dimensions_can_be_selected_and_deleted_together() { + let mut s = new_sketch(); + run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]})); + run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"})); + run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30})); + let ids: Vec = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect(); + assert_eq!(ids.len(), 2); + let items: Vec = ids.iter().map(|id| json!({"type": "sketch_constraint", "id": id})).collect(); + run(&mut s, "select.set", json!({ "items": items })); + assert_eq!(s.selection.len(), 2); + run(&mut s, "selection.delete", json!({"items": items})); + assert!(sketch(&s).constraints.is_empty()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 130ba8c4..35ebf0ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -94,6 +94,10 @@ pub enum Sel { SketchPoint { id: String, }, + /// A constraint or dimension (by id) of the active sketch. + SketchConstraint { + id: String, + }, /// A closed profile of a sketch (by index). Profile { sketch: u64, diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index f20d8301..602610bc 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -772,6 +772,60 @@ impl Sketch { out } + /// The curves joined end to end with curve `ci` (through shared or coincident end points), + /// `ci` first, then the others in sketch order: what a double-click selects. + pub fn chain(&self, ci: usize) -> Vec { + if ci >= self.curves.len() { + return Vec::new(); + } + // Points made one by coincident constraints. + let mut root: Vec = (0..self.points.len()).collect(); + fn find(r: &mut [usize], mut i: usize) -> usize { + while let Some(&p) = r.get(i) { + if p == i { + break; + } + i = p; + } + i + } + for c in &self.constraints { + if let ConstraintKind::Coincident { p, q } = c.kind { + let (a, b) = (find(&mut root, p), find(&mut root, q)); + if let Some(x) = root.get_mut(a) { + *x = b; + } + } + } + let ends: Vec> = + self.curves.iter().map(|c| c.kind.ends().map(|(a, b)| vec![find(&mut root, a), find(&mut root, b)]).unwrap_or_default()).collect(); + let mut seen = vec![false; self.curves.len()]; + let mut stack = vec![ci]; + let mut out = Vec::new(); + while let Some(i) = stack.pop() { + if seen.get(i).copied().unwrap_or(true) { + continue; + } + if let Some(s) = seen.get_mut(i) { + *s = true; + } + out.push(i); + let mine = ends.get(i).cloned().unwrap_or_default(); + for (j, e) in ends.iter().enumerate() { + if !seen.get(j).copied().unwrap_or(true) && e.iter().any(|p| mine.contains(p)) { + stack.push(j); + } + } + } + let first = out.first().copied(); + out.sort_unstable(); + if let Some(f) = first { + out.retain(|x| *x != f); + out.insert(0, f); + } + out + } + /// Is curve `c` held in place: fixed or projected (linked). pub fn curve_locked(&self, c: usize) -> bool { self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some()) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 79e64e4e..94e534c7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -204,7 +204,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> "dialog": app.dialog.as_ref().map(|d| d.inputs.iter().map(|i| json!({"label": i.label, "items": i.items})).collect::>()), "hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")), "dimensions": crate::dim_view::drawn().into_iter().map(|(id, p)| json!({"id": id, "x": p.x, "y": p.y})).collect::>(), - "dimension_selected": crate::dim_view::selected(), + "dimension_selected": crate::dim_view::selected(app), "dimension_editing": crate::dim_view::editing(), })), "ui.sketchToScreen" => { diff --git a/crates/ui-egui/src/delete.rs b/crates/ui-egui/src/delete.rs index 818ca92c..b22b7125 100644 --- a/crates/ui-egui/src/delete.rs +++ b/crates/ui-egui/src/delete.rs @@ -31,7 +31,7 @@ fn is_empty(p: &Value) -> bool { /// Delete what is selected (the Delete key). A selected sketch dimension or constraint glyph /// goes first, as before. pub fn delete_selection(app: &mut SolveApp) { - if crate::dim_view::delete_selected(app) || crate::sketch_tools::delete_glyph(app) { + if crate::sketch_tools::delete_glyph(app) { return; } request(app, selection_params(app)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b5479418..f7838e7f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -255,7 +255,7 @@ fn fits(a: Accept, s: &Sel) -> bool { Sel::Axis { .. } => a & AXES != 0, Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0, Sel::Vertex { .. } => a & selection::VERTICES != 0, - Sel::Feature { .. } | Sel::SketchPoint { .. } => false, + Sel::Feature { .. } | Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } => false, } } diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index ef83cc5f..3761d3fa 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -13,6 +13,7 @@ use std::cell::RefCell; use egui::{FontId, Pos2, Rect, Shape, Stroke, vec2}; use serde_json::json; +use solvecraft_engine::Sel; use solvecraft_engine::geom::Vec2; use solvecraft_engine::sketch::{ConstraintKind, DimFrame, chain_centres, default_text, dim_frame, dim_layout}; @@ -44,7 +45,6 @@ struct Edit { thread_local! { static DRAWN: RefCell> = const { RefCell::new(Vec::new()) }; - static SELECTED: RefCell> = const { RefCell::new(None) }; /// A text being dragged: dimension id, undo depth at the start, grab offset (sketch). static DRAG: RefCell> = const { RefCell::new(None) }; static EDIT: RefCell> = const { RefCell::new(None) }; @@ -74,36 +74,20 @@ pub fn hit(pos: Pos2) -> Option { }) } -/// The selected dimension (constraint id). -pub fn selected() -> Option { - SELECTED.with(|s| s.borrow().clone()) +/// The selected dimensions (constraint ids; they are in the session's selection). +pub fn selected(app: &SolveApp) -> Vec { + let dims: Vec = DRAWN.with(|d| d.borrow().iter().map(|x| x.id.clone()).collect()); + app.session + .selection + .iter() + .filter_map(|s| if let Sel::SketchConstraint { id } = s { Some(id.clone()) } else { None }) + .filter(|id| dims.contains(id)) + .collect() } -/// A click without a tool: select the dimension under it (true: the click was used). A click -/// elsewhere drops the dimension selection. -pub fn click(app: &mut SolveApp, pos: Pos2) -> bool { - if app.session.active_sketch.is_none() { - return false; - } - let h = hit(pos); - SELECTED.with(|s| *s.borrow_mut() = h.clone()); - if let Some(id) = &h { - let _ = app.run("select.clear", json!({})); - app.set_status(format!("Dimension {id} selected: drag to move, double-click to edit, Delete removes it"), false); - } - h.is_some() -} - -/// Drop the dimension selection (true when there was one). -pub fn deselect() -> bool { - SELECTED.with(|s| s.borrow_mut().take()).is_some() -} - -/// Delete the selected dimension (true when one was selected). -pub fn delete_selected(app: &mut SolveApp) -> bool { - let Some(id) = SELECTED.with(|s| s.borrow_mut().take()) else { return false }; - let _ = app.run("sketch.delete", json!({"entities": [id]})); - true +/// Dimension text boxes drawn last frame (for box selection). +pub fn text_rects() -> Vec<(String, Rect)> { + DRAWN.with(|d| d.borrow().iter().map(|x| (x.id.clone(), x.text)).collect()) } /// A double-click on a dimension opens its value for editing (true: it was on one). @@ -114,7 +98,6 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool { }) else { return false; }; - SELECTED.with(|s| *s.borrow_mut() = Some(d.id.clone())); let Some(param) = d.param else { app.set_status("A driven dimension measures the sketch; it has no value to edit", false); return true; @@ -132,7 +115,6 @@ pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2) -> bool { }) else { return false; }; - SELECTED.with(|s| *s.borrow_mut() = Some(d.id.clone())); DRAG.with(|g| *g.borrow_mut() = Some((d.id, app.session.undo.len(), d.at - press_sketch))); true } @@ -181,7 +163,6 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P let st = app.session.model.state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { DRAWN.with(|d| d.borrow_mut().clear()); - SELECTED.with(|s| *s.borrow_mut() = None); EDIT.with(|e| *e.borrow_mut() = None); return; }; @@ -191,11 +172,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P return; } let tk = Tokens::get(); - let sel = selected(); - // A selected dimension that was deleted (undo, another command) is forgotten. - if sel.as_ref().is_some_and(|id| !sk.constraints.iter().any(|c| &c.id == id)) { - SELECTED.with(|s| *s.borrow_mut() = None); - } + let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id)); let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p)).and_then(hit); let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); let font = FontId::proportional(12.0); @@ -217,7 +194,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P let failing = ss.report.failing.contains(&c.id); let col = if failing { tk.error - } else if sel.as_deref() == Some(c.id.as_str()) || hover.as_deref() == Some(c.id.as_str()) { + } else if is_sel(&c.id) || hover.as_deref() == Some(c.id.as_str()) { tk.accent } else if c.driven { tk.dimension_driven diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index b9fb0d47..7dbd02c3 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -147,6 +147,16 @@ pub fn refine_snap( }) } +/// The constraint glyph under a screen position (its constraint id). +pub fn glyph_at(pos: Pos2) -> Option { + GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= 7.0).map(|(id, _)| id.clone())) +} + +/// Constraint glyphs drawn last frame: (constraint id, screen centre). +pub fn glyph_positions() -> Vec<(String, Pos2)> { + GLYPHS.with(|g| g.borrow().clone()) +} + /// A click on a constraint glyph selects it (true: the click was used). pub fn click_glyph(app: &mut SolveApp, _proj: &Proj, pos: Pos2) -> bool { if app.session.active_sketch.is_none() { @@ -731,7 +741,8 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { } placed.push(sp); let failing = ss.report.failing.contains(&c.id); - let selected = picked.as_deref() == Some(c.id.as_str()); + let selected = picked.as_deref() == Some(c.id.as_str()) + || app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchConstraint { id } if *id == c.id)); let col = if failing { tk.error } else if selected { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4fbbff99..824e8056 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -364,7 +364,16 @@ fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); ui.label(egui::RichText::new("Selection filter").color(t.text_dim).size(11.0)); ui.checkbox(&mut app.ui.pick_bodies, "Select bodies (a face picks its body)"); - for (k, label) in [("faces", "Faces"), ("edges", "Edges"), ("vertices", "Vertices"), ("sketch", "Sketch geometry and profiles")] { + for (k, label) in [ + ("faces", "Faces"), + ("edges", "Edges"), + ("vertices", "Vertices"), + ("sketch", "Sketch geometry and profiles"), + ("sketch_curves", "Sketch curves"), + ("sketch_points", "Sketch points"), + ("dimensions", "Sketch dimensions"), + ("constraints", "Sketch constraints"), + ] { let mut on = !app.ui.pick_off.iter().any(|x| x == k); if ui.checkbox(&mut on, label).changed() { app.ui.pick_off.retain(|x| x != k); @@ -417,7 +426,6 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { crate::tools::finish(app); } else if app.dialog.is_some() { crate::dialogs::cancel(app); - } else if crate::dim_view::deselect() { } else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() { let _ = app.run("select.clear", json!({})); } else { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 86903057..98297df5 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -672,8 +672,9 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> { let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); hits.iter() .filter(|h| match h { - Hit::SketchCurve { sketch, .. } => app.session.active_sketch == Some(*sketch) && !off("sketch"), - Hit::SketchPoint { .. } | Hit::Profile { .. } => !off("sketch"), + Hit::SketchCurve { sketch, .. } => app.session.active_sketch == Some(*sketch) && !off("sketch") && !off("sketch_curves"), + Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"), + Hit::Profile { .. } => !off("sketch"), Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies, Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies, Hit::Face { .. } => !off("faces") || app.ui.pick_bodies, @@ -1132,13 +1133,32 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.dialog = Some(d); } } else { - if !crate::dim_view::click(app, p) && !crate::sketch_tools::click_glyph(app, &proj, p) { - select(app, cand.and_then(|c| c.1), add); + // Dimensions and constraint glyphs sit on top of the geometry. + let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); + let sketching = app.session.active_sketch.is_some(); + let on_dim = crate::dim_view::hit(p).filter(|_| sketching && !off("dimensions")); + let on_glyph = crate::sketch_tools::glyph_at(p).filter(|_| sketching && !off("constraints")); + match on_dim.or(on_glyph) { + Some(id) => select(app, Some(Sel::SketchConstraint { id }), add), + None => select(app, cand.and_then(|c| c.1), add), } } } // Double-click a dimension: edit its value in place. let dim_edit = resp.double_clicked() && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p); + // Double-click a sketch curve: select the chain joined to it end to end. + if resp.double_clicked() + && !dim_edit + && app.tool.is_none() + && app.dialog.is_none() + && let Some(Hit::SketchCurve { sketch, id, .. }) = app.viewport.hover.clone() + && app.session.active_sketch == Some(sketch) + && let Ok(v) = app.session.execute("sketch.chain", &json!({ "curve": id })) + { + let items: Vec = + v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str()).map(|id| Sel::SketchCurve { id: id.into() }).collect(); + let _ = app.run("select.set", json!({ "items": items, "add": add })); + } // Double-click an edge: select the edges that continue it smoothly (the loop). if resp.double_clicked() && !dim_edit @@ -1302,17 +1322,36 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { if app.dialog.is_none() && let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid)) { - for (i, c) in ss.sketch.curves.iter().enumerate() { + // What the selection filter lets through (window: wholly inside; crossing: touching). + let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); + for (i, c) in ss.sketch.curves.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_curves")) { + if c.link.is_some() && ss.sketch.view.hide_projected { + continue; + } let pts: Vec = ss.sketch.segs(i).iter().flat_map(|sg| sg.polyline(0.05)).map(|q| ss.plane.to_world(q)).collect(); if bx.takes(&to2(&pts)) { out.push(Sel::SketchCurve { id: c.id.clone() }); } } - for p in &ss.sketch.points { - if bx.takes(&to2(&[ss.plane.to_world(p.pos)])) { + for (i, p) in ss.sketch.points.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_points")) { + if i != 0 && solvecraft_engine::view::sketch_point_visible(&ss.sketch, i) && bx.takes(&to2(&[ss.plane.to_world(p.pos)])) { out.push(Sel::SketchPoint { id: p.id.clone() }); } } + if !off("dimensions") { + for (id, r) in crate::dim_view::text_rects() { + if bx.takes(&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]) { + out.push(Sel::SketchConstraint { id }); + } + } + } + if !off("constraints") { + for (id, c) in crate::sketch_tools::glyph_positions() { + if bx.takes(&[c]) { + out.push(Sel::SketchConstraint { id }); + } + } + } } else { let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES); for b in &st.bodies {