From 5d748289cc1b300da5523d406382d1c5ab69a949 Mon Sep 17 00:00:00 2001 From: trailcode Date: Wed, 7 Oct 2026 18:09:17 -0600 Subject: [PATCH 1/4] Shp copy --- CHANGELOG.md | 3 + docs/usage-settings.md | 132 ++++++++++----------- docs/usage-sketch.md | 48 ++++---- docs/usage.md | 28 ++++- res/ezycad_settings.json | 3 +- src/doc/gui.md | 42 +++---- src/doc/shape.md | 15 +-- src/gui_hotkeys.cpp | 3 +- src/gui_hotkeys.h | 1 + src/gui_mode.cpp | 6 +- src/gui_occt_view.cpp | 249 +++++++++++++++++++++++++++++++++++++++ src/gui_occt_view.h | 4 + tests/shp_tests.cpp | 178 ++++++++++++++++++++++++++++ 13 files changed, 588 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5191b2..4c66ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Delete shortcut**: remappable **Delete** defaults to Ctrl+D (`edit.delete`). Shift+D is now **Duplicate**. Delete and Backspace still always delete the selection. A saved `gui.hotkeys` entry that still has **Delete** on Shift+D moves **Delete** back to Ctrl+D so **Duplicate** can take Shift+D. - **Task toolbar**: Sketch / Design / Workbench switcher; the tools row shows only the current task. Design has its own Move / Rotate / Scale / Align shafts (bake bodies for CSG). Workbench has Move / Rotate / Align shafts (instance pose). G / R / J stay on the current task; S enters Design Scale. - **Mode names**: Design idle is `Design_inspection` (was `Normal`); Sketch idle is `Sketch_inspection` (was `Sketch_inspection_mode`). `ezy.get_mode()` / `ezy.set_mode` use the new names; `Normal` and `Sketch_inspection_mode` still parse. - **Move / rotate / scale space**: Options **Local** (default) or **World**. Local uses the first selected solid's frame (origin and X/Y/Z from **Show axes**). World keeps global XYZ at that solid's bounding-box center. Persisted as **`gui.transform_space`**. @@ -34,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Duplicate shapes** (Shift+D, `edit.duplicate`): in **Design**, clones selected solids (or the current group's subtree) as independent bodies at the same pose. In **Workbench**, adds another link to the same Design body at the same pose. Copies stay under the original parent, then the tool switches to **Move** on the current task. One undo step. Leaves the copy/paste clipboard unchanged. + - **Workbench List**: separate pane of geometry **links** to Shape List solids (own location/rotation). **Add to Workbench** from the Shape List (button, context menu, or drag). Design geometry edits update links; Design move/rotate/scale do not move instances. Workbench Move / Rotate / Align shafts edit the instance frame only. `.ezy` stores `workbench[]` (no BREP). Settings: **`gui.show_workbench_list`**. - **Status toast kinds**: the bottom-right toast is colored by kind (**success**, **info**, **constraint**, **warning**, **error**) instead of always red. Failed `Status` toasts follow `Result_status` (`User_error` is amber constraint). Scripts: `ezy.msg(text [, kind])`. **Add bone** uses info for the next click, amber for rejected input, green on commit, and red if create fails. diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 6a27b9d..bfc5049 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -79,7 +79,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, Copy/Paste, New/Open/Save, and Undo/Redo. See [Keyboard shortcuts](#keyboard-shortcuts). +7. **Keyboard shortcuts** — Remappable chords for modeling and sketch tools, booleans, Delete, Duplicate, 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). @@ -152,7 +152,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, 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. +Remap modeling and sketch tool chords, boolean commands, Delete, Duplicate, 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** @@ -216,70 +216,70 @@ If saved layout text has no `[Docking]` section (older installs), a default dock ### `gui` -| Key | Type | Meaning | -| ------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dark_mode` | boolean | Light/dark theme. | -| `show_options` | boolean | Options panel visible. | -| `show_sketch_list` | boolean | Sketch List pane visible. | -| `show_shape_list` | boolean | Shape List pane visible. | -| `show_workbench_list` | boolean | Workbench List pane visible (default **true**). | -| `log_window_visible` | boolean | Log window visible. | -| `show_settings_dialog` | boolean | Whether the Settings pane was open when last saved (usually false). | -| `show_lua_console` | boolean | Lua console pane visible (default **false**). | -| `show_python_console` | boolean | Python console pane visible (native builds with Python; default **false**). | -| `show_dbg` | boolean | Debug pane visible (debug builds only). | -| `inspection_orthographic` | boolean | Non-sketch modes Options: orthographic camera when true (default false). Sketch modes always use orthographic. | -| `edge_dim_label_h` | integer | Length dimension label placement: **0** near first point, **1** near second, **2** center, **3** automatic. | -| `edge_dim_line_width` | number | Sketch length dimension line width (**0.5** to **8.0**; desktop only; ineffective in WebAssembly). | -| `edge_dim_arrow_size` | number | Arrow head length (**1.0** to **24.0**). | -| `edge_dim_color` | array of 3 numbers | Dimension line, arrow, and text RGB (**0** to **1** per channel; default olive **0.54**, **0.54**, **0.21**). | -| `edge_dim_text_scale` | number | Label height multiplier (**0.5** to **3.0**; default **1.0**). | -| `edge_dim_text_render_mode` | integer | **0** opaque 2D, **1** SetCommonColor, **2** 2D screen, **3** 3D text, **4** Z Top, **5** Z Topmost (default). | -| `edge_dim_arrow_style` | integer | **0** standard, **1** sharp, **2** wide, **3** 3D shaded. | -| `edge_dim_arrow_orientation` | integer | **0** automatic, **1** internal, **2** external. | -| `show_sketch_dimensions` | boolean | When false, hides length dimensions on all sketches. | -| `permanent_node_anno_scale` | number | Scale for permanent **+** markers: the sketch **Origin** and user-placed Add node points ([Sketch origin](usage-sketch.md#sketch-origin); **0.25** to **3.0**; default **1.0**). | -| `bone_add_center_nodes` | boolean | Add-bone Options: commit permanent **Bone A** / **Bone B** (default **true**). | -| `bone_add_radius_nodes` | boolean | Add-bone Options: commit permanent **Bone A+** / **A-** / **B+** / **B-** axis-tangent nodes (default **true**). | -| `bone_add_total_length_nodes` | boolean | Add-bone Options: commit permanent **Bone A tip** / **Bone B tip** outer-end nodes (default **true**). | -| `bone_holes` | integer | Add-bone Options holes mode: **0** None, **1** One radius, **2** Two radii (default **0**). | -| `transform_space` | integer | Move / rotate / scale Options: **0** Local (first selected solid's frame; default), **1** World (global XYZ at that solid's bbox center). | -| `origin_marker_color` | array of 3 numbers | RGB color for the **active** sketch's Origin marker (+ with circle; **0** to **1** per channel; default cyan **0.0**, **0.75**, **1.0**). | -| `sketch_edge_color` | array of 4 numbers | Current-sketch edge RGBA (**0** to **1**; alpha = opacity; default green **0**, **1**, **0**, **1**). | -| `sketch_edge_selection_color` | array of 4 numbers | Selected sketch-edge RGBA (default orange **1**, **0.545**, **0**, **1**). Falls back to `sketch_edge_highlight_color` when absent. | -| `sketch_edge_highlight_color` | array of 4 numbers | Mouse-over (dynamic) highlight RGBA for sketch edges (default yellow **1**, **1**, **0**, **1**). | -| `sketch_edge_line_width` | number | Current-sketch edge line width (**0.5** to **8.0**; default **1.0**; desktop only; ineffective in WebAssembly). | -| `sketch_face_color` | array of 4 numbers | Current-sketch face fill RGBA (default mauve **0.301**, **0.245**, **0.321**, **0.297**). | -| `sketch_face_selection_color` | array of 4 numbers | Selected sketch-face **fill** RGBA (default magenta **0.799**, **0.187**, **0.591**, **1**). Falls back to `sketch_face_highlight_color` when absent. | -| `sketch_face_highlight_color` | array of 4 numbers | Mouse-over (dynamic) sketch-face **fill** RGBA (default violet **0.823**, **0**, **1**, **1**). | -| `snap_guide_color_node` | array of 3 numbers | RGB for snap guides when both axes lock to the same node (float **0** to **1**; default lavender **0.82**, **0.55**, **0.95**). Legacy `snap_guide_color` loads here when `snap_guide_color_node` is absent. | -| `snap_guide_color_axis` | array of 3 numbers | RGB for snap guides when aligned on X or Y only (float **0** to **1**; default magenta **0.96**, **0.06**, **0.54**). Legacy `snap_guide_color` sets both node and axis colors. | -| `snap_guide_mode` | integer | **0** *Traditional* (local markers), **1** *Fullscreen* (view-spanning axis lines), **2** *Both* (default **2**), **3** *None* (disable snap-to-node and snap guides). | -| `snap_guide_line_width` | number | Open CASCADE line width for snap guides (axis lines, markers, co-axial overlay; **0.5** to **8.0**; default **1.0**; desktop only; ineffective in WebAssembly). | -| `annotate_all_coaxial_nodes` | boolean | When true (default), show axis guides and markers for *all* co-axial nodes (current sketch plus other visible sketches). When false, only the closest node per active axis is annotated. Also in sketch **Options**. | -| `imgui_style_dark` | object | ImGui layout for **dark mode** (see keys below). | -| `imgui_style_light` | object | ImGui layout for **light mode** (same keys). | -| `settings_headers` | object | Which Settings pane collapsing sections are expanded (booleans; see keys below). | -| `imgui_rounding_general` | number | **Legacy:** copied into both theme objects on load when `imgui_style_*` is absent. | -| `imgui_rounding_scroll` | number | **Legacy:** same. | -| `imgui_rounding_tabs` | number | **Legacy:** same. | -| `underlay_highlight_color` | array of 3 numbers | Default underlay tint (float RGB **0** to **1** per channel; default **0.64**, **0.56**, **0.31**). | -| `elm_list_hover_color` | array of 4 numbers | RGBA highlight for rows hovered in the **Shape List** or **Sketch List** dimensions table (float **0** to **1** per channel; default purple **0.40**, **0.10**, **0.47**, **1**). | -| `shape_selection_color` | array of 4 numbers | Selected 3D shape highlight RGBA (AIS SelectionStyle; float **0** to **1**; alpha = opacity; default purple **0.75**, **0.07**, **0.85**, **1**). | -| `curve_deviation_angle_deg` | number | Max AIS curve segment angle in degrees (**2** to **20**; default **6**). Smaller is smoother on-screen (sketch arcs, round solids). Display only; STEP/STL export is unchanged. See [usage-occt-view.md](usage-occt-view.md#curve-display). | -| `sketch_shape_faint_style` | integer | How 3D shapes appear in sketch mode when enabled: **0** Off (hide), **1** Ghost (default), **2** Wire. See [usage-sketch.md](usage-sketch.md#sketching-2d). | -| `sketch_shape_faint_opacity` | number | Shape Faint Strength as opacity (**0.05** to **0.85**; default **0.14**; Settings shows **5%**–**85%**). Ghost and Wire. OCCT transparency is **1 - opacity**. | -| `sketch_shape_faint_enabled` | boolean | Master switch for faint solids in all sketch tools (default **true**). Also **Options -> Sketch options -> Faint shapes**. When false, solids are hidden in sketch modes. | -| `extrude_fast_preview` | boolean | When **true** (default), extrude uses a face-copy drag preview for faces with more edges than `extrude_fast_preview_edge_threshold`. Settings -> Sketch -> Appearance. See [Extrude](usage.md#extrude-sketch-face-tool-e). | -| `extrude_fast_preview_edge_threshold` | integer | Edge-count threshold for extrude fast preview (**4** to **256**; default **24**). | -| `view_roll_step_deg` | number | Degrees per **NumPad 8**/**2**/**4**/**6** orbit and **Shift+NumPad 4**/**6** roll (allowed range **0.1** to **180** in code; default **45**). | -| `view_zoom_scroll_scale` | number | Multiplier for `UpdateZoom` scroll delta from wheel and keyboard zoom (allowed range **0.25** to **64** in code; default **4**). With **Shift** held, the effective step is multiplied by **0.1** (Blender-style finer zoom). | -| `default_project_unit` | string | Default **File -> New** project unit: `"inch"` or `"millimeter"` (default **`inch`**). Edited under **Settings -> New project defaults**. | -| `default_2d_view_width` | number | Horizontal sketch-plane span for **File -> New** / projects with no saved camera, stored in **inches** (allowed range **0.1** to **1000**; default **3**). Settings UI shows this in **`default_project_unit`**. | -| `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"`, `"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). | +| Key | Type | Meaning | +| ------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dark_mode` | boolean | Light/dark theme. | +| `show_options` | boolean | Options panel visible. | +| `show_sketch_list` | boolean | Sketch List pane visible. | +| `show_shape_list` | boolean | Shape List pane visible. | +| `show_workbench_list` | boolean | Workbench List pane visible (default **true**). | +| `log_window_visible` | boolean | Log window visible. | +| `show_settings_dialog` | boolean | Whether the Settings pane was open when last saved (usually false). | +| `show_lua_console` | boolean | Lua console pane visible (default **false**). | +| `show_python_console` | boolean | Python console pane visible (native builds with Python; default **false**). | +| `show_dbg` | boolean | Debug pane visible (debug builds only). | +| `inspection_orthographic` | boolean | Non-sketch modes Options: orthographic camera when true (default false). Sketch modes always use orthographic. | +| `edge_dim_label_h` | integer | Length dimension label placement: **0** near first point, **1** near second, **2** center, **3** automatic. | +| `edge_dim_line_width` | number | Sketch length dimension line width (**0.5** to **8.0**; desktop only; ineffective in WebAssembly). | +| `edge_dim_arrow_size` | number | Arrow head length (**1.0** to **24.0**). | +| `edge_dim_color` | array of 3 numbers | Dimension line, arrow, and text RGB (**0** to **1** per channel; default olive **0.54**, **0.54**, **0.21**). | +| `edge_dim_text_scale` | number | Label height multiplier (**0.5** to **3.0**; default **1.0**). | +| `edge_dim_text_render_mode` | integer | **0** opaque 2D, **1** SetCommonColor, **2** 2D screen, **3** 3D text, **4** Z Top, **5** Z Topmost (default). | +| `edge_dim_arrow_style` | integer | **0** standard, **1** sharp, **2** wide, **3** 3D shaded. | +| `edge_dim_arrow_orientation` | integer | **0** automatic, **1** internal, **2** external. | +| `show_sketch_dimensions` | boolean | When false, hides length dimensions on all sketches. | +| `permanent_node_anno_scale` | number | Scale for permanent **+** markers: the sketch **Origin** and user-placed Add node points ([Sketch origin](usage-sketch.md#sketch-origin); **0.25** to **3.0**; default **1.0**). | +| `bone_add_center_nodes` | boolean | Add-bone Options: commit permanent **Bone A** / **Bone B** (default **true**). | +| `bone_add_radius_nodes` | boolean | Add-bone Options: commit permanent **Bone A+** / **A-** / **B+** / **B-** axis-tangent nodes (default **true**). | +| `bone_add_total_length_nodes` | boolean | Add-bone Options: commit permanent **Bone A tip** / **Bone B tip** outer-end nodes (default **true**). | +| `bone_holes` | integer | Add-bone Options holes mode: **0** None, **1** One radius, **2** Two radii (default **0**). | +| `transform_space` | integer | Move / rotate / scale Options: **0** Local (first selected solid's frame; default), **1** World (global XYZ at that solid's bbox center). | +| `origin_marker_color` | array of 3 numbers | RGB color for the **active** sketch's Origin marker (+ with circle; **0** to **1** per channel; default cyan **0.0**, **0.75**, **1.0**). | +| `sketch_edge_color` | array of 4 numbers | Current-sketch edge RGBA (**0** to **1**; alpha = opacity; default green **0**, **1**, **0**, **1**). | +| `sketch_edge_selection_color` | array of 4 numbers | Selected sketch-edge RGBA (default orange **1**, **0.545**, **0**, **1**). Falls back to `sketch_edge_highlight_color` when absent. | +| `sketch_edge_highlight_color` | array of 4 numbers | Mouse-over (dynamic) highlight RGBA for sketch edges (default yellow **1**, **1**, **0**, **1**). | +| `sketch_edge_line_width` | number | Current-sketch edge line width (**0.5** to **8.0**; default **1.0**; desktop only; ineffective in WebAssembly). | +| `sketch_face_color` | array of 4 numbers | Current-sketch face fill RGBA (default mauve **0.301**, **0.245**, **0.321**, **0.297**). | +| `sketch_face_selection_color` | array of 4 numbers | Selected sketch-face **fill** RGBA (default magenta **0.799**, **0.187**, **0.591**, **1**). Falls back to `sketch_face_highlight_color` when absent. | +| `sketch_face_highlight_color` | array of 4 numbers | Mouse-over (dynamic) sketch-face **fill** RGBA (default violet **0.823**, **0**, **1**, **1**). | +| `snap_guide_color_node` | array of 3 numbers | RGB for snap guides when both axes lock to the same node (float **0** to **1**; default lavender **0.82**, **0.55**, **0.95**). Legacy `snap_guide_color` loads here when `snap_guide_color_node` is absent. | +| `snap_guide_color_axis` | array of 3 numbers | RGB for snap guides when aligned on X or Y only (float **0** to **1**; default magenta **0.96**, **0.06**, **0.54**). Legacy `snap_guide_color` sets both node and axis colors. | +| `snap_guide_mode` | integer | **0** *Traditional* (local markers), **1** *Fullscreen* (view-spanning axis lines), **2** *Both* (default **2**), **3** *None* (disable snap-to-node and snap guides). | +| `snap_guide_line_width` | number | Open CASCADE line width for snap guides (axis lines, markers, co-axial overlay; **0.5** to **8.0**; default **1.0**; desktop only; ineffective in WebAssembly). | +| `annotate_all_coaxial_nodes` | boolean | When true (default), show axis guides and markers for *all* co-axial nodes (current sketch plus other visible sketches). When false, only the closest node per active axis is annotated. Also in sketch **Options**. | +| `imgui_style_dark` | object | ImGui layout for **dark mode** (see keys below). | +| `imgui_style_light` | object | ImGui layout for **light mode** (same keys). | +| `settings_headers` | object | Which Settings pane collapsing sections are expanded (booleans; see keys below). | +| `imgui_rounding_general` | number | **Legacy:** copied into both theme objects on load when `imgui_style_*` is absent. | +| `imgui_rounding_scroll` | number | **Legacy:** same. | +| `imgui_rounding_tabs` | number | **Legacy:** same. | +| `underlay_highlight_color` | array of 3 numbers | Default underlay tint (float RGB **0** to **1** per channel; default **0.64**, **0.56**, **0.31**). | +| `elm_list_hover_color` | array of 4 numbers | RGBA highlight for rows hovered in the **Shape List** or **Sketch List** dimensions table (float **0** to **1** per channel; default purple **0.40**, **0.10**, **0.47**, **1**). | +| `shape_selection_color` | array of 4 numbers | Selected 3D shape highlight RGBA (AIS SelectionStyle; float **0** to **1**; alpha = opacity; default purple **0.75**, **0.07**, **0.85**, **1**). | +| `curve_deviation_angle_deg` | number | Max AIS curve segment angle in degrees (**2** to **20**; default **6**). Smaller is smoother on-screen (sketch arcs, round solids). Display only; STEP/STL export is unchanged. See [usage-occt-view.md](usage-occt-view.md#curve-display). | +| `sketch_shape_faint_style` | integer | How 3D shapes appear in sketch mode when enabled: **0** Off (hide), **1** Ghost (default), **2** Wire. See [usage-sketch.md](usage-sketch.md#sketching-2d). | +| `sketch_shape_faint_opacity` | number | Shape Faint Strength as opacity (**0.05** to **0.85**; default **0.14**; Settings shows **5%**–**85%**). Ghost and Wire. OCCT transparency is **1 - opacity**. | +| `sketch_shape_faint_enabled` | boolean | Master switch for faint solids in all sketch tools (default **true**). Also **Options -> Sketch options -> Faint shapes**. When false, solids are hidden in sketch modes. | +| `extrude_fast_preview` | boolean | When **true** (default), extrude uses a face-copy drag preview for faces with more edges than `extrude_fast_preview_edge_threshold`. Settings -> Sketch -> Appearance. See [Extrude](usage.md#extrude-sketch-face-tool-e). | +| `extrude_fast_preview_edge_threshold` | integer | Edge-count threshold for extrude fast preview (**4** to **256**; default **24**). | +| `view_roll_step_deg` | number | Degrees per **NumPad 8**/**2**/**4**/**6** orbit and **Shift+NumPad 4**/**6** roll (allowed range **0.1** to **180** in code; default **45**). | +| `view_zoom_scroll_scale` | number | Multiplier for `UpdateZoom` scroll delta from wheel and keyboard zoom (allowed range **0.25** to **64** in code; default **4**). With **Shift** held, the effective step is multiplied by **0.1** (Blender-style finer zoom). | +| `default_project_unit` | string | Default **File -> New** project unit: `"inch"` or `"millimeter"` (default **`inch`**). Edited under **Settings -> New project defaults**. | +| `default_2d_view_width` | number | Horizontal sketch-plane span for **File -> New** / projects with no saved camera, stored in **inches** (allowed range **0.1** to **1000**; default **3**). Settings UI shows this in **`default_project_unit`**. | +| `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": "Ctrl+D"`, `"edit.duplicate": "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-sketch.md b/docs/usage-sketch.md index bde5b01..c5312ca 100644 --- a/docs/usage-sketch.md +++ b/docs/usage-sketch.md @@ -99,24 +99,24 @@ Common keyboard shortcuts (hotkeys) while working in 2D sketch mode or with sket ### Common sketch hotkeys -| Hotkey | Action | -| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | -| Tab | Open precise distance / length / radius / size input dialog (most creation tools) | -| Shift+Tab | Open angle (degrees) input dialog for constrained line / multi-line / add-node placement | -| Esc | Cancel the current tool, step, or rubber-band preview | -| Enter | Confirm current numeric input or finalize the step | -| I | Sketch inspection mode (default; remappable) | -| N | Add node (default; remappable) | -| L / Shift+L | Add line / multi-line edge (defaults; remappable) | -| A / Shift+A | Add arc / operation axis (defaults; remappable) | -| Q / B / Shift+B | Add square / rectangle / rectangle from center (defaults; remappable) | -| O / Shift+O | Add circle / three-point circle (defaults; remappable) | -| U | Add slot (default; remappable) | -| Shift+U | Add bone (default; remappable) | -| P | Sketch from planar face (default; remappable) | -| D | Activate the Dimension tool (default; remappable) | -| Shift+D / Delete / Backspace | Delete the selected sketch element(s) or dimension | -| Right-click | In multi-line / sequences: complete current item and continue, or finish the whole operation | +| Hotkey | Action | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Tab | Open precise distance / length / radius / size input dialog (most creation tools) | +| Shift+Tab | Open angle (degrees) input dialog for constrained line / multi-line / add-node placement | +| Esc | Cancel the current tool, step, or rubber-band preview | +| Enter | Confirm current numeric input or finalize the step | +| I | Sketch inspection mode (default; remappable) | +| N | Add node (default; remappable) | +| L / Shift+L | Add line / multi-line edge (defaults; remappable) | +| A / Shift+A | Add arc / operation axis (defaults; remappable) | +| Q / B / Shift+B | Add square / rectangle / rectangle from center (defaults; remappable) | +| O / Shift+O | Add circle / three-point circle (defaults; remappable) | +| U | Add slot (default; remappable) | +| Shift+U | Add bone (default; remappable) | +| P | Sketch from planar face (default; remappable) | +| D | Activate the Dimension tool (default; remappable) | +| Ctrl+D / Delete / Backspace | Delete the selected sketch element(s) or dimension (Ctrl+D is the default remappable Delete chord) | +| Right-click | In multi-line / sequences: complete current item and continue, or finish the whole operation | **Notes:** - Tab / Shift+Tab work even when focus is in the 3D view (they are routed to the active sketch tool for precise entry). @@ -882,15 +882,15 @@ Edge dimension tool creates/removes **length dimensions between two sketch nodes | **Node-pair dimensions** | Dimensions are defined by two sketch nodes | | **Fast edge workflow** | Click a straight edge to toggle a dimension between its two endpoint nodes | | **Two-node workflow** | Click one node, then a second node, to toggle a dimension between them; after the first node, a **preview line** follows the cursor (same idea as the line-edge rubber band), so two-node mode is visually distinct from a single edge click | -| **Selectable/deletable** | Dimension objects can be selected in sketch mode and deleted (for example with Shift+D, Delete, or Backspace) | +| **Selectable/deletable** | Dimension objects can be selected in sketch mode and deleted (for example with Ctrl+D, Delete, or Backspace) | | **Helpful for verification** | Quickly verify that your sketch has the correct dimensions | **Shortcuts:** -| | | -| ------------------------------------------------------------------------: | ---------------------------------------------- | -| D | Activate dimension tool | -| Shift+D, Delete, or Backspace | Delete selected dimension (or other selection) | +| | | +| -----------------------------------------------------------------------: | ---------------------------------------------- | +| D | Activate dimension tool | +| Ctrl+D, Delete, or Backspace | Delete selected dimension (or other selection) | **How to Use:** 1. ![TechDraw_LengthDimension](res/icons/TechDraw_LengthDimension.png) Press D or select the **Edge Dimensions** tool from the toolbar @@ -898,7 +898,7 @@ Edge dimension tool creates/removes **length dimensions between two sketch nodes - **Edge click:** Click a straight edge to toggle the dimension between its endpoints - **Node pair:** Click node A, then click node B to toggle the dimension between A and B 3. Click the same edge (or same node pair) again to remove that dimension -4. To delete directly, select the dimension object and press Shift+D, Delete, or Backspace +4. To delete directly, select the dimension object and press Ctrl+D, Delete, or Backspace **When to Use:** diff --git a/docs/usage.md b/docs/usage.md index 64d5764..3e71474 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -130,6 +130,7 @@ Each row (left to right): - **M** - Solids only: left-click opens the material popup. Right-click uses the same row menu as the rest of the entry. - **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. +- **Duplicate** - Shift+D (remappable) clones the selection in place and switches to [Move](#shape-move-tool-g). See [Duplicate shapes](#duplicate-shapes). - **Right-click the row** - Works on the whole entry (checkboxes, **M**, tree arrow, name field, and row padding). Solids: **Zoom to** / **Shape info...** / local-frame display and edit (below) / **Delete**. Groups: **Zoom to** (fits all descendant solids; disabled when the group has none) / **Ungroup** (moves **all** direct children to the group's parent, then removes the group) / **Delete** (cascade-deletes the whole subtree). **Zoom to** keeps the current camera orientation and frames the solid or group with a little padding. - **Local frame (solids)** - Each solid has a local `XYZ` frame (default: world-aligned at the bounding-box center; **Z** is the primary axis, **Y** is up). Right-click the row for: - **Show axes** / **Show plane** / **Show up** - toggle 3D annotations (off by default; saved in the `.ezy` file; hidden while any sketch tool is active, when **Hide all** is on, or when the solid or an ancestor group is hidden). @@ -166,6 +167,7 @@ The **Workbench List** is a second pane for arranging solids in the Workbench ta - Adding a shape or group from the Shape List creates Workbench rows that share that body's geometry. Fillet, chamfer, and other Design geometry edits update the Workbench solids. Moving, rotating, or scaling a body in Design does not move its Workbench instances. - Each Workbench solid has its own instance pose and a local tool frame (Show axes / Local move). Right-click **Set from planar face...** / **Set from cylindrical face...** stays in Workbench and does not move the instance. - The pane matches the Shape List for **Hide all**, visibility, shaded/wireframe, material, rename, groups, drag-to-reparent, and right-click **Zoom to** / **Delete** / local-frame actions. +- **Duplicate** (Shift+D) adds another Workbench instance of the selected link (same Design body, same pose) and switches to Workbench **Move**. See [Duplicate shapes](#duplicate-shapes). - Deleting a Shape List solid removes Workbench rows that link to it. Deleting a Workbench row does not delete the Design body. - The 3D view shows Shape List solids in Sketch and Design, and Workbench solids in the Workbench task (so the two trees do not overlap). @@ -210,9 +212,12 @@ EzyCad can load a **default document** when it starts (geometry, camera, tool mo Edit operations change your model (sketches or 3D shapes) and can be navigated with undo/redo. - **Delete selected** - - Use Shift+D, Delete, or Backspace to remove the currently selected sketch elements or shapes. + - Use Ctrl+D (remappable), Delete, or Backspace to remove the currently selected sketch elements or shapes. - Deletions are recorded in the undo history and can be undone/redone. +- **Duplicate shapes** + - In **Design** or **Workbench**, Shift+D clones the selection and switches to Move. See [Duplicate shapes](#duplicate-shapes). + - **Undo and Redo** EzyCad includes document-level undo/redo for both sketches and 3D shapes. @@ -256,7 +261,7 @@ The typical modeling workflow in EzyCad follows these steps: 3. **Create a Sketch from a Planar Face**: Once you have 3D shapes, use [Create sketch from planar face](usage-sketch.md#create-sketch-from-planar-face-tool) to pick a flat face on a solid. EzyCad extracts the face boundary into a new sketch aligned with that face (Origin at the face bounding-box center). Edit the sketch, then extrude again to add or cut features on the existing model. -4. **Modify 3D Shapes**: Use [3D Modeling tools](#3d-modeling) to transform shapes ([move](#shape-move-tool-g), [rotate](#shape-rotate-tool-r), [scale](#shape-scale-tool-s), [align shafts](#align-shafts-tool-j)) or create patterns ([polar duplicate](#shape-polar-duplicate-tool)). +4. **Modify 3D Shapes**: Use [3D Modeling tools](#3d-modeling) to transform shapes ([move](#shape-move-tool-g), [rotate](#shape-rotate-tool-r), [scale](#shape-scale-tool-s), [align shafts](#align-shafts-tool-j)), [duplicate](#duplicate-shapes) them, or create patterns ([polar duplicate](#shape-polar-duplicate-tool)). 5. **Apply Feature Operations**: Use [boolean operations](#boolean-operations) (cut, fuse, common) or edge-based feature operations (chamfer with C, fillet with F) to refine your 3D model. @@ -391,9 +396,23 @@ More detail: [Sketch snapping](usage-sketch.md#sketch-snapping) in the sketch gu - ![Shape Rotate Tool](res/icons/Draft_Rotate.png) [Rotate objects (R)](#shape-rotate-tool-r) - ![Shape Scale Tool](res/icons/Part_Scale.png) [Scale elements (S)](#shape-scale-tool) - ![Align Shafts Tool](res/icons/Assembly_Move.png) [Align shafts (J)](#align-shafts-tool-j) + - [Duplicate shapes (Shift+D)](#duplicate-shapes) - ![Polar Duplicate Tool](res/icons/Draft_PolarArray.png) [Polar duplicate](#shape-polar-duplicate-tool) - ![Cross-section Tool](res/icons/Curves_ExtractSubshape.png) [Preview a local-plane cross-section](#shape-cross-section-tool) +#### Duplicate shapes + +Shift+D clones the selected shapes and switches to [Move](#shape-move-tool-g) so you can place the copies (same pattern as Blender). It is available in the **Design** and **Workbench** tasks. Remap it in **Settings -> Keyboard shortcuts** (`edit.duplicate`). + +- **Design** copies each selected solid as a new body at the same pose, with its own geometry. Click a Shape List **group** first (so that group is current and its solids are selected) to clone the whole subtree as a sibling of the group. +- **Workbench** adds another instance of the same Design body at the same pose. Both rows stay links to that one body. +- Copies keep the original parent. Names gain a numeric suffix (`Box.001`, `Box.002`, ...). One undo step removes the copies. +- The copy/paste clipboard is left unchanged. + +Delete, Backspace, and the remappable **Delete** shortcut (Ctrl+D by default) remove the selection, including sketch elements. In **Sketch**, use those delete keys. + +If Move, Rotate, Scale, or Align shafts is already in progress, Shift+D drops that preview and duplicates the last committed shapes. + #### Shape Move Tool (G) ![Shape Move Tool](res/icons/Assembly_AxialMove.png) @@ -762,7 +781,7 @@ For more on 3D solids and the viewer, see [3D viewer (Open CASCADE)](usage-occt- ## Hotkeys -Mode, file, and edit chords in the **General Operations** and **Modeling Shortcuts** tables below are the **defaults**. Remap them in **View -> Settings -> Keyboard shortcuts** — see [usage-settings.md -> Keyboard shortcuts](usage-settings.md#keyboard-shortcuts) for bindable keys, reserved chords, Reset/Defaults, and **`gui.hotkeys`** persistence. Toolbar tooltips for remappable modes show the current chord. **Delete** and **Backspace** always delete the selection even if **Delete** is remapped off Shift+D. Ctrl+Shift+Z remains a fixed redo alias alongside the remappable Ctrl+Y. +Mode, file, and edit chords in the **General Operations** and **Modeling Shortcuts** tables below are the **defaults**. Remap them in **View -> Settings -> Keyboard shortcuts** — see [usage-settings.md -> Keyboard shortcuts](usage-settings.md#keyboard-shortcuts) for bindable keys, reserved chords, Reset/Defaults, and **`gui.hotkeys`** persistence. Toolbar tooltips for remappable modes show the current chord. **Delete** and **Backspace** always delete the selection even if **Delete** is remapped off Ctrl+D. Ctrl+Shift+Z remains a fixed redo alias alongside the remappable Ctrl+Y. ### General Operations @@ -777,7 +796,8 @@ Mode, file, and edit chords in the **General Operations** and **Modeling Shortcu | Enter | Confirm current operation | | Tab | Distance/dimension input | | Shift+Tab | Angle input (for line edges with angle constraint) | -| Shift+D, Delete, or Backspace | Remove selected elements | +| Ctrl+D, Delete, or Backspace | Remove selected elements | +| Shift+D | [Duplicate](#duplicate-shapes) selected shapes, then Move | | 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) | diff --git a/res/ezycad_settings.json b/res/ezycad_settings.json index 282e5f2..7bd3a7b 100644 --- a/res/ezycad_settings.json +++ b/res/ezycad_settings.json @@ -66,7 +66,8 @@ "cmd.shape_cut": "Ctrl+Shift+C", "cmd.shape_fuse": "Ctrl+Shift+F", "cmd.shape_common": "Ctrl+Shift+M", - "edit.delete": "Shift+D", + "edit.delete": "Ctrl+D", + "edit.duplicate": "Shift+D", "edit.copy": "Ctrl+C", "edit.paste": "Ctrl+V", "file.new": "Ctrl+N", diff --git a/src/doc/gui.md b/src/doc/gui.md index d9c6a99..21e7e45 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -158,26 +158,28 @@ Overlay popups (`FloatEdit`, `AngleEdit`, `MessageStatus`, modals) keep `NoSaved Remappable chords live in `Gui_hotkeys` (`gui_hotkeys.h` / `.cpp`), owned by `GUI::m_hotkeys`. Stable action ids (e.g. `mode.move`, `mode.add_edge`, `cmd.shape_cut`, `edit.undo`) map to `Key_chord { key, mods }`. Persistence: `gui.hotkeys` in `ezycad_settings.json` as human-readable strings (`"G"`, `"Shift+L"`, `"Ctrl+Shift+C"`); missing keys merge to built-in defaults. On load, `merge_from_json` drops reserved/invalid chords, then resolves duplicate chords: later actions reset to factory; if that factory chord is still held by an earlier remap, that earlier row is also restored to factory (defaults are unique, so `action_for` never keeps a silent collision). Settings **Keyboard shortcuts** captures the next `GLFW_PRESS` (Esc cancels; `set_chord` rejects conflicts and **reserved** fixed chords via `is_reserved_chord`). Per-row **Reset** calls `reset_action` (factory chord via `set_chord`, so duplicates are rejected with the same inline conflict message). Capture is cleared when Settings closes. Toolbar tooltips for remappable modes and boolean commands are rebuilt via `sync_toolbar_hotkey_tooltips_()`. -| Input | Condition | Handler | -| --------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `+` / `-` / numpad +/- | No Ctrl/Alt | `Occt_view::zoom_view_wheel_notches` | -| Shift + 4/6 / arrows / numpad 4/6 | No Ctrl/Alt | `Occt_view::roll_view_z_deg` | -| Numpad 5 | No modifiers | `Occt_view::snap_view_to_nearest_standard_axis` | -| Numpad 2/4/6/8 | No modifiers | `Occt_view::orbit_view_screen_step_deg` | -| Hotkey capture active | Settings | `try_capture_hotkey_press_` (assign / Esc cancel / conflict message) | -| `1`-`9` / numpad `1`-`9` | `is_shape_browse_mode` | `set_shp_selection_mode` (TopAbs enum index); fixed; Design Inspection + Workbench idle | -| Esc | | cancel capture if listening; else `cancel_underlay_calib_`, `Occt_view::cancel`, hide dist/angle edit | -| Tab | not Design/Workbench move/rotate/align | `Occt_view::dimension_input`; those modes: `break` into mode handlers | -| Shift+Tab | not Design/Workbench move/rotate/align | `Occt_view::angle_input`; those modes: `break` into mode handlers (cyl-align: clock/angle) | -| Enter | not Design/Workbench rotate/align | hide edits, `Occt_view::on_enter`; those modes: `break` into mode handlers (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, copy/paste, file, undo/redo) | -| Move-mode keys | `Design_move` / `Workbench_move` | `on_key_move_mode_` (axis constraints X/Y/Z); hardcoded | -| Rotate-mode keys | `Design_rotate` / `Workbench_rotate` | `on_key_rotate_mode_` (axis pick, Tab angle); hardcoded | -| Align-shafts keys | `Design_shaft_align` / `Workbench_shaft_align` | `on_key_cyl_align_mode_` (Tab depth, Shift+Tab clock/angle, 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+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)). +| Input | Condition | Handler | +| --------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `+` / `-` / numpad +/- | No Ctrl/Alt | `Occt_view::zoom_view_wheel_notches` | +| Shift + 4/6 / arrows / numpad 4/6 | No Ctrl/Alt | `Occt_view::roll_view_z_deg` | +| Numpad 5 | No modifiers | `Occt_view::snap_view_to_nearest_standard_axis` | +| Numpad 2/4/6/8 | No modifiers | `Occt_view::orbit_view_screen_step_deg` | +| Hotkey capture active | Settings | `try_capture_hotkey_press_` (assign / Esc cancel / conflict message) | +| `1`-`9` / numpad `1`-`9` | `is_shape_browse_mode` | `set_shp_selection_mode` (TopAbs enum index); fixed; Design Inspection + Workbench idle | +| Esc | | cancel capture if listening; else `cancel_underlay_calib_`, `Occt_view::cancel`, hide dist/angle edit | +| Tab | not Design/Workbench move/rotate/align | `Occt_view::dimension_input`; those modes: `break` into mode handlers | +| Shift+Tab | not Design/Workbench move/rotate/align | `Occt_view::angle_input`; those modes: `break` into mode handlers (cyl-align: clock/angle) | +| Enter | not Design/Workbench rotate/align | hide edits, `Occt_view::on_enter`; those modes: `break` into mode handlers (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, duplicate, copy/paste, file, undo/redo) | +| Move-mode keys | `Design_move` / `Workbench_move` | `on_key_move_mode_` (axis constraints X/Y/Z); hardcoded | +| Rotate-mode keys | `Design_rotate` / `Workbench_rotate` | `on_key_rotate_mode_` (axis pick, Tab angle); hardcoded | +| Align-shafts keys | `Design_shaft_align` / `Workbench_shaft_align` | `on_key_cyl_align_mode_` (Tab depth, Shift+Tab clock/angle, 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; Ctrl+D delete; Shift+D duplicate (then Move); 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)). + +**Duplicate** (`edit.duplicate`, default Shift+D): `Occt_view::duplicate_selected_shapes`. Design task deep-copies selected solids, or the current group subtree when its descendant solids match the selection. Workbench task clones instances as new links (`source_id` unchanged, same frame). Copies keep the source parent, get uniquified names, and one `Shape_add_delta`. Selection moves to the copies, then `GUI::set_mode` enters `Design_move` or `Workbench_move`. An in-progress Move / Rotate / Scale / Align shafts is cancelled first so the copy uses committed geometry. Sketch task returns a user error. The shape clipboard is not read or written. **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`. diff --git a/src/doc/shape.md b/src/doc/shape.md index 1b83adc..b656032 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -27,6 +27,7 @@ Typical uses: - 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`). +- In-place duplicate (`duplicate_selected_shapes`): Design deep-copies solids or the current group subtree; Workbench adds links with the same `source_id` and frame. Selects the copies and enters Move. ## Requirements and invariants @@ -194,13 +195,13 @@ Full GLFW -> `GUI` routing before these delegates: [`src/doc/gui.md`](gui.md). Shape ops use typed deltas from [`shp_delta.h`](../shp_delta.h) (see [undo-redo.md](undo-redo.md)). No full-document JSON for these paths. -| Mechanism | When | -| --------------------------------- | ------------------------------------------------------------- | -| `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 | -| `push_undo_snapshot()` (fallback) | Delete when selection also includes sketch edges / dimensions | +| Mechanism | When | +| --------------------------------- | --------------------------------------------------------------------- | +| `Shape_add_delta` | Primitives, extrude, revolve, STEP/PLY import, shape paste, duplicate | +| `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 | +| `push_undo_snapshot()` (fallback) | Delete when selection also includes sketch edges / dimensions | Shapes carry a stable `Shape_id` persisted as `shapes[].id` in project JSON. diff --git a/src/gui_hotkeys.cpp b/src/gui_hotkeys.cpp index 5f589f4..557212d 100644 --- a/src/gui_hotkeys.cpp +++ b/src/gui_hotkeys.cpp @@ -49,7 +49,8 @@ constexpr Action_meta c_actions[] = { {Gui_action::Cmd_shape_cut, "cmd.shape_cut", "Shape cut", {GLFW_KEY_C, GLFW_MOD_CONTROL | GLFW_MOD_SHIFT}}, {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_delete, "edit.delete", "Delete", {GLFW_KEY_D, GLFW_MOD_CONTROL}}, + {Gui_action::Edit_duplicate, "edit.duplicate", "Duplicate", {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}}, diff --git a/src/gui_hotkeys.h b/src/gui_hotkeys.h index 06eeed2..2a3fe8b 100644 --- a/src/gui_hotkeys.h +++ b/src/gui_hotkeys.h @@ -38,6 +38,7 @@ enum class Gui_action Cmd_shape_fuse, Cmd_shape_common, Edit_delete, + Edit_duplicate, Edit_copy, Edit_paste, File_new, diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 7b0b03a..b7a9b96 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -360,7 +360,7 @@ void GUI::on_key(int key, int scancode, int action, int mods) case GLFW_KEY_DELETE: case GLFW_KEY_BACKSPACE: - // Fixed aliases: remapping edit.delete (default Shift+D) must not remove these keys. + // Fixed aliases: remapping edit.delete (default Ctrl+D) must not remove these keys. m_view->delete_selected(); return; @@ -455,6 +455,10 @@ void GUI::dispatch_hotkey_action_(Gui_action action) break; case Gui_action::Edit_delete: m_view->delete_selected(); break; + case Gui_action::Edit_duplicate: + if (Status s = m_view->duplicate_selected_shapes(); !s.is_ok()) + show_status(s); + break; case Gui_action::Edit_copy: if (Status s = m_view->copy_selected_shapes(); !s.is_ok()) show_status(s); diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 24d4ad5..72f86a0 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -2811,6 +2811,255 @@ Status Occt_view::paste_clipboard_shapes() return Status::ok(); } +Status Occt_view::duplicate_selected_shapes() +{ + const Task task = task_of(get_mode()); + if (task == Task::Sketch) + return Status::user_error("Duplicate shapes in Design or Workbench."); + + const bool workbench = (task == Task::Workbench); + + // Drop an in-progress transform so the copy matches committed geometry. + // cancel() returns to that task's idle and restores the operand selection. + switch (get_mode()) + { + case Mode::Design_move: + case Mode::Workbench_move: + shp_move().cancel(); + break; + + case Mode::Design_rotate: + case Mode::Workbench_rotate: + shp_rotate().cancel(); + break; + + case Mode::Scale: + shp_scale().cancel(); + break; + + case Mode::Design_shaft_align: + case Mode::Workbench_shaft_align: + shp_cyl_align().cancel(); + break; + + default: + break; + } + + const std::vector selected_all = get_selected_shps(); + std::vector selected; + selected.reserve(selected_all.size()); + for (const Shp_ptr& s : selected_all) + if (!s.IsNull() && s->is_workbench() == workbench) + selected.push_back(s); + + if (selected.empty()) + return Status::user_error("Nothing to duplicate."); + + std::unordered_set selected_ids; + selected_ids.reserve(selected.size()); + for (const Shp_ptr& s : selected) + selected_ids.insert(s->get_id()); + + const auto descendants_of = [&](Shape_id id) + { + return workbench ? workbench_descendant_solids(id) : shape_descendant_solids(id); + }; + const auto children_of = [&](Shape_id id) { return workbench ? workbench_children(id) : shape_children(id); }; + const auto find_in_store = [&](Shape_id id) -> Shp_ptr + { + return workbench ? find_workbench_shape_by_id(id) : find_design_shape_by_id(id); + }; + + std::vector root_ids; + const Shape_id gid = workbench ? current_workbench_group_id() : current_group_id(); + if (gid != 0) + { + const std::vector desc = descendants_of(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()); + + 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); + } + + std::vector snap; + std::unordered_set seen; + for (Shape_id root_id : collapsed) + { + Shp_ptr root = find_in_store(root_id); + if (root.IsNull()) + continue; + + std::vector stack = {root}; + 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 = children_of(cur->get_id()); + for (auto it = kids.rbegin(); it != kids.rend(); ++it) + stack.push_back(*it); + } + } + + for (const Shp_ptr& n : ordered) + snap.push_back(capture_shape_rec(*n)); + } + + if (snap.empty()) + return Status::user_error("Nothing to duplicate."); + + std::unordered_map id_map; + id_map.reserve(snap.size()); + for (const Shape_rec& rec : snap) + { + 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 duplicate."); + + id_map[rec.id] = new_id; + } + + const std::list& store = workbench ? m_wbk_shps : m_shps; + std::vector existing_names; + existing_names.reserve(store.size() + snap.size()); + for (const Shp_ptr& s : store) + if (!s.IsNull()) + existing_names.push_back(s->get_name()); + + std::unordered_map next_order; + auto alloc_order = [&](Shape_id parent) -> int + { + const auto it = next_order.find(parent); + if (it != next_order.end()) + return it->second++; + + const int n = workbench ? next_order_in_store_(m_wbk_shps, parent) : next_sibling_order(parent); + next_order.emplace(parent, n + 1); + return n; + }; + + std::vector added; + added.reserve(snap.size()); + Shape_id first_group = 0; + std::vector new_roots; + new_roots.reserve(collapsed.size()); + + for (const Shape_rec& src : snap) + { + Shape_rec rec = src; + rec.id = id_map[src.id]; + + const auto parent_it = id_map.find(src.parent_id); + const bool is_root = (parent_it == id_map.end()); + if (is_root) + { + rec.sibling_order = alloc_order(src.parent_id); + new_roots.push_back(rec.id); + if (rec.is_group && first_group == 0) + first_group = rec.id; + } + else + rec.parent_id = parent_it->second; + + if (rec.parent_id == rec.id || would_reparent_create_cycle(rec.id, rec.parent_id)) + return Status::user_error("Internal error: duplicate 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 (!workbench) + { + Shp_ptr live = find_in_store(src.id); + if (!live.IsNull()) + { + const gp_Trsf& tr = live->LocalTransformation(); + if (tr.Form() != gp_Identity) + { + BRepBuilderAPI_Transform transformer(live->Shape(), tr, true); + rec.geom = transformer.Shape(); + rec.frame.Transform(tr); + } + } + } + + if (rec.geom.IsNull()) + return Status::user_error("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."); + } + + added.push_back(std::move(rec)); + } + + for (const Shape_rec& rec : added) + insert_shape_rec(rec); + + if (first_group != 0) + { + if (workbench) + set_current_workbench_group_id(first_group); + else + set_current_group_id(first_group); + } + + push_undo_delta(std::make_unique(std::move(added))); + sync_sketch_shape_faint_style(); + + std::vector to_select; + for (Shape_id root : new_roots) + for (const Shp_ptr& s : descendants_of(root)) + to_select.push_back(s); + + set_selected_shps(to_select); + gui().set_mode(workbench ? Mode::Workbench_move : Mode::Design_move); + return Status::ok(); +} + void Occt_view::remove_selected_length_dimensions_from_sketches_() { std::vector selected_dims; diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h index c60e267..c910297 100644 --- a/src/gui_occt_view.h +++ b/src/gui_occt_view.h @@ -239,6 +239,10 @@ class Occt_view : protected AIS_ViewController /// 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(); + /// Clone the selection in place (same parent and pose), select the copies, and enter Move. + /// Design: independent solids. Workbench: new links to the same Design bodies. + /// Does not use the shape clipboard. Sketch task is rejected. + [[nodiscard]] Status duplicate_selected_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). diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 0dcd18a..3db34a8 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -75,6 +75,12 @@ void select_shapes(Occt_view& view, const std::vector& shapes) cctx.AddOrRemoveSelected(shp, true); } +struct Restore_design_inspection +{ + GUI& gui; + ~Restore_design_inspection() { gui.set_mode(Mode::Design_inspection); } +}; + int displayed_object_count(AIS_InteractiveContext& ctx) { NCollection_List displayed; @@ -1450,6 +1456,178 @@ TEST_F(Shp_test, New_file_keeps_shape_clipboard) EXPECT_EQ(leaves, 1u); } +TEST_F(Shp_test, Duplicate_design_solid_copies_geometry_and_enters_move) +{ + Restore_design_inspection restore{gui()}; + gui().set_mode(Mode::Design_inspection); + + view().add_box(0, 0, 0, 2, 3, 4); + Shp_ptr src = view().get_shapes().back(); + ASSERT_FALSE(src.IsNull()); + const Shape_id src_id = src->get_id(); + const gp_Pnt origin = src->get_frame().Location(); + select_shapes(view(), {src}); + + ASSERT_TRUE(view().duplicate_selected_shapes().is_ok()); + EXPECT_EQ(gui().get_mode(), Mode::Design_move); + EXPECT_TRUE(view().shp_move().has_operation_shps()); + + size_t leaves = 0; + Shp_ptr copy; + for (const Shp_ptr& s : view().get_shapes()) + if (!s.IsNull() && !s->is_group()) + { + ++leaves; + if (s->get_id() != src_id) + copy = s; + } + + EXPECT_EQ(leaves, 2u); + ASSERT_FALSE(copy.IsNull()); + EXPECT_EQ(copy->get_parent_id(), src->get_parent_id()); + EXPECT_EQ(copy->get_name(), "Box.001"); + EXPECT_FALSE(copy->Shape().IsPartner(src->Shape())); + EXPECT_NEAR(volume_of(copy->Shape()), volume_of(src->Shape()), 1e-6); + EXPECT_TRUE(copy->get_frame().Location().IsEqual(origin, 1e-6)); + + const std::vector selected = view().get_selected_shps(); + ASSERT_EQ(selected.size(), 1u); + EXPECT_EQ(selected[0]->get_id(), copy->get_id()); + + EXPECT_TRUE(view().undo()); + leaves = 0; + for (const Shp_ptr& s : view().get_shapes()) + if (!s.IsNull() && !s->is_group()) + ++leaves; + + EXPECT_EQ(leaves, 1u); +} + +TEST_F(Shp_test, Duplicate_design_group_is_sibling) +{ + Restore_design_inspection restore{gui()}; + gui().set_mode(Mode::Design_inspection); + + view().add_box(0, 0, 0, 1, 1, 1); + view().add_box(3, 0, 0, 1, 1, 1); + std::vector boxes; + for (const Shp_ptr& s : view().get_shapes()) + if (!s.IsNull() && !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.IsNull() && 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().duplicate_selected_shapes().is_ok()); + EXPECT_EQ(gui().get_mode(), Mode::Design_move); + + int groups = 0; + int solids = 0; + Shp_ptr copy_grp; + for (const Shp_ptr& s : view().get_shapes()) + { + if (s.IsNull()) + continue; + + if (s->is_group()) + { + ++groups; + if (s->get_id() != gid) + copy_grp = s; + } + else + ++solids; + } + + EXPECT_EQ(groups, 2); + EXPECT_EQ(solids, 4); + ASSERT_FALSE(copy_grp.IsNull()); + EXPECT_EQ(copy_grp->get_parent_id(), grp->get_parent_id()); + EXPECT_EQ(view().shape_descendant_solids(copy_grp->get_id()).size(), 2u); + EXPECT_EQ(view().shape_descendant_solids(gid).size(), 2u); + EXPECT_EQ(view().current_group_id(), copy_grp->get_id()); +} + +TEST_F(Shp_test, Duplicate_workbench_link_keeps_source_and_enters_move) +{ + Restore_design_inspection restore{gui()}; + view().add_box(1, 2, 3, 4, 5, 6); + Shp_ptr src = view().get_shapes().back(); + ASSERT_TRUE(view().add_to_workbench({src}).is_ok()); + Shp_ptr inst = view().get_workbench_shapes().back(); + ASSERT_FALSE(inst.IsNull()); + const Shape_id inst_id = inst->get_id(); + const gp_Pnt origin = inst->get_frame().Location(); + + gui().set_mode(Mode::Workbench_inspection); + select_shapes(view(), {inst}); + ASSERT_TRUE(view().duplicate_selected_shapes().is_ok()); + EXPECT_EQ(gui().get_mode(), Mode::Workbench_move); + EXPECT_TRUE(view().shp_move().has_operation_shps()); + EXPECT_EQ(view().get_shapes().size(), 1u); + EXPECT_EQ(view().get_workbench_shapes().size(), 2u); + + Shp_ptr copy; + for (const Shp_ptr& s : view().get_workbench_shapes()) + if (!s.IsNull() && s->get_id() != inst_id) + copy = s; + + ASSERT_FALSE(copy.IsNull()); + EXPECT_TRUE(copy->is_workbench_link()); + EXPECT_EQ(copy->get_source_id(), src->get_id()); + EXPECT_EQ(copy->get_parent_id(), inst->get_parent_id()); + EXPECT_TRUE(copy->get_frame().Location().IsEqual(origin, 1e-6)); + + const std::vector selected = view().get_selected_shps(); + ASSERT_EQ(selected.size(), 1u); + EXPECT_EQ(selected[0]->get_id(), copy->get_id()); + + EXPECT_TRUE(view().undo()); + EXPECT_EQ(view().get_workbench_shapes().size(), 1u); + EXPECT_EQ(view().get_shapes().size(), 1u); +} + +TEST_F(Shp_test, Duplicate_in_sketch_is_rejected) +{ + Restore_design_inspection restore{gui()}; + view().add_box(0, 0, 0, 1, 1, 1); + select_shapes(view(), {view().get_shapes().back()}); + gui().set_mode(Mode::Sketch_inspection); + + EXPECT_FALSE(view().duplicate_selected_shapes().is_ok()); + EXPECT_EQ(gui().get_mode(), Mode::Sketch_inspection); + EXPECT_EQ(view().get_shapes().size(), 1u); +} + +TEST_F(Shp_test, Duplicate_leaves_shape_clipboard) +{ + Restore_design_inspection restore{gui()}; + gui().set_mode(Mode::Design_inspection); + view().add_box(0, 0, 0, 1, 1, 1); + select_shapes(view(), {view().get_shapes().back()}); + ASSERT_TRUE(view().copy_selected_shapes().is_ok()); + ASSERT_TRUE(view().duplicate_selected_shapes().is_ok()); + EXPECT_TRUE(view().has_shape_clipboard()); + + gui().set_mode(Mode::Design_inspection); + ASSERT_TRUE(view().paste_clipboard_shapes().is_ok()); + + size_t leaves = 0; + for (const Shp_ptr& s : view().get_shapes()) + if (!s.IsNull() && !s->is_group()) + ++leaves; + + EXPECT_EQ(leaves, 3u); +} + TEST_F(Shp_test, Grouped_solids_stay_displayed_and_selectable) { view().add_box(0, 0, 0, 1, 1, 1); From 580495948e486ca15cd3540c36789e4ecfbf4299 Mon Sep 17 00:00:00 2001 From: trailcode Date: Wed, 7 Oct 2026 18:20:57 -0600 Subject: [PATCH 2/4] Shp name fix --- CHANGELOG.md | 1 + docs/usage.md | 2 +- src/doc/utility.md | 14 +++++++------- src/utl.cpp | 37 +++++++++++++++++++++++++++++++++---- src/utl.h | 4 +++- tests/shp_tests.cpp | 15 +++++++++++++++ 6 files changed, 60 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c66ca7..8efb528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Shape and Workbench copy names**: duplicating `Box.001` takes the next free `Box` / `Box.NNN` slot. It no longer stacks another `.001` (`Box.001.001`). - **Workbench task icon**: `res/icons/Workbench_Assembly.png` is included so the task button and the usage guide can load it (Sphinx `-W` failed on the missing image). - **Workbench toolbar**: the tools row no longer repeats the Workbench task icon; idle is the task button only. - **Workbench Align shafts**: cylindrical face picking uses Face selection on Workbench instances (same as Design). Insert position uses the instance placement (axis direction was already correct). diff --git a/docs/usage.md b/docs/usage.md index 3e71474..b3a513a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -406,7 +406,7 @@ More detail: [Sketch snapping](usage-sketch.md#sketch-snapping) in the sketch gu - **Design** copies each selected solid as a new body at the same pose, with its own geometry. Click a Shape List **group** first (so that group is current and its solids are selected) to clone the whole subtree as a sibling of the group. - **Workbench** adds another instance of the same Design body at the same pose. Both rows stay links to that one body. -- Copies keep the original parent. Names gain a numeric suffix (`Box.001`, `Box.002`, ...). One undo step removes the copies. +- Copies keep the original parent. Names use the next free slot in that sequence (`Box` then `Box.001`; copying `Box.001` yields `Box.002`, or an earlier gap such as `Box` when that name is unused). One undo step removes the copies. - The copy/paste clipboard is left unchanged. Delete, Backspace, and the remappable **Delete** shortcut (Ctrl+D by default) remove the selection, including sketch elements. In **Sketch**, use those delete keys. diff --git a/src/doc/utility.md b/src/doc/utility.md index 1f825fb..f5ffba2 100644 --- a/src/doc/utility.md +++ b/src/doc/utility.md @@ -48,13 +48,13 @@ CMake IDE group: `src\utl` (pattern `^utl(_|\.)`). ### General helpers -| API | Purpose | -| ---------------------------------------- | ------------------------------------------------------------------------------------- | -| `clear_all(...)` | Reset optional/containers/arithmetic/handles (`Nullify`)/enums/aggregates in one call | -| `unique_sequential_name(base, existing)` | `Name`, `Name.001`, ... for sketches/shapes | -| `load_texture(path)` | Toolbar icon loading | -| `decode_image_bytes(bytes)` | stb_image -> RGBA for underlay import | -| `safe_cstr_copy` | ImGui fixed-buffer copies (MSVC-safe) | +| API | Purpose | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `clear_all(...)` | Reset optional/containers/arithmetic/handles (`Nullify`)/enums/aggregates in one call | +| `unique_sequential_name(base, existing)` | `Name`, `Name.001`, ... First free slot. A trailing `.NNN` on `base` is the suffix, not a new stem (`Name.001` -> `Name.002`). | +| `load_texture(path)` | Toolbar icon loading | +| `decode_image_bytes(bytes)` | stb_image -> RGBA for underlay import | +| `safe_cstr_copy` | ImGui fixed-buffer copies (MSVC-safe) | ## Geometry (`utl_geom`) diff --git a/src/utl.cpp b/src/utl.cpp index eec52a4..3074f37 100644 --- a/src/utl.cpp +++ b/src/utl.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -12,14 +13,42 @@ #define STB_IMAGE_IMPLEMENTATION #include +namespace +{ +bool is_digit_suffix_(const std::string& suffix) +{ + return !suffix.empty() && + std::all_of(suffix.begin(), suffix.end(), [](unsigned char c) { return std::isdigit(c) != 0; }); +} + +/// "Shape.001.001" and "Shape.001" share the stem "Shape". A non-numeric tail stays put. +std::string name_sequence_stem_(const std::string& name) +{ + std::string stem = name; + while (true) + { + const size_t dot = stem.rfind('.'); + if (dot == std::string::npos || dot == 0) + break; + + if (!is_digit_suffix_(stem.substr(dot + 1))) + break; + + stem.resize(dot); + } + return stem; +} +} // namespace + std::string unique_sequential_name(const std::string& base_name, std::span existing_names) { + const std::string stem = name_sequence_stem_(base_name); std::set used; - const std::string prefix = base_name + "."; + const std::string prefix = stem + "."; for (const std::string& n : existing_names) { - if (n == base_name) + if (n == stem) used.insert(0); else if (n.size() > prefix.size() && n.compare(0, prefix.size(), prefix) == 0) { @@ -47,11 +76,11 @@ std::string unique_sequential_name(const std::string& base_name, std::span bool contains(const Container& con return it != container.end(); } -/// Returns \a base_name or \a base_name.NNN (three-digit suffix) not used in \a existing_names. +/// Returns the stem or stem.NNN (three-digit suffix) not used in \a existing_names. +/// A trailing .NNN on \a base_name is a sequence suffix, not part of the stem +/// (copying Name.001 yields the first free slot: Name, Name.002, ...). std::string unique_sequential_name(const std::string& base_name, std::span existing_names); uint32_t load_texture(const std::string& path); diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 3db34a8..53bc1da 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -597,6 +597,21 @@ TEST_F(Shp_test, UniqueShapeNames_increment) EXPECT_EQ(view().get_shapes().back()->get_name(), "Box.001"); } +TEST_F(Shp_test, UniqueShapeNames_suffixed_source_uses_next_free_slot) +{ + view().add_box(0, 0, 0, 1, 1, 1); + view().get_shapes().back()->set_name("Box.001"); + EXPECT_EQ(view().get_unique_shape_name("Box.001"), "Box"); + EXPECT_EQ(view().get_unique_shape_name("Box.001.001"), "Box"); + + view().get_shapes().back()->set_name("Box"); + view().add_box(2, 0, 0, 1, 1, 1); + EXPECT_EQ(view().get_unique_shape_name("Box.001"), "Box.002"); + + view().get_shapes().back()->set_name("Box.003"); + EXPECT_EQ(view().get_unique_shape_name("Box.003"), "Box.001"); +} + TEST_F(Shp_test, AddSphere_and_SetVisible) { view().add_sphere(0, 0, 0, 2.0); From ab47dd9b87455ee8a17483663dfd550107eb04ef Mon Sep 17 00:00:00 2001 From: trailcode Date: Wed, 7 Oct 2026 18:37:21 -0600 Subject: [PATCH 3/4] Code style update --- agents.md | 1 + agents/README.md | 38 +++---- docs/ezycad_code_style.md | 14 +++ scripts/code_style_check.py | 205 +++++++++++++++++++++++++++++++++++- src/gui_occt_view.cpp | 31 ++---- src/main.cpp | 5 +- src/shp_cyl_align.cpp | 5 +- src/shp_move.cpp | 5 +- src/shp_rotate.cpp | 5 +- src/shp_scale.cpp | 5 +- src/skt.cpp | 5 +- src/skt_bone.cpp | 5 +- src/skt_dims.cpp | 8 +- src/skt_nodes.cpp | 10 +- src/skt_topo.cpp | 5 +- src/utl_geom.cpp | 8 +- src/utl_occt.cpp | 5 +- tests/skt_tests.cpp | 15 +-- 18 files changed, 263 insertions(+), 112 deletions(-) diff --git a/agents.md b/agents.md index e681211..407f9e7 100644 --- a/agents.md +++ b/agents.md @@ -12,6 +12,7 @@ Pointer for AI coding assistants. Details live in [agents/README.md](agents/READ - [agents/conventions/ascii-source.md](agents/conventions/ascii-source.md) for `src/` and `tests/` - [docs/ezycad_code_style.md](docs/ezycad_code_style.md) for C++ style +- When a `for` or `while` body is only `Type name = init;` plus an `if` that tests `name`, write `for`/`while (...) if (Type name = init; condition)` and omit the loop braces. Keep those braces when the loop is the unbraced then-branch of an `if` that has an `else`. - After creating or editing C++ (`src/`, `tests/`) or Markdown tables: `python scripts/agent_check.py ` (ASCII; table alignment for `.md`). Optional local style: `python scripts/code_style_check.py`. Do not run the individual ASCII scripts unless that one failed. ## When needed diff --git a/agents/README.md b/agents/README.md index 3036c38..bbd7e44 100644 --- a/agents/README.md +++ b/agents/README.md @@ -6,24 +6,24 @@ Root markers: [AGENTS.md](../AGENTS.md) / [agents.md](../agents.md). ## Quick index -| Need | File | -| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ASCII / `src/` edits | [conventions/ascii-source.md](conventions/ascii-source.md) — after edits: `python scripts/agent_check.py ` | -| C++ style (full) | [docs/ezycad_code_style.md](../docs/ezycad_code_style.md) — Vertical rhythm includes a blank line after an early-return `if`; optional local: `python scripts/code_style_check.py` (not CI) | -| User docs when UI changes | [conventions/user-docs-sync.md](conventions/user-docs-sync.md) | -| Sketch module (dev doc) | [src/doc/sketch.md](../src/doc/sketch.md) — read when editing sketch code; update if API/architecture changes | -| Shape module (dev doc) | [src/doc/shape.md](../src/doc/shape.md) — read when editing `shp_*` code; update if API/operations change | -| GUI module (dev doc) | [src/doc/gui.md](../src/doc/gui.md) — read when editing `gui_*` / viewer shell; update if routing or settings change; **new Mode/Command → hotkeys checklist**; **sketch Options: tool Options above Sketch options** | -| Script module (dev doc) | [src/doc/script.md](../src/doc/script.md) — read when editing `scr_*`; update if bindings change | -| Utility module (dev doc) | [src/doc/utility.md](../src/doc/utility.md) — read when editing `utl_*`; update if shared helpers or I/O change | -| Build / test / wasm | [workflows/local-dev.md](workflows/local-dev.md) | -| OCCT desktop 8 vs wasm 7.9.3 | [conventions/occt-wasm-dual-version.md](conventions/occt-wasm-dual-version.md) — until wasm works on OCCT 8 | -| OCCT handles (`*_ptr`) | [conventions/occt-handles.md](conventions/occt-handles.md) — prefer aliases over `Handle()` for clang-format | -| Release | [workflows/release.md](workflows/release.md) | -| Issue/PR drafts | [drafts/](drafts/) — [github-drafts.md](conventions/github-drafts.md) | -| Feature plans (opt-in) | [plans/](plans/) — load **only** when the prompt matches that feature ([token-lean](conventions/token-lean.md)) | -| Token-saving rules | [conventions/token-lean.md](conventions/token-lean.md) | -| Markdown tables | [conventions/markdown-tables.md](conventions/markdown-tables.md) — align GFM pipes for source + preview | -| Outreach (optional) | [outreach/discoverability.md](outreach/discoverability.md) | +| Need | File | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ASCII / `src/` edits | [conventions/ascii-source.md](conventions/ascii-source.md) — after edits: `python scripts/agent_check.py ` | +| C++ style (full) | [docs/ezycad_code_style.md](../docs/ezycad_code_style.md) — blank line after an early-return `if`; fold a decl-then-if loop into `if (Type name = init; cond)` with no loop braces. Optional local: `python scripts/code_style_check.py` (not CI) | +| User docs when UI changes | [conventions/user-docs-sync.md](conventions/user-docs-sync.md) | +| Sketch module (dev doc) | [src/doc/sketch.md](../src/doc/sketch.md) — read when editing sketch code; update if API/architecture changes | +| Shape module (dev doc) | [src/doc/shape.md](../src/doc/shape.md) — read when editing `shp_*` code; update if API/operations change | +| GUI module (dev doc) | [src/doc/gui.md](../src/doc/gui.md) — read when editing `gui_*` / viewer shell; update if routing or settings change; **new Mode/Command → hotkeys checklist**; **sketch Options: tool Options above Sketch options** | +| Script module (dev doc) | [src/doc/script.md](../src/doc/script.md) — read when editing `scr_*`; update if bindings change | +| Utility module (dev doc) | [src/doc/utility.md](../src/doc/utility.md) — read when editing `utl_*`; update if shared helpers or I/O change | +| Build / test / wasm | [workflows/local-dev.md](workflows/local-dev.md) | +| OCCT desktop 8 vs wasm 7.9.3 | [conventions/occt-wasm-dual-version.md](conventions/occt-wasm-dual-version.md) — until wasm works on OCCT 8 | +| OCCT handles (`*_ptr`) | [conventions/occt-handles.md](conventions/occt-handles.md) — prefer aliases over `Handle()` for clang-format | +| Release | [workflows/release.md](workflows/release.md) | +| Issue/PR drafts | [drafts/](drafts/) — [github-drafts.md](conventions/github-drafts.md) | +| Feature plans (opt-in) | [plans/](plans/) — load **only** when the prompt matches that feature ([token-lean](conventions/token-lean.md)) | +| Token-saving rules | [conventions/token-lean.md](conventions/token-lean.md) | +| Markdown tables | [conventions/markdown-tables.md](conventions/markdown-tables.md) — align GFM pipes for source + preview | +| Outreach (optional) | [outreach/discoverability.md](outreach/discoverability.md) | Full user-doc style: [docs/ezycad_doc_style.md](../docs/ezycad_doc_style.md). OCCT build: [docs/building-occt.md](../docs/building-occt.md). diff --git a/docs/ezycad_code_style.md b/docs/ezycad_code_style.md index b83e8c6..bf54cac 100644 --- a/docs/ezycad_code_style.md +++ b/docs/ezycad_code_style.md @@ -128,6 +128,20 @@ rather than leading with the failure `if` and putting the main work in `else`, u - Initialize members and locals with `=` (`bool ok = false;`), not brace-init (`bool ok {false};`), so clang-format can align consecutive declarations and assignments. - Declare locals close to first use. - Omit braces on single-statement `if`/`for`/`while` bodies when clear. +- When a `for` or `while` body is only a declaration and an `if` that tests that name, fold it into an if-with-initializer and drop the loop braces: + +```cpp +for (Shape_geom_delta::Geom_change& ch : changes) + if (Shp_ptr shp = view().find_shape_by_id(ch.id); !shp.IsNull()) + { + ch.after_geom = shp->Shape(); + ch.after_frame = shp->get_frame(); + } +``` + +Keep the loop braces when the loop is the unbraced then-branch of an `if` that has an `else`: dropping them would bind `else` to the inner `if`. + +Optional local check: `python scripts/code_style_check.py --rule if-initializer`. ## Documentation diff --git a/scripts/code_style_check.py b/scripts/code_style_check.py index 3c7c6e5..da91992 100644 --- a/scripts/code_style_check.py +++ b/scripts/code_style_check.py @@ -1,10 +1,14 @@ #!/usr/bin/env python3 """Optional local check of C++ against docs/ezycad_code_style.md. -Not part of CI or the default agent_check.py run. Start with Vertical rhythm -(blank lines), including a blank line after an early-exit `if` (return / continue / -break / CHK_RET) before the next statement in the same block. Do not add groups -here to CI unless a rule is as objective as ASCII. +Not part of CI or the default agent_check.py run. Groups: + +- vertical-rhythm: blank lines, including after an early-exit `if` (return / + continue / break / CHK_RET) before the next statement in the same block. +- if-initializer: a `for` / `while` whose body is only `Type name = init;` plus + an `if` that tests `name` should be `for (...) if (Type name = init; cond)`. + +Do not add groups here to CI unless a rule is as objective as ASCII. Usage: python scripts/code_style_check.py [paths...] @@ -820,6 +824,198 @@ def _check_span(src: Source, start: int, end: int, findings: list[Finding]) -> N ) +# A declaration-then-if loop body should be an if-with-initializer, not a braced block. +# `using` / control keywords are not declarators. +_DECL_LEAD_REJECT = CONTROL_STARTS | frozenset( + { + "using", + "typedef", + "return", + "else", + "case", + "goto", + "co_return", + "co_await", + "static_assert", + } +) +_DECL_QUALIFIERS = frozenset( + {"const", "volatile", "constexpr", "static", "mutable", "register", "extern", "inline", "thread_local"} +) + + +def check_if_initializer(src: Source) -> list[Finding]: + findings: list[Finding] = [] + _check_if_initializer_span(src, 0, len(src.masked), findings, None) + return findings + + +def _check_if_initializer_span( + src: Source, start: int, end: int, findings: list[Finding], parent: Stmt | None +) -> None: + if start >= end: + return + for stmt in parse_statements(src, start, end): + for b0, b1 in stmt.bodies: + _check_if_initializer_span(src, b0, b1, findings, stmt) + if stmt.kind not in ("for", "while"): + continue + if src.in_clang_format_off(stmt.start): + continue + if not _loop_body_is_braced(src, stmt): + continue + # Dropping the loop braces would make a following else bind to the inner if. + if _unbraced_then_of_if_else(src, stmt, parent): + continue + if len(stmt.bodies) != 1: + continue + b0, b1 = stmt.bodies[0] + body = parse_statements(src, b0, b1) + if len(body) != 2 or body[1].kind != "if": + continue + name = _init_decl_name(src, body[0]) + if not name or not _ident_in_if_condition(src, body[1], name): + continue + findings.append( + Finding( + src.path, + src.line_of(stmt.start), + "if-initializer", + "loop body is a declaration plus if; prefer " + "for/while (...) if (Type name = init; condition) { ... } " + "(no braces around the loop)", + ) + ) + + +def _unbraced_then_of_if_else(src: Source, loop: Stmt, parent: Stmt | None) -> bool: + """True when `loop` is the unbraced then-branch of an if that has an else.""" + if parent is None or parent.kind != "if" or not parent.has_else or not parent.bodies: + return False + b0, b1 = parent.bodies[0] + i = skip_ws_and_pp(src.masked, b0, b1) + if i < b1 and src.masked[i] == "{": + return False + return b0 <= loop.start and loop.end <= b1 + + +def _loop_body_is_braced(src: Source, stmt: Stmt) -> bool: + i = skip_ident(src.masked, stmt.start, stmt.end) + i = skip_ws(src.masked, i, stmt.end) + if i < stmt.end and src.masked[i] == "(": + i = skip_balanced(src.masked, i, stmt.end, "(", ")") + i = skip_ws_and_pp(src.masked, i, stmt.end) + return i < stmt.end and src.masked[i] == "{" + + +def _init_decl_name(src: Source, stmt: Stmt) -> str: + """Variable name of `Type name = init;`, or empty when it is not that form.""" + if stmt.kind != "stmt": + return "" + masked = src.masked + end = stmt.end + i = skip_ws_and_pp(masked, stmt.start, end) + first = peek_ident(masked, i, end) + if not first or first in _DECL_LEAD_REJECT: + return "" + + paren = 0 + bracket = 0 + angle = 0 + eq = -1 + while i < end: + ch = masked[i] + if ch == "(": + paren += 1 + elif ch == ")": + paren = max(0, paren - 1) + elif ch == "[": + bracket += 1 + elif ch == "]": + bracket = max(0, bracket - 1) + elif ch == "<" and paren == 0 and bracket == 0: + angle += 1 + elif ch == ">" and paren == 0 and bracket == 0 and angle: + angle -= 1 + elif ch == "," and paren == 0 and bracket == 0 and angle == 0: + # Another declarator (`T a = x, b = y`) or a comma before `=`. + return "" + elif ch == "{": + return "" + elif ch == "=" and paren == 0 and bracket == 0 and angle == 0 and eq < 0: + nxt, is_assign = skip_equals_token(masked, i, end) + if is_assign: + eq = i + i = nxt + continue + i = nxt + continue + i += 1 + + if eq < 0: + return "" + + name = _prev_ident(masked, eq) + if not name or name in _DECL_LEAD_REJECT or name in _DECL_QUALIFIERS: + return "" + + name_start = eq - 1 + while name_start >= stmt.start and masked[name_start] in " \t\n\r*&": + name_start -= 1 + name_start = name_start - len(name) + 1 + if name_start < stmt.start or not _span_has_type(masked, stmt.start, name_start): + return "" + return name + + +def _span_has_type(masked: str, start: int, end: int) -> bool: + p = start + while p < end: + p = skip_ws_and_pp(masked, p, end) + if p >= end: + break + ident = peek_ident(masked, p, end) + if ident and ident not in _DECL_QUALIFIERS: + return True + if not ident: + if masked[p] in "*&": + p += 1 + continue + return False + p = skip_ident(masked, p, end) + return False + + +def _ident_in_if_condition(src: Source, stmt: Stmt, name: str) -> bool: + """True when `name` appears in the `if (` ... `)` header (the test, not only the body).""" + masked = src.masked + i = skip_ws_and_pp(masked, stmt.start, stmt.end) + if peek_ident(masked, i, stmt.end) != "if": + return False + i = skip_ident(masked, i, stmt.end) + i = skip_ws(masked, i, stmt.end) + if i >= stmt.end or masked[i] != "(": + return False + close = skip_balanced(masked, i, stmt.end, "(", ")") + return _ident_used(masked, name, i + 1, close - 1) + + +def _ident_used(masked: str, name: str, start: int, end: int) -> bool: + i = start + n = min(end, len(masked)) + while i < n: + if masked[i] in IDENT_START: + j = i + 1 + while j < n and masked[j] in IDENT_CONT: + j += 1 + if masked[i:j] == name: + return True + i = j + continue + i += 1 + return False + + def iter_cpp_files(paths: list[Path]) -> list[Path]: files: list[Path] = [] for path in paths: @@ -845,6 +1041,7 @@ def repo_root() -> Path: RULES = { "vertical-rhythm": check_vertical_rhythm, + "if-initializer": check_if_initializer, } diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 72f86a0..2951aba 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -204,11 +204,9 @@ void Occt_view::init_viewer() aViewer->SetLightOn(); for (NCollection_List::Iterator aLightIter(aViewer->ActiveLights()); aLightIter.More(); aLightIter.Next()) - { - const Graphic3d_CLight_ptr& aLight = aLightIter.Value(); - if (aLight->Type() == Graphic3d_TypeOfLightSource_Directional) + if (const Graphic3d_CLight_ptr& aLight = aLightIter.Value(); + aLight->Type() == Graphic3d_TypeOfLightSource_Directional) aLight->SetCastShadows(true); - } // ImGui owns HiDPI: canvas + GLFW window are CSS * DPR (see imgui_impl_glfw OnCanvasSizeChange). // ToScaleBacking=false so OCCT does not resize the shared canvas again; DevicePixelRatio=1 so @@ -376,14 +374,11 @@ void Occt_view::snap_view_to_nearest_standard_axis() int best_i = 0; double best_dot = -2.0; for (int i = 0; i < 6; ++i) - { - const double d = fwd.X() * k_axes[i].X() + fwd.Y() * k_axes[i].Y() + fwd.Z() * k_axes[i].Z(); - if (d > best_dot) + if (const double d = fwd.X() * k_axes[i].X() + fwd.Y() * k_axes[i].Y() + fwd.Z() * k_axes[i].Z(); d > best_dot) { best_dot = d; best_i = i; } - } const gp_Dir f = k_axes[best_i]; @@ -1468,14 +1463,11 @@ Status Occt_view::group_workbench_shapes(const std::vector& nodes) } for (const Shape_tree_delta::Link_change& ch : links) - { - Shp_ptr n = find_workbench_shape_by_id(ch.id); - if (!n.IsNull()) + if (Shp_ptr n = find_workbench_shape_by_id(ch.id); !n.IsNull()) { n->set_parent_id(ch.new_parent); n->set_sibling_order(ch.new_order); } - } push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*grp)}, std::vector{}, std::move(links))); @@ -5437,20 +5429,17 @@ Status Occt_view::build_export_shape_(TopoDS_Shape& out_shape) const std::vector parts; const std::vector selected = get_selected_shps(); if (!selected.empty()) + { for (const Shp_ptr& shp : selected) - { - TopoDS_Shape t = shape_with_local_transform_(shp); - if (!t.IsNull()) + if (TopoDS_Shape t = shape_with_local_transform_(shp); !t.IsNull()) parts.push_back(t); - } - + } else + { for (const Shp_ptr& shp : m_shps) - { - TopoDS_Shape t = shape_with_local_transform_(shp); - if (!t.IsNull()) + if (TopoDS_Shape t = shape_with_local_transform_(shp); !t.IsNull()) parts.push_back(t); - } + } if (parts.empty()) return Status::user_error("Nothing to export (no shapes)."); diff --git a/src/main.cpp b/src/main.cpp index ec0e268..efcd4ac 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -492,9 +492,7 @@ bool parse_cli_listen_(int argc, char** argv, bool& want_listen, std::string& li listen_arg.clear(); error.clear(); for (int i = 1; i < argc; ++i) - { - const std::string a = argv[i] ? argv[i] : ""; - if (a == "--listen") + if (const std::string a = argv[i] ? argv[i] : ""; a == "--listen") { if (i + 1 >= argc || argv[i + 1] == nullptr || argv[i + 1][0] == '\0') { @@ -505,7 +503,6 @@ bool parse_cli_listen_(int argc, char** argv, bool& want_listen, std::string& li want_listen = true; listen_arg = argv[++i]; } - } return true; } #endif diff --git a/src/shp_cyl_align.cpp b/src/shp_cyl_align.cpp index 2626b98..b963e7d 100644 --- a/src/shp_cyl_align.cpp +++ b/src/shp_cyl_align.cpp @@ -316,14 +316,11 @@ void Shp_cyl_align::finalize() operation_shps_finalize_(); for (Shape_geom_delta::Geom_change& ch : changes) - { - Shp_ptr shp = view().find_shape_by_id(ch.id); - if (!shp.IsNull()) + if (Shp_ptr shp = view().find_shape_by_id(ch.id); !shp.IsNull()) { ch.after_geom = shp->Shape(); ch.after_frame = shp->get_frame(); } - } view().push_undo_delta(std::make_unique(std::move(changes))); reset(); diff --git a/src/shp_move.cpp b/src/shp_move.cpp index 0fb46a8..7860fb2 100644 --- a/src/shp_move.cpp +++ b/src/shp_move.cpp @@ -121,14 +121,11 @@ void Shp_move::finalize() operation_shps_finalize_(); for (Shape_geom_delta::Geom_change& ch : changes) - { - Shp_ptr shp = view().find_shape_by_id(ch.id); - if (!shp.IsNull()) + if (Shp_ptr shp = view().find_shape_by_id(ch.id); !shp.IsNull()) { ch.after_geom = shp->Shape(); ch.after_frame = shp->get_frame(); } - } view().push_undo_delta(std::make_unique(std::move(changes))); reset(); diff --git a/src/shp_rotate.cpp b/src/shp_rotate.cpp index de4bdbe..1a6913e 100644 --- a/src/shp_rotate.cpp +++ b/src/shp_rotate.cpp @@ -264,14 +264,11 @@ void Shp_rotate::finalize() operation_shps_finalize_(); for (Shape_geom_delta::Geom_change& ch : changes) - { - Shp_ptr shp = view().find_shape_by_id(ch.id); - if (!shp.IsNull()) + if (Shp_ptr shp = view().find_shape_by_id(ch.id); !shp.IsNull()) { ch.after_geom = shp->Shape(); ch.after_frame = shp->get_frame(); } - } view().push_undo_delta(std::make_unique(std::move(changes))); reset(); diff --git a/src/shp_scale.cpp b/src/shp_scale.cpp index 5511658..76ec042 100644 --- a/src/shp_scale.cpp +++ b/src/shp_scale.cpp @@ -93,14 +93,11 @@ void Shp_scale::finalize() operation_shps_finalize_(); for (Shape_geom_delta::Geom_change& ch : changes) - { - Shp_ptr shp = view().find_shape_by_id(ch.id); - if (!shp.IsNull()) + if (Shp_ptr shp = view().find_shape_by_id(ch.id); !shp.IsNull()) { ch.after_geom = shp->Shape(); ch.after_frame = shp->get_frame(); } - } view().push_undo_delta(std::make_unique(std::move(changes))); reset(); diff --git a/src/skt.cpp b/src/skt.cpp index a3d0711..ed51442 100644 --- a/src/skt.cpp +++ b/src/skt.cpp @@ -340,9 +340,7 @@ std::vector Sketch::inspector_node_labels() const { std::vector labels; for (size_t i = 0; i < m_nodes.size(); ++i) - { - const Sketch_nodes::Node& n = m_nodes[i]; - if (n.permanent && !n.deleted) + if (const Sketch_nodes::Node& n = m_nodes[i]; n.permanent && !n.deleted) { std::string lbl; if (n.origin) @@ -352,7 +350,6 @@ std::vector Sketch::inspector_node_labels() const labels.push_back(std::move(lbl)); } - } return labels; } diff --git a/src/skt_bone.cpp b/src/skt_bone.cpp index cffd427..4c4e36b 100644 --- a/src/skt_bone.cpp +++ b/src/skt_bone.cpp @@ -423,13 +423,10 @@ std::optional solve_cut_radius_for_waist_(const Bone_frame& f, double ta // leftover neck is at least the target. Start from a guess on the order of the knobs. double r_hi = std::max(r_lo + 1.0, f.r1 + f.r2); for (int i = 0; i < 48; ++i) - { - const double w_hi = leftover_neck(r_hi); - if (!std::isfinite(w_hi) || w_hi < target_waist) + if (const double w_hi = leftover_neck(r_hi); !std::isfinite(w_hi) || w_hi < target_waist) r_hi *= 2.0; // still too pinched; grow the scoop else break; - } const double w_hi = leftover_neck(r_hi); if (!std::isfinite(w_hi) || w_hi < target_waist) diff --git a/src/skt_dims.cpp b/src/skt_dims.cpp index a2059f4..d87b981 100644 --- a/src/skt_dims.cpp +++ b/src/skt_dims.cpp @@ -123,10 +123,9 @@ void Sketch_dims::rebuild_length_dimension_display_(Length_dimension& d) void Sketch_dims::purge_stale_length_dimensions() { for (auto it = m_length_dimensions.begin(); it != m_length_dimensions.end();) - { - const bool bad = it->node_idx_lo >= m_sketch.m_nodes.size() || it->node_idx_hi >= m_sketch.m_nodes.size() || - m_sketch.m_nodes[it->node_idx_lo].deleted || m_sketch.m_nodes[it->node_idx_hi].deleted; - if (bad) + if (const bool bad = it->node_idx_lo >= m_sketch.m_nodes.size() || it->node_idx_hi >= m_sketch.m_nodes.size() || + m_sketch.m_nodes[it->node_idx_lo].deleted || m_sketch.m_nodes[it->node_idx_hi].deleted; + bad) { if (!it->dim.IsNull()) m_sketch.m_ctx.Remove(it->dim, true); @@ -135,7 +134,6 @@ void Sketch_dims::purge_stale_length_dimensions() } else ++it; - } } void Sketch_dims::refresh_all_length_dimensions() diff --git a/src/skt_nodes.cpp b/src/skt_nodes.cpp index b1ff176..32eed65 100644 --- a/src/skt_nodes.cpp +++ b/src/skt_nodes.cpp @@ -642,18 +642,12 @@ std::optional Sketch_nodes::Impl::try_get_node_idx_snap( } for (const auto& p : m_outside_snap_pts) - { - double axis_diff = std::fabs(guide_val - p.XY().Coord(axis_idx + 1)); - if (axis_diff <= Precision::Confusion()) + if (double axis_diff = std::fabs(guide_val - p.XY().Coord(axis_idx + 1)); axis_diff <= Precision::Confusion()) matches.push_back(p); - } for (const auto& p : m_session_snap_pts) - { - double axis_diff = std::fabs(guide_val - p.XY().Coord(axis_idx + 1)); - if (axis_diff <= Precision::Confusion()) + if (double axis_diff = std::fabs(guide_val - p.XY().Coord(axis_idx + 1)); axis_diff <= Precision::Confusion()) matches.push_back(p); - } if (!matches.empty()) { diff --git a/src/skt_topo.cpp b/src/skt_topo.cpp index c64beaa..7d7640b 100644 --- a/src/skt_topo.cpp +++ b/src/skt_topo.cpp @@ -580,13 +580,10 @@ void Sketch_topo::update_faces() { Face_meta& face_a = face_metas[face_i]; for (size_t face_j = face_i + 1; face_j < num; ++face_j) - { - Face_meta& face_b = face_metas[face_j]; - if (is_face_contained(face_b.shp->Shape(), face_a.shp->Shape())) + if (Face_meta& face_b = face_metas[face_j]; is_face_contained(face_b.shp->Shape(), face_a.shp->Shape())) // Check if face_a is better (smaller) parent than the current one if (face_b.parent_idx == -1 || face_a.area < face_metas[face_a.parent_idx].area) face_b.parent_idx = static_cast(face_i); - } } // Assign holes diff --git a/src/utl_geom.cpp b/src/utl_geom.cpp index 5cdad91..055e1a5 100644 --- a/src/utl_geom.cpp +++ b/src/utl_geom.cpp @@ -918,17 +918,11 @@ const gp_Pnt& closest_to_camera(const V3d_View_ptr& view, const std::vectorCamera()->Eye(); for (size_t idx = 0, num = pnts.size(); idx < num; ++idx) - { - // Compute Euclidean distance to camera - double distance = pnts[idx].Distance(camera_pos); - - // Update closest point if this distance is smaller - if (distance < min_distance) + if (double distance = pnts[idx].Distance(camera_pos); distance < min_distance) { min_distance = distance; best_idx = idx; } - } return pnts[best_idx]; } diff --git a/src/utl_occt.cpp b/src/utl_occt.cpp index 7a9ec71..f1f1d85 100644 --- a/src/utl_occt.cpp +++ b/src/utl_occt.cpp @@ -141,14 +141,11 @@ TopoDS_Shape solids_from_shells_(const TopoDS_Shape& shape) int solid_count = 0; for (TopExp_Explorer exp(shape, TopAbs_SHELL); exp.More(); exp.Next()) - { - const TopoDS_Shape solid = solid_from_shell_(TopoDS::Shell(exp.Current())); - if (!solid.IsNull()) + if (const TopoDS_Shape solid = solid_from_shell_(TopoDS::Shell(exp.Current())); !solid.IsNull()) { builder.Add(out, solid); ++solid_count; } - } if (solid_count == 0) return TopoDS_Shape(); diff --git a/tests/skt_tests.cpp b/tests/skt_tests.cpp index 9f77a0c..1a764b1 100644 --- a/tests/skt_tests.cpp +++ b/tests/skt_tests.cpp @@ -503,14 +503,11 @@ TEST_F(Sketch_test, SketchOriginNodePlane) std::optional origin_idx; for (size_t i = 0; i < sketch.get_nodes().size(); ++i) - { - const Sketch_nodes::Node& n = sketch.get_nodes()[i]; - if (!n.deleted && n.origin) + if (const Sketch_nodes::Node& n = sketch.get_nodes()[i]; !n.deleted && n.origin) { origin_idx = i; break; } - } ASSERT_TRUE(origin_idx.has_value()); const Sketch_nodes::Node& origin = sketch.get_nodes()[*origin_idx]; @@ -530,14 +527,11 @@ TEST_F(Sketch_test, SketchOriginNodeFromFaceBBoxCenter) std::optional origin_idx; for (size_t i = 0; i < sketch.get_nodes().size(); ++i) - { - const Sketch_nodes::Node& n = sketch.get_nodes()[i]; - if (!n.deleted && n.origin) + if (const Sketch_nodes::Node& n = sketch.get_nodes()[i]; !n.deleted && n.origin) { origin_idx = i; break; } - } ASSERT_TRUE(origin_idx.has_value()); const Sketch_nodes::Node& origin = sketch.get_nodes()[*origin_idx]; @@ -559,14 +553,11 @@ TEST_F(Sketch_test, SketchOriginNodeSurvivesOnMode) std::optional origin_idx; for (size_t i = 0; i < sketch.get_nodes().size(); ++i) - { - const Sketch_nodes::Node& n = sketch.get_nodes()[i]; - if (!n.deleted && n.origin) + if (const Sketch_nodes::Node& n = sketch.get_nodes()[i]; !n.deleted && n.origin) { origin_idx = i; break; } - } ASSERT_TRUE(origin_idx.has_value()); EXPECT_NEAR(sketch.get_nodes()[*origin_idx].X(), 5.0, Precision::Confusion()); From 8c4b5b7467e9f3df5f9fcd97630af011ac7d3fdd Mon Sep 17 00:00:00 2001 From: trailcode Date: Wed, 7 Oct 2026 18:42:18 -0600 Subject: [PATCH 4/4] Code style --- src/gui_mode.cpp | 1 + src/gui_occt_view.cpp | 1 + 2 files changed, 2 insertions(+) diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index b7a9b96..3601491 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -98,6 +98,7 @@ const char* GUI::current_mode_description_() const // Modes entered only from Shape List / menus / task buttons (no tools-row button). if (m_mode == Mode::Workbench_inspection) return "Workbench"; + if (m_mode == Mode::Shape_set_frame || m_mode == Mode::Workbench_set_frame) return "Set local frame"; diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 2951aba..f7815d3 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -2857,6 +2857,7 @@ Status Occt_view::duplicate_selected_shapes() { return workbench ? workbench_descendant_solids(id) : shape_descendant_solids(id); }; + const auto children_of = [&](Shape_id id) { return workbench ? workbench_children(id) : shape_children(id); }; const auto find_in_store = [&](Shape_id id) -> Shp_ptr {