Files
OneCAD/crates/engine/src/cmd/sketch_clipboard_tests.rs
T
bherbruckandClaude Opus 5.5 843c548589 Copy, cut and paste sketch geometry (Ctrl+C/X/V, context menu) (#1500)
* Copy, cut and paste sketch geometry (Ctrl+C/X/V, context menu)

Sketch geometry had no copy or paste: Ctrl+C / Ctrl+X / Ctrl+V arrive as
clipboard events that nothing handled, and the sketch context menu had no
Copy, Cut or Paste.

- sketch.copy / sketch.cut / sketch.paste: lines, arcs, circles, ellipses,
  splines, conics, points and text. Constraints and dimensions wholly among
  the copied entities come along (each dimension gets its own parameter
  with the same expression); ones that reach outside, fixes and pattern or
  offset relations are dropped. Paste goes centred at `at`, by
  `translate`, or offset from the original (same sketch) / at the same
  coordinates (another sketch, on its plane). One undo step; the pasted
  geometry is selected.
- Ctrl+C / Ctrl+X / Ctrl+V in a sketch (paste at the cursor), and Cut,
  Copy and Paste in the sketch context menu. Outside a sketch Ctrl+C /
  Ctrl+V copy and paste the selected timeline features. A copy also puts a
  line of text on the system clipboard so the next Ctrl+V is delivered.

Fixes #1480

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a pasted text's height and angle expressions, drop its place's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:55:10 -04:00

234 lines
12 KiB
Rust

use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, Sketch};
use crate::{Sel, Session};
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
s.execute(cmd, &params).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn strs(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn sketch(s: &Session, id: u64) -> &Sketch {
s.doc.sketch(id).unwrap()
}
fn pos(sk: &Sketch, curve: &str) -> Vec<Vec2> {
let c = &sk.curves[sk.curve_index(curve).unwrap()];
c.kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect()
}
/// A sketch on XY with a dimensioned, constrained rectangle tied to the origin, a circle with a
/// diameter, an arc, a spline and a point.
fn drawing() -> (Session, Vec<String>, String) {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}))["curves"]);
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", rect[0]), "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [rect[0]], "value": 20}));
run(&mut s, "sketch.dimension", json!({"entities": [rect[1]], "value": 10}));
let circle = strs(&run(&mut s, "sketch.circle.center", json!({"center": [40, 5], "diameter": 6}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [circle], "type": "diameter", "value": 6}));
(s, rect, circle)
}
#[test]
fn copy_paste_keeps_internal_constraints_and_dimensions_and_drops_outside_ones() {
let (mut s, rect, circle) = drawing();
let sid = s.active_sketch.unwrap();
let mut entities = rect.clone();
entities.push(circle.clone());
let copied = run(&mut s, "sketch.copy", json!({"entities": entities}));
assert_eq!(copied["curves"], 5);
let before = sketch(&s, sid).clone();
let params_before = s.doc.params.len();
let undo = s.undo.len();
let out = run(&mut s, "sketch.paste", json!({"at": [100, 50]}));
assert_eq!(s.undo.len(), undo + 1, "one paste, one undo step");
let pasted = strs(&out["curves"]);
assert_eq!(pasted.len(), 5);
let sk = sketch(&s, sid);
// The originals are where they were.
for c in entities.iter() {
assert_eq!(pos(sk, c), pos(&before, c));
}
// Centred on `at`: the copy's bounding box (0..43 x 0..10) centre goes to (100, 50).
let p0 = pos(sk, &pasted[0]);
assert!(p0[0].dist(Vec2::new(100.0 - 21.5, 50.0 - 5.0)) < 1e-6, "{p0:?}");
// The horizontal/vertical, coincident-corner and parallel constraints and the three dimensions
// came along; the coincidence with the origin did not.
let new_cons: Vec<_> = sk.constraints.iter().filter(|c| !before.constraints.iter().any(|b| b.id == c.id)).collect();
let internal = before.constraints.iter().filter(|c| !c.kind.refers_to_point(0)).count();
assert_eq!(new_cons.len(), internal, "{new_cons:#?}");
assert!(!new_cons.iter().any(|c| c.kind.refers_to_point(0)));
let dims: Vec<_> = new_cons.iter().filter(|c| c.kind.is_dimension()).collect();
assert_eq!(dims.len(), 3);
// Each pasted dimension has its own parameter.
assert_eq!(s.doc.params.len(), params_before + 3);
assert!(dims.iter().all(|d| d.param.is_some() && !before.constraints.iter().any(|b| b.param == d.param)));
let length = dims
.iter()
.find(|d| matches!(d.kind, ConstraintKind::Length { .. } | ConstraintKind::Distance { .. }))
.and_then(|d| d.param.clone())
.unwrap();
// The paste is selected.
let sel: Vec<_> = s.selection.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
assert_eq!(sel, pasted);
// The pasted rectangle is free to move (the original is held by the origin): dragging it moves it.
run(&mut s, "sketch.drag", json!({"entity": pasted[0], "from": [p0[0].x, p0[0].y], "to": [p0[0].x + 5.0, p0[0].y + 5.0]}));
let moved = pos(sketch(&s, sid), &pasted[0]);
assert!(moved[0].dist(p0[0]) > 1.0, "{moved:?}");
assert_eq!(pos(sketch(&s, sid), &rect[0]), pos(&before, &rect[0]));
// Editing a pasted dimension changes the copy only.
run(&mut s, "parameters.change", json!({"name": length, "expression": "30"}));
let sk = sketch(&s, sid);
let span = |c: &str| {
let p = pos(sk, c);
p[0].dist(p[1])
};
assert!((span(&rect[0]) - 20.0).abs() < 1e-6 && (span(&rect[1]) - 10.0).abs() < 1e-6);
assert!(pasted[..4].iter().any(|c| (span(c) - 30.0).abs() < 1e-6), "{:?}", pasted.iter().map(|c| span(c)).collect::<Vec<_>>());
}
#[test]
fn paste_is_one_undo_step() {
let (mut s, rect, circle) = drawing();
let sid = s.active_sketch.unwrap();
let before = s.doc.to_json();
run(&mut s, "sketch.copy", json!({"entities": [rect[0], rect[1], circle]}));
run(&mut s, "sketch.paste", json!({}));
assert_ne!(s.doc.to_json(), before);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.active_sketch, Some(sid));
}
#[test]
fn paste_without_a_position_steps_away_from_the_original_each_time() {
let (mut s, rect, _) = drawing();
let sid = s.active_sketch.unwrap();
run(&mut s, "sketch.copy", json!({"entities": [rect[0]]}));
let a = strs(&run(&mut s, "sketch.paste", json!({}))["curves"]);
let b = strs(&run(&mut s, "sketch.paste", json!({}))["curves"]);
let sk = sketch(&s, sid);
let (o, pa, pb) = (pos(sk, &rect[0])[0], pos(sk, &a[0])[0], pos(sk, &b[0])[0]);
// A 20 mm line: steps of 2 mm.
assert!(pa.dist(o + Vec2::new(2.0, -2.0)) < 1e-6, "{o:?} {pa:?}");
assert!(pb.dist(o + Vec2::new(4.0, -4.0)) < 1e-6, "{o:?} {pb:?}");
}
#[test]
fn every_kind_of_sketch_geometry_pastes() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let line = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))["curves"]);
let arc = strs(&run(&mut s, "sketch.arc.three_point", json!({"start": [20, 0], "end": [30, 0], "through": [25, 4]}))["curves"]);
let circle = strs(&run(&mut s, "sketch.circle.center", json!({"center": [40, 0], "radius": 3}))["curves"]);
let spline = strs(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 10], [5, 14], [10, 10], [15, 13]]}))["curves"]);
let point = run(&mut s, "sketch.point", json!({"point": [50, 5]}));
let point_id = strs(&point["points"]).first().cloned().unwrap_or_else(|| point["point"].as_str().unwrap().to_string());
let text = run(&mut s, "sketch.text", json!({"text": "Hi", "at": [0, 20], "height": 5}));
let text_curve = strs(&text["curves"])[0].clone();
let sid = s.active_sketch.unwrap();
let before = sketch(&s, sid).clone();
let mut all: Vec<String> = [line, arc, circle, spline].concat();
all.push(point_id.clone());
all.push(text_curve);
// Copy from the selection, as Ctrl+C does.
s.selection =
all.iter().map(|id| if *id == point_id { Sel::SketchPoint { id: id.clone() } } else { Sel::SketchCurve { id: id.clone() } }).collect();
run(&mut s, "sketch.copy", json!({}));
let out = run(&mut s, "sketch.paste", json!({"translate": [0, 100]}));
let sk = sketch(&s, sid);
let new_curves: Vec<_> = sk.curves.iter().filter(|c| !before.curves.iter().any(|b| b.id == c.id)).collect();
let kinds = |pred: fn(&CurveKind) -> bool| new_curves.iter().filter(|c| pred(&c.kind)).count();
assert_eq!(kinds(|k| matches!(k, CurveKind::Arc { .. })), 1);
assert_eq!(kinds(|k| matches!(k, CurveKind::Circle { .. })), 1);
assert_eq!(
kinds(|k| matches!(k, CurveKind::Spline { .. }))
- before.curves.iter().filter(|c| c.link.is_some() && matches!(c.kind, CurveKind::Spline { .. })).count(),
1
);
// The text came as a new, editable text 100 mm up.
let texts: Vec<_> = sk.links.iter().filter(|l| l.kind == LinkKind::Text).collect();
assert_eq!(texts.len(), 2);
let Some(solidcraft_sketch::LinkSource::Text { at, text, .. }) =
texts.iter().map(|l| &l.source).find(|src| !before.links.iter().any(|b| &b.source == *src))
else {
panic!()
};
assert_eq!(text, "Hi");
assert!(at.dist(Vec2::new(0.0, 120.0)) < 1e-9);
// The lone point.
assert_eq!(strs(&out["points"]).len(), 1);
let q = sk.resolve_point(&strs(&out["points"])[0]).and_then(|i| sk.point(i)).unwrap();
assert!(q.dist(Vec2::new(50.0, 105.0)) < 1e-9);
// Every pasted curve is selected.
assert_eq!(s.selection.len(), strs(&out["curves"]).len() + 1);
}
#[test]
fn paste_into_another_sketch_lands_on_its_plane() {
let (mut s, rect, circle) = drawing();
run(&mut s, "sketch.copy", json!({"entities": [rect[0], rect[1], rect[2], rect[3], circle]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "B"}));
let b = s.active_sketch.unwrap();
let out = run(&mut s, "sketch.paste", json!({}));
let sk = sketch(&s, b);
let pasted = strs(&out["curves"]);
assert_eq!(pasted.len(), 5);
// Same sketch coordinates, on B's plane; constraints and dimensions came along.
let p = pos(sk, &pasted[0]);
assert!(p[0].dist(Vec2::new(0.0, 0.0)) < 1e-6 && p[1].dist(Vec2::new(20.0, 0.0)) < 1e-6, "{p:?}");
assert_eq!(sk.constraints.iter().filter(|c| c.kind.is_dimension()).count(), 3);
assert!(!sk.constraints.iter().any(|c| c.kind.refers_to_point(0)));
let st = s.world_state();
let ss = st.sketch(b).unwrap();
let w = ss.plane.to_world(Vec2::new(20.0, 10.0));
assert!(w.y.abs() < 1e-9 && (w.x - 20.0).abs() < 1e-9, "{w:?}");
// A profile closes on B.
run(&mut s, "sketch.finish", json!({}));
assert!(!s.model.state().sketches.iter().find(|x| x.feature == b).unwrap().profiles.is_empty());
}
#[test]
fn copy_and_paste_refuse_politely() {
let mut s = Session::default();
assert!(s.execute("sketch.copy", &json!({"entities": ["l1"]})).is_err());
run(&mut s, "sketch.create", json!({"plane": "XY"}));
assert!(s.execute("sketch.paste", &json!({})).is_err(), "nothing copied");
assert!(s.execute("sketch.copy", &json!({})).is_err(), "nothing selected");
assert!(s.execute("sketch.copy", &json!({"entities": ["nope"]})).is_err());
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
run(&mut s, "sketch.copy", json!({"entities": ["l1"]}));
for bad in [json!({"at": [f64::MAX, 0]}), json!({"at": "x"}), json!({"translate": [1e308, 1e308]})] {
let doc = s.doc.to_json();
assert!(s.execute("sketch.paste", &bad).is_err(), "{bad}");
assert_eq!(s.doc.to_json(), doc);
}
}
#[test]
fn cut_copies_and_deletes_in_one_step_and_pastes_back_in_place() {
let (mut s, rect, circle) = drawing();
let sid = s.active_sketch.unwrap();
let before = sketch(&s, sid).clone();
let undo = s.undo.len();
run(&mut s, "sketch.cut", json!({"entities": [circle]}));
assert_eq!(s.undo.len(), undo + 1);
assert!(sketch(&s, sid).curve_index(&circle).is_none());
// Pasted back without a position: where it was, with its diameter.
let pasted = strs(&run(&mut s, "sketch.paste", json!({}))["curves"]);
let sk = sketch(&s, sid);
assert_eq!(pos(sk, &pasted[0]), pos(&before, &circle));
assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Diameter { .. })));
assert_eq!(pos(sk, &rect[0]), pos(&before, &rect[0]));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(sketch(&s, sid), &before);
}