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 <[email protected]> * Keep a pasted text's height and angle expressions, drop its place's Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7934edcad4
commit
843c548589
20 files changed
+981
-8
No files matched your search
@@ -64,6 +64,7 @@ pub(crate) use sketch::author_path_for_rebind;
|
||||
pub(crate) use sketch::created as sketch_created;
|
||||
mod sketch_3d;
|
||||
mod sketch_canvas;
|
||||
pub mod sketch_clipboard;
|
||||
mod sketch_constraints;
|
||||
mod sketch_create;
|
||||
mod sketch_freeform;
|
||||
@@ -252,6 +253,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(delete::COMMANDS.iter());
|
||||
v.extend(edit::COMMANDS.iter());
|
||||
v.extend(clipboard::COMMANDS.iter());
|
||||
v.extend(sketch_clipboard::COMMANDS.iter());
|
||||
v.extend(script::COMMANDS.iter());
|
||||
v.extend(parameters::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Copy and paste of sketch geometry (#1480). `sketch.copy` keeps the chosen curves and points
|
||||
//! with the constraints and dimensions that lie wholly among them (a dimension keeps its
|
||||
//! expression); `sketch.paste` adds an independent copy to the sketch being edited, at a point or
|
||||
//! offset from the original, as one undo step, and selects it. Constraints that reach outside
|
||||
//! the copied geometry (to the origin, to projected edges, to other curves) are left behind, as
|
||||
//! are fixes, so the pasted geometry can be moved straight away.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::sketch::{edit, target_sketch};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, string_list, vec2};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.copy", "Copy Sketch Entities", copy)
|
||||
.noundo()
|
||||
.enabled(in_sketch)
|
||||
.params("entities?: [curve or point ids] (default: the selected sketch geometry); the constraints and dimensions among them come along"),
|
||||
CommandSpec::new("sketch.cut", "Cut Sketch Entities", cut)
|
||||
.enabled(in_sketch)
|
||||
.params("entities?: [curve or point ids] (default: the selected sketch geometry): copies them as sketch.copy does, then deletes them (one undo step)"),
|
||||
CommandSpec::new("sketch.paste", "Paste Sketch Entities", paste).enabled(in_sketch).params(
|
||||
"at?: [x, y] (sketch point the copy's centre goes to) | translate?: [x, y]; without either, offset from the original in its own sketch and at the same coordinates in another; sketch?: target (default: the active sketch)",
|
||||
),
|
||||
];
|
||||
|
||||
/// A curve of the clipboard; its points are indices into [`SketchClip::points`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClipCurve {
|
||||
pub kind: CurveKind,
|
||||
pub construction: bool,
|
||||
pub centerline: bool,
|
||||
pub reversed: bool,
|
||||
}
|
||||
|
||||
/// A constraint or dimension of the clipboard, over clipboard points and curves. A dimension
|
||||
/// keeps the expression and unit of its parameter.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClipConstraint {
|
||||
pub kind: ConstraintKind,
|
||||
pub param: Option<(String, String)>,
|
||||
pub driven: bool,
|
||||
pub text: Option<Vec2>,
|
||||
}
|
||||
|
||||
/// Sketch geometry copied with `sketch.copy`, in the sketch coordinates it was copied from.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SketchClip {
|
||||
/// The sketch it was copied from.
|
||||
pub sketch: u64,
|
||||
pub points: Vec<Vec2>,
|
||||
pub curves: Vec<ClipCurve>,
|
||||
pub constraints: Vec<ClipConstraint>,
|
||||
/// Text sources (their outlines are made again on paste). A path, when kept, is the index of
|
||||
/// a clipboard curve.
|
||||
pub texts: Vec<(LinkSource, Option<usize>)>,
|
||||
/// Lone points (not used by a copied curve), as indices into `points`.
|
||||
pub lone: Vec<usize>,
|
||||
/// Centre of the copied geometry's bounding box: what `at` places.
|
||||
pub center: Vec2,
|
||||
/// Size of the bounding box (the larger side), for the paste offset.
|
||||
pub extent: f64,
|
||||
/// Pastes made without a position since the copy (each lands one offset further on).
|
||||
pub pastes: u32,
|
||||
}
|
||||
|
||||
/// Curve and point ids named by `entities`, or the selected sketch geometry.
|
||||
fn chosen(s: &Session, p: &Value) -> Vec<String> {
|
||||
if p.get("entities").is_some() {
|
||||
return string_list(p, "entities");
|
||||
}
|
||||
s.selection
|
||||
.iter()
|
||||
.filter_map(|sel| match sel {
|
||||
Sel::SketchCurve { id } | Sel::SketchPoint { id } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn text_of(sk: &Sketch, ci: usize) -> Option<&str> {
|
||||
let link = sk.curves.get(ci)?.link.as_deref()?;
|
||||
sk.link(link).filter(|l| l.kind == LinkKind::Text).map(|l| l.id.as_str())
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.copy";
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let ids = chosen(s, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select the sketch curves or points to copy"));
|
||||
}
|
||||
let sk = s.doc.sketch(id)?;
|
||||
let (mut cs, mut ps, mut texts): (Vec<usize>, Vec<usize>, Vec<String>) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for e in &ids {
|
||||
if let Some(c) = sk.curve_index(e) {
|
||||
match text_of(sk, c) {
|
||||
Some(t) if !texts.iter().any(|x| x == t) => texts.push(t.to_string()),
|
||||
Some(_) => {}
|
||||
None if !cs.contains(&c) => cs.push(c),
|
||||
None => {}
|
||||
}
|
||||
} else if let Some(q) = sk.resolve_point(e) {
|
||||
match sk.points.get(q).and_then(|x| x.link.as_deref()).and_then(|l| sk.link(l)).filter(|l| l.kind == LinkKind::Text) {
|
||||
Some(l) if !texts.contains(&l.id) => texts.push(l.id.clone()),
|
||||
Some(_) => {}
|
||||
None if !ps.contains(&q) => ps.push(q),
|
||||
None => {}
|
||||
}
|
||||
} else {
|
||||
return Err(bad(cmd, format!("unknown sketch entity `{e}`")));
|
||||
}
|
||||
}
|
||||
let mut clip = SketchClip { sketch: id, ..Default::default() };
|
||||
// Points: those of the copied curves, then the lone ones, each once.
|
||||
let mut pmap: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let take = |q: usize, clip: &mut SketchClip, pmap: &mut BTreeMap<usize, usize>| -> usize {
|
||||
*pmap.entry(q).or_insert_with(|| {
|
||||
clip.points.push(sk.point(q).unwrap_or_default());
|
||||
clip.points.len() - 1
|
||||
})
|
||||
};
|
||||
let mut cmap: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
for &c in &cs {
|
||||
let Some(cu) = sk.curves.get(c) else { continue };
|
||||
let mut kind = cu.kind.clone();
|
||||
let used: Vec<(usize, usize)> = kind.point_ids().into_iter().map(|q| (q, take(q, &mut clip, &mut pmap))).collect();
|
||||
kind.map_points(&|q| used.iter().find(|(o, _)| *o == q).map_or(q, |(_, n)| *n));
|
||||
cmap.insert(c, clip.curves.len());
|
||||
// Projected geometry comes in as ordinary geometry.
|
||||
clip.curves.push(ClipCurve { kind, construction: cu.construction, centerline: cu.centerline, reversed: cu.reversed });
|
||||
}
|
||||
for &q in &ps {
|
||||
let known = pmap.contains_key(&q);
|
||||
let n = take(q, &mut clip, &mut pmap);
|
||||
if !known {
|
||||
clip.lone.push(n);
|
||||
}
|
||||
}
|
||||
for c in &sk.constraints {
|
||||
// Fixes and pattern or offset relations stay with the original.
|
||||
if matches!(
|
||||
c.kind,
|
||||
ConstraintKind::Fix { .. }
|
||||
| ConstraintKind::PatternPoint { .. }
|
||||
| ConstraintKind::PatternRadius { .. }
|
||||
| ConstraintKind::OffsetDistance { .. }
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let mut kind = c.kind.clone();
|
||||
if !kind.remap(&|q| pmap.get(&q).copied(), &|k| cmap.get(&k).copied()) {
|
||||
continue;
|
||||
}
|
||||
let param = c.param.as_ref().map(|name| match s.doc.param(name) {
|
||||
Some(x) => (x.expr.clone(), x.unit.clone()),
|
||||
None => (format!("{}", kind.value().unwrap_or_default()), if kind.is_angle() { "rad".into() } else { "mm".into() }),
|
||||
});
|
||||
clip.constraints.push(ClipConstraint { kind, param, driven: c.driven, text: c.text });
|
||||
}
|
||||
for t in &texts {
|
||||
let Some(mut src) = sk.link(t).map(|l| l.source.clone()) else { continue };
|
||||
let LinkSource::Text { style, .. } = &mut src else { continue };
|
||||
// A text on a path copied with its path follows the copy; otherwise it is set straight.
|
||||
let path = style.path.as_deref().and_then(|pid| sk.curve_index(pid)).and_then(|pc| cmap.get(&pc).copied());
|
||||
if path.is_none() {
|
||||
style.path = None;
|
||||
}
|
||||
clip.texts.push((src, path));
|
||||
}
|
||||
// Bounds of everything copied, as drawn.
|
||||
let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
let mut add = |q: Vec2| {
|
||||
lo = Vec2::new(lo.x.min(q.x), lo.y.min(q.y));
|
||||
hi = Vec2::new(hi.x.max(q.x), hi.y.max(q.y));
|
||||
};
|
||||
let mut drawn: Vec<usize> = cs.clone();
|
||||
for t in &texts {
|
||||
drawn.extend(sk.link_curves(t));
|
||||
}
|
||||
for c in drawn {
|
||||
sk.polyline(c).into_iter().for_each(&mut add);
|
||||
}
|
||||
for &n in &clip.lone {
|
||||
if let Some(q) = clip.points.get(n) {
|
||||
add(*q);
|
||||
}
|
||||
}
|
||||
if !(lo.x.is_finite() && hi.x.is_finite()) {
|
||||
return Err(bad(cmd, "nothing to copy"));
|
||||
}
|
||||
clip.center = (lo + hi) * 0.5;
|
||||
clip.extent = (hi.x - lo.x).max(hi.y - lo.y);
|
||||
let out = json!({
|
||||
"curves": clip.curves.len() + texts.iter().map(|t| sk.link_curves(t).len()).sum::<usize>(),
|
||||
"points": clip.lone.len(),
|
||||
"texts": texts.len(),
|
||||
"constraints": clip.constraints.len(),
|
||||
"center": [clip.center.x, clip.center.y],
|
||||
});
|
||||
s.sketch_clipboard = Some(clip);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let entities = chosen(s, p);
|
||||
let mut q = p.clone();
|
||||
q["entities"] = json!(entities);
|
||||
let out = copy(s, &q)?;
|
||||
let delete = super::find_command("sketch.delete").ok_or_else(|| bad("sketch.cut", "sketch.delete is missing"))?;
|
||||
let mut d = json!({ "entities": entities });
|
||||
if let Some(sk) = p.get("sketch") {
|
||||
d["sketch"] = sk.clone();
|
||||
}
|
||||
delete.run_nested(s, &d)?;
|
||||
s.selection.clear();
|
||||
// The cut geometry is gone: a paste back into this sketch goes where it was, not offset.
|
||||
if let Some(c) = s.sketch_clipboard.as_mut() {
|
||||
c.sketch = u64::MAX;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The step a paste without a position moves by: a tenth of the copy's size, rounded up to 1, 2
|
||||
/// or 5 times a power of ten (at least 1 mm).
|
||||
fn paste_step(extent: f64) -> f64 {
|
||||
let want = (extent * 0.1).max(1.0);
|
||||
let mag = 10f64.powf(want.log10().floor());
|
||||
[1.0, 2.0, 5.0, 10.0].iter().map(|k| k * mag).find(|x| *x >= want - 1e-9).unwrap_or(want)
|
||||
}
|
||||
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.paste";
|
||||
let Some(clip) = s.sketch_clipboard.clone() else { return Err(bad(cmd, "nothing copied (sketch.copy first)")) };
|
||||
let target = target_sketch(s, p, cmd)?;
|
||||
let positioned = p.get("at").is_some() || p.get("translate").is_some();
|
||||
let t = if let Some(v) = p.get("at") {
|
||||
vec2(v).ok_or_else(|| bad(cmd, "`at` must be [x, y]"))? - clip.center
|
||||
} else if let Some(v) = p.get("translate") {
|
||||
vec2(v).ok_or_else(|| bad(cmd, "`translate` must be [x, y]"))?
|
||||
} else if target == clip.sketch {
|
||||
let d = paste_step(clip.extent) * f64::from(clip.pastes.saturating_add(1));
|
||||
Vec2::new(d, -d)
|
||||
} else {
|
||||
Vec2::ZERO
|
||||
};
|
||||
if !(t.x.is_finite() && t.y.is_finite()) {
|
||||
return Err(bad(cmd, "the paste position must be finite"));
|
||||
}
|
||||
let mut p = p.clone();
|
||||
p["sketch"] = json!(target);
|
||||
let ((curves, lone, constraints), info) = edit(s, &p, cmd, false, |sk, doc| {
|
||||
if sk.curves.len().saturating_add(clip.curves.len()) > solidcraft_sketch::MAX_CURVES {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let mut pts = Vec::with_capacity(clip.points.len());
|
||||
for q in &clip.points {
|
||||
pts.push(sk.add_point(*q + t, None)?);
|
||||
}
|
||||
let mut made = Vec::with_capacity(clip.curves.len());
|
||||
for c in &clip.curves {
|
||||
let mut kind = c.kind.clone();
|
||||
kind.map_points(&|q| pts.get(q).copied().unwrap_or(usize::MAX));
|
||||
let i = sk.add_curve(kind, None)?;
|
||||
if let Some(cu) = sk.curves.get_mut(i) {
|
||||
cu.construction = c.construction;
|
||||
cu.centerline = c.centerline;
|
||||
cu.reversed = c.reversed;
|
||||
}
|
||||
made.push(i);
|
||||
}
|
||||
let mut cons = Vec::new();
|
||||
for c in &clip.constraints {
|
||||
let mut kind = c.kind.clone();
|
||||
if !kind.remap(&|q| pts.get(q).copied(), &|k| made.get(k).copied()) {
|
||||
continue;
|
||||
}
|
||||
// Each pasted dimension gets its own parameter, with the same expression.
|
||||
let param = c.param.as_ref().map(|(e, u)| doc.new_model_param(e, u));
|
||||
let id = sk.add_constraint(kind, param)?;
|
||||
if let Some(nc) = sk.constraints.last_mut() {
|
||||
nc.driven = c.driven;
|
||||
nc.text = c.text;
|
||||
}
|
||||
cons.push(id);
|
||||
}
|
||||
let mut curve_ids: Vec<String> = made.iter().filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone())).collect();
|
||||
for (src, path) in &clip.texts {
|
||||
let mut src = src.clone();
|
||||
let LinkSource::Text { at, style, exprs, .. } = &mut src else { continue };
|
||||
style.path = path.and_then(|k| made.get(k)).and_then(|i| sk.curves.get(*i)).map(|c| c.id.clone());
|
||||
*at += t;
|
||||
// The copy sits where it was pasted: its place no longer follows the original's expressions.
|
||||
exprs.x = None;
|
||||
exprs.y = None;
|
||||
let geom = sk.text_source_geometry(&src)?;
|
||||
let link = sk.add_link(LinkKind::Text, src, &geom)?;
|
||||
curve_ids.extend(sk.link_curves(&link).into_iter().filter_map(|i| sk.curves.get(i).map(|c| c.id.clone())));
|
||||
}
|
||||
let lone: Vec<String> = clip.lone.iter().filter_map(|n| pts.get(*n)).filter_map(|i| sk.points.get(*i).map(|q| q.id.clone())).collect();
|
||||
Ok((curve_ids, lone, cons))
|
||||
})?;
|
||||
if !positioned
|
||||
&& target == clip.sketch
|
||||
&& let Some(c) = s.sketch_clipboard.as_mut()
|
||||
{
|
||||
c.pastes = c.pastes.saturating_add(1);
|
||||
}
|
||||
// The pasted geometry is selected, ready to move.
|
||||
if s.active_sketch == Some(target) {
|
||||
s.selection =
|
||||
curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).chain(lone.iter().map(|id| Sel::SketchPoint { id: id.clone() })).collect();
|
||||
}
|
||||
Ok(json!({"curves": curves, "points": lone, "constraints": constraints, "translate": [t.x, t.y], "sketch": info}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_clipboard_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,233 @@
|
||||
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, ¶ms).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);
|
||||
}
|
||||
Reference in new issue
Block a user