diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d5233296..7415df25 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -208,6 +208,10 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> for k in 1..=steps { app.synthetic.push(egui::Event::PointerMoved(a + (z - a) * (k as f32 / steps as f32))); } + // `hold_end`: frames to keep the button down at the end (to look at a drag's snap). + for _ in 0..f("hold_end").unwrap_or(0.0).clamp(0.0, 600.0) as usize { + app.synthetic.push(egui::Event::PointerMoved(z)); + } app.synthetic.push(egui::Event::PointerButton { pos: z, button, pressed: false, modifiers }); ok(Value::Null) } diff --git a/crates/ui-egui/src/drag_snap.rs b/crates/ui-egui/src/drag_snap.rs new file mode 100644 index 00000000..fc5a8d71 --- /dev/null +++ b/crates/ui-egui/src/drag_snap.rs @@ -0,0 +1,282 @@ +//! Snapping while a sketch point or a line is dragged, like Fusion: the dragged point snaps to +//! other points (ends, centres, the origin, projected points), to the middle of a line and onto +//! curves, and lines up horizontally or vertically with other points (a dashed guide). Letting +//! go on a point, a midpoint or a curve adds the matching constraint; lining up adds none. Ctrl +//! or Alt held: no snapping (here and while drawing). + +use std::cell::{Cell, RefCell}; + +use egui::{Align2, FontId, Shape, Stroke}; +use serde_json::json; +use solvecraft_engine::geom::Vec2; +use solvecraft_engine::sketch::{CurveKind, Sketch}; + +use crate::SolveApp; +use crate::theme::Tokens; +use crate::viewport::Proj; + +/// What the dragged point snapped to. +#[derive(Clone, Debug, PartialEq)] +pub enum Target { + /// Another point (its id): coincident on release. + Point(String), + /// The middle of a line (its id): midpoint on release. + Mid(String), + /// On a curve (its id): point on curve on release. + On(String), + /// Lined up with other points: no constraint. + Align, +} + +/// A snap: the moving point (its id) and where it goes, the move added to the cursor's, the +/// target, and the dashed guides (sketch coordinates). +#[derive(Clone, Debug, PartialEq)] +pub struct Snap { + pub point: String, + pub at: Vec2, + pub offset: Vec2, + pub target: Target, + pub guides: Vec<(Vec2, Vec2)>, +} + +impl Snap { + pub fn label(&self) -> &'static str { + match self.target { + Target::Point(_) => "Pt", + Target::Mid(_) => "Mid", + Target::On(_) => "On", + Target::Align => "", + } + } +} + +/// Snap distances, in screen pixels. +const POINT_PX: f64 = 8.0; +const CURVE_PX: f64 = 6.0; +const ALIGN_PX: f64 = 6.0; + +thread_local! { + static CURRENT: RefCell> = const { RefCell::new(None) }; + static SUPPRESSED: Cell = const { Cell::new(false) }; +} + +/// Ctrl or Alt held this frame (no snapping). +pub fn set_suppressed(on: bool) { + SUPPRESSED.with(|s| s.set(on)); +} + +pub fn suppressed() -> bool { + SUPPRESSED.with(Cell::get) +} + +pub fn current() -> Option { + CURRENT.with(|c| c.borrow().clone()) +} + +pub fn clear() { + CURRENT.with(|c| *c.borrow_mut() = None); +} + +fn keep(s: Option) -> Option { + CURRENT.with(|c| *c.borrow_mut() = s.clone()); + s +} + +/// The best snap for points moving to `moving` (point index, position after the cursor's move), +/// `mm_px` millimetres per pixel. Points in `exclude` (the ones moving with the drag) are not +/// targets, nor are curves using them. +pub fn find(sk: &Sketch, moving: &[(usize, Vec2)], exclude: &[usize], mm_px: f64) -> Option { + if suppressed() || moving.is_empty() || !(mm_px > 0.0 && mm_px.is_finite()) { + return keep(None); + } + let id = |i: usize| sk.points.get(i).map(|p| p.id.clone()).unwrap_or_default(); + let targets: Vec<(usize, Vec2)> = sk.points.iter().enumerate().filter(|(i, _)| !exclude.contains(i)).map(|(i, p)| (i, p.pos)).collect(); + let free_curves: Vec = + (0..sk.curves.len()).filter(|&c| sk.curves.get(c).is_some_and(|cu| !exclude.iter().any(|&p| cu.kind.uses(p)))).collect(); + let mut best: Option<(f64, Snap)> = None; + let offer = |best: &mut Option<(f64, Snap)>, d: f64, s: Snap| { + if best.as_ref().is_none_or(|b| d < b.0) { + *best = Some((d, s)); + } + }; + // Points, then midpoints, then curves. + for &(mi, p) in moving { + for &(ti, t) in &targets { + let d = p.dist(t); + if d < POINT_PX * mm_px { + offer(&mut best, d, Snap { point: id(mi), at: t, offset: t - p, target: Target::Point(id(ti)), guides: Vec::new() }); + } + } + } + if best.is_none() { + for &(mi, p) in moving { + for &c in &free_curves { + let Some(cu) = sk.curves.get(c) else { continue }; + let CurveKind::Line { a, b } = cu.kind else { continue }; + let (Some(a), Some(b)) = (sk.point(a), sk.point(b)) else { continue }; + let m = (a + b) * 0.5; + let d = p.dist(m); + if d < POINT_PX * mm_px { + offer(&mut best, d, Snap { point: id(mi), at: m, offset: m - p, target: Target::Mid(cu.id.clone()), guides: Vec::new() }); + } + } + } + } + if best.is_none() { + for &(mi, p) in moving { + for &c in &free_curves { + let (Some(cu), Some(sh)) = (sk.curves.get(c), sk.shape(c)) else { continue }; + let d = sh.dist(p); + if d < CURVE_PX * mm_px { + let q = sh.project(p); + offer(&mut best, d, Snap { point: id(mi), at: q, offset: q - p, target: Target::On(cu.id.clone()), guides: Vec::new() }); + } + } + } + } + if let Some((_, s)) = best { + return keep(Some(s)); + } + // Lining up: the nearest horizontal and the nearest vertical alignment, each on its own axis. + let tol = ALIGN_PX * mm_px; + let (mut hx, mut vy): (Option<(f64, usize, Vec2, Vec2)>, Option<(f64, usize, Vec2, Vec2)>) = (None, None); + for &(mi, p) in moving { + for &(_, t) in &targets { + let (dy, dx) = ((p.y - t.y).abs(), (p.x - t.x).abs()); + if dy < tol && dx > tol && hx.is_none_or(|h| dy < h.0) { + hx = Some((dy, mi, p, t)); + } + if dx < tol && dy > tol && vy.is_none_or(|v| dx < v.0) { + vy = Some((dx, mi, p, t)); + } + } + } + let mut offset = Vec2::ZERO; + let mut guides = Vec::new(); + let mut point = None; + if let Some((_, mi, p, t)) = hx { + offset.y = t.y - p.y; + point = Some(mi); + guides.push((t, Vec2::new(p.x, t.y))); + } + if let Some((_, mi, p, t)) = vy { + offset.x = t.x - p.x; + point = point.or(Some(mi)); + guides.push((t, Vec2::new(t.x, p.y))); + } + // The guides end at the snapped point (both alignments may move it). + let Some(mi) = point else { return keep(None) }; + let at = moving.iter().find(|m| m.0 == mi).map_or(Vec2::ZERO, |m| m.1) + offset; + for g in &mut guides { + g.1 = if (g.0.y - g.1.y).abs() < 1e-12 { Vec2::new(at.x, g.0.y) } else { Vec2::new(g.0.x, at.y) }; + } + keep(Some(Snap { point: id(mi), at, offset, target: Target::Align, guides })) +} + +/// Points the drag of `entity` (a point or curve id) moves, and the points that move with them. +pub fn moving_points(sk: &Sketch, entity: &str) -> (Vec, Vec) { + let own: Vec = if let Some(p) = sk.point_index(entity) { + vec![p] + } else { + match sk.curve_index(entity).and_then(|c| sk.curves.get(c)) { + // Only lines slide whole; a circle or arc grabbed on its edge changes its radius. + Some(c) if matches!(c.kind, CurveKind::Line { .. }) => c.kind.point_ids(), + _ => Vec::new(), + } + }; + let mut ex = own.clone(); + for c in &sk.curves { + if own.iter().any(|&p| c.kind.uses(p)) { + ex.extend(c.kind.point_ids()); + } + } + ex.sort_unstable(); + ex.dedup(); + (own, ex) +} + +/// The drag let go: the constraint of the snap it ended on (none for lining up). +pub fn release(app: &mut SolveApp) { + let Some(s) = current() else { return }; + clear(); + let (cmd, params) = match &s.target { + Target::Point(t) => ("ConstraintCoincident", json!({"a": s.point, "b": t})), + Target::Mid(l) => ("ConstraintMidPoint", json!({"point": s.point, "line": l})), + Target::On(c) => ("ConstraintCoincident", json!({"a": s.point, "b": c})), + Target::Align => return, + }; + // A snap that would over-constrain the sketch is simply not kept. + let _ = app.session.execute(cmd, ¶ms); +} + +/// The snap of the drag in progress: its dashed guides, a ring on the target and its label. +pub fn show(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + if app.viewport.point_drag.is_none() { + return; + } + let Some(s) = current() else { return }; + let st = app.session.world_state(); + let Some(ss) = app.session.active_sketch.and_then(|i| st.sketch(i)) else { return }; + let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); + let tk = Tokens::get(); + for (a, b) in &s.guides { + if let (Some(a), Some(b)) = (to(*a), to(*b)) { + painter.add(Shape::dashed_line(&[a, b], Stroke::new(1.0, tk.rubber_band), 5.0, 4.0)); + } + } + let Some(sp) = to(s.at) else { return }; + painter.circle_stroke(sp, 6.0, Stroke::new(1.5, tk.accent)); + let label = s.label(); + if !label.is_empty() { + let r = egui::Rect::from_min_size(sp + egui::vec2(12.0, -24.0), egui::vec2(24.0, 14.0)); + painter.rect_filled(r, 3.0, tk.accent_soft); + painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(10.0), tk.text); + } + crate::scenario::publish_count("drag_guides", s.guides.len() as f64); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sk() -> Sketch { + let mut s = Sketch::new(); + let _ = s.add_line(Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), None, None, Some("l1")); + let _ = s.add_line(Vec2::new(20.0, 5.0), Vec2::new(30.0, 15.0), None, None, Some("l2")); + s + } + + fn drag(s: &Sketch, entity: &str, p: Vec2) -> Option { + let (own, ex) = moving_points(s, entity); + find(s, &[(own[0], p)], &ex, 0.1) + } + + #[test] + fn a_dragged_end_snaps_to_points_midpoints_and_curves() { + let s = sk(); + let end = s.resolve_point("l2.start").map(|i| s.points[i].id.clone()).unwrap_or_default(); + let at = drag(&s, &end, Vec2::new(10.3, 0.4)).expect("point"); + assert_eq!(at.target, Target::Point(s.points[s.resolve_point("l1.end").unwrap_or(0)].id.clone())); + assert_eq!(at.at, Vec2::new(10.0, 0.0)); + let mid = drag(&s, &end, Vec2::new(5.2, 0.3)).expect("mid"); + assert_eq!(mid.target, Target::Mid("l1".into())); + let on = drag(&s, &end, Vec2::new(7.5, 0.3)).expect("on"); + assert_eq!(on.target, Target::On("l1".into())); + assert!(on.at.y.abs() < 1e-12); + } + + #[test] + fn lining_up_moves_the_point_but_names_no_constraint() { + let s = sk(); + let end = s.points[s.resolve_point("l2.end").unwrap_or(0)].id.clone(); + // Level with l1's end (y 0), far from it in x. + let a = drag(&s, &end, Vec2::new(40.0, 0.35)).expect("aligned"); + assert_eq!(a.target, Target::Align); + assert_eq!(a.at.y, 0.0); + assert_eq!(a.guides.len(), 1); + assert!(drag(&s, &end, Vec2::new(40.0, 3.0)).is_none()); + set_suppressed(true); + assert!(drag(&s, &end, Vec2::new(10.1, 0.1)).is_none()); + set_suppressed(false); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2da8d252..ae567538 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod dialogs_plastic; pub mod dialogs_sheet; pub mod dim_view; pub mod documents; +pub mod drag_snap; pub mod gizmo; pub mod gpu; pub mod help; @@ -193,6 +194,8 @@ pub struct SolveApp { pub integrated_titlebar: bool, pub frame_ms: f64, pub synthetic: Vec, + /// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves). + pub synthetic_mods: egui::Modifiers, control_rx: Option>, pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, queued_shots: Vec<(u64, f64, u32)>, @@ -241,6 +244,7 @@ impl SolveApp { integrated_titlebar: false, frame_ms: 0.0, synthetic: Vec::new(), + synthetic_mods: egui::Modifiers::default(), control_rx: None, pending_shots: Vec::new(), queued_shots: Vec::new(), @@ -676,10 +680,13 @@ impl SolveApp { } // egui keeps the modifier keys as input state, changed by their own event. match self.synthetic.first() { - Some(egui::Event::PointerButton { modifiers, .. } | egui::Event::Key { modifiers, .. }) => { + Some(egui::Event::PointerButton { modifiers, pressed, .. }) => { + // A drag keeps its press's modifiers until the release. + self.synthetic_mods = if *pressed { *modifiers } else { egui::Modifiers::default() }; raw.events.push(egui::Event::ModifiersChanged(*modifiers)) } - Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())), + Some(egui::Event::Key { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), + Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(self.synthetic_mods)), _ => {} } let n = n.min(self.synthetic.len()); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index f1128b1c..3e009fa3 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -7,7 +7,7 @@ //! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its //! text (with `near`: the one in that label's row) //! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`, -//! `{"drag": [AT, AT], "shift"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}` +//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}` //! or `{"sketch": [x,y]}` (the sketch being edited) //! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an //! error) @@ -321,7 +321,7 @@ impl Harness { let [x0, y0] = self.at(d.get(0).unwrap_or(&Value::Null))?; let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?; let steps = s.get("steps").cloned().unwrap_or(json!(8)); - self.call("ui.drag", json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift")})) + self.call("ui.drag", json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl")})) } else if let Some(m) = s.get("call").and_then(Value::as_str) { self.call(m, s.get("params").cloned().unwrap_or(json!({}))) } else if s.get("note").is_some() { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 119aac3d..e55084e5 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1140,6 +1140,9 @@ pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, let (o, d) = proj.ray(pos); let w = ss.plane.intersect_ray(o, d)?; let lp = ss.plane.to_local(w); + if crate::drag_snap::suppressed() { + return Some((lp, None)); + } let (minor, _) = grid_step(app.cam.half_height()); let step = minor / 10.0; if let Some(p) = crate::sketch_tools::refine_snap(app, proj, &ss.sketch, &ss.plane, lp) { @@ -1178,6 +1181,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0)); let inside = hover.is_some_and(|p| rect.contains(p) && !cube.contains(p)); app.viewport.mouse = hover.filter(|_| inside); + // Ctrl or Alt held: drawing and dragging don't snap. + crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); // ---- navigation ---- let h = rect.height().max(1.0) as f64; @@ -1424,9 +1429,30 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } if let Some((id, depth, grab)) = app.viewport.point_drag.clone() && resp.dragged_by(egui::PointerButton::Primary) - && let Some(q) = hover.and_then(|p| on_plane(app, p)) + && let Some(mut q) = hover.and_then(|p| on_plane(app, p)) { let from = grab.unwrap_or(q); + // Snap the dragged point (or the line's ends) to other geometry, like drawing. + let snap = { + let st = app.session.world_state(); + app.session.active_sketch.and_then(|s| st.sketch(s)).and_then(|ss| { + let sk = &ss.sketch; + let (own, exclude) = crate::drag_snap::moving_points(sk, &id); + if own.iter().any(|&p| sk.point_locked(p)) { + return crate::drag_snap::find(sk, &[], &[], 0.0); + } + let a = proj.to_screen(ss.plane.to_world(q))?; + let b = proj.to_screen(ss.plane.to_world(q + Vec2::X))?; + let mm_px = 1.0 / (a.distance(b) as f64).max(1e-9); + let d = q - from; + let moving: Vec<(usize, Vec2)> = + own.iter().filter_map(|&p| if grab.is_some() { sk.point(p).map(|x| (p, x + d)) } else { Some((p, q)) }).collect(); + crate::drag_snap::find(sk, &moving, &exclude, mm_px) + }) + }; + if let Some(s) = snap { + q += s.offset; + } if app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y]})).is_ok() { app.session.undo.truncate(depth + 1); } @@ -1435,6 +1461,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } } if resp.drag_stopped() { + if let Some((_, depth, _)) = app.viewport.point_drag.clone() { + // Let go on a point, midpoint or curve: the matching constraint, in the drag's undo step. + crate::drag_snap::release(app); + app.session.undo.truncate(depth + 1); + } + crate::drag_snap::clear(); app.viewport.point_drag = None; crate::dim_view::drag_end(); crate::sketch3d::drag_end(); @@ -1468,6 +1500,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::dim_view::show(app, ui, &painter, &proj); crate::sketch3d::show(app, &painter, &proj); crate::inference::show(app, &painter, &proj); + crate::drag_snap::show(app, &painter, &proj); hover_highlight(app, &painter, &proj); points_2d(app, &painter, &proj); if let Some(bx) = app.viewport.boxsel { diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index f19144c3..b7e4150c 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -56,7 +56,8 @@ scenarios!( s36_sketch_palette_visible, s32_cut_in_active_component, s30_pattern_a_hole, - s41_type_value_after_picking + s41_type_value_after_picking, + s42_drag_snap ); /// Scenarios written ahead of their fixes or features (the user's open bugs): run with diff --git a/crates/ui-egui/tests/scenarios/s42_drag_snap.json b/crates/ui-egui/tests/scenarios/s42_drag_snap.json new file mode 100644 index 00000000..f6cd5267 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s42_drag_snap.json @@ -0,0 +1,282 @@ +[ + { + "note": "Dragging sketch geometry snaps like Fusion: onto a point (coincident on release), level with a point (a dashed guide, no constraint), not at all with Ctrl held; a line dragged by its body snaps by its ends." + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "call": "engine.execute", + "params": { + "command": "DrawPolyline", + "params": { + "points": [ + [ + 10, + 10 + ], + [ + 30, + 10 + ] + ] + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "DrawPolyline", + "params": { + "points": [ + [ + 10, + 30 + ], + [ + 30, + 40 + ] + ] + } + } + }, + { + "expect": { + "dof": 8 + } + }, + { + "note": "An end dropped near another end snaps onto it and becomes coincident." + }, + { + "drag": [ + { + "sketch": [ + 10, + 30 + ] + }, + { + "sketch": [ + 30.4, + 10.3 + ] + } + ], + "steps": 12 + }, + { + "expect": { + "dof": 6, + "query": { + "command": "sketch.inspect", + "path": [ + "curves", + 1, + "start_at" + ], + "value": [ + 30.0, + 10.0 + ] + } + } + }, + { + "expect": { + "query": { + "command": "sketch.inspect", + "path": [ + "constraints", + 0, + "type" + ], + "value": "coincident" + } + } + }, + { + "note": "Level with l1's start: y snaps to 10 exactly, nothing is added." + }, + { + "drag": [ + { + "sketch": [ + 30, + 40 + ] + }, + { + "sketch": [ + 55, + 10.4 + ] + } + ], + "steps": 12 + }, + { + "expect": { + "dof": 6, + "query": { + "command": "sketch.inspect", + "path": [ + "curves", + 1, + "end_at", + 1 + ], + "value": 10.0 + } + } + }, + { + "expect": { + "query": { + "command": "sketch.inspect", + "path": [ + "constraints", + 1 + ], + "value": null + } + } + }, + { + "note": "Ctrl held: no snap (the end stays where it is let go)." + }, + { + "drag": [ + { + "sketch": [ + 55, + 10 + ] + }, + { + "sketch": [ + 10.3, + 10.3 + ] + } + ], + "steps": 12, + "ctrl": true + }, + { + "expect": { + "dof": 6, + "query": { + "command": "sketch.inspect", + "path": [ + "constraints", + 1 + ], + "value": null + } + } + }, + { + "key": "Z", + "cmd": true + }, + { + "expect": { + "query": { + "command": "sketch.inspect", + "path": [ + "curves", + 1, + "end_at", + 1 + ], + "value": 10.0 + } + } + }, + { + "note": "Undo takes the snap's constraint with the drag that made it." + }, + { + "key": "Z", + "cmd": true + }, + { + "key": "Z", + "cmd": true + }, + { + "expect": { + "dof": 8, + "query": { + "command": "sketch.inspect", + "path": [ + "constraints", + 0 + ], + "value": null + } + } + }, + { + "note": "A line dragged by its body: its start, brought near l1's start, snaps onto it (coincident)." + }, + { + "call": "engine.execute", + "params": { + "command": "DrawPolyline", + "params": { + "points": [ + [ + 40, + 30 + ], + [ + 50, + 30 + ] + ] + } + } + }, + { + "drag": [ + { + "sketch": [ + 45, + 30 + ] + }, + { + "sketch": [ + 15.3, + 10.3 + ] + } + ], + "steps": 16 + }, + { + "expect": { + "dof": 10, + "query": { + "command": "sketch.inspect", + "path": [ + "curves", + 2, + "start_at" + ], + "value": [ + 10.0, + 10.0 + ] + } + } + } +] \ No newline at end of file diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a08295cf..16aec022 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -27,7 +27,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input | | `ui.move` | `x`, `y` | | -| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it) | +| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) | | `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled) | | `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does | | `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened |