The old ids live only in crates/engine/src/legacy_ids.rs (the legacy migration table): find_command maps them, so old scripts, recipes and control calls keep working, and saved shortcuts, recent and pinned commands are migrated on load (designs store no command ids). Parity maps the observed catalog ids to ours through xtask/data/fusion-ids.tsv. The agent rule now reads: our own dotted id, never another product's. Cargo.toml's repository points at github.com/bherbruck/solvecraft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
11 KiB
Rust
283 lines
11 KiB
Rust
//! 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) => ("sketch.constraint.coincident", json!({"a": s.point, "b": t})),
|
|
Target::Mid(l) => ("sketch.constraint.midpoint", json!({"point": s.point, "line": l})),
|
|
Target::On(c) => ("sketch.constraint.coincident", 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);
|
|
}
|
|
}
|