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:
1 parent
b8d00aea13
commit
305c1c9a9b
8 files changed
+616
-7
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ¶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<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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user