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:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 15:55:10 -04:00
1 parent 7934edcad4
commit 843c548589
20 files changed
+981 -8

No files matched your search

+3
View File
@@ -840,6 +840,8 @@
"cmd.sketch_constraint_fix": "Fixieren/Lösen",
"cmd.sketch_constraint_midpoint": "Mittelpunkt",
"cmd.sketch_construction": "Normal/Konstruktion",
"cmd.sketch_copy": "Skizzenelemente kopieren",
"cmd.sketch_cut": "Skizzenelemente ausschneiden",
"cmd.sketch_delete": "Skizzenentitäten löschen",
"cmd.sketch_dimension_text": "Maßtext verschieben",
"cmd.sketch_drag": "Skizzengeometrie ziehen",
@@ -864,6 +866,7 @@
"cmd.sketch_move_point": "Skizzenpunkt ziehen",
"cmd.sketch_offset_break": "Versatzverknüpfung trennen",
"cmd.sketch_options": "Skizzenpalettenoptionen",
"cmd.sketch_paste": "Skizzenelemente einfügen",
"cmd.sketch_point3d": "3D-Punkt",
"cmd.sketch_polygon_circumscribed": "Umgeschriebenes Polygon",
"cmd.sketch_polygon_edge": "Polygon über eine Kante",
+3
View File
@@ -203,6 +203,8 @@
"cmd.sketch_constraint_fix": "Fix/UnFix",
"cmd.sketch_constraint_midpoint": "MidPoint",
"cmd.sketch_construction": "Normal/Construction",
"cmd.sketch_copy": "Copy Sketch Entities",
"cmd.sketch_cut": "Cut Sketch Entities",
"cmd.sketch_delete": "Delete Sketch Entities",
"cmd.sketch_dimension_text": "Move Dimension Text",
"cmd.sketch_drag": "Drag Sketch Geometry",
@@ -227,6 +229,7 @@
"cmd.sketch_move_point": "Drag Sketch Point",
"cmd.sketch_offset_break": "Break Offset Association",
"cmd.sketch_options": "Sketch Palette Options",
"cmd.sketch_paste": "Paste Sketch Entities",
"cmd.sketch_point3d": "3D Point",
"cmd.sketch_polygon_circumscribed": "Circumscribed Polygon",
"cmd.sketch_polygon_edge": "Edge Polygon",
+3
View File
@@ -203,6 +203,8 @@
"cmd.sketch_constraint_fix": "Fixer/Libérer",
"cmd.sketch_constraint_midpoint": "Point milieu",
"cmd.sketch_construction": "Normal/Construction",
"cmd.sketch_copy": "Copier les entités d'esquisse",
"cmd.sketch_cut": "Couper les entités d'esquisse",
"cmd.sketch_delete": "Supprimer des entités d'esquisse",
"cmd.sketch_dimension_text": "Déplacer le texte de cote",
"cmd.sketch_drag": "Faire glisser la géométrie d'esquisse",
@@ -227,6 +229,7 @@
"cmd.sketch_move_point": "Faire glisser un point d'esquisse",
"cmd.sketch_offset_break": "Rompre l'association du décalage",
"cmd.sketch_options": "Options de la palette d'esquisse",
"cmd.sketch_paste": "Coller les entités d'esquisse",
"cmd.sketch_point3d": "Point 3D",
"cmd.sketch_polygon_circumscribed": "Polygone circonscrit",
"cmd.sketch_polygon_edge": "Polygone par côté",
+3
View File
@@ -203,6 +203,8 @@
"cmd.sketch_constraint_fix": "固定/固定解除",
"cmd.sketch_constraint_midpoint": "中点",
"cmd.sketch_construction": "標準/コンストラクション",
"cmd.sketch_copy": "スケッチ要素をコピー",
"cmd.sketch_cut": "スケッチ要素を切り取り",
"cmd.sketch_delete": "スケッチ要素を削除",
"cmd.sketch_dimension_text": "寸法テキストを移動",
"cmd.sketch_drag": "スケッチ ジオメトリをドラッグ",
@@ -227,6 +229,7 @@
"cmd.sketch_move_point": "スケッチ点をドラッグ",
"cmd.sketch_offset_break": "オフセット関連付けを解除",
"cmd.sketch_options": "スケッチ パレット オプション",
"cmd.sketch_paste": "スケッチ要素を貼り付け",
"cmd.sketch_point3d": "3D 点",
"cmd.sketch_polygon_circumscribed": "外接ポリゴン",
"cmd.sketch_polygon_edge": "エッジ ポリゴン",
+3
View File
@@ -840,6 +840,8 @@
"cmd.sketch_constraint_fix": "고정/고정 해제",
"cmd.sketch_constraint_midpoint": "중간점",
"cmd.sketch_construction": "일반/구성선",
"cmd.sketch_copy": "스케치 요소 복사",
"cmd.sketch_cut": "스케치 요소 잘라내기",
"cmd.sketch_delete": "스케치 개체 삭제",
"cmd.sketch_dimension_text": "치수 텍스트 이동",
"cmd.sketch_drag": "스케치 기하 드래그",
@@ -864,6 +866,7 @@
"cmd.sketch_move_point": "스케치 점 드래그",
"cmd.sketch_offset_break": "오프셋 연결 끊기",
"cmd.sketch_options": "스케치 팔레트 옵션",
"cmd.sketch_paste": "스케치 요소 붙여넣기",
"cmd.sketch_point3d": "3D 점",
"cmd.sketch_polygon_circumscribed": "외접 다각형",
"cmd.sketch_polygon_edge": "변 다각형",
+3
View File
@@ -191,6 +191,8 @@
"cmd.sketch_constraint_fix": "Зафиксировать/Освободить",
"cmd.sketch_constraint_midpoint": "Середина",
"cmd.sketch_construction": "Обычная/Вспомогательная",
"cmd.sketch_copy": "Копировать элементы эскиза",
"cmd.sketch_cut": "Вырезать элементы эскиза",
"cmd.sketch_delete": "Удалить объекты эскиза",
"cmd.sketch_dimension_text": "Переместить текст размера",
"cmd.sketch_drag": "Перетащить геометрию эскиза",
@@ -215,6 +217,7 @@
"cmd.sketch_move_point": "Перетащить точку эскиза",
"cmd.sketch_offset_break": "Разорвать связь смещения",
"cmd.sketch_options": "Параметры палитры эскиза",
"cmd.sketch_paste": "Вставить элементы эскиза",
"cmd.sketch_point3d": "3D-точка",
"cmd.sketch_polygon_circumscribed": "Описанный многоугольник",
"cmd.sketch_polygon_edge": "Многоугольник по ребру",
+3
View File
@@ -193,6 +193,8 @@
"cmd.sketch_constraint_fix": "Закріпити/розкріпити",
"cmd.sketch_constraint_midpoint": "Середня точка",
"cmd.sketch_construction": "Звичайна/конструкційна",
"cmd.sketch_copy": "Копіювати елементи ескізу",
"cmd.sketch_cut": "Вирізати елементи ескізу",
"cmd.sketch_delete": "Видалити елементи ескізу",
"cmd.sketch_dimension_text": "Перемістити текст розміру",
"cmd.sketch_drag": "Перетягнути геометрію ескізу",
@@ -217,6 +219,7 @@
"cmd.sketch_move_point": "Перетягнути точку ескізу",
"cmd.sketch_offset_break": "Розірвати зв'язок зсуву",
"cmd.sketch_options": "Параметри палітри ескізу",
"cmd.sketch_paste": "Вставити елементи ескізу",
"cmd.sketch_point3d": "3D-точка",
"cmd.sketch_polygon_circumscribed": "Описаний багатокутник",
"cmd.sketch_polygon_edge": "Багатокутник за ребром",
+3
View File
@@ -203,6 +203,8 @@
"cmd.sketch_constraint_fix": "固定/解除固定",
"cmd.sketch_constraint_midpoint": "中点",
"cmd.sketch_construction": "普通/构造",
"cmd.sketch_copy": "复制草图图元",
"cmd.sketch_cut": "剪切草图图元",
"cmd.sketch_delete": "删除草图图元",
"cmd.sketch_dimension_text": "移动尺寸文字",
"cmd.sketch_drag": "拖动草图几何",
@@ -227,6 +229,7 @@
"cmd.sketch_move_point": "拖动草图点",
"cmd.sketch_offset_break": "断开偏移关联",
"cmd.sketch_options": "草图选项板选项",
"cmd.sketch_paste": "粘贴草图图元",
"cmd.sketch_point3d": "三维点",
"cmd.sketch_polygon_circumscribed": "外切多边形",
"cmd.sketch_polygon_edge": "边长多边形",
+2
View File
@@ -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());
+325
View File
@@ -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, &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);
}
+4
View File
@@ -222,6 +222,8 @@ pub struct Session {
removed_later_cache: std::sync::Mutex<Option<occurrences::RemovedLaterCache>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solidcraft_doc::Feature>,
/// Sketch geometry copied with `sketch.copy` (pasted by `sketch.paste`).
pub sketch_clipboard: Option<cmd::sketch_clipboard::SketchClip>,
/// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden).
pub section: Option<(Vec3, Vec3)>,
/// Surface analysis shading the model (zebra, draft, curvature map).
@@ -326,6 +328,7 @@ impl Session {
occurrence_visibility_cache: Default::default(),
removed_later_cache: Default::default(),
clipboard: Vec::new(),
sketch_clipboard: None,
section: None,
analysis: None,
checkpoints: Vec::new(),
@@ -792,6 +795,7 @@ impl Session {
occurrence_visibility_cache: Default::default(),
removed_later_cache: Default::default(),
clipboard: self.clipboard.clone(),
sketch_clipboard: self.sketch_clipboard.clone(),
section: None,
analysis: None,
checkpoints: Vec::new(),
+3 -1
View File
@@ -438,7 +438,9 @@ impl ConstraintKind {
OffsetDistance { .. } => (vec![], vec![]),
}
}
pub(crate) fn remap(&mut self, pmap: &dyn Fn(usize) -> Option<usize>, cmap: &dyn Fn(usize) -> Option<usize>) -> bool {
/// Renumber the points and curves the constraint refers to; false when one has no new
/// number (the constraint is then partly renumbered and should be dropped).
pub fn remap(&mut self, pmap: &dyn Fn(usize) -> Option<usize>, cmap: &dyn Fn(usize) -> Option<usize>) -> bool {
use ConstraintKind::*;
let mut ok = true;
let mut fp = |i: &mut usize| match pmap(*i) {
+8
View File
@@ -756,6 +756,8 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
v.push(cmd(app, "sketch.rectangle.two_point", "Rectangle"));
v.push(cmd(app, "sketch.circle.center", "Circle"));
v.push(cmd(app, "sketch.dimension", "Sketch Dimension"));
v.push(Item::sep());
v.push(act("ui.paste", "Paste", "").key("Ctrl+V").on(app.session.sketch_clipboard.is_some()));
return v;
}
let ents: Vec<String> = curves.iter().chain(points.iter()).cloned().collect();
@@ -770,6 +772,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
v.push(cmd(app, "sketch.edit_text", "Edit Text"));
}
v.push(cmd(app, "sketch.move", "Move/Copy").key("M"));
v.push(act("ui.cut", "Cut", "").key("Ctrl+X"));
v.push(act("ui.copy", "Copy", "").key("Ctrl+C"));
v.push(act("ui.paste", "Paste", "").key("Ctrl+V").on(app.session.sketch_clipboard.is_some()));
if !curves.is_empty() {
v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves })));
}
@@ -852,6 +857,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.delete" if item.params.is_some() => crate::delete::request(app, p),
"ui.delete" => delete(app),
"ui.finishSketch" => app.finish_sketch(),
"ui.copy" => drop(crate::edit_clipboard::copy(app)),
"ui.cut" => drop(crate::edit_clipboard::cut(app)),
"ui.paste" => drop(crate::edit_clipboard::paste(app, Some(at))),
"ui.editSketchPattern" => {
if let Some(id) = p.get("pattern").and_then(Value::as_str) {
crate::sketch_modify_ui::edit_pattern(app, id);
+183
View File
@@ -0,0 +1,183 @@
//! Copy, Cut and Paste from the keyboard (Ctrl+C / Ctrl+X / Ctrl+V, ⌘C / ⌘X / ⌘V) and the context
//! menu (#1480).
//!
//! In a sketch they copy the selected sketch geometry (`sketch.copy`) and paste it at the cursor
//! (`sketch.paste` with `at`); Cut copies the sketch geometry and deletes it (`sketch.cut`), or offset from the original when the cursor is not over the
//! viewport. Outside a sketch they copy the selected timeline features and paste them at the
//! history marker (`timeline.copy` / `timeline.paste`).
//!
//! The window system turns Ctrl+C / Ctrl+V into clipboard events, not key presses, and only sends
//! the paste event while the system clipboard holds text. So a copy also puts a short line of
//! text on the system clipboard (a menu's Copy leaves it for the next frame, which has the
//! context to write it).
use std::sync::Mutex;
use egui::Pos2;
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use crate::SolveApp;
/// Text for the system clipboard, written on the next frame.
static PENDING_TEXT: Mutex<Option<String>> = Mutex::new(None);
fn leave_text(text: String) {
if let Ok(mut pending) = PENDING_TEXT.lock() {
*pending = Some(text);
}
}
/// The selected sketch geometry of the active sketch.
fn sketch_selection(app: &SolveApp) -> Vec<String> {
app.session
.selection
.iter()
.filter_map(|s| match s {
Sel::SketchCurve { id } | Sel::SketchPoint { id } => Some(id.clone()),
_ => None,
})
.collect()
}
/// Is there something Copy would copy?
pub fn can_copy(app: &SolveApp) -> bool {
if app.session.active_sketch.is_some() { !sketch_selection(app).is_empty() } else { !crate::timeline::selected_feature_ids(app).is_empty() }
}
/// Is there something Paste would paste here?
pub fn can_paste(app: &SolveApp) -> bool {
if app.session.active_sketch.is_some() { app.session.sketch_clipboard.is_some() } else { !app.session.clipboard.is_empty() }
}
/// Copy the selection: sketch geometry in a sketch, otherwise timeline features.
pub fn copy(app: &mut SolveApp) -> bool {
if app.session.active_sketch.is_some() {
let entities = sketch_selection(app);
if entities.is_empty() {
app.set_status("Select sketch geometry to copy", true);
return false;
}
let Ok(out) = app.run("sketch.copy", json!({ "entities": entities })) else { return false };
let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0);
let k = out.get("constraints").and_then(Value::as_u64).unwrap_or(0);
let text = format!("SolidCraft sketch geometry: {n} entities, {k} constraints and dimensions");
app.set_status(format!("Copied {n} sketch entities with {k} constraints and dimensions"), false);
leave_text(text);
return true;
}
let features = crate::timeline::selected_feature_ids(app);
if features.is_empty() {
app.set_status("Select sketch geometry or timeline features to copy", true);
return false;
}
let Ok(out) = app.run("timeline.copy", json!({ "features": features })) else { return false };
let names: Vec<&str> = out.get("copied").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
app.set_status(format!("Copied {}", names.join(", ")), false);
leave_text(format!("SolidCraft features: {}", names.join(", ")));
true
}
/// Cut the selected sketch geometry: copy it, then delete it (one undo step).
pub fn cut(app: &mut SolveApp) -> bool {
if app.session.active_sketch.is_none() {
app.set_status("Cut works on sketch geometry: edit a sketch and select some", true);
return false;
}
let entities = sketch_selection(app);
if entities.is_empty() {
app.set_status("Select sketch geometry to cut", true);
return false;
}
let Ok(out) = app.run("sketch.cut", json!({ "entities": entities })) else { return false };
let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0);
app.set_status(format!("Cut {n} sketch entities"), false);
leave_text(format!("SolidCraft sketch geometry: {n} entities"));
true
}
/// The point of the active sketch under `at` (screen), when `at` is over the viewport.
fn sketch_point(app: &SolveApp, at: Option<Pos2>) -> Option<[f64; 2]> {
let at = at?;
let rect = app.viewport.rect?;
if !rect.contains(at) {
return None;
}
let proj = crate::viewport::projection(app, rect);
let (p, _) = crate::viewport::sketch_point_at(app, &proj, at)?;
Some([p.x, p.y])
}
/// Paste: in a sketch, the copied sketch geometry centred at `at` (or offset from the original
/// when `at` is not over the sketch); otherwise the copied features at the history marker.
pub fn paste(app: &mut SolveApp, at: Option<Pos2>) -> bool {
if app.session.active_sketch.is_some() {
if app.session.sketch_clipboard.is_none() {
app.set_status("Nothing to paste: copy sketch geometry first", true);
return false;
}
let params = match sketch_point(app, at) {
Some(p) => json!({ "at": p }),
None => json!({}),
};
let Ok(out) = app.run("sketch.paste", params) else { return false };
let n = out.get("curves").and_then(Value::as_array).map_or(0, Vec::len) + out.get("points").and_then(Value::as_array).map_or(0, Vec::len);
app.set_status(format!("Pasted {n} sketch entities (selected: drag them, or press M to move them)"), false);
return true;
}
if app.session.clipboard.is_empty() {
app.set_status("Nothing to paste: copy timeline features first", true);
return false;
}
app.run("timeline.paste", json!({})).is_ok()
}
/// The frame's Copy, Cut and Paste events (and Ctrl+C / Ctrl+X / Ctrl+V sent as key presses), plus any
/// clipboard text a menu's Copy left. Called by the global shortcuts when no text field has the
/// keyboard.
pub fn handle(app: &mut SolveApp, ctx: &egui::Context) {
let (copies, cuts, pastes, pointer) = ctx.input(|i| {
let mut copies = 0;
let mut cuts = 0;
let mut pastes = 0;
for e in &i.events {
match e {
egui::Event::Copy => copies += 1,
egui::Event::Cut => cuts += 1,
egui::Event::Paste(_) => pastes += 1,
egui::Event::Key { key: egui::Key::C, pressed: true, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt => {
copies += 1
}
egui::Event::Key { key: egui::Key::X, pressed: true, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt => {
cuts += 1
}
egui::Event::Key { key: egui::Key::V, pressed: true, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt => {
pastes += 1
}
_ => {}
}
}
(copies, cuts, pastes, i.pointer.latest_pos())
});
if copies > 0 {
copy(app);
}
if cuts > 0 {
cut(app);
}
if pastes > 0 {
paste(app, pointer);
}
flush(ctx);
}
/// Write clipboard text a copy left.
pub fn flush(ctx: &egui::Context) {
if let Some(text) = PENDING_TEXT.lock().ok().and_then(|mut p| p.take()) {
ctx.copy_text(text);
}
}
#[cfg(test)]
#[path = "edit_clipboard_tests.rs"]
mod tests;
+164
View File
@@ -0,0 +1,164 @@
//! Copy and Paste from the keyboard and the context menu (#1480).
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::geom::Vec2;
use crate::scenario::Harness;
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()
}
/// A sketch on XY with a 20 × 10 rectangle (dimensioned, its corner on the origin), looked at
/// from the top, and the rectangle selected.
fn rectangle() -> (Harness, Vec<String>) {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
let rect = strs(&h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})).unwrap()["curves"]);
h.app.run("sketch.constraint.coincident", json!({"a": format!("{}.start", rect[0]), "b": "origin"})).unwrap();
h.app.run("sketch.dimension", json!({"entities": [rect[0]], "value": 20})).unwrap();
h.app.run("sketch.dimension", json!({"entities": [rect[1]], "value": 10})).unwrap();
h.call("ui.view", json!({"view": "top"}));
h.call("ui.camera", json!({"target": [20, 10, 0], "distance": 150}));
let items: Vec<Value> = rect.iter().map(|id| json!({"type": "sketch_curve", "id": id})).collect();
h.app.run("select.set", json!({ "items": items })).unwrap();
h.frames(4);
(h, rect)
}
/// Send the window system's clipboard event (what Ctrl+C / Ctrl+V become), with the pointer at
/// `at` (sketch coordinates) or off the viewport.
fn clipboard_event(h: &mut Harness, event: egui::Event, at: Option<[f64; 2]>) {
match at {
Some(p) => h.step(&json!({"move": {"sketch": p}})).unwrap(),
None => drop(h.call("ui.move", json!({"x": 100, "y": 990}))),
}
h.app.synthetic.push(event);
h.settle();
}
fn curves(h: &Harness) -> Vec<String> {
h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap().curves.iter().map(|c| c.id.clone()).collect()
}
fn start(h: &Harness, id: &str) -> Vec2 {
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let c = &sk.curves[sk.curve_index(id).unwrap()];
sk.point(c.kind.point_ids()[0]).unwrap()
}
#[test]
fn ctrl_c_ctrl_v_pastes_the_sketch_selection_at_the_cursor_selected_in_one_undo_step() {
let (mut h, rect) = rectangle();
let before = curves(&h);
let undo = h.app.session.undo.len();
clipboard_event(&mut h, egui::Event::Copy, Some([10.0, 5.0]));
assert!(h.app.session.sketch_clipboard.is_some(), "{:?}", h.app.status);
// The window system only sends Paste while its clipboard holds text: a copy leaves some.
h.call("ui.inspect", json!({}));
clipboard_event(&mut h, egui::Event::Paste("x".into()), Some([60.0, 40.0]));
let after = curves(&h);
assert_eq!(after.len(), before.len() + 4, "{:?}", h.app.status);
let pasted: Vec<String> = after.iter().filter(|c| !before.contains(c)).cloned().collect();
// Centred at the cursor: the rectangle (centre 10, 5) goes to (60, 40), give or take the snap.
let corner = start(&h, &pasted[0]);
assert!(corner.dist(Vec2::new(50.0, 35.0)) < 1.0, "{corner:?}");
// The originals stay put, with their constraints.
assert!(start(&h, &rect[0]).dist(Vec2::ZERO) < 1e-9);
// Selected, ready to move.
let selected: Vec<String> =
h.app.session.selection.iter().filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }).collect();
assert_eq!(selected, pasted);
// One undo step.
assert_eq!(h.app.session.undo.len(), undo + 1);
h.step(&json!({"key": "Z", "cmd": true})).unwrap();
assert_eq!(curves(&h), before);
}
#[test]
fn copy_leaves_text_on_the_system_clipboard_so_paste_can_follow() {
let (mut h, _) = rectangle();
h.step(&json!({"move": {"sketch": [10, 5]}})).unwrap();
h.app.synthetic.push(egui::Event::Copy);
let mut copied = Vec::new();
for _ in 0..6 {
h.frame();
copied.extend(h.output.commands.iter().filter_map(|c| match c {
egui::OutputCommand::CopyText(text) => Some(text.clone()),
_ => None,
}));
}
assert!(copied.iter().any(|t| t.starts_with("SolidCraft sketch geometry")), "{copied:?}");
}
#[test]
fn paste_away_from_the_viewport_offsets_from_the_original_and_cmd_keys_work_too() {
let (mut h, rect) = rectangle();
let before = curves(&h);
// Ctrl+C / Ctrl+V as key presses (as the control channel and some platforms send them).
h.step(&json!({"move": {"sketch": [10, 5]}})).unwrap();
h.step(&json!({"key": "C", "cmd": true})).unwrap();
h.call("ui.move", json!({"x": 100, "y": 990}));
h.step(&json!({"key": "V", "cmd": true})).unwrap();
let pasted: Vec<String> = curves(&h).into_iter().filter(|c| !before.contains(c)).collect();
assert_eq!(pasted.len(), 4, "{:?}", h.app.status);
// 20 mm wide: a 2 mm step down and to the right.
let d = start(&h, &pasted[0]) - start(&h, &rect[0]);
assert!(d.dist(Vec2::new(2.0, -2.0)) < 1e-6, "{d:?}");
}
#[test]
fn the_context_menu_copies_and_pastes_sketch_geometry_at_the_click() {
let (mut h, rect) = rectangle();
let before = curves(&h);
h.step(&json!({"click": {"sketch": [10, 0]}, "button": "right"})).unwrap();
let menu = h.call("ui.menu", json!({}));
let labels: Vec<&str> = menu["result"]["items"].as_array().unwrap().iter().filter_map(|i| i["label"].as_str()).collect();
assert!(labels.contains(&"Copy") && labels.contains(&"Paste"), "{labels:?}");
h.call("ui.menuPick", json!({"item": "Copy"}));
assert!(h.app.session.sketch_clipboard.is_some());
h.step(&json!({"click": {"sketch": [70, 30]}, "button": "right"})).unwrap();
h.call("ui.menuPick", json!({"item": "Paste"}));
h.frames(3);
let pasted: Vec<String> = curves(&h).into_iter().filter(|c| !before.contains(c)).collect();
assert!(!pasted.is_empty(), "{:?}", h.app.status);
// Whatever the right-click selected was copied, and its copy is centred on the click.
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let pts: Vec<Vec2> = pasted.iter().flat_map(|c| sk.curves[sk.curve_index(c).unwrap()].kind.point_ids()).map(|i| sk.point(i).unwrap()).collect();
let (lo, hi) = pts.iter().fold((Vec2::new(1e9, 1e9), Vec2::new(-1e9, -1e9)), |(lo, hi), p| {
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
});
assert!(((lo + hi) * 0.5).dist(Vec2::new(70.0, 30.0)) < 1.0, "{lo:?} {hi:?}");
assert!(start(&h, &rect[0]).dist(Vec2::ZERO) < 1e-9);
}
#[test]
fn ctrl_c_ctrl_v_outside_a_sketch_copies_and_pastes_timeline_features() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
h.frames(3);
let id = h.app.session.doc.features[0].id;
h.app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})).unwrap();
h.frames(2);
clipboard_event(&mut h, egui::Event::Copy, None);
clipboard_event(&mut h, egui::Event::Paste("x".into()), None);
assert_eq!(h.app.session.doc.features.len(), 2, "{:?}", h.app.status);
}
#[test]
fn ctrl_x_cuts_the_sketch_selection_and_ctrl_v_brings_it_back() {
let (mut h, rect) = rectangle();
let before = curves(&h);
clipboard_event(&mut h, egui::Event::Cut, Some([10.0, 5.0]));
let left = curves(&h);
assert!(rect.iter().all(|c| !left.contains(c)), "{:?}", h.app.status);
assert!(h.app.session.sketch_clipboard.is_some());
clipboard_event(&mut h, egui::Event::Paste("x".into()), Some([10.0, 5.0]));
assert_eq!(curves(&h).len(), before.len(), "{:?}", h.app.status);
// The context menu offers Cut too.
h.step(&json!({"click": {"sketch": [10, 0]}, "button": "right"})).unwrap();
let menu = h.call("ui.menu", json!({}));
let labels: Vec<&str> = menu["result"]["items"].as_array().unwrap().iter().filter_map(|i| i["label"].as_str()).collect();
assert!(labels.contains(&"Cut"), "{labels:?}");
}
+19 -2
View File
@@ -13,8 +13,25 @@ use crate::SolveApp;
use crate::theme::Tokens;
/// Keys the application handles before any command shortcut.
pub const RESERVED: &[&str] =
&["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma", "Ctrl+Shift+A"];
pub const RESERVED: &[&str] = &[
"Escape",
"S",
"V",
"F2",
"F6",
"Delete",
"Backspace",
"Ctrl+Z",
"Ctrl+Y",
"Ctrl+S",
"Ctrl+N",
"Ctrl+O",
"Ctrl+Comma",
"Ctrl+Shift+A",
"Ctrl+C",
"Ctrl+X",
"Ctrl+V",
];
#[derive(Default)]
pub struct Keymap {
+1
View File
@@ -39,6 +39,7 @@ pub mod dim_view;
mod dimension_create_ui_tests;
pub mod documents;
pub mod drag_snap;
pub mod edit_clipboard;
pub mod fonts;
pub mod frame;
pub mod gizmo;
+7
View File
@@ -1261,6 +1261,11 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
});
// Home hides the design: only New, Open and Preferences work there.
let shown = design_shown(app);
if shown {
crate::edit_clipboard::handle(app, ctx);
} else {
crate::edit_clipboard::flush(ctx);
}
for k in keys {
use egui::Key;
if mods.command {
@@ -1277,6 +1282,8 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
_ if !shown => {}
Key::Z => drop(app.run("edit.undo", json!({}))),
Key::Y => drop(app.run("edit.redo", json!({}))),
// Copy, Cut and Paste (`edit_clipboard::handle`).
Key::C | Key::X | Key::V if !mods.shift && !mods.alt => {}
Key::S => save(app),
_ => crate::keymap::press(app, &crate::keymap::key_name(k, mods)),
}
+8 -5
View File
@@ -1,8 +1,8 @@
# Features
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **368 commands.** Planned (registered, not built yet): 4. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **373 commands.** Planned (registered, not built yet): 3. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
## Sketch (99)
## Sketch (102)
| Command | Id | Toolbar |
|---|---|---|
@@ -104,6 +104,9 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Break Link | `sketch.break_link` | – |
| Auto-Project Edges on Reference | `sketch.auto_project` | – |
| Redefine Sketch Plane | `sketch.redefine` | – |
| Copy Sketch Entities | `sketch.copy` | – |
| Cut Sketch Entities | `sketch.cut` | – |
| Paste Sketch Entities | `sketch.paste` | – |
| Inspect Sketch | `sketch.inspect` | – |
## Solid modelling (35)
@@ -146,7 +149,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Align | `solid.align` | SOLID › MODIFY |
| Remove | `solid.remove` | SOLID › MODIFY |
## Surfaces (14, 3 planned)
## Surfaces (15, 2 planned)
| Command | Id | Toolbar |
|---|---|---|
@@ -162,7 +165,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Offset Surface | `surface.offset` | SURFACE › CREATE |
| Ruled Surface | `surface.ruled` | SURFACE › CREATE |
| Boundary Surface *(planned: [#763](https://github.com/bherbruck/solidcraft/issues/763))* | `surface.boundary` | SURFACE › CREATE |
| Blend Surface *(planned: [#764](https://github.com/bherbruck/solidcraft/issues/764))* | `surface.blend` | SURFACE › CREATE |
| Blend Surface | `surface.blend` | SURFACE › CREATE |
| Untrim | `surface.untrim` | SURFACE › MODIFY |
| Unstitch | `surface.unstitch` | SURFACE › MODIFY |
| Reverse Normal | `surface.reverse_normal` | SURFACE › MODIFY |
@@ -238,7 +241,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Paste New | `component.paste_new` | – |
| Capture Position | `component.capture_position` | SOLID › POSITION |
| Revert Position | `component.revert_position` | SOLID › POSITION |
| Insert Design | `component.insert_design` | – |
| Insert Design | `component.insert_design` | SOLID › ASSEMBLE |
| Update Links | `component.update_links` | – |
| Break Link | `component.break_link` | – |
| List Links | `component.links` | – |