Dragging sketch geometry snaps like Fusion: a dragged point (or a line dragged by its body, by its ends) snaps onto other points, line midpoints and curves, and letting go there adds the coincident, midpoint or point-on-curve constraint in the drag's undo step; it lines up horizontally and vertically with other points (dashed guides, no constraint). Snap distances in screen pixels; Ctrl or Alt held turns snapping off, also while drawing. Ring and Pt/Mid/On label on the snap. Scenario s42 (coincident and DOF, exact H alignment without a constraint, Ctrl-drag, undo, line body drag); ui.drag hold_end; synthetic drags keep their modifiers

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 18:00:42 +00:00
1 parent b8d00aea13
commit 305c1c9a9b
8 files changed
+616 -7

No files matched your search

+4
View File
@@ -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)
}
+282
View File
@@ -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<Option<Snap>> = const { RefCell::new(None) };
static SUPPRESSED: Cell<bool> = 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<Snap> {
CURRENT.with(|c| c.borrow().clone())
}
pub fn clear() {
CURRENT.with(|c| *c.borrow_mut() = None);
}
fn keep(s: Option<Snap>) -> Option<Snap> {
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<Snap> {
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<usize> =
(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<usize>, Vec<usize>) {
let own: Vec<usize> = 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, &params);
}
/// 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<Snap> {
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);
}
}
+9 -2
View File
@@ -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<egui::Event>,
/// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves).
pub synthetic_mods: egui::Modifiers,
control_rx: Option<Receiver<ControlRequest>>,
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, 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());
+2 -2
View File
@@ -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() {
+34 -1
View File
@@ -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 {
+2 -1
View File
@@ -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
@@ -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
]
}
}
}
]
+1 -1
View File
@@ -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 |