From 843c54858909172b203d116df51970303292e4c6 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 15:55:10 -0400 Subject: [PATCH] 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 * Keep a pasted text's height and angle expressions, drop its place's Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- assets/locales/de.json | 3 + assets/locales/en.json | 3 + assets/locales/fr.json | 3 + assets/locales/ja.json | 3 + assets/locales/ko.json | 3 + assets/locales/ru.json | 3 + assets/locales/uk.json | 3 + assets/locales/zh.json | 3 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/sketch_clipboard.rs | 325 ++++++++++++++++++ .../engine/src/cmd/sketch_clipboard_tests.rs | 233 +++++++++++++ crates/engine/src/lib.rs | 4 + crates/sketch/src/model.rs | 4 +- crates/ui-egui/src/context_menu.rs | 8 + crates/ui-egui/src/edit_clipboard.rs | 183 ++++++++++ crates/ui-egui/src/edit_clipboard_tests.rs | 164 +++++++++ crates/ui-egui/src/keymap.rs | 21 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/toolbar.rs | 7 + docs/features.md | 13 +- 20 files changed, 981 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_clipboard.rs create mode 100644 crates/engine/src/cmd/sketch_clipboard_tests.rs create mode 100644 crates/ui-egui/src/edit_clipboard.rs create mode 100644 crates/ui-egui/src/edit_clipboard_tests.rs diff --git a/assets/locales/de.json b/assets/locales/de.json index f0b16b46..35e5f8af 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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", diff --git a/assets/locales/en.json b/assets/locales/en.json index 537634b5..2b8345bc 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 029212ff..0b185008 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -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é", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 2eee9b3b..5f718d41 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "エッジ ポリゴン", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 25c9e393..a2b1c812 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "변 다각형", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 9ebf530a..15477724 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -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": "Многоугольник по ребру", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 1842a89c..a5ea3674 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -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": "Багатокутник за ребром", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 64799acc..2168aa33 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "边长多边形", diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 18c3f103..bb6ed59d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/sketch_clipboard.rs b/crates/engine/src/cmd/sketch_clipboard.rs new file mode 100644 index 00000000..5818063c --- /dev/null +++ b/crates/engine/src/cmd/sketch_clipboard.rs @@ -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, +} + +/// 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, + pub curves: Vec, + pub constraints: Vec, + /// 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)>, + /// Lone points (not used by a copied curve), as indices into `points`. + pub lone: Vec, + /// 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 { + 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 { + 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, Vec, Vec) = (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 = BTreeMap::new(); + let take = |q: usize, clip: &mut SketchClip, pmap: &mut BTreeMap| -> usize { + *pmap.entry(q).or_insert_with(|| { + clip.points.push(sk.point(q).unwrap_or_default()); + clip.points.len() - 1 + }) + }; + let mut cmap: BTreeMap = 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 = 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::(), + "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 { + 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 { + 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 = 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 = 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; diff --git a/crates/engine/src/cmd/sketch_clipboard_tests.rs b/crates/engine/src/cmd/sketch_clipboard_tests.rs new file mode 100644 index 00000000..86ec2e24 --- /dev/null +++ b/crates/engine/src/cmd/sketch_clipboard_tests.rs @@ -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 { + 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 { + 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) { + 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::>()); +} + +#[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 = [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); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c1e527b7..8fc7f217 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -222,6 +222,8 @@ pub struct Session { removed_later_cache: std::sync::Mutex>, /// Features copied with `timeline.copy` (pasted by `timeline.paste`). pub clipboard: Vec, + /// Sketch geometry copied with `sketch.copy` (pasted by `sketch.paste`). + pub sketch_clipboard: Option, /// 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(), diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index bf278854..2a483cc2 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -438,7 +438,9 @@ impl ConstraintKind { OffsetDistance { .. } => (vec![], vec![]), } } - pub(crate) fn remap(&mut self, pmap: &dyn Fn(usize) -> Option, cmap: &dyn Fn(usize) -> Option) -> 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, cmap: &dyn Fn(usize) -> Option) -> bool { use ConstraintKind::*; let mut ok = true; let mut fp = |i: &mut usize| match pmap(*i) { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index d517d474..fa5f5c54 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -756,6 +756,8 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { 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 = curves.iter().chain(points.iter()).cloned().collect(); @@ -770,6 +772,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { 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); diff --git a/crates/ui-egui/src/edit_clipboard.rs b/crates/ui-egui/src/edit_clipboard.rs new file mode 100644 index 00000000..3806474e --- /dev/null +++ b/crates/ui-egui/src/edit_clipboard.rs @@ -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> = 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 { + 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) -> 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) -> 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; diff --git a/crates/ui-egui/src/edit_clipboard_tests.rs b/crates/ui-egui/src/edit_clipboard_tests.rs new file mode 100644 index 00000000..b0967aaf --- /dev/null +++ b/crates/ui-egui/src/edit_clipboard_tests.rs @@ -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 { + 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) { + 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 = 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 { + 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 = 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 = + 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 = 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 = 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 = 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:?}"); +} diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 0b2652e2..11dc230e 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -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 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb07b176..b9eb20f9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b6a86f3d..9ae138f4 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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)), } diff --git a/docs/features.md b/docs/features.md index 3bd83885..e07ed6b8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 ''`, 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 ''`, 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` | – |