diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a1391ad..08c6f04d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Shape copy / paste** (Ctrl+C / Ctrl+V): in-app clipboard for selected solids, or a Shape List group subtree when the selection matches that group's descendant solids. Paste deep-copies under the current group (new ids, uniquified names, same pose) with one undo step. Survives **New** so you can copy, start a blank project, and paste. Remappable as **`edit.copy`** / **`edit.paste`**. Does not use the OS clipboard; sketch geometry is not copied. + - **Align cylinders** (J): pick two cylindrical faces (first moves, second is fixed), coaxially align, then drag insert depth along the shared axis (or Tab for an exact depth). Options **Flip direction** reverses axis sense. Bakes like Move/Rotate (`Shape_geom_delta`); radius mismatch logs a warning but still places. Remappable as **`mode.cyl_align`**. - **STEP Import as**: the Import dialog replaces the **Union shapes** checkbox with an **Import as** combo (**Preserve hierarchy** default, **Flat solids**, **Union shapes**). Hierarchy keeps XCAF assembly groups and product/instance names; flat adds leaf solids at the document root; union fuses into one solid. diff --git a/agents/drafts/issues/active/gh-242-shape-copy-paste.md b/agents/drafts/issues/active/gh-242-shape-copy-paste.md new file mode 100644 index 00000000..8124f654 --- /dev/null +++ b/agents/drafts/issues/active/gh-242-shape-copy-paste.md @@ -0,0 +1,80 @@ +--- +github_issue: 242 +github_pr: 243 +status: active +paired_draft: ../prs/active/gh-243-shape-copy-paste.md +--- + +# In-app shape copy and paste (Ctrl+C / Ctrl+V) + +**Suggested labels:** `enhancement`, `ui` + +--- + +## Title (GitHub) + +In-app shape copy and paste (Ctrl+C / Ctrl+V) + +## Body (GitHub) + +### Summary + +Add remappable **Ctrl+C** / **Ctrl+V** for an in-app clipboard that deep-copies selected solids and Shape List group subtrees, with undo via `Shape_add_delta`. Sketch entities and the OS clipboard are out of scope. + +### Problem + +- No CAD-level copy/paste existed; closest tools were polar duplicate, sketch mirror, and ImGui text clipboard. +- Users need to duplicate solids/groups and seed a new project from a selection (copy -> New -> paste). + +### Implemented scope + +**Code:** + +- `Gui_action::Edit_copy` / `Edit_paste` (`edit.copy` / `edit.paste`, defaults Ctrl+C / Ctrl+V) +- `Occt_view::copy_selected_shapes` / `paste_clipboard_shapes` — session clipboard; group subtree when selection matches current group's descendant solids; paste under `current_group_id` with new ids and uniquified names +- Clipboard survives **New** (not cleared on `new_file`) + +**Docs / tests:** + +- `docs/usage.md`, `docs/usage-settings.md`, `CHANGELOG.md`, `src/doc/gui.md`, `src/doc/shape.md` +- Unit tests in `tests/shp_tests.cpp` (`Copy_*`, `New_file_keeps_shape_clipboard`) + +### Acceptance criteria + +- [x] Ctrl+C / Ctrl+V remappable; work when ImGui does not want text input +- [x] Copy solids; copy group subtree when Shape List group selection matches all descendants +- [x] Paste deep-copies under current group; one undo step; same pose +- [x] Clipboard survives New project +- [x] Unit tests pass; user docs and CHANGELOG updated + +### Out of scope + +- OS / cross-app clipboard +- Sketch edge copy/paste +- Ctrl+X cut +- Hold-key duplicate-translate (`edit.duplicate_translate`) + +### Files touched + +- `src/gui_hotkeys.h`, `src/gui_hotkeys.cpp`, `src/gui_mode.cpp` +- `src/gui_occt_view.h`, `src/gui_occt_view.cpp` +- `res/ezycad_settings.json` +- `tests/shp_tests.cpp` +- `docs/usage.md`, `docs/usage-settings.md`, `CHANGELOG.md` +- `src/doc/gui.md`, `src/doc/shape.md` +- `agents/drafts/issues/active/gh-242-shape-copy-paste.md` (this draft) + +### Related + +- Issue: https://github.com/trailcode/EzyCad/issues/242 +- PR: https://github.com/trailcode/EzyCad/pull/243 +- Branch: `copy-paste` +- Follow-up: hold-key duplicate translate (configurable-hotkeys plan) +- Shape List duplicate group (hierarchy phase 2, #215) + +### Test plan + +- [x] `EzyCad_tests --gtest_filter=Shp_test.Copy*:Shp_test.New_file*` +- [ ] Manual: select solids Ctrl+C / Ctrl+V; Shape List group copy; copy -> New -> paste +- [ ] Settings Keyboard shortcuts shows Copy / Paste +- [ ] Script console text Ctrl+C/V still works (WantTextInput) diff --git a/agents/drafts/prs/active/gh-243-shape-copy-paste.md b/agents/drafts/prs/active/gh-243-shape-copy-paste.md new file mode 100644 index 00000000..24c1b5f1 --- /dev/null +++ b/agents/drafts/prs/active/gh-243-shape-copy-paste.md @@ -0,0 +1,51 @@ +--- +github_issue: 242 +github_pr: 243 +status: active +paired_draft: ../issues/active/gh-242-shape-copy-paste.md +--- + +# PR - copy-paste + +## Title + +In-app shape copy and paste (Ctrl+C / Ctrl+V) + +## Summary + +- Adds remappable **Ctrl+C** / **Ctrl+V** (`edit.copy` / `edit.paste`) for an in-app shape clipboard. +- Deep-copies selected solids, or a Shape List group subtree when the selection matches that group's descendant solids. +- Paste inserts under the current group (new ids, uniquified names, same pose) with one `Shape_add_delta`; clipboard survives **New**. +- Docs, settings defaults, and unit tests included. + +## Files Changed + +- `src/gui_hotkeys.h`, `src/gui_hotkeys.cpp`, `src/gui_mode.cpp` +- `src/gui_occt_view.h`, `src/gui_occt_view.cpp` +- `res/ezycad_settings.json` +- `tests/shp_tests.cpp` +- `docs/usage.md`, `docs/usage-settings.md`, `CHANGELOG.md` +- `src/doc/gui.md`, `src/doc/shape.md` +- `agents/drafts/issues/active/gh-242-shape-copy-paste.md` +- `agents/drafts/prs/active/gh-243-shape-copy-paste.md` (this draft) + +## Related + +- Issue: https://github.com/trailcode/EzyCad/issues/242 +- PR: https://github.com/trailcode/EzyCad/pull/243 +- Branch: `copy-paste` + +## Test Plan + +- [x] `EzyCad_tests --gtest_filter=Shp_test.Copy*:Shp_test.New_file*` +- [ ] Manual: select solids → Ctrl+C / Ctrl+V (in place, undo) +- [ ] Manual: click Shape List group → Ctrl+C → paste preserves nesting +- [ ] Manual: copy → New → paste into blank project +- [ ] Settings → Keyboard shortcuts shows Copy / Paste (Ctrl+C / Ctrl+V) +- [ ] Script console text Ctrl+C/V still works while typing +- [ ] Spot-check `docs/usage.md` / `usage-settings.md` / CHANGELOG + +## Notes + +- Closes #242 +- Out of scope: OS clipboard, sketch copy/paste, cut, hold-key duplicate-translate diff --git a/docs/usage-settings.md b/docs/usage-settings.md index a4acf502..8c548dd2 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -78,7 +78,7 @@ Between those, the pane has collapsible sections. Expand a section to see its co **WebAssembly build** — Open CASCADE line-width controls (`SetWidth` / `Prs3d` line width) have no visible effect in the browser (WebGL/GLES). The **Settings** pane hides **Edge thickness**, **Dimension line width**, and **Snap guide line width** on the web build. Saved JSON may still contain those keys from a desktop session; they are not shown as editable rows in the browser UI. -7. **Keyboard shortcuts** — Remappable chords for modeling and sketch tools, booleans, Delete, New/Open/Save, and Undo/Redo. See [Keyboard shortcuts](#keyboard-shortcuts). +7. **Keyboard shortcuts** — Remappable chords for modeling and sketch tools, booleans, Delete, Copy/Paste, New/Open/Save, and Undo/Redo. See [Keyboard shortcuts](#keyboard-shortcuts). 8. **Startup project** — **Desktop only:** **Load last opened on startup** (checkbox, with **?**), then **Last opened path:** … or **(No path saved yet.)** Then **Save current as startup project**, **Clear saved startup** (with **?**). **WebAssembly:** no load-last row; only the two buttons and **?**. See [Startup project](#startup-project). @@ -147,7 +147,7 @@ Similar to Blender's startup file: EzyCad can load a **default document** when i ## Keyboard shortcuts -Remap modeling and sketch tool chords, boolean commands, Delete, New/Open/Save, and Undo/Redo in **View -> Settings -> Keyboard shortcuts**. Default key lists live in [usage.md -> Hotkeys](usage.md#hotkeys) (and [usage-sketch.md -> Hotkeys](usage-sketch.md#hotkeys) for sketch-focused summaries). Toolbar tooltips for remappable modes and boolean commands follow the current bindings. +Remap modeling and sketch tool chords, boolean commands, Delete, Copy/Paste, New/Open/Save, and Undo/Redo in **View -> Settings -> Keyboard shortcuts**. Default key lists live in [usage.md -> Hotkeys](usage.md#hotkeys) (and [usage-sketch.md -> Hotkeys](usage-sketch.md#hotkeys) for sketch-focused summaries). Toolbar tooltips for remappable modes and boolean commands follow the current bindings. **How to remap** @@ -267,7 +267,7 @@ If saved layout text has no `[Docking]` section (older installs), a default dock | `default_2d_view_height` | number | Vertical sketch-plane span, stored in **inches** (allowed range **0.1** to **1000**; default **3**). Settings UI shows this in **`default_project_unit`**. | | `load_last_opened_on_startup` | boolean | Desktop: open the last `.ezy` on launch. **Legacy:** `load_last_saved_on_startup` is read as a fallback if the newer key is absent. | | `last_opened_project_path` | string | Path of the last opened project for the option above. **Legacy:** `last_saved_project_path` is accepted if the newer key is missing. | -| `hotkeys` | object | Remappable keyboard shortcuts: action id string keys to human-readable chord strings (for example `"mode.move": "G"`, `"mode.add_edge": "L"`, `"cmd.shape_cut": "Ctrl+Shift+C"`, `"edit.delete": "Shift+D"`). Missing keys merge to built-in defaults. See [Keyboard shortcuts](#keyboard-shortcuts) and [usage.md#hotkeys](usage.md#hotkeys). | +| `hotkeys` | object | Remappable keyboard shortcuts: action id string keys to human-readable chord strings (for example `"mode.move": "G"`, `"mode.add_edge": "L"`, `"cmd.shape_cut": "Ctrl+Shift+C"`, `"edit.delete": "Shift+D"`, `"edit.copy": "Ctrl+C"`, `"edit.paste": "Ctrl+V"`). Missing keys merge to built-in defaults. See [Keyboard shortcuts](#keyboard-shortcuts) and [usage.md#hotkeys](usage.md#hotkeys). | Each **`imgui_style_dark`** / **`imgui_style_light`** object may contain: diff --git a/docs/usage.md b/docs/usage.md index c9206f51..b46b7c7f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -125,6 +125,7 @@ Each row (left to right): - **Solid / wire** - Checkbox (solids only) to switch **shaded** or **wireframe**. - **M** - Solids only: material popup; right-click for **Shape info...** or **Delete**. - **Name** - Expandable tree row with an editable name. Click the row to select that solid (or all descendant solids for a group) and update the current group. **Ctrl+click** toggles multi-select. Drag to reparent (empty area below the list = document root). +- **Copy / Paste** - Ctrl+C / Ctrl+V (remappable) copy selected solids, or a whole group subtree when you clicked that group (all its descendant solids are selected). Paste inserts an independent deep copy under the current group, at the same pose. If you still have that copied group as the current group, paste places a sibling copy beside it (not nested inside it). This is an in-app clipboard (survives **New**; it does not use the system clipboard). Sketch edges are not copied. - **Right-click the name** - Solids: **Shape info...** / **Delete**. Groups: **Ungroup** (moves **all** direct children to the group's parent, then removes the group) / **Delete** (cascade-deletes the whole subtree). Boolean results stay under the shared parent of their inputs when all inputs share one parent; otherwise they are placed at the document root. **File -> Import** STEP assemblies use **Import as** (default **Preserve hierarchy**) to keep product/assembly groups in the tree, import **Flat solids** at the root, or **Union shapes** into one solid. @@ -735,6 +736,8 @@ Mode, file, and edit chords in the **General Operations** and **Modeling Shortcu | Tab | Distance/dimension input | | Shift+Tab | Angle input (for line edges with angle constraint) | | Shift+D, Delete, or Backspace | Remove selected elements | +| Ctrl+C | Copy selected shapes (or current group subtree) to the in-app clipboard | +| Ctrl+V | Paste clipboard shapes under the current group (same pose; undoable) | ### Modeling Shortcuts diff --git a/res/ezycad_settings.json b/res/ezycad_settings.json index e0b29f92..7741f053 100644 --- a/res/ezycad_settings.json +++ b/res/ezycad_settings.json @@ -60,6 +60,8 @@ "cmd.shape_fuse": "Ctrl+Shift+F", "cmd.shape_common": "Ctrl+Shift+M", "edit.delete": "Shift+D", + "edit.copy": "Ctrl+C", + "edit.paste": "Ctrl+V", "file.new": "Ctrl+N", "file.open": "Ctrl+O", "file.save": "Ctrl+S", diff --git a/src/doc/gui.md b/src/doc/gui.md index 873634a9..8cb653bb 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -159,12 +159,14 @@ Remappable chords live in `Gui_hotkeys` (`gui_hotkeys.h` / `.cpp`), owned by `GU | Enter | not Rotate | hide edits, `Occt_view::on_enter`; Rotate: `break` into `on_key_rotate_mode_` (finalize) | | Delete / Backspace | | `Occt_view::delete_selected` (fixed aliases; remapping `edit.delete` does not remove these) | | Ctrl+Shift+Z | | `Occt_view::redo` (fixed second redo; remappable `edit.redo` defaults to Ctrl+Y) | -| Remappable chord | `m_hotkeys` hit | `dispatch_hotkey_action_` (`Gui_action`: sketch/shape modes, booleans, delete, file, undo/redo) | +| Remappable chord | `m_hotkeys` hit | `dispatch_hotkey_action_` (`Gui_action`: sketch/shape modes, booleans, delete, copy/paste, file, undo/redo) | | Move-mode keys | `Mode::Move` | `on_key_move_mode_` (axis constraints X/Y/Z); hardcoded | | Rotate-mode keys | `Mode::Rotate` | `on_key_rotate_mode_` (axis pick, Tab angle); hardcoded | | Cyl-align keys | `Mode::Shape_cyl_align` | `on_key_cyl_align_mode_` (Tab depth, Enter finalize); hardcoded | -Default remappable chords include G/R/S/J/E/C/F/D shape tools; sketch tools N/L/A/Q/B/O/U/I/P and Shift variants; Shift+P polar, Shift+X cross-section; Ctrl+Shift+C/F/M booleans; Shift+D delete; Ctrl+N/O/S; Ctrl+Z / Ctrl+Y. Unmodified X/Y/Z are reserved for Move/Rotate axis toggles (`is_reserved_chord`); Shift+X remains free for cross-section. Remappable keys must pass `is_bindable_key` (letters, digits, Space, and named keys that round-trip in settings JSON); punctuation such as `,` / `.` and numpad keys are rejected. Settings **Keyboard shortcuts** has a `?` to `doc_urls::k_hotkeys` ([usage-settings.md#keyboard-shortcuts](../../docs/usage-settings.md#keyboard-shortcuts)). +Default remappable chords include G/R/S/J/E/C/F/D shape tools; sketch tools N/L/A/Q/B/O/U/I/P and Shift variants; Shift+P polar, Shift+X cross-section; Ctrl+Shift+C/F/M booleans; Shift+D delete; Ctrl+C / Ctrl+V copy/paste (in-app shape clipboard); Ctrl+N/O/S; Ctrl+Z / Ctrl+Y. Unmodified X/Y/Z are reserved for Move/Rotate axis toggles (`is_reserved_chord`); Shift+X remains free for cross-section. Remappable keys must pass `is_bindable_key` (letters, digits, Space, and named keys that round-trip in settings JSON); punctuation such as `,` / `.` and numpad keys are rejected. Settings **Keyboard shortcuts** has a `?` to `doc_urls::k_hotkeys` ([usage-settings.md#keyboard-shortcuts](../../docs/usage-settings.md#keyboard-shortcuts)). + +**In-app shape clipboard** (`edit.copy` / `edit.paste`): `Occt_view::copy_selected_shapes` / `paste_clipboard_shapes` hold a session `Shape_rec` forest (not the OS clipboard). Copy roots are selected solids, or the current group when its descendant solids match the selection exactly (Shape List group click). Paste deep-copies under `current_group_id` with new ids, uniquified names, and a single `Shape_add_delta`. When the current group is still a copied group root, paste inserts as a **sibling** of that group (not nested under it). Survives `new_file` (copy -> New -> paste); live source-root ids are cleared on New. Sketch geometry is out of scope; ImGui text widgets keep OS text clipboard via `WantTextInput`. See also [`src/doc/sketch.md`](sketch.md) and [`src/doc/shape.md`](shape.md) for per-mode mouse routing after `GUI` delegates to `Occt_view`. @@ -237,7 +239,7 @@ Shared sketch controls (snap, midpoint nodes, place-from-center) live in `option Sketch List expand **Faces**: each face row supports **`E`** and right-click **Extrude** via `GUI::sketch_list_extrude_face_` (`set_mode(Sketch_face_extrude)` + `Occt_view::begin_sketch_face_extrude` / `Shp_extrude::begin_face_extrude`). Hovering a **Faces**, **Edges**, or **Nodes** row calls `Occt_view::set_sketch_list_hover_{face,edge,node}` (temporarily displays the AIS when hidden outside sketch modes; uses `Graphic3d_ZLayerId_Topmost` so solids do not occlude the highlight). -**Shape List outliner:** `shape_list_` draws a tree of document shapes/groups via `shape_children(0)` and recursive `TreeNodeEx` rows. Fixed-width vis/disp/mat columns are on the left; the name column stretches on the right with tree indent (`IndentEnable` on name only). An empty pad row after the last item is a drag-drop target for document root (`reparent_shape(..., 0)`); it shows a "Move to root" hint while dragging. Groups support expand/collapse (`ui.shapeList.expanded`), drag-drop reparent (`EZY_SHAPE_ID` payload), Group / New group / Ungroup, and cascade delete. Clicking a group sets `Occt_view::current_group_id` (including empty groups) and selects descendant solids; clicking a solid selects it and sets current group to its parent. New primitives/extrudes/revolves parent under the current group. Ctrl+click multi-selects. Hover uses `set_shape_list_hover` on leaf solids only. `ui.shapeList.currentGroupId` is persisted in `.ezy`. +**Shape List outliner:** `shape_list_` draws a tree of document shapes/groups via `shape_children(0)` and recursive `TreeNodeEx` rows. Fixed-width vis/disp/mat columns are on the left; the name column stretches on the right with tree indent (`IndentEnable` on name only). An empty pad row after the last item is a drag-drop target for document root (`reparent_shape(..., 0)`); it shows a "Move to root" hint while dragging. Groups support expand/collapse (`ui.shapeList.expanded`), drag-drop reparent (`EZY_SHAPE_ID` payload), Group / New group / Ungroup, and cascade delete. Clicking a group sets `Occt_view::current_group_id` (including empty groups) and selects descendant solids; clicking a solid selects it and sets current group to its parent. New primitives/extrudes/revolves parent under the current group. Ctrl+click multi-selects. Copy/paste (Ctrl+C/V) deep-copies the current group subtree when the selection matches that group's descendant solids. Hover uses `set_shape_list_hover` on leaf solids only. `ui.shapeList.currentGroupId` is persisted in `.ezy`. **Sketch List UI in the project file:** `GUI::serialized_project_json_` writes `ui.sketchList` (scroll Y plus per-sketch `rows` keyed by sketch `id`: `expanded`, `dimensions`, `nodes`, `edges`, `faces`). `GUI::on_file` restores via `apply_sketch_list_ui_from_json_`. Subsection open state is app-owned (`Sketch_list_row_ui` + `SetNextItemOpen`), not ImGui ini storage. diff --git a/src/doc/shape.md b/src/doc/shape.md index 92197b74..4d6ff362 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -26,6 +26,7 @@ Typical uses: - Preview cross-sections on a shape-local XY, XZ, or YZ plane (optional hide-back preview; **Clip** commits a half-space cut). - Fillet/chamfer by shape, face, wire, or edge pick mode. - Polar duplicate selected shapes about an arm on the current sketch plane. +- In-app copy/paste of solids and group subtrees (`copy_selected_shapes` / `paste_clipboard_shapes`). ## Requirements and invariants @@ -191,7 +192,7 @@ Shape ops use typed deltas from [`shp_delta.h`](../shp_delta.h) (see [undo-redo. | Mechanism | When | | --------------------------------- | ------------------------------------------------------------- | -| `Shape_add_delta` | Primitives, extrude, revolve, STEP/PLY import | +| `Shape_add_delta` | Primitives, extrude, revolve, STEP/PLY import, shape paste | | `Shape_remove_delta` | Delete selection when only `Shp` objects are selected | | `Shape_geom_delta` | Move / rotate / scale **finalize** (not preview) | | `Shape_replace_delta` | Fuse / cut / common / fillet / chamfer / polar duplicate | diff --git a/src/gui.cpp b/src/gui.cpp index 4572659e..72e77a0d 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -2609,9 +2609,13 @@ void GUI::shape_list_() // Tree node always uses NoTreePushOnOpen; indent children with an explicit TreePush/TreePop // pair so table rows cannot leave the ImGui tree stack unbalanced (which nested siblings // under the wrong parent and made Ungroup look like it only moved one child). + std::unordered_set shape_list_ancestors; auto draw_shape_row = [&](auto&& self, const Shp_ptr& shape) -> void { EZY_ASSERT(shape); + if (!shape_list_ancestors.insert(shape->get_id()).second) + return; // Parent cycle: skip rather than hang the Shape List. + const bool is_group = shape->is_group(); const std::vector children = m_view->shape_children(shape->get_id()); const bool has_children = !children.empty(); @@ -2828,6 +2832,7 @@ void GUI::shape_list_() } ImGui::PopID(); + shape_list_ancestors.erase(shape->get_id()); }; const ImGuiTableFlags table_flags = diff --git a/src/gui_hotkeys.cpp b/src/gui_hotkeys.cpp index 42daf19f..30d3e582 100644 --- a/src/gui_hotkeys.cpp +++ b/src/gui_hotkeys.cpp @@ -49,6 +49,8 @@ constexpr Action_meta c_actions[] = { {Gui_action::Cmd_shape_fuse, "cmd.shape_fuse", "Shape fuse", {GLFW_KEY_F, GLFW_MOD_CONTROL | GLFW_MOD_SHIFT}}, {Gui_action::Cmd_shape_common, "cmd.shape_common", "Shape common", {GLFW_KEY_M, GLFW_MOD_CONTROL | GLFW_MOD_SHIFT}}, {Gui_action::Edit_delete, "edit.delete", "Delete", {GLFW_KEY_D, GLFW_MOD_SHIFT}}, + {Gui_action::Edit_copy, "edit.copy", "Copy", {GLFW_KEY_C, GLFW_MOD_CONTROL}}, + {Gui_action::Edit_paste, "edit.paste", "Paste", {GLFW_KEY_V, GLFW_MOD_CONTROL}}, {Gui_action::File_new, "file.new", "New project", {GLFW_KEY_N, GLFW_MOD_CONTROL}}, {Gui_action::File_open, "file.open", "Open", {GLFW_KEY_O, GLFW_MOD_CONTROL}}, {Gui_action::File_save, "file.save", "Save", {GLFW_KEY_S, GLFW_MOD_CONTROL}}, diff --git a/src/gui_hotkeys.h b/src/gui_hotkeys.h index d5bed7d3..2bdd992c 100644 --- a/src/gui_hotkeys.h +++ b/src/gui_hotkeys.h @@ -37,6 +37,8 @@ enum class Gui_action Cmd_shape_fuse, Cmd_shape_common, Edit_delete, + Edit_copy, + Edit_paste, File_new, File_open, File_save, diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 8070a5ca..5abf8883 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -414,6 +414,14 @@ void GUI::dispatch_hotkey_action_(Gui_action action) break; case Gui_action::Edit_delete: m_view->delete_selected(); break; + case Gui_action::Edit_copy: + if (Status s = m_view->copy_selected_shapes(); !s.is_ok()) + show_message(s.message()); + break; + case Gui_action::Edit_paste: + if (Status s = m_view->paste_clipboard_shapes(); !s.is_ok()) + show_message(s.message()); + break; case Gui_action::File_new: new_project_(); break; case Gui_action::File_open: open_file_dialog_(); break; case Gui_action::File_save: save_file_dialog_(); break; diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 48245fa1..8736c2fd 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,7 @@ #include #include #include +#include #include #include "utl_dbg.h" @@ -1169,12 +1171,17 @@ bool Occt_view::would_reparent_create_cycle(Shape_id id, Shape_id new_parent) co if (new_parent == id) return true; - Shape_id walk = new_parent; + // Also: id is an ancestor of new_parent (same as "new_parent is under id"). + std::unordered_set seen; + Shape_id walk = new_parent; while (walk != 0) { if (walk == id) return true; + if (!seen.insert(walk).second) + break; // Corrupt parent cycle; treat as no further ancestors. + Shp_ptr p = find_shape_by_id(walk); if (p.IsNull()) break; @@ -1208,12 +1215,13 @@ std::vector Occt_view::shape_descendant_solids(Shape_id id) const if (root.IsNull()) return out; - std::vector stack = {root}; + std::unordered_set seen; + std::vector stack = {root}; while (!stack.empty()) { Shp_ptr n = stack.back(); stack.pop_back(); - if (n.IsNull()) + if (n.IsNull() || !seen.insert(n->get_id()).second) continue; if (!n->is_group()) @@ -1230,9 +1238,13 @@ std::vector Occt_view::shape_descendant_solids(Shape_id id) const bool Occt_view::shape_ancestors_visible(const Shp& shp) const { - Shape_id walk = shp.get_parent_id(); + std::unordered_set seen; + Shape_id walk = shp.get_parent_id(); while (walk != 0) { + if (!seen.insert(walk).second) + break; // Parent cycle; treat as visible rather than hang. + Shp_ptr p = find_shape_by_id(walk); if (p.IsNull()) break; @@ -1919,6 +1931,271 @@ void Occt_view::delete_shapes(std::vector to_delete) cancel(Set_parent_mode::No); // In case we are in the middle of a operation. } +Shape_rec Occt_view::capture_clipboard_shape_rec_(const Shp& shp) const +{ + Shape_rec rec; + rec.id = shp.get_id(); + rec.name = shp.get_name(); + rec.material = shp.Material(); + rec.parent_id = shp.get_parent_id(); + rec.sibling_order = shp.get_sibling_order(); + rec.is_group = shp.is_group(); + rec.visible = shp.get_visible(); + rec.frame = shp.get_frame(); + + if (!rec.is_group) + { + // Bake AIS local transform, then deep-copy so clipboard never shares TShape with the document. + const TopoDS_Shape& s = shp.Shape(); + const gp_Trsf& tr = shp.LocalTransformation(); + BRepBuilderAPI_Transform transformer(s, tr, true); + BRepBuilderAPI_Copy copier(transformer.Shape()); + rec.geom = copier.Shape(); + if (tr.Form() != gp_Identity) + rec.frame.Transform(tr); + } + + return rec; +} + +Status Occt_view::copy_selected_shapes() +{ + const std::vector selected = get_selected_shps(); + if (selected.empty()) + return Status::user_error("Nothing to copy."); + + std::unordered_set selected_ids; + selected_ids.reserve(selected.size()); + for (const Shp_ptr& s : selected) + selected_ids.insert(s->get_id()); + + std::vector root_ids; + + // Exact match: Shape List clicked a group (all descendant solids selected). + const Shape_id gid = current_group_id(); + if (gid != 0) + { + const std::vector desc = shape_descendant_solids(gid); + if (!desc.empty() && desc.size() == selected.size()) + { + bool exact = true; + for (const Shp_ptr& d : desc) + if (selected_ids.find(d->get_id()) == selected_ids.end()) + { + exact = false; + break; + } + + if (exact) + root_ids.push_back(gid); + } + } + + if (root_ids.empty()) + { + for (const Shp_ptr& s : selected) + root_ids.push_back(s->get_id()); + } + + // Collapse: drop roots that sit under another root. + // would_reparent_create_cycle(other, id) <=> id is under other (incl. equal). + std::vector collapsed; + collapsed.reserve(root_ids.size()); + for (Shape_id id : root_ids) + { + bool under_other = false; + for (Shape_id other : root_ids) + { + if (other == id) + continue; + + if (would_reparent_create_cycle(other, id)) + { + under_other = true; + break; + } + } + if (!under_other) + collapsed.push_back(id); + } + + // Snapshot each root subtree (pre-order); normalize root parent_id to 0. + std::vector clip; + std::vector source_roots; + std::unordered_set seen; + for (Shape_id root_id : collapsed) + { + Shp_ptr root = find_shape_by_id(root_id); + if (root.IsNull()) + continue; + + source_roots.push_back(root_id); + + std::vector stack = {root}; + // Pre-order via explicit queue for stable child order. + std::vector ordered; + while (!stack.empty()) + { + Shp_ptr cur = stack.back(); + stack.pop_back(); + if (cur.IsNull() || !seen.insert(cur->get_id()).second) + continue; + + ordered.push_back(cur); + if (cur->is_group()) + { + const std::vector kids = shape_children(cur->get_id()); + for (auto it = kids.rbegin(); it != kids.rend(); ++it) + stack.push_back(*it); + } + } + + for (const Shp_ptr& n : ordered) + { + Shape_rec rec = capture_clipboard_shape_rec_(*n); + if (n->get_id() == root_id) + rec.parent_id = 0; + clip.push_back(std::move(rec)); + } + } + + if (clip.empty()) + return Status::user_error("Nothing to copy."); + + m_shape_clipboard = std::move(clip); + m_shape_clipboard_source_roots = std::move(source_roots); + return Status::ok(); +} + +Status Occt_view::paste_clipboard_shapes() +{ + if (m_shape_clipboard.empty()) + return Status::user_error("Clipboard is empty."); + + ensure_current_group_valid_(); + Shape_id paste_parent = m_current_group_id; + + // If the user still has a copied group as the current group (Shape List click + Ctrl+C + + // Ctrl+V), paste as a sibling of that group -- not as a child of itself. + for (Shape_id src_root : m_shape_clipboard_source_roots) + { + if (src_root == 0 || src_root != paste_parent) + continue; + + Shp_ptr src = find_shape_by_id(src_root); + if (!src.IsNull() && src->is_group()) + { + paste_parent = src->get_parent_id(); + break; + } + } + + if (paste_parent != 0) + { + Shp_ptr p = find_shape_by_id(paste_parent); + if (p.IsNull() || !p->is_group()) + return Status::user_error("Current group is invalid."); + + // Pasting a clipboard root under one of its own live source roots is handled above; + // also reject nesting under any live descendant/ancestor of a copied source root. + for (Shape_id src_root : m_shape_clipboard_source_roots) + if (would_reparent_create_cycle(src_root, paste_parent)) + return Status::user_error("Cannot paste a group into its own subtree."); + } + + std::unordered_map id_map; + id_map.reserve(m_shape_clipboard.size()); + for (const Shape_rec& rec : m_shape_clipboard) + { + const Shape_id new_id = allocate_shape_id(); + if (!find_shape_by_id(new_id).IsNull()) + return Status::user_error("Internal error: shape id collision on paste."); + + id_map[rec.id] = new_id; + } + + std::vector existing_names; + existing_names.reserve(m_shps.size() + m_shape_clipboard.size()); + for (const Shp_ptr& s : m_shps) + if (!s.IsNull()) + existing_names.push_back(s->get_name()); + + // Build the full insert set before mutating the document (all-or-nothing). + std::vector added; + added.reserve(m_shape_clipboard.size()); + Shape_id first_pasted_group = 0; + int root_order_base = next_sibling_order(paste_parent); + + for (const Shape_rec& src : m_shape_clipboard) + { + Shape_rec rec = src; + rec.id = id_map[src.id]; + + const bool is_root = (src.parent_id == 0); + if (is_root) + { + rec.parent_id = paste_parent; + rec.sibling_order = root_order_base++; + } + else + { + const auto it = id_map.find(src.parent_id); + if (it == id_map.end()) + return Status::user_error("Clipboard tree is corrupt."); + + rec.parent_id = it->second; + } + + if (rec.parent_id == rec.id || would_reparent_create_cycle(rec.id, rec.parent_id)) + return Status::user_error("Internal error: paste would create a parent cycle."); + + rec.name = unique_sequential_name(src.name, existing_names); + existing_names.push_back(rec.name); + + if (!rec.is_group) + { + if (rec.geom.IsNull()) + return Status::user_error("Clipboard solid has no geometry."); + + BRepBuilderAPI_Copy copier(rec.geom); + rec.geom = copier.Shape(); + if (rec.geom.IsNull()) + return Status::user_error("Failed to copy solid geometry."); + } + + if (rec.is_group && is_root && first_pasted_group == 0) + first_pasted_group = rec.id; + + added.push_back(std::move(rec)); + } + + for (const Shape_rec& rec : added) + insert_shape_rec(rec); + + push_undo_delta(std::make_unique(std::move(added))); + + if (first_pasted_group != 0) + set_current_group_id(first_pasted_group); + + // Sync display before selection: sketch faint sync clears AIS selection when active. + sync_sketch_shape_faint_style(); + + // Select pasted root solids (and solids under pasted groups). + std::vector to_select; + for (const Shape_rec& src : m_shape_clipboard) + { + if (src.parent_id != 0) + continue; + + const Shape_id new_root = id_map[src.id]; + for (const Shp_ptr& s : shape_descendant_solids(new_root)) + to_select.push_back(s); + } + set_selected_shps(to_select); + + return Status::ok(); +} + void Occt_view::remove_selected_length_dimensions_from_sketches_() { std::vector selected_dims; @@ -4122,6 +4399,9 @@ void Occt_view::new_file() remove(m_shps); clear_all(m_shps, m_sketches, m_cur_sketch); m_assets.clear(); + // Keep m_shape_clipboard so Copy then New then Paste can seed a fresh document. + // Live source-root ids are invalid after the document is cleared. + m_shape_clipboard_source_roots.clear(); m_next_sketch_id = 1; m_next_shape_id = 1; m_current_group_id = 0; diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h index 7ed7d623..73933fd8 100644 --- a/src/gui_occt_view.h +++ b/src/gui_occt_view.h @@ -28,6 +28,7 @@ #include "shp_rotate.h" #include "shp_scale.h" #include "shp_cross_section.h" +#include "shp_delta.h" #include "utl_types.h" #include "utl_asset_store.h" #include "utl_cad_file_info.h" @@ -39,7 +40,6 @@ class Delta; class GUI; class Sketch; -struct Shape_rec; struct Sketch_annotation_refresh; struct Length_dimension_style; class Prs3d_Drawer; @@ -207,6 +207,20 @@ class Occt_view : protected AIS_ViewController void delete_shapes(std::vector to_delete); void delete_(std::vector& to_delete); + /// Copy selected solids / current-group subtree into the in-app shape clipboard. + [[nodiscard]] Status copy_selected_shapes(); + /// Paste clipboard shapes under current_group_id (undoable deep copy). + /// If current_group_id is still a copied group root, pastes as a sibling of that group. + [[nodiscard]] Status paste_clipboard_shapes(); + /// True when the in-app shape clipboard holds at least one node. + [[nodiscard]] bool has_shape_clipboard() const { return !m_shape_clipboard.empty(); } + /// Clear the in-app shape clipboard (New project leaves it intact). + void clear_shape_clipboard() + { + m_shape_clipboard.clear(); + m_shape_clipboard_source_roots.clear(); + } + // Member function to delete variable arguments template void remove(Args&&... args); @@ -424,6 +438,8 @@ class Occt_view : protected AIS_ViewController void add_shp_(Shp_ptr& shp, bool use_current_group = false); void ensure_current_group_valid_(); std::string unique_shape_name_(const char* base_name) const; + /// Snapshot one shape for the in-app clipboard (independent BREP; local transform baked). + [[nodiscard]] Shape_rec capture_clipboard_shape_rec_(const Shp& shp) const; TopoDS_Shape shape_with_local_transform_(const AIS_Shape_ptr& ais) const; [[nodiscard]] Status build_export_shape_(TopoDS_Shape& out_shape) const; @@ -473,6 +489,10 @@ class Occt_view : protected AIS_ViewController size_t m_next_sketch_id{1}; Shape_id m_next_shape_id{1}; Shape_id m_current_group_id{0}; + /// In-app clipboard: forest of Shape_rec (roots have parent_id 0; independent BREP). + std::vector m_shape_clipboard; + /// Live document ids of clipboard roots at copy time (for paste-as-sibling when still current). + std::vector m_shape_clipboard_source_roots; Ezy_asset_store m_assets; // -------------------------------------------------------------------- diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 68ce0289..58dadff0 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -745,6 +745,33 @@ TEST_F(Shp_test, Group_reparent_cycle_rejected) EXPECT_FALSE(view().reparent_shape(grp->get_id(), a->get_id()).is_ok()); } +TEST_F(Shp_test, Parent_chain_walk_tolerates_corrupt_cycle) +{ + // Defensive: a corrupt parent loop must not hang ancestor walks used by reparent + // and copy/paste collapse / paste-into-subtree checks. + Shp_ptr g1 = view().create_group("G1", 0); + Shp_ptr g2 = view().create_group("G2", 0); + ASSERT_FALSE(g1.IsNull()); + ASSERT_FALSE(g2.IsNull()); + g1->set_parent_id(g2->get_id()); + g2->set_parent_id(g1->get_id()); + + EXPECT_TRUE(view().would_reparent_create_cycle(g1->get_id(), g2->get_id())); + EXPECT_FALSE(view().would_reparent_create_cycle(g1->get_id(), 0)); + + view().add_box(0, 0, 0, 1, 1, 1); + Shp_ptr box; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + box = s; + ASSERT_FALSE(box.IsNull()); + select_shapes(view(), {box}); + ASSERT_TRUE(view().copy_selected_shapes().is_ok()); + view().set_current_group_id(g1->get_id()); + // Paste under a node trapped in a parent cycle must terminate (ok or error). + (void)view().paste_clipboard_shapes(); +} + TEST_F(Shp_test, Ungroup_moves_all_direct_children) { view().add_box(0, 0, 0, 1, 1, 1); @@ -920,3 +947,235 @@ TEST_F(Shp_test, Current_group_parents_new_primitives) ASSERT_TRUE(view().ungroup_shape(grp->get_id()).is_ok()); EXPECT_EQ(view().current_group_id(), 0u); } + +// --------------------------------------------------------------------------- +// In-app shape copy / paste +// --------------------------------------------------------------------------- + +TEST_F(Shp_test, Copy_paste_loose_solids_new_ids_and_undo) +{ + view().add_box(0, 0, 0, 1, 1, 1); + view().add_box(2, 0, 0, 1, 1, 1); + std::vector boxes; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + boxes.push_back(s); + + ASSERT_EQ(boxes.size(), 2u); + const Shape_id id0 = boxes[0]->get_id(); + const Shape_id id1 = boxes[1]->get_id(); + select_shapes(view(), boxes); + + ASSERT_TRUE(view().copy_selected_shapes().is_ok()); + EXPECT_TRUE(view().has_shape_clipboard()); + ASSERT_TRUE(view().paste_clipboard_shapes().is_ok()); + + size_t leaves = 0; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + ++leaves; + + EXPECT_EQ(leaves, 4u); + + const std::vector selected = view().get_selected_shps(); + ASSERT_EQ(selected.size(), 2u); + for (const Shp_ptr& s : selected) + { + EXPECT_NE(s->get_id(), id0); + EXPECT_NE(s->get_id(), id1); + EXPECT_EQ(s->get_parent_id(), 0u); + } + + EXPECT_TRUE(view().undo()); + leaves = 0; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + ++leaves; + + EXPECT_EQ(leaves, 2u); +} + +TEST_F(Shp_test, Copy_paste_group_subtree_preserves_nesting) +{ + view().add_box(0, 0, 0, 1, 1, 1); + view().add_box(2, 0, 0, 1, 1, 1); + std::vector boxes; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + boxes.push_back(s); + + ASSERT_TRUE(view().group_shapes(boxes).is_ok()); + Shp_ptr grp; + for (const Shp_ptr& s : view().get_shapes()) + if (s->is_group()) + grp = s; + + ASSERT_FALSE(grp.IsNull()); + const Shape_id gid = grp->get_id(); + + // Nested empty group under the outer group. + Shp_ptr nested = view().create_group("Nested", gid); + ASSERT_FALSE(nested.IsNull()); + + // Mimic Shape List group click: current group + all descendant solids selected. + view().set_current_group_id(gid); + select_shapes(view(), view().shape_descendant_solids(gid)); + + ASSERT_TRUE(view().copy_selected_shapes().is_ok()); + view().set_current_group_id(0); + ASSERT_TRUE(view().paste_clipboard_shapes().is_ok()); + + size_t groups = 0; + size_t leaves = 0; + for (const Shp_ptr& s : view().get_shapes()) + { + if (s->is_group()) + ++groups; + else + ++leaves; + } + // Original: outer + nested; pasted: outer + nested. + EXPECT_EQ(groups, 4u); + EXPECT_EQ(leaves, 4u); + + // Pasted root group should be current and have two direct children (2 boxes or nested+boxes). + const Shape_id pasted_gid = view().current_group_id(); + EXPECT_NE(pasted_gid, 0u); + EXPECT_NE(pasted_gid, gid); + Shp_ptr pasted_grp = view().find_shape_by_id(pasted_gid); + ASSERT_FALSE(pasted_grp.IsNull()); + EXPECT_TRUE(pasted_grp->is_group()); + EXPECT_EQ(pasted_grp->get_parent_id(), 0u); + + size_t nested_under_paste = 0; + size_t solids_under_paste = 0; + for (const Shp_ptr& c : view().shape_children(pasted_gid)) + { + if (c->is_group()) + ++nested_under_paste; + else + ++solids_under_paste; + } + EXPECT_EQ(nested_under_paste, 1u); + EXPECT_EQ(solids_under_paste, 2u); + EXPECT_EQ(view().shape_descendant_solids(pasted_gid).size(), 2u); +} + +TEST_F(Shp_test, Copy_partial_group_selection_copies_solids_not_group) +{ + view().add_box(0, 0, 0, 1, 1, 1); + view().add_box(2, 0, 0, 1, 1, 1); + std::vector boxes; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + boxes.push_back(s); + + ASSERT_TRUE(view().group_shapes(boxes).is_ok()); + Shp_ptr grp; + for (const Shp_ptr& s : view().get_shapes()) + if (s->is_group()) + grp = s; + + ASSERT_FALSE(grp.IsNull()); + view().set_current_group_id(grp->get_id()); + // Only one solid selected -> do not treat as whole-group copy. + select_shapes(view(), {boxes[0]}); + + ASSERT_TRUE(view().copy_selected_shapes().is_ok()); + view().set_current_group_id(0); + ASSERT_TRUE(view().paste_clipboard_shapes().is_ok()); + + size_t groups = 0; + size_t leaves = 0; + for (const Shp_ptr& s : view().get_shapes()) + { + if (s->is_group()) + ++groups; + else + ++leaves; + } + EXPECT_EQ(groups, 1u); + EXPECT_EQ(leaves, 3u); + + const std::vector selected = view().get_selected_shps(); + ASSERT_EQ(selected.size(), 1u); + EXPECT_EQ(selected[0]->get_parent_id(), 0u); +} + +TEST_F(Shp_test, Copy_paste_group_while_still_current_group) +{ + // User workflow: click group in Shape List (current_group = that group), Ctrl+C, Ctrl+V + // without changing the current group first. Paste must be a sibling, not a child of itself. + view().add_box(0, 0, 0, 1, 1, 1); + view().add_box(2, 0, 0, 1, 1, 1); + std::vector boxes; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + boxes.push_back(s); + + ASSERT_TRUE(view().group_shapes(boxes).is_ok()); + Shp_ptr grp; + for (const Shp_ptr& s : view().get_shapes()) + if (s->is_group()) + grp = s; + + ASSERT_FALSE(grp.IsNull()); + const Shape_id gid = grp->get_id(); + view().set_current_group_id(gid); + select_shapes(view(), view().shape_descendant_solids(gid)); + + ASSERT_TRUE(view().copy_selected_shapes().is_ok()); + // Intentionally leave current_group_id == gid. + ASSERT_EQ(view().current_group_id(), gid); + ASSERT_TRUE(view().paste_clipboard_shapes().is_ok()); + + size_t groups = 0; + size_t leaves = 0; + for (const Shp_ptr& s : view().get_shapes()) + { + ASSERT_FALSE(s.IsNull()); + if (s->is_group()) + ++groups; + else + { + ++leaves; + EXPECT_FALSE(s->Shape().IsNull()); + } + } + EXPECT_EQ(groups, 2u); + EXPECT_EQ(leaves, 4u); + EXPECT_FALSE(view().find_shape_by_id(gid).IsNull()); + + // Both groups remain document roots (sibling paste, not nested under the source). + const std::vector roots = view().shape_children(0); + EXPECT_EQ(roots.size(), 2u); + EXPECT_EQ(view().get_shapes().size(), 6u); + + const Shape_id pasted_gid = view().current_group_id(); + EXPECT_NE(pasted_gid, 0u); + EXPECT_NE(pasted_gid, gid); + Shp_ptr pasted = view().find_shape_by_id(pasted_gid); + ASSERT_FALSE(pasted.IsNull()); + EXPECT_TRUE(pasted->is_group()); + EXPECT_EQ(pasted->get_parent_id(), 0u); + EXPECT_EQ(view().shape_descendant_solids(pasted_gid).size(), 2u); + EXPECT_EQ(view().shape_descendant_solids(gid).size(), 2u); +} + +TEST_F(Shp_test, New_file_keeps_shape_clipboard) +{ + view().add_box(0, 0, 0, 1, 1, 1); + select_shapes(view(), {view().get_shapes().back()}); + ASSERT_TRUE(view().copy_selected_shapes().is_ok()); + EXPECT_TRUE(view().has_shape_clipboard()); + view().new_file(); + EXPECT_TRUE(view().has_shape_clipboard()); + ASSERT_TRUE(view().paste_clipboard_shapes().is_ok()); + + size_t leaves = 0; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + ++leaves; + + EXPECT_EQ(leaves, 1u); +}