diff --git a/CHANGELOG.md b/CHANGELOG.md index 887e9e1..cd69273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **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`**. - **Desktop deps**: MSVC GLFW/GLEW come from **vcpkg** (`vcpkg.json` + toolchain) instead of NuGet; local configure matches CI. - **Extrude Both sides**: Options **Both sides** defaults to on (session sticky; not a Settings key). ### Fixed +- **Shape List right-click**: the row context menu opens from the whole entry (checkboxes, **M**, tree arrow, name field, and padding), not only from some widgets. Right-click on the name field opens the same menu. +- **Shape rotate axis**: Choosing X/Y/Z (or view-to-object) before the first drag is kept. Constrained rotation uses the plane perpendicular to that axis when the view faces it. Local space rotates about the assigned frame, not only world XYZ through the bbox. +- **Shape rotate drag**: View-to-object axis and the constrained axis-vs-view plane are captured on the first mouse sample. Orbit or pan during rotate no longer changes the axis/plane against the original mouse anchor. +- **Shape rotate space**: Switching Options **Local** / **World** mid-drag keeps the current angle. The next mouse move re-baselines against the new pivot instead of measuring from the old world-space sample. +- **Shape scale space**: Switching Options **Local** / **World** mid-drag keeps the current scale factor. The next mouse move re-baselines against the new pivot instead of jumping. - **Add bone Holes**: switching **Holes** to **None** after the waist is set commits the outline. Clicks and finalize no longer run hole handlers when holes are off (those handlers reject `None` and left the tool stuck). - **Sketch overlapping edges**: Drawing the same line or circle twice, or adding a collinear / same-circle overlap, no longer stacks a second copy. The sketch splits at overlap ends and keeps each atomic piece once. Undo records only the new atomic pieces (for arcs, each leftover start/bulge/end, not the input triple), so extending a line past an existing collinear edge no longer deletes the original on undo, and a circle over an existing same-circle cap reverses fully. A no-op redraw of an already-covered span does not push undo. Fixes an access violation in `TKGeomBase` when a new circle coincided with an existing arc (for example a bone end-cap). - **Wasm configure/link**: Detect Emscripten via `EMSCRIPTEN` / `CMAKE_SYSTEM_NAME` (modern emsdk reports Clang). Link the FreeType package from the OCCT wasm install so `wasm-ld` finds `libfreetype.a` instead of bare `-lfreetype`. diff --git a/CMakeLists.txt b/CMakeLists.txt index ff5d49a..f9c14d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -133,6 +133,10 @@ set(gui_mode_cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/gui_mode.cpp) list(REMOVE_ITEM srcs "src/gui_mode.cpp" ${gui_mode_cpp}) list(APPEND srcs ${gui_mode_cpp}) +set(gui_shp_pane_cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/gui_shp_pane.cpp) +list(REMOVE_ITEM srcs "src/gui_shp_pane.cpp" ${gui_shp_pane_cpp}) +list(APPEND srcs ${gui_shp_pane_cpp}) + # Define source files for the application # Including ImGui sources and main application file set(App_SRCS ${imguiSrc} ${srcs}) diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 5ca799e..f6fb858 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -108,6 +108,8 @@ Under **Material**: - Document preset for new solids that do not inherit from a clicked shape (toolbar **Box**, **polar duplicate** output, and similar). To change material on an existing solid, use the [Shape List](usage.md#shape-list). +- **Move / rotate / scale**: **Local** (default) or **World** axis space. Local uses the first selected solid's frame (origin and X/Y/Z; same triad as Shape List **Show axes**). World uses global XYZ at that solid's bounding-box center. Persisted as **`gui.transform_space`**. Rotate also has view-to-object / X / Y / Z. See [Move](usage.md#shape-move-tool-g), [Rotate](usage.md#shape-rotate-tool-r), and [Scale](usage.md#shape-scale-tool). + For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Invert normal**, **Hide back side**, **Show section outline**, a bbox-ranged **Offset** slider (Ctrl+click to type), **Clip** (half-space cut, replaces selection), **Cross section sketch** (new sketch from section outline), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). ### Sketch tools @@ -238,6 +240,7 @@ If saved layout text has no `[Docking]` section (older installs), a default dock | `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. | @@ -307,7 +310,7 @@ Each **`settings_headers`** object may contain (default: only nested Sketch **Ap | `startup` | boolean | **Startup project** section expanded. | | `hotkeys` | boolean | **Keyboard shortcuts** section expanded. | -Scripting API **`ezy.occt_view_settings_json()`** returns a JSON string with **`occt_view`** plus selected **`gui`** keys (including dimension and snap keys above, **`gui.permanent_node_anno_scale`**, **`gui.inspection_orthographic`**, **`gui.view_roll_step_deg`**, **`gui.view_zoom_scroll_scale`**, **`gui.curve_deviation_angle_deg`**, **`gui.default_project_unit`**, **`gui.default_2d_view_width`**, **`gui.default_2d_view_height`** when saved). See [scripting.md](scripting.md). +Scripting API **`ezy.occt_view_settings_json()`** returns a JSON string with **`occt_view`** plus selected **`gui`** keys (including dimension and snap keys above, **`gui.permanent_node_anno_scale`**, **`gui.inspection_orthographic`**, **`gui.view_roll_step_deg`**, **`gui.view_zoom_scroll_scale`**, **`gui.curve_deviation_angle_deg`**, **`gui.transform_space`**, **`gui.default_project_unit`**, **`gui.default_2d_view_width`**, **`gui.default_2d_view_height`** when saved). See [scripting.md](scripting.md). --- diff --git a/docs/usage-sketch.md b/docs/usage-sketch.md index ee05299..bde5b01 100644 --- a/docs/usage-sketch.md +++ b/docs/usage-sketch.md @@ -131,6 +131,8 @@ Common keyboard shortcuts (hotkeys) while working in 2D sketch mode or with sket | Y | Toggle / cycle Y | | Z | Toggle / cycle Z | +Those X/Y/Z directions follow Options **Local** (first selected solid's frame) or **World** (global XYZ). Polar duplicate still uses the current sketch plane. + See the individual tool sections (and [usage.md#hotkeys](usage.md#hotkeys)) for full context and additional view / general hotkeys. (line-edge-creation-tools)= diff --git a/docs/usage.md b/docs/usage.md index bf0e3b4..5765ba5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -123,15 +123,16 @@ Each row (left to right): - **Visibility** - Checkbox to show or hide that node. Hiding a **group** hides its whole subtree in the 3D view. - **Solid / wire** - Checkbox (solids only) to switch **shaded** or **wireframe**. -- **M** - Solids only: material popup; right-click for **Zoom to**, **Shape info...**, or **Delete**. +- **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. -- **Right-click the name** - 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 name or **M** for: +- **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). - **Reset frame to bbox** - world-aligned frame at the bbox center (turns **Show axes** on). - **Set from planar face...** / **Set from cylindrical face...** - pick a face on that solid; plane sets **Z** = normal with origin at the face area center (circle center for a disk); cylinder sets **Z** = axis with origin = solid center projected onto the axis. - **Flip up** / **Flip axis (Z)** - reverse the up direction or primary axis (undoable). + - **Move / rotate / scale** default to this frame (**Options -> Local**): X/Y/Z and the pivot are the assigned triad, not world XYZ. Boolean results stay under the shared parent of their inputs when all inputs share one parent; otherwise they are placed at the document root. **File -> Import** STEP assemblies use **Import as** (default **Preserve hierarchy**) to keep product/assembly groups in the tree, import **Flat solids** at the root, or **Union shapes** into one solid. @@ -141,7 +142,7 @@ The window can be closed with its close button; use **View -> Shape List** again #### Shape info -Right-click a shape **name** or the **M** button in the Shape List and choose **Shape info...** to open a property dialog for that 3D shape. Use **Refresh** to recompute the values after the geometry changes. +Right-click a Shape List row and choose **Shape info...** to open a property dialog for that 3D shape. Use **Refresh** to recompute the values after the geometry changes. The dialog reports document fields (name, material, shaded vs wireframe display, visibility) and Open CASCADE (OCCT) topology and measurements, including: @@ -386,7 +387,7 @@ The shape move tool allows you to reposition selected shapes in the 3D viewer wi | | | | -------------------------------: | --------------------------------------------------------------------------------------------------------------------------------------- | -| **Axis Constraints** | Restrict movement to the X, Y, or Z axis by toggling axis constraints in the options panel or using keyboard shortcuts. | +| **Axis Constraints** | Restrict movement to X, Y, or Z. Those axes are the first selected solid's frame (**Local**, default) or world XYZ (**World**). | | **Interactive Distance Editing** | Enter or adjust the distance moved along each axis for precise control. Real-time feedback is provided in the viewer and options panel. | | **Improved Plane Handling** | The move plane is automatically estimated based on the center of the selected shapes, making movement more intuitive. | | **Finalization Logic** | The move operation completes when you confirm the action (e.g., left mouse button). | @@ -394,7 +395,7 @@ The shape move tool allows you to reposition selected shapes in the 3D viewer wi **How to Use:** 1. ![Assembly_AxialMove](res/icons/Assembly_AxialMove.png) **Activate Move Tool:** Select one or more shapes and press G or click the icon. -2. **Constrain Movement (Optional):** Use the options panel to lock movement to a specific axis, or use keyboard shortcuts (e.g., X, Y, Z). +2. **Choose space and constrain (Optional):** In Options, **Local** uses the first selected solid's frame (same triad as **Show axes**); **World** uses global XYZ at that solid's bounding-box center. Then lock X/Y/Z in Options or with X, Y, Z. ![Move constrain axis example](images/move_constrain_axis.png) @@ -417,23 +418,25 @@ The shape rotate tool enables precise rotation of selected shapes around a speci **Features:** -| | | -| ----------------------------: | ------------------------------------------------------------------------------------------------ | -| **Rotation Axis Options** | Choose between view-to-object rotation or constrain rotation to X, Y, or Z axis. | -| **Interactive Angle Editing** | Enter or adjust the rotation angle for precise control with real-time preview. | -| **Visual Feedback** | The rotation axis is displayed with color-coded indicators (Red for X, Green for Y, Blue for Z). | +| | | +| ----------------------------: | ---------------------------------------------------------------------------------------------- | +| **Rotation Axis Options** | View-to-object, or constrain to X, Y, or Z in **Local** (assigned frame) or **World** space. | +| **Interactive Angle Editing** | Enter or adjust the rotation angle for precise control with real-time preview. | +| **Visual Feedback** | The rotation axis is displayed (red X, green Y, blue Z, cyan view-to-object) plus a red pivot. | **How to Use:** 1. **Activate Rotate Tool:** ![Draft_Rotate](res/icons/Draft_Rotate.png) Select one or more shapes and press R or click the icon. You can also activate the tool and select the shape(s) to rotate afterwards. -2. **Select Rotation Axis: (Optional)** +2. **Select space and rotation axis (Optional):** ![Rotate constrain axis example](images/rotate_constrain_axis.png) - *Example: Rotation around on the X axis.* - - Press X to rotate around the X-axis (Red) - - Press Y to rotate around the Y-axis (Green) - - Press Z to rotate around the Z-axis (Blue) - - Press the same axis key again to switch to view-to-object rotation + *Example: Rotation around the X axis.* + - In Options, **Local** (default) uses the first selected solid's frame origin and axes. **World** uses global XYZ at the bounding-box center. + - Press X to rotate around X (red) + - Press Y to rotate around Y (green) + - Press Z to rotate around Z (blue) + - Press the same axis key again to switch to view-to-object rotation (cyan) + - You can pick the axis in Options before moving the mouse. 3. **Edit Angle (Optional):** - Press Tab to activate the angle input box @@ -446,23 +449,24 @@ The shape rotate tool enables precise rotation of selected shapes around a speci - Press Esc to cancel and revert to the original position **Tips:** -- Use view-to-object rotation for intuitive free-form rotation -- Use axis constraints for precise rotations around specific axes -- The rotation center point is displayed as a red dot for reference - - Visible in wirefame rendering of the shape(s) +- Use view-to-object rotation for intuitive free-form rotation (through the same pivot as Local/World) +- Use X/Y/Z for precise rotations; with **Local**, Z is the assigned primary axis (for example a cylinder axis from **Set from cylindrical face**) +- After the first mouse sample, orbit or pan keeps that axis and plane so the angle preview does not jump +- Switching **Local** / **World** mid-drag keeps the current angle; the next mouse move re-baselines from the new pivot +- The pivot is a red cross on top of the solids (shaded or wireframe) - You can combine rotation with other operations for complex transformations #### Shape Scale Tool (S) ![Shape Scale Tool](res/icons/Part_Scale.png) -The shape scale tool allows you to uniformly scale selected shapes around a computed center point. +The shape scale tool allows you to uniformly scale selected shapes around a pivot: the first selected solid's frame origin (**Local**) or that solid's bounding-box center (**World**). **Features:** | | | | -----------------------------: | -------------------------------------------------------------------------------------------------------------------------------- | -| **Automatic center detection** | The scale center is estimated from the bounding box center of the selected shapes. | +| **Automatic center detection** | Pivot is the first selected solid's frame origin (**Local**) or bounding-box center (**World**). | | **Screen-plane scaling** | Scaling happens in a plane derived from the current view, making the interaction intuitive. | | **Interactive preview** | Moving the mouse adjusts the scale factor and updates the shapes in real time. | | **Safe bounds** | The scale factor is clamped to a reasonable range (e.g., between very small and very large values) to avoid degenerate geometry. | @@ -471,11 +475,12 @@ The shape scale tool allows you to uniformly scale selected shapes around a comp 1. **Select shapes:** Select one or more shapes in the 3D view or Shape List. 2. ![Shape Scale Tool](res/icons/Part_Scale.png) **Activate Scale Tool:** Click the *Shape scale* icon in the toolbar (or choose Scale from the Edit/Transform area if present). -3. **Move the mouse:** - - The tool computes a scale center and a view-aligned plane. +3. **Choose space (Optional):** Options **Local** (default) scales about the first selected solid's frame origin. **World** uses that solid's bounding-box center. +4. **Move the mouse:** + - The tool uses that pivot and a view-aligned plane. - Moving the mouse away from or toward the center changes the scale factor and previews the scaled result. -4. **Finalize:** Confirm the operation (e.g., by clicking to complete the interaction) to apply the scale permanently. -5. **Cancel:** Press Esc to cancel and revert to the original shape sizes. +5. **Finalize:** Confirm the operation (e.g., by clicking to complete the interaction) to apply the scale permanently. +6. **Cancel:** Press Esc to cancel and revert to the original shape sizes. **Tips:** diff --git a/res/ezycad_settings.json b/res/ezycad_settings.json index bb7b57a..2ece838 100644 --- a/res/ezycad_settings.json +++ b/res/ezycad_settings.json @@ -7,6 +7,7 @@ "bone_add_radius_nodes": true, "bone_add_total_length_nodes": true, "bone_holes": 0, + "transform_space": 0, "annotate_all_coaxial_nodes": true, "dark_mode": true, "edge_dim_arrow_orientation": 0, diff --git a/src/doc/gui.md b/src/doc/gui.md index 9111d65..a2024c1 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -88,6 +88,7 @@ GUI (gui.h / gui.cpp) +-- gui_mode.cpp set_mode, on_key, Options panel per Mode +-- gui_hotkeys.* remappable Gui_action <-> Key_chord map +-- gui_add.cpp Add menu dialogs (primitives, new sketch) + +-- gui_shp_pane.cpp Shape List pane, Shape info dialog, ui.shapeList +-- gui_settings.cpp Settings dialog, load/save ezycad_settings.json | +-- Occt_view (gui_occt_view.h / gui_occt_view.cpp / .inl) @@ -216,7 +217,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Mode` | Options function | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `Normal` | `options_normal_mode_` (selection filter, orthographic) | -| `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | +| `Move` / `Rotate` / `Scale` | `options_*_mode_` (Local/World space, constraints, axis) | | `Shape_shaft_align` | `options_shape_shaft_align_mode_` (Flip direction, Clock rotation; pick / depth / clock help) | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | @@ -242,23 +243,23 @@ Shared sketch controls (snap, faint shapes) live in `options_sketch_shared_contr ## ImGui frame order (`render_gui`) -| Order | Function | Purpose | -| ----- | ------------------------------------------------------------- | ------------------------------------- | -| 1 | `flush_view_events` | Sync camera before UI uses projection | -| 2 | `menu_bar_`, `toolbar_` | File / View / mode tools | -| 3 | `dist_edit_`, `angle_edit_` | Floating numeric entry | -| 4 | `sketch_list_`, `sketch_properties_dialog_` | Sketch List + underlay/properties | -| 5 | `shape_list_`, `shape_info_dialog_`, `file_inspector_dialog_` | Shape List + info + Import dialog | -| 6 | `options_` | Mode-specific Options pane | -| 7 | `message_status_window_`, `about_dialog_` | Status + About | -| 8 | `add_*_dialog_` | Primitive / sketch creation popups | -| 9 | `log_window_`, consoles, `settings_`, `dbg_` | Log, Lua/Python, Settings | +| Order | Function | Purpose | +| ----- | ------------------------------------------------------------- | ----------------------------------------------- | +| 1 | `flush_view_events` | Sync camera before UI uses projection | +| 2 | `menu_bar_`, `toolbar_` | File / View / mode tools | +| 3 | `dist_edit_`, `angle_edit_` | Floating numeric entry | +| 4 | `sketch_list_`, `sketch_properties_dialog_` | Sketch List + underlay/properties | +| 5 | `shape_list_`, `shape_info_dialog_`, `file_inspector_dialog_` | Shape List + info (`gui_shp_pane.cpp`) + Import | +| 6 | `options_` | Mode-specific Options pane | +| 7 | `message_status_window_`, `about_dialog_` | Status + About | +| 8 | `add_*_dialog_` | Primitive / sketch creation popups | +| 9 | `log_window_`, consoles, `settings_`, `dbg_` | Log, Lua/Python, Settings | `GUI::show_message` drives the transient status toast (`message_status_window_`) and also appends via `log_message`. The toast color comes from `Status_msg` (`Success`, `Info`, `Constraint`, `Warning`, `Error`; default `Info`). `show_status` maps `Result_status` (`User_error` -> Constraint, `Error` / `Topo_error` -> Error, ok -> Info). `show_error_dialog` logs `title: message` once and toasts the title as Error. Sketch List expand **Faces**: each face row supports **`E`** and right-click **Extrude** via `GUI::sketch_list_extrude_face_` (`set_mode(Sketch_face_extrude)` + `Occt_view::begin_sketch_face_extrude` / `Shp_extrude::begin_face_extrude`). Hovering a **Faces**, **Edges**, or **Nodes** row calls `Occt_view::set_sketch_list_hover_{face,edge,node}` (temporarily displays the AIS when hidden outside sketch modes; uses `Graphic3d_ZLayerId_Topmost` so solids do not occlude the highlight). -**Shape List outliner:** `shape_list_` draws a tree of document shapes/groups via `shape_children(0)` and recursive `TreeNodeEx` rows. Fixed-width vis/disp/mat columns are on the left; the name column stretches on the right with tree indent (`IndentEnable` on name only). An empty pad row after the last item is a drag-drop target for document root (`reparent_shape(..., 0)`); it shows a "Move to root" hint while dragging. Groups support expand/collapse (`ui.shapeList.expanded`), drag-drop reparent (`EZY_SHAPE_ID` payload), Group / New group / Ungroup, and cascade delete. Clicking a group sets `Occt_view::current_group_id` (including empty groups) and selects descendant solids; clicking a solid selects it and sets current group to its parent. New primitives/extrudes/revolves parent under the current group. Ctrl+click multi-selects. Row highlight for **selection** follows AIS only; the **current group** uses a weaker tint so it is not mistaken for a selected subtree after Alt-drag rectangle select clears AIS. Copy/paste (Ctrl+C/V) deep-copies the current group subtree when the selection matches that group's descendant solids. Context menu **Zoom to** calls `Occt_view::fit_shapes_in_view` (solid or group descendant solids; keeps camera orientation). Hover uses `set_shape_list_hover` on leaf solids only. `ui.shapeList.currentGroupId` is persisted in `.ezy`. +**Shape List outliner:** `shape_list_` (`gui_shp_pane.cpp`) draws a tree of document shapes/groups via `shape_children(0)` and recursive `gui_shp_detail::Shape_list_row_drawer` rows (`TreeNodeEx`). Fixed-width vis/disp/mat columns are on the left; the name column stretches on the right with tree indent (`IndentEnable` on name only). An empty pad row after the last item is a drag-drop target for document root (`reparent_shape(..., 0)`); it shows a "Move to root" hint while dragging. Groups support expand/collapse (`ui.shapeList.expanded`), drag-drop reparent (`EZY_SHAPE_ID` payload), Group / New group / Ungroup, and cascade delete. Clicking a group sets `Occt_view::current_group_id` (including empty groups) and selects descendant solids; clicking a solid selects it and sets current group to its parent. New primitives/extrudes/revolves parent under the current group. Ctrl+click multi-selects. Row highlight for **selection** follows AIS only; the **current group** uses a weaker tint so it is not mistaken for a selected subtree after Alt-drag rectangle select clears AIS. Copy/paste (Ctrl+C/V) deep-copies the current group subtree when the selection matches that group's descendant solids. Row context menu opens on right-click anywhere on the table row (not only `InputText` / **M**). **Zoom to** calls `Occt_view::fit_shapes_in_view` (solid or group descendant solids; keeps camera orientation). Hover uses `set_shape_list_hover` on leaf solids only. `ui.shapeList.currentGroupId` is persisted in `.ezy`. **Sketch List UI in the project file:** `GUI::serialized_project_json_` writes `ui.sketchList` (scroll Y plus per-sketch `rows` keyed by sketch `id`: `expanded`, `dimensions`, `nodes`, `edges`, `faces`). `GUI::on_file` restores via `apply_sketch_list_ui_from_json_`. Subsection open state is app-owned (`Sketch_list_row_ui` + `SetNextItemOpen`), not ImGui ini storage. @@ -268,6 +269,7 @@ Sketch List expand **Faces**: each face row supports **`E`** and right-click **E | File | Role | | --------------------------- | ------------------------------------------------------------------- | +| `gui_shp_pane.cpp` | Shape List, Shape info dialog, `.ezy` `ui.shapeList` | | `gui_settings.cpp` | Settings dialog UI; read/write `ezycad_settings.json` | | `save_occt_view_settings` | Persists `gui.*`, `occt_view.*`, pane visibility, last project path | | `load_occt_view_settings_` | Called from `GUI::init` | diff --git a/src/doc/shape.md b/src/doc/shape.md index 41b8652..5cb16ca 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -105,6 +105,7 @@ Occt_view shp.h / shp.cpp Shp AIS wrapper (name, visibility, display mode) shp_operation.h / shp_operation.cpp Shp_operation_base shared helpers +shp_transform.h / shp_transform.cpp Local/World axes and constrained translation for move/rotate/scale shp_create.* stateless primitive TopoDS builders (namespace shp_create) shp_info.* shape info dialog lines (namespace shp_info) ``` @@ -150,11 +151,12 @@ Protected helpers used by all operation classes: | `shp_fuse.h` | `Shp_fuse` | `selected_fuse()` -- sequential `BRepAlgoAPI_Fuse` on all selected shapes -> one new `Shp`. | | `shp_cut.h` | `Shp_cut` | `selected_cut()` -- first selected = blank, rest = tools (`BRepAlgoAPI_Cut`). | | `shp_common.h` | `Shp_common` | `selected_common()` -- sequential `BRepAlgoAPI_Common` (intersection). | -| `shp_move.h` | `Shp_move` | Drag on view plane; axis constraints (`Move_options`); Tab distance entry; finalize bakes translation. | -| `shp_rotate.h` | `Shp_rotate` | Rotate about view axis, global X/Y/Z, or view-to-object; angle Tab entry; optional axis/center AIS guides. | -| `shp_scale.h` | `Shp_scale` | Uniform scale from bbox center vs mouse distance; clamped factor 0.01..100. | +| `shp_move.h` | `Shp_move` | Drag on view plane; `Transform_space` Local/World; axis constraints (`Move_options`); Tab distance entry; finalize bakes translation. | +| `shp_rotate.h` | `Shp_rotate` | Rotate about view axis or Local/World X/Y/Z through the current pivot; angle Tab entry; axis/center AIS guides (Topmost). Axis can be set before the first drag. View-to-object axis and the axis-vs-view drag plane are captured on the first mouse sample so orbit/pan does not retarget a stale anchor. Mid-drag Local/World re-seeds the pivot and re-baselines the mouse anchor so the angle does not jump. | +| `shp_scale.h` | `Shp_scale` | Uniform scale from Local frame origin or World bbox center vs mouse distance; clamped factor 0.01..100. Mid-drag Local/World re-seeds the pivot and re-baselines distance so the factor does not jump. | +| `shp_transform.h` | helpers | `transform_axes_for` / `transform_translation` shared by move, rotate, scale. | | `shp_cyl_align.h` | `Shp_cyl_align` | Pick two cylindrical faces (first moves); coaxial `cyl_align_trsf`; drag axial depth; Options **Clock rotation** (default off) then LMB / Shift+Tab about shared axis; Options flip; bake like Move. | -| `shp_set_frame.h` | `Shp_set_frame` | Shape List-only: pick planar or cylindrical face to redefine `Shp` local frame; enables Show axes. | +| `shp_set_frame.h` | `Shp_set_frame` | Shape List-only: pick planar or cylindrical face to redefine `Shp` local frame; enables Show axes. | | `shp_fillet.h` | `Shp_fillet` | `add_fillet(..., Fillet_mode)` -- `BRepFilletAPI_MakeFillet`; modes: Shape, Face, Wire, Edge (`mode.h`). | | `shp_chamfer.h` | `Shp_chamfer` | `add_chamfer(..., Chamfer_mode)` -- diagonal distance converted to setback (`dist/sqrt(2)`). | | `shp_polar_dup.h` | `Shp_polar_dup` | Arm on sketch plane; `dup()` copies selection at polar steps; options: rotate copies, combine into one solid. | @@ -170,7 +172,7 @@ Protected helpers used by all operation classes: | `Mode::Move` | `shp_move().move_selected` | `shp_move().finalize` | `shp_move().show_dist_edit` (`gui_mode`) | `shp_move().cancel` -> `Normal` | | `Mode::Rotate` | `shp_rotate().rotate_selected` | `shp_rotate().finalize` | `shp_rotate().show_angle_edit` | `shp_rotate().cancel` -> `Normal` | | `Mode::Scale` | `shp_scale().scale_selected` | `shp_scale().finalize` | -- | `shp_scale().cancel` -> `Normal` | -| `Mode::Shape_shaft_align` | `drag_depth` / `drag_twist` | Pick faces; LMB depth->clock if Clock rotation on; LMB/Enter finalize | Tab depth; Shift+Tab clock/angle; Enter finalize | `shp_cyl_align().cancel` -> `Normal` | +| `Mode::Shape_shaft_align` | `drag_depth` / `drag_twist` | Pick faces; LMB depth->clock if Clock rotation on; LMB/Enter finalize | Tab depth; Shift+Tab clock/angle; Enter finalize | `shp_cyl_align().cancel` -> `Normal` | | `Mode::Sketch_face_extrude` | `sketch_face_extrude(..., true)` | Pick face; with preview: `on_left_click()` locks height->twist if Twist on, else `finalize` (`GUI` skips re-pick) | Tab: height (`dimension_input`); Shift+Tab: twist angle in twist phase | `m_shp_extrude.cancel` | | `Mode::Shape_fillet` | -- | `shp_fillet().add_fillet(..., Fillet_mode)` | -- | -- | | `Mode::Shape_chamfer` | -- | `shp_chamfer().add_chamfer(..., Chamfer_mode)` | -- | -- | diff --git a/src/gui.cpp b/src/gui.cpp index 37bacc2..d9a631f 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -15,7 +15,6 @@ #include #include #include -#include #include #include "utl_settings.h" @@ -35,7 +34,6 @@ #include "doc_delta.h" #include "utl_io.h" #include "scr_python_console.h" -#include "shp_info.h" #include "skt.h" #include "utl.h" #include "utl_cad_file_info.h" @@ -2523,653 +2521,6 @@ bool GUI::try_underlay_calib_click_(const ScreenCoords& screen_coords) } } -void GUI::shape_list_() -{ - if (!show_shape_list_effective()) - { - m_view->set_shape_list_hover(nullptr); - return; - } - - if (!ImGui::Begin("Shape List", &m_show_shape_list, ImGuiWindowFlags_None)) - { - m_view->set_shape_list_hover(nullptr); - ImGui::End(); - return; - } - - if (ImGui::Checkbox("Hide all", &m_hide_all_shapes)) - { - if (m_hide_all_shapes) - m_view->set_shape_list_hover(nullptr); - m_view->sync_sketch_shape_faint_style(); - } - - ImGui::SameLine(); - if (ImGui::SmallButton("New group")) - { - Shp_ptr grp = m_view->create_group("Group", m_view->current_group_id()); - if (!grp.IsNull()) - m_view->set_current_group_id(grp->get_id()); - } - - ImGui::SameLine(); - { - const std::vector sel = m_view->get_selected_shps(); - const bool can_group = !sel.empty(); - if (!can_group) - ImGui::BeginDisabled(); - - if (ImGui::SmallButton("Group")) - (void)m_view->group_shapes(sel); - - if (!can_group) - ImGui::EndDisabled(); - } - - ImGui::Separator(); - - const std::vector& mat_names = occt_material_combo_labels_(); - const int nmat = static_cast(mat_names.size()); - float mat_label_w_max = 0.0f; - for (int mi = 0; mi < nmat; ++mi) - mat_label_w_max = std::max(mat_label_w_max, ImGui::CalcTextSize(mat_names[static_cast(mi)].c_str()).x); - - const ImGuiStyle& st_mat = ImGui::GetStyle(); - const float mat_popup_w = std::min(440.0f, std::max(280.0f, mat_label_w_max + st_mat.WindowPadding.x * 2.0f + - st_mat.FramePadding.x * 2.0f + st_mat.ScrollbarSize + 8.0f)); - const float check_col_w = ImGui::GetFrameHeight(); - const float mat_col_w = ImGui::CalcTextSize("M").x + st_mat.FramePadding.x * 2.0f; - - Shp_ptr shape_to_delete; - Shp_ptr shape_list_hover; - Shape_id shape_to_ungroup_id = 0; - - std::unordered_set selected_in_viewer; - for (const AIS_Shape_ptr& ais : m_view->get_selected()) - if (!ais.IsNull()) - selected_in_viewer.insert(ais.get()); - - auto row_is_selected = [&](const Shp_ptr& shape) -> bool - { - if (shape.IsNull()) - return false; - - if (shape->is_group()) - { - for (const Shp_ptr& leaf : m_view->shape_descendant_solids(shape->get_id())) - if (selected_in_viewer.count(leaf.get()) != 0) - return true; - - return false; - } - return selected_in_viewer.count(shape.get()) != 0; - }; - - auto select_shape_row = [&](const Shp_ptr& shape) - { - AIS_InteractiveContext& ctx = m_view->ctx(); - const bool ctrl = ImGui::GetIO().KeyCtrl; - if (!ctrl) - ctx.ClearSelected(false); - - if (shape->is_group()) - { - m_view->set_current_group_id(shape->get_id()); - for (const Shp_ptr& leaf : m_view->shape_descendant_solids(shape->get_id())) - ctx.AddOrRemoveSelected(leaf, true); - } - else - { - m_view->set_current_group_id(shape->get_parent_id()); - ctx.AddOrRemoveSelected(shape, true); - } - - ctx.UpdateCurrentViewer(); - }; - - // Tree node always uses NoTreePushOnOpen; indent children with an explicit TreePush/TreePop - // pair so table rows cannot leave the ImGui tree stack unbalanced (which nested siblings - // under the wrong parent and made Ungroup look like it only moved one child). - std::unordered_set shape_list_ancestors; - auto draw_shape_row = [&](auto&& self, const Shp_ptr& shape) -> void - { - EZY_ASSERT(shape); - if (!shape_list_ancestors.insert(shape->get_id()).second) - return; // Parent cycle: skip rather than hang the Shape List. - - const bool is_group = shape->is_group(); - const std::vector children = m_view->shape_children(shape->get_id()); - const bool has_children = !children.empty(); - const bool is_current_group = is_group && shape->get_id() == m_view->current_group_id(); - // Selection highlight follows the 3D viewer only. Current group uses a distinct tint so - // Alt-drag / clear-selection cannot look like the group (or its children) stayed selected. - const bool row_selected = row_is_selected(shape); - bool row_hovered = false; - - ImGui::PushID(static_cast(shape->get_id())); - ImGui::TableNextRow(); - if (row_selected) - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_Header, 0.45f)); - else if (is_current_group) - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_Header, 0.18f)); - - char name_buffer[1024]; - safe_cstr_copy(name_buffer, sizeof(name_buffer), shape->get_name().c_str()); - - int mat_idx = 0; - if (!is_group) - { - mat_idx = shape->Material(); - if (mat_idx < 0 || mat_idx >= nmat) - mat_idx = static_cast(m_view->get_default_material().Name()); - } - - auto apply_shape_material = [&](int i) - { - if (is_group || i < 0 || i >= nmat) - return; - - // OwnColor (e.g. from older wasm SetColor workarounds) overrides material presets. - if (shape->HasColor()) - shape->UnsetColor(); - shape->SetMaterial(Graphic3d_MaterialAspect(static_cast(i))); - m_view->refresh_shape_shading_(shape); - m_view->ctx().Redisplay(shape, true); - m_view->ctx().UpdateCurrentViewer(); - }; - - // Columns 0-2: fixed actions on the left (no tree indent). - ImGui::TableSetColumnIndex(0); - bool visible = shape->get_visible(); - if (ImGui::Checkbox("##vis", &visible)) - { - if (!visible && m_view->shape_list_hover() == shape) - m_view->set_shape_list_hover(nullptr); - - shape->set_visible(visible); - m_view->sync_sketch_shape_faint_style(); - } - row_hovered |= ImGui::IsItemHovered(); - if (ui_show_contextual_help() && ImGui::IsItemHovered()) - ImGui::SetTooltip(is_group ? "Show/hide group subtree" : "visibility"); - - ImGui::TableSetColumnIndex(1); - if (is_group) - ImGui::TextUnformatted(""); - else - { - bool shaded = shape->get_disp_mode() == AIS_Shaded; - if (ImGui::Checkbox("##shaded", &shaded)) - shape->set_disp_mode(shaded ? AIS_Shaded : AIS_WireFrame); - - row_hovered |= ImGui::IsItemHovered(); - if (ui_show_contextual_help() && ImGui::IsItemHovered()) - ImGui::SetTooltip("solid/wire"); - } - - ImGui::TableSetColumnIndex(2); - if (is_group) - ImGui::TextUnformatted(""); - else - { - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4.0f, ImGui::GetStyle().FramePadding.y)); - if (ImGui::Button("M")) - ImGui::OpenPopup("mat_pick"); - - ImGui::PopStyleVar(); - row_hovered |= ImGui::IsItemHovered(); - if (ui_show_contextual_help() && ImGui::IsItemHovered()) - ImGui::SetTooltip("%s\n(click: material; right-click name: Delete)", mat_names[static_cast(mat_idx)].c_str()); - - ImGui::SetNextWindowSize(ImVec2(mat_popup_w, 0.0f), ImGuiCond_Appearing); - if (ImGui::BeginPopup("mat_pick")) - { - ImGui::TextUnformatted("Material"); - ImGui::Separator(); - const float max_h = ImGui::GetTextLineHeightWithSpacing() * 12.0f; - const float sc_w = std::max(1.0f, ImGui::GetContentRegionAvail().x); - if (ImGui::BeginChild("mat_sc", ImVec2(sc_w, max_h), ImGuiChildFlags_Borders, ImGuiWindowFlags_AlwaysVerticalScrollbar)) - { - for (int i = 0; i < nmat; ++i) - if (ImGui::Selectable(mat_names[static_cast(i)].c_str(), i == mat_idx)) - { - apply_shape_material(i); - ImGui::CloseCurrentPopup(); - } - - ImGui::EndChild(); - } - - ImGui::EndPopup(); - } - - if (ImGui::BeginPopupContextItem("mat_btn_ctx")) - { - if (ImGui::MenuItem("Zoom to")) - { - select_shape_row(shape); - m_view->fit_shapes_in_view(shape); - } - - if (ImGui::MenuItem("Shape info...")) - open_shape_info_(shape); - - if (true) - { - ImGui::Separator(); - bool show_axes = shape->show_frame_axes(); - if (ImGui::MenuItem("Show axes", nullptr, show_axes)) - shape->set_show_frame_axes(!show_axes); - bool show_plane = shape->show_frame_plane(); - if (ImGui::MenuItem("Show plane", nullptr, show_plane)) - shape->set_show_frame_plane(!show_plane); - bool show_up = shape->show_frame_up(); - if (ImGui::MenuItem("Show up", nullptr, show_up)) - shape->set_show_frame_up(!show_up); - ImGui::Separator(); - if (ImGui::MenuItem("Reset frame to bbox")) - { - select_shape_row(shape); - m_view->set_shape_frame(shape, Shp::default_frame_for(shape->Shape())); - shape->set_show_frame_axes(true); - } - if (ImGui::MenuItem("Set from planar face...")) - { - select_shape_row(shape); - m_view->shp_set_frame().begin(shape, Shp_set_frame::Pick::Planar_face); - set_mode(Mode::Shape_set_frame); - } - if (ImGui::MenuItem("Set from cylindrical face...")) - { - select_shape_row(shape); - m_view->shp_set_frame().begin(shape, Shp_set_frame::Pick::Cylindrical_face); - set_mode(Mode::Shape_set_frame); - } - if (ImGui::MenuItem("Flip up")) - { - select_shape_row(shape); - gp_Ax3 f = shape->get_frame(); - f.XReverse(); - m_view->set_shape_frame(shape, f); - } - if (ImGui::MenuItem("Flip axis (Z)")) - { - select_shape_row(shape); - gp_Ax3 f = shape->get_frame(); - f.ZReverse(); - m_view->set_shape_frame(shape, f); - } - } - - if (ImGui::MenuItem("Delete")) - shape_to_delete = shape; - - ImGui::EndPopup(); - } - } - - // Column 3: tree arrow + name (indent applies here so hierarchy stays under the name). - ImGui::TableSetColumnIndex(3); - ImGui::AlignTextToFramePadding(); - - ImGuiTreeNodeFlags node_flags = ImGuiTreeNodeFlags_FramePadding | ImGuiTreeNodeFlags_OpenOnArrow | - ImGuiTreeNodeFlags_DrawLinesToNodes | ImGuiTreeNodeFlags_NoTreePushOnOpen; - if (!has_children) - node_flags |= ImGuiTreeNodeFlags_Leaf; - - if (row_selected) - node_flags |= ImGuiTreeNodeFlags_Selected; - - bool open = true; - if (has_children) - { - const auto exp_it = m_shape_list_expanded.find(shape->get_id()); - open = (exp_it == m_shape_list_expanded.end()) ? true : exp_it->second; - ImGui::SetNextItemOpen(open); - } - - ImGui::SetNextItemAllowOverlap(); - const bool node_open = ImGui::TreeNodeEx("##node", node_flags); - if (has_children && node_open != open) - m_shape_list_expanded[shape->get_id()] = node_open; - - if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) - select_shape_row(shape); - - row_hovered |= ImGui::IsItemHovered(); - if (ui_show_contextual_help() && ImGui::IsItemHovered()) - { - if (row_selected) - ImGui::SetTooltip("Selected in 3D viewer"); - else if (is_current_group) - ImGui::SetTooltip("Current group (new shapes go here)"); - } - - if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) - { - const Shape_id drag_id = shape->get_id(); - ImGui::SetDragDropPayload("EZY_SHAPE_ID", &drag_id, sizeof(drag_id)); - ImGui::TextUnformatted(shape->get_name().c_str()); - ImGui::EndDragDropSource(); - } - - // Drop onto group -> that group; onto solid -> solid's parent. Register on both the - // tree arrow and the name field - the name covers most of the row. - auto accept_reparent_drop = [&]() - { - if (!ImGui::BeginDragDropTarget()) - return; - - if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("EZY_SHAPE_ID")) - { - Shape_id drag_id = 0; - std::memcpy(&drag_id, payload->Data, sizeof(drag_id)); - const Shape_id new_parent = is_group ? shape->get_id() : shape->get_parent_id(); - (void)m_view->reparent_shape(drag_id, new_parent, -1, true); - } - ImGui::EndDragDropTarget(); - }; - accept_reparent_drop(); - - ImGui::SameLine(); - ImGui::SetNextItemWidth(std::max(1.0f, ImGui::GetContentRegionAvail().x)); - if (ImGui::InputText("##name", name_buffer, sizeof(name_buffer))) - shape->set_name(std::string(name_buffer)); - - if (ImGui::IsItemClicked()) - select_shape_row(shape); - - if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) - { - const Shape_id drag_id = shape->get_id(); - ImGui::SetDragDropPayload("EZY_SHAPE_ID", &drag_id, sizeof(drag_id)); - ImGui::TextUnformatted(shape->get_name().c_str()); - ImGui::EndDragDropSource(); - } - accept_reparent_drop(); - - row_hovered |= ImGui::IsItemHovered(); - if (ImGui::BeginPopupContextItem("shape_name_ctx")) - { - const bool can_zoom = is_group ? !m_view->shape_descendant_solids(shape->get_id()).empty() : !shape->Shape().IsNull(); - if (ImGui::MenuItem("Zoom to", nullptr, false, can_zoom)) - { - select_shape_row(shape); - m_view->fit_shapes_in_view(shape); - } - - if (!is_group && ImGui::MenuItem("Shape info...")) - open_shape_info_(shape); - - if (is_group && ImGui::MenuItem("Ungroup")) - shape_to_ungroup_id = shape->get_id(); - - if (!is_group) - { - ImGui::Separator(); - bool show_axes = shape->show_frame_axes(); - if (ImGui::MenuItem("Show axes", nullptr, show_axes)) - shape->set_show_frame_axes(!show_axes); - bool show_plane = shape->show_frame_plane(); - if (ImGui::MenuItem("Show plane", nullptr, show_plane)) - shape->set_show_frame_plane(!show_plane); - bool show_up = shape->show_frame_up(); - if (ImGui::MenuItem("Show up", nullptr, show_up)) - shape->set_show_frame_up(!show_up); - ImGui::Separator(); - if (ImGui::MenuItem("Reset frame to bbox")) - { - select_shape_row(shape); - m_view->set_shape_frame(shape, Shp::default_frame_for(shape->Shape())); - shape->set_show_frame_axes(true); - } - if (ImGui::MenuItem("Set from planar face...")) - { - select_shape_row(shape); - m_view->shp_set_frame().begin(shape, Shp_set_frame::Pick::Planar_face); - set_mode(Mode::Shape_set_frame); - } - if (ImGui::MenuItem("Set from cylindrical face...")) - { - select_shape_row(shape); - m_view->shp_set_frame().begin(shape, Shp_set_frame::Pick::Cylindrical_face); - set_mode(Mode::Shape_set_frame); - } - if (ImGui::MenuItem("Flip up")) - { - select_shape_row(shape); - gp_Ax3 f = shape->get_frame(); - f.XReverse(); - m_view->set_shape_frame(shape, f); - } - if (ImGui::MenuItem("Flip axis (Z)")) - { - select_shape_row(shape); - gp_Ax3 f = shape->get_frame(); - f.ZReverse(); - m_view->set_shape_frame(shape, f); - } - } - - if (ImGui::MenuItem("Delete")) - shape_to_delete = shape; - - ImGui::EndPopup(); - } - - if (row_hovered && shape->get_visible() && !is_group) - shape_list_hover = shape; - - if (has_children && node_open) - { - ImGui::TreePush(reinterpret_cast(static_cast(shape->get_id()))); - for (const Shp_ptr& child : children) - self(self, child); - ImGui::TreePop(); - } - - ImGui::PopID(); - shape_list_ancestors.erase(shape->get_id()); - }; - - const ImGuiTableFlags table_flags = - ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_NoBordersInBody | ImGuiTableFlags_SizingFixedFit; - if (ImGui::BeginTable("##shape_outliner", 4, table_flags, ImVec2(0.f, 0.f))) - { - // Fixed actions on the left; stretch name on the right (tree indent on name only). - ImGui::TableSetupColumn( - "vis", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_IndentDisable, - check_col_w); - ImGui::TableSetupColumn( - "disp", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_IndentDisable, - check_col_w); - ImGui::TableSetupColumn( - "mat", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_IndentDisable, - mat_col_w); - ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_IndentEnable, 1.0f); - - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, - ImVec2(st_mat.FramePadding.x, std::max(1.0f, st_mat.FramePadding.y * 0.65f))); - ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(st_mat.CellPadding.x, std::max(1.0f, st_mat.CellPadding.y * 0.5f))); - - for (const Shp_ptr& root : m_view->shape_children(0)) - draw_shape_row(draw_shape_row, root); - - // Empty pad below the last row: drop here to move to document root (no permanent root node). - // Size from the visible clip remainder, not GetContentRegionAvail().y -- inside a ScrollY - // table that avail tracks content size, so a fill-avail pad feeds back into growing scroll. - { - const float min_pad_h = ImGui::GetFrameHeight(); - const ImGuiWindow* inner = ImGui::GetCurrentWindow(); - const float visible_remain = inner->InnerClipRect.Max.y - ImGui::GetCursorScreenPos().y - ImGui::GetStyle().CellPadding.y; - const float pad_h = std::max(min_pad_h, visible_remain); - ImGui::TableNextRow(ImGuiTableRowFlags_None, pad_h); - ImGui::TableSetColumnIndex(0); - - const ImGuiPayload* active_payload = ImGui::GetDragDropPayload(); - const bool dragging_shape = active_payload != nullptr && active_payload->IsDataType("EZY_SHAPE_ID") && - active_payload->DataSize == sizeof(Shape_id); - - if (dragging_shape) - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_Header, 0.20f)); - - ImGui::Selectable("##shape_root_drop", false, - ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap | ImGuiSelectableFlags_Disabled, - ImVec2(0.0f, pad_h)); - - bool root_drop_hovered = false; - if (ImGui::BeginDragDropTarget()) - { - const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("EZY_SHAPE_ID", ImGuiDragDropFlags_AcceptBeforeDelivery); - if (payload != nullptr) - { - root_drop_hovered = payload->Preview; - if (payload->IsDelivery()) - { - Shape_id drag_id = 0; - std::memcpy(&drag_id, payload->Data, sizeof(drag_id)); - (void)m_view->reparent_shape(drag_id, 0, -1, true); - } - } - ImGui::EndDragDropTarget(); - } - - if (dragging_shape) - { - const ImVec2 rmin = ImGui::GetItemRectMin(); - const ImVec2 rmax = ImGui::GetItemRectMax(); - if (root_drop_hovered) - ImGui::GetWindowDrawList()->AddRectFilled(rmin, rmax, ImGui::GetColorU32(ImGuiCol_HeaderHovered, 0.55f)); - - const char* hint = "Move to root"; - const ImVec2 ts = ImGui::CalcTextSize(hint); - ImGui::GetWindowDrawList()->AddText( - ImVec2(rmin.x + (rmax.x - rmin.x - ts.x) * 0.5f, rmin.y + (rmax.y - rmin.y - ts.y) * 0.5f), - ImGui::GetColorU32(ImGuiCol_TextDisabled), hint); - } - } - - ImGui::PopStyleVar(2); - ImGui::EndTable(); - } - - m_view->set_shape_list_hover(shape_list_hover); - - if (shape_to_ungroup_id != 0) - { - m_shape_list_expanded.erase(shape_to_ungroup_id); - (void)m_view->ungroup_shape(shape_to_ungroup_id); - } - - if (shape_to_delete) - m_view->delete_shapes({shape_to_delete}); - - ImGui::End(); -} - -void GUI::open_shape_info_(const Shp_ptr& shape) -{ - if (shape.IsNull() || shape->is_group()) - return; - - const std::vector& mat_names = occt_material_combo_labels_(); - int mat_idx = shape->Material(); - if (mat_idx < 0 || mat_idx >= static_cast(mat_names.size())) - mat_idx = static_cast(m_view->get_default_material().Name()); - - shp_info::Display_meta meta; - meta.name = shape->get_name(); - meta.material = mat_names[static_cast(mat_idx)]; - meta.display_mode = shape->get_disp_mode() == AIS_Shaded ? "Shaded" : "Wireframe"; - meta.visible = shape->get_visible(); - - m_shape_info_shp = shape; - m_shape_info_lines = shp_info::collect(shape->Shape(), &meta); - m_shape_info_open = true; -} - -void GUI::shape_info_dialog_() -{ - if (!m_shape_info_open) - return; - - if (m_shape_info_shp.IsNull()) - { - m_shape_info_open = false; - return; - } - - bool shape_still_exists = false; - for (const Shp_ptr& s : m_view->get_shapes()) - if (s == m_shape_info_shp) - { - shape_still_exists = true; - break; - } - - if (!shape_still_exists) - { - clear_all(m_shape_info_open, m_shape_info_shp, m_shape_info_lines); - return; - } - - const std::string title = "Shape info: " + m_shape_info_shp->get_name(); - ImGui::SetNextWindowSize(ImVec2(440.0f, 0.0f), ImGuiCond_FirstUseEver); - bool open = m_shape_info_open; - if (!ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_None)) - { - m_shape_info_open = open; - ImGui::End(); - return; - } - - if (ImGui::Button("Refresh")) - open_shape_info_(m_shape_info_shp); - - ImGui::Separator(); - - const float max_h = ImGui::GetTextLineHeightWithSpacing() * 18.0f; - if (ImGui::BeginChild("shape_info_scroll", ImVec2(0.0f, max_h), ImGuiChildFlags_Borders, - ImGuiWindowFlags_AlwaysVerticalScrollbar)) - { - if (ImGui::BeginTable("shape_info_tbl", 2, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_RowBg)) - { - ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, 120.0f); - ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch); - - for (const shp_info::Line& line : m_shape_info_lines) - { - if (line.label.empty() && line.value.empty()) - { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::Separator(); - ImGui::TableSetColumnIndex(1); - ImGui::Separator(); - continue; - } - - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::TextUnformatted(line.label.c_str()); - ImGui::TableSetColumnIndex(1); - ImGui::TextUnformatted(line.value.c_str()); - } - - ImGui::EndTable(); - } - - ImGui::EndChild(); - } - - m_shape_info_open = open; - if (!open) - m_shape_info_shp.Nullify(); - - ImGui::End(); -} - void GUI::open_file_inspector_(const std::string& file_path, const std::string& file_bytes) { m_file_inspector_path = file_path; @@ -3872,62 +3223,6 @@ void GUI::clear_sketch_list_ui_() m_shape_list_expanded.clear(); } -nlohmann::json GUI::shape_list_ui_to_json_() const -{ - using namespace nlohmann; - json out; - json expanded = json::object(); - for (const auto& [id, is_open] : m_shape_list_expanded) - { - if (!is_open) - expanded[std::to_string(id)] = false; - } - if (!expanded.empty()) - out["expanded"] = std::move(expanded); - - if (m_view && m_view->current_group_id() != 0) - out["currentGroupId"] = m_view->current_group_id(); - - return out; -} - -void GUI::apply_shape_list_ui_from_json_(const nlohmann::json& j) -{ - using namespace nlohmann; - m_shape_list_expanded.clear(); - if (m_view) - m_view->set_current_group_id(0); - - if (!j.contains("ui") || !j["ui"].is_object()) - return; - - const json& ui = j["ui"]; - if (!ui.contains("shapeList") || !ui["shapeList"].is_object()) - return; - - const json& sl = ui["shapeList"]; - if (sl.contains("expanded") && sl["expanded"].is_object()) - { - for (auto it = sl["expanded"].begin(); it != sl["expanded"].end(); ++it) - { - if (!it.value().is_boolean()) - continue; - - try - { - const Shape_id id = static_cast(std::stoull(it.key())); - m_shape_list_expanded[id] = it.value().get(); - } - catch (...) - { - } - } - } - - if (m_view && sl.contains("currentGroupId") && sl["currentGroupId"].is_number_unsigned()) - m_view->set_current_group_id(sl["currentGroupId"].get()); -} - nlohmann::json GUI::sketch_list_ui_to_json_() const { using namespace nlohmann; diff --git a/src/gui.h b/src/gui.h index ff3e607..abf61c3 100644 --- a/src/gui.h +++ b/src/gui.h @@ -28,6 +28,7 @@ #include "imgui_markdown.h" #include "utl_log.h" #include "mode.h" +#include "shp_transform.h" #include "gui_hotkeys.h" #include "gui_occt_view.h" #include "shp_info.h" @@ -42,6 +43,13 @@ class Python_console; class Sketch; struct GLFWwindow; +// Defined in gui_shp_pane.cpp (Shape List row walk). Friend so it can call open_shape_info_ +// and read/write m_shape_list_expanded without a public API. +namespace gui_shp_detail +{ +struct Shape_list_row_drawer; +} + enum class Command { Shape_cut, @@ -242,6 +250,7 @@ inline constexpr const char* k_revolve_solid_conversion = "https://ezycad.re inline constexpr const char* k_shape_selection_filter = "https://ezycad.readthedocs.io/en/latest/usage.html#shape-selection-filter-normal-mode-only"; inline constexpr const char* k_add_node_tool = "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#add-node-tool"; inline constexpr const char* k_bone_creation_tool = "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#bone-creation-tool"; +inline constexpr const char* k_shape_rotate_tool = "https://ezycad.readthedocs.io/en/latest/usage.html#shape-rotate-tool-r"; inline constexpr const char* k_image_underlay = "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#image-underlay"; inline constexpr const char* k_usage_settings_options = "https://ezycad.readthedocs.io/en/latest/usage-settings.html#options-panel"; inline constexpr const char* k_occt_view = "https://ezycad.readthedocs.io/en/latest/usage-occt-view.html"; @@ -341,6 +350,8 @@ class GUI bool get_bone_add_total_length_nodes() const { return m_bone_add_total_length_nodes; } /// Add-bone Options: hole clicks after waist (`gui.bone_holes`). Bone_holes get_bone_holes() const { return m_bone_holes; } + /// Move / rotate / scale axis space (`gui.transform_space`). Default Local. + Transform_space get_transform_space() const { return m_transform_space; } #if DEV_MODE /// Add-bone construction overlays (DEV_MODE Options checkboxes; not persisted). const Bone_debug_flags& get_bone_debug_flags() const { return m_bone_debug; } @@ -472,10 +483,12 @@ class GUI void clear_sketch_list_ui_(); void apply_sketch_list_ui_from_json_(const nlohmann::json& j); [[nodiscard]] nlohmann::json sketch_list_ui_to_json_() const; - [[nodiscard]] nlohmann::json shape_list_ui_to_json_() const; - void apply_shape_list_ui_from_json_(const nlohmann::json& j); void sketch_properties_dialog_(); void sketch_origin_panel_settings_(const std::shared_ptr& sk); + // Shape List + Shape info (gui_shp_pane.cpp) + friend struct gui_shp_detail::Shape_list_row_drawer; + [[nodiscard]] nlohmann::json shape_list_ui_to_json_() const; + void apply_shape_list_ui_from_json_(const nlohmann::json& j); void shape_list_(); void shape_info_dialog_(); void open_shape_info_(const Shp_ptr& shape); @@ -496,6 +509,7 @@ class GUI void options_move_mode_(); void options_scale_mode_(); void options_rotate_mode_(); + void options_transform_space_(); void options_shape_chamfer_mode_(); void options_shape_fillet_mode_(); void options_shape_polar_duplicate_mode_(); @@ -702,7 +716,8 @@ class GUI bool m_bone_add_center_nodes = true; bool m_bone_add_radius_nodes = true; bool m_bone_add_total_length_nodes = true; - Bone_holes m_bone_holes = Bone_holes::None; + Bone_holes m_bone_holes = Bone_holes::None; + Transform_space m_transform_space = Transform_space::Local; #if DEV_MODE Bone_debug_flags m_bone_debug; #endif diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index e302980..5aaf591 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -618,6 +618,33 @@ void GUI::options_normal_mode_() options_orthographic_projection_(); } +void GUI::options_transform_space_() +{ + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Space"); + ImGui::SameLine(); + int space = static_cast(m_transform_space); + bool changed = false; + if (ImGui::RadioButton("Local", &space, static_cast(Transform_space::Local))) + changed = true; + + ImGui::SameLine(); + if (ImGui::RadioButton("World", &space, static_cast(Transform_space::World))) + changed = true; + + if (changed && (space == static_cast(Transform_space::Local) || space == static_cast(Transform_space::World))) + { + m_transform_space = static_cast(space); + save_occt_view_settings(); + m_view->on_transform_space_changed(); + } + + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + GUI_DOC_HELP_("Local uses the first selected solid's frame (Show axes): origin and X/Y/Z. " + "World uses global XYZ at the first solid's bounding-box center. Click ? to open the user guide.", + doc_urls::k_shape_rotate_tool); +} + void GUI::options_move_mode_() { EZY_ASSERT(get_mode() == Mode::Move); @@ -625,6 +652,7 @@ void GUI::options_move_mode_() ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); + options_transform_space_(); ImGui::TextUnformatted("Constrain axis:"); Move_options& opts = m_view->shp_move().get_opts(); @@ -645,6 +673,7 @@ void GUI::options_scale_mode_() ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); + options_transform_space_(); options_orthographic_projection_(); } @@ -700,18 +729,19 @@ void GUI::options_rotate_mode_() ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); + options_transform_space_(); int selected_axis = static_cast(m_view->shp_rotate().get_rotation_axis()); if (ImGui::RadioButton("View to object axis", &selected_axis, static_cast(Rotation_axis::View_to_object))) m_view->shp_rotate().set_rotation_axis(Rotation_axis::View_to_object); - if (ImGui::RadioButton("Around X axis", &selected_axis, static_cast(Rotation_axis::X_axis))) + if (ImGui::RadioButton("Around X", &selected_axis, static_cast(Rotation_axis::X_axis))) m_view->shp_rotate().set_rotation_axis(Rotation_axis::X_axis); - if (ImGui::RadioButton("Around Y axis", &selected_axis, static_cast(Rotation_axis::Y_axis))) + if (ImGui::RadioButton("Around Y", &selected_axis, static_cast(Rotation_axis::Y_axis))) m_view->shp_rotate().set_rotation_axis(Rotation_axis::Y_axis); - if (ImGui::RadioButton("Around Z axis", &selected_axis, static_cast(Rotation_axis::Z_axis))) + if (ImGui::RadioButton("Around Z", &selected_axis, static_cast(Rotation_axis::Z_axis))) m_view->shp_rotate().set_rotation_axis(Rotation_axis::Z_axis); options_orthographic_projection_(); diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 47281b7..c7e5f54 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -3986,6 +3986,13 @@ Shp_extrude& Occt_view::shp_extrude() { return m_shp_extrude; } Shp_cross_section& Occt_view::shp_cross_section() { return m_shp_cross_section; } // clang-format on +void Occt_view::on_transform_space_changed() +{ + shp_move().on_transform_space_changed(); + shp_rotate().on_transform_space_changed(); + shp_scale().on_transform_space_changed(); +} + void Occt_view::set_dynamic_highlight_enabled(bool enabled) { SetAllowHighlight(enabled); diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h index d4413bc..d330cfa 100644 --- a/src/gui_occt_view.h +++ b/src/gui_occt_view.h @@ -276,6 +276,9 @@ class Occt_view : protected AIS_ViewController Shp_extrude& shp_extrude(); Shp_cross_section& shp_cross_section(); + /// Re-seed Move / Rotate / Scale pivot after Options Local/World change. + void on_transform_space_changed(); + /// Enable/disable AIS_ViewController dynamic highlight (MoveTo hover). Off during transform drag. void set_dynamic_highlight_enabled(bool enabled); diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index 7f6079a..47ec2c3 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -68,6 +68,7 @@ std::string GUI::occt_view_settings_json() const {"bone_add_radius_nodes", m_bone_add_radius_nodes}, {"bone_add_total_length_nodes", m_bone_add_total_length_nodes}, {"bone_holes", static_cast(m_bone_holes)}, + {"transform_space", static_cast(m_transform_space)}, {"view_roll_step_deg", m_view_roll_step_deg}, {"view_zoom_scroll_scale", m_view_zoom_scroll_scale}, {"default_2d_view_width", m_default_2d_view_width}, @@ -165,6 +166,7 @@ void GUI::save_occt_view_settings() {"bone_add_radius_nodes", m_bone_add_radius_nodes}, {"bone_add_total_length_nodes", m_bone_add_total_length_nodes}, {"bone_holes", static_cast(m_bone_holes)}, + {"transform_space", static_cast(m_transform_space)}, {"load_last_opened_on_startup", m_load_last_opened_on_startup}, {"last_opened_project_path", m_last_opened_project_path}, {"imgui_style_dark", imgui_style_to_json_(m_imgui_style_dark)}, @@ -485,6 +487,13 @@ void GUI::parse_gui_panes_settings_(const std::string& content) if (holes >= 0 && holes < static_cast(Bone_holes::_count)) m_bone_holes = static_cast(holes); } + m_transform_space = Transform_space::Local; + if (g.contains("transform_space") && g["transform_space"].is_number_integer()) + { + const int space = g["transform_space"].get(); + if (space == static_cast(Transform_space::Local) || space == static_cast(Transform_space::World)) + m_transform_space = static_cast(space); + } m_load_last_opened_on_startup = b("load_last_opened_on_startup", b("load_last_saved_on_startup", false)); if (g.contains("last_opened_project_path") && g["last_opened_project_path"].is_string()) diff --git a/src/gui_shp_pane.cpp b/src/gui_shp_pane.cpp new file mode 100644 index 0000000..3603422 --- /dev/null +++ b/src/gui_shp_pane.cpp @@ -0,0 +1,730 @@ +// Shape List pane, Shape info dialog, and .ezy ui.shapeList expand/current-group state. + +#include "gui.h" + +#include "gui_occt_view.h" +#include "imgui.h" +#include "imgui_internal.h" +#include "shp_info.h" +#include "shp_set_frame.h" +#include "utl.h" + +#include +#include +#include +#include +#include +#include + +namespace gui_shp_detail +{ + +struct Shape_list_row_drawer +{ + Shape_list_row_drawer(GUI& gui, const std::vector& mat_names, int nmat, float mat_popup_w); + + void draw(const Shp_ptr& shape); + + Shp_ptr hover; + Shp_ptr to_delete; + Shape_id to_ungroup_id = 0; + +private: + [[nodiscard]] bool row_is_selected_(const Shp_ptr& shape) const; + void select_row_(const Shp_ptr& shape); + void apply_material_(const Shp_ptr& shape, int i); + void draw_ctx_menu_(const Shp_ptr& shape, bool is_group); + void accept_reparent_drop_(const Shp_ptr& shape, bool is_group); + + GUI& m_gui; + Occt_view& m_view; + const std::vector& m_mat_names; + int m_nmat = 0; + float m_mat_popup_w = 0.0f; + std::unordered_set m_selected_in_viewer; + std::unordered_set m_ancestors; +}; + +} // namespace gui_shp_detail + +void GUI::shape_list_() +{ + if (!show_shape_list_effective()) + { + m_view->set_shape_list_hover(nullptr); + return; + } + + if (!ImGui::Begin("Shape List", &m_show_shape_list, ImGuiWindowFlags_None)) + { + m_view->set_shape_list_hover(nullptr); + ImGui::End(); + return; + } + + if (ImGui::Checkbox("Hide all", &m_hide_all_shapes)) + { + if (m_hide_all_shapes) + m_view->set_shape_list_hover(nullptr); + m_view->sync_sketch_shape_faint_style(); + } + + ImGui::SameLine(); + if (ImGui::SmallButton("New group")) + { + Shp_ptr grp = m_view->create_group("Group", m_view->current_group_id()); + if (!grp.IsNull()) + m_view->set_current_group_id(grp->get_id()); + } + + ImGui::SameLine(); + { + const std::vector sel = m_view->get_selected_shps(); + const bool can_group = !sel.empty(); + if (!can_group) + ImGui::BeginDisabled(); + + if (ImGui::SmallButton("Group")) + (void)m_view->group_shapes(sel); + + if (!can_group) + ImGui::EndDisabled(); + } + + ImGui::Separator(); + + const std::vector& mat_names = occt_material_combo_labels_(); + const int nmat = static_cast(mat_names.size()); + float mat_label_w_max = 0.0f; + for (int mi = 0; mi < nmat; ++mi) + mat_label_w_max = std::max(mat_label_w_max, ImGui::CalcTextSize(mat_names[static_cast(mi)].c_str()).x); + + const ImGuiStyle& st_mat = ImGui::GetStyle(); + const float mat_popup_w = std::min(440.0f, std::max(280.0f, mat_label_w_max + st_mat.WindowPadding.x * 2.0f + + st_mat.FramePadding.x * 2.0f + st_mat.ScrollbarSize + 8.0f)); + const float check_col_w = ImGui::GetFrameHeight(); + const float mat_col_w = ImGui::CalcTextSize("M").x + st_mat.FramePadding.x * 2.0f; + + gui_shp_detail::Shape_list_row_drawer row(*this, mat_names, nmat, mat_popup_w); + + const ImGuiTableFlags table_flags = + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_NoBordersInBody | ImGuiTableFlags_SizingFixedFit; + if (ImGui::BeginTable("##shape_outliner", 4, table_flags, ImVec2(0.f, 0.f))) + { + // Fixed actions on the left; stretch name on the right (tree indent on name only). + ImGui::TableSetupColumn( + "vis", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_IndentDisable, + check_col_w); + ImGui::TableSetupColumn( + "disp", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_IndentDisable, + check_col_w); + ImGui::TableSetupColumn( + "mat", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_IndentDisable, + mat_col_w); + ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_IndentEnable, 1.0f); + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, + ImVec2(st_mat.FramePadding.x, std::max(1.0f, st_mat.FramePadding.y * 0.65f))); + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(st_mat.CellPadding.x, std::max(1.0f, st_mat.CellPadding.y * 0.5f))); + + for (const Shp_ptr& root : m_view->shape_children(0)) + row.draw(root); + + // Empty pad below the last row: drop here to move to document root (no permanent root node). + // Size from the visible clip remainder, not GetContentRegionAvail().y -- inside a ScrollY + // table that avail tracks content size, so a fill-avail pad feeds back into growing scroll. + { + const float min_pad_h = ImGui::GetFrameHeight(); + const ImGuiWindow* inner = ImGui::GetCurrentWindow(); + const float visible_remain = inner->InnerClipRect.Max.y - ImGui::GetCursorScreenPos().y - ImGui::GetStyle().CellPadding.y; + const float pad_h = std::max(min_pad_h, visible_remain); + ImGui::TableNextRow(ImGuiTableRowFlags_None, pad_h); + ImGui::TableSetColumnIndex(0); + + const ImGuiPayload* active_payload = ImGui::GetDragDropPayload(); + const bool dragging_shape = active_payload != nullptr && active_payload->IsDataType("EZY_SHAPE_ID") && + active_payload->DataSize == sizeof(Shape_id); + + if (dragging_shape) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_Header, 0.20f)); + + ImGui::Selectable("##shape_root_drop", false, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap | ImGuiSelectableFlags_Disabled, + ImVec2(0.0f, pad_h)); + + bool root_drop_hovered = false; + if (ImGui::BeginDragDropTarget()) + { + const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("EZY_SHAPE_ID", ImGuiDragDropFlags_AcceptBeforeDelivery); + if (payload != nullptr) + { + root_drop_hovered = payload->Preview; + if (payload->IsDelivery()) + { + Shape_id drag_id = 0; + std::memcpy(&drag_id, payload->Data, sizeof(drag_id)); + (void)m_view->reparent_shape(drag_id, 0, -1, true); + } + } + ImGui::EndDragDropTarget(); + } + + if (dragging_shape) + { + const ImVec2 rmin = ImGui::GetItemRectMin(); + const ImVec2 rmax = ImGui::GetItemRectMax(); + if (root_drop_hovered) + ImGui::GetWindowDrawList()->AddRectFilled(rmin, rmax, ImGui::GetColorU32(ImGuiCol_HeaderHovered, 0.55f)); + + const char* hint = "Move to root"; + const ImVec2 ts = ImGui::CalcTextSize(hint); + ImGui::GetWindowDrawList()->AddText( + ImVec2(rmin.x + (rmax.x - rmin.x - ts.x) * 0.5f, rmin.y + (rmax.y - rmin.y - ts.y) * 0.5f), + ImGui::GetColorU32(ImGuiCol_TextDisabled), hint); + } + } + + ImGui::PopStyleVar(2); + ImGui::EndTable(); + } + + m_view->set_shape_list_hover(row.hover); + + if (row.to_ungroup_id != 0) + { + m_shape_list_expanded.erase(row.to_ungroup_id); + (void)m_view->ungroup_shape(row.to_ungroup_id); + } + + if (row.to_delete) + m_view->delete_shapes({row.to_delete}); + + ImGui::End(); +} + +void GUI::open_shape_info_(const Shp_ptr& shape) +{ + if (shape.IsNull() || shape->is_group()) + return; + + const std::vector& mat_names = occt_material_combo_labels_(); + int mat_idx = shape->Material(); + if (mat_idx < 0 || mat_idx >= static_cast(mat_names.size())) + mat_idx = static_cast(m_view->get_default_material().Name()); + + shp_info::Display_meta meta; + meta.name = shape->get_name(); + meta.material = mat_names[static_cast(mat_idx)]; + meta.display_mode = shape->get_disp_mode() == AIS_Shaded ? "Shaded" : "Wireframe"; + meta.visible = shape->get_visible(); + + m_shape_info_shp = shape; + m_shape_info_lines = shp_info::collect(shape->Shape(), &meta); + m_shape_info_open = true; +} + +void GUI::shape_info_dialog_() +{ + if (!m_shape_info_open) + return; + + if (m_shape_info_shp.IsNull()) + { + m_shape_info_open = false; + return; + } + + bool shape_still_exists = false; + for (const Shp_ptr& s : m_view->get_shapes()) + if (s == m_shape_info_shp) + { + shape_still_exists = true; + break; + } + + if (!shape_still_exists) + { + clear_all(m_shape_info_open, m_shape_info_shp, m_shape_info_lines); + return; + } + + const std::string title = "Shape info: " + m_shape_info_shp->get_name(); + ImGui::SetNextWindowSize(ImVec2(440.0f, 0.0f), ImGuiCond_FirstUseEver); + bool open = m_shape_info_open; + if (!ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_None)) + { + m_shape_info_open = open; + ImGui::End(); + return; + } + + if (ImGui::Button("Refresh")) + open_shape_info_(m_shape_info_shp); + + ImGui::Separator(); + + const float max_h = ImGui::GetTextLineHeightWithSpacing() * 18.0f; + if (ImGui::BeginChild("shape_info_scroll", ImVec2(0.0f, max_h), ImGuiChildFlags_Borders, + ImGuiWindowFlags_AlwaysVerticalScrollbar)) + { + if (ImGui::BeginTable("shape_info_tbl", 2, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_RowBg)) + { + ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch); + + for (const shp_info::Line& line : m_shape_info_lines) + { + if (line.label.empty() && line.value.empty()) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::Separator(); + ImGui::TableSetColumnIndex(1); + ImGui::Separator(); + continue; + } + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(line.label.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(line.value.c_str()); + } + + ImGui::EndTable(); + } + + ImGui::EndChild(); + } + + m_shape_info_open = open; + if (!open) + m_shape_info_shp.Nullify(); + + ImGui::End(); +} + +nlohmann::json GUI::shape_list_ui_to_json_() const +{ + using namespace nlohmann; + json out; + json expanded = json::object(); + for (const auto& [id, is_open] : m_shape_list_expanded) + { + if (!is_open) + expanded[std::to_string(id)] = false; + } + if (!expanded.empty()) + out["expanded"] = std::move(expanded); + + if (m_view && m_view->current_group_id() != 0) + out["currentGroupId"] = m_view->current_group_id(); + + return out; +} + +void GUI::apply_shape_list_ui_from_json_(const nlohmann::json& j) +{ + using namespace nlohmann; + m_shape_list_expanded.clear(); + if (m_view) + m_view->set_current_group_id(0); + + if (!j.contains("ui") || !j["ui"].is_object()) + return; + + const json& ui = j["ui"]; + if (!ui.contains("shapeList") || !ui["shapeList"].is_object()) + return; + + const json& sl = ui["shapeList"]; + if (sl.contains("expanded") && sl["expanded"].is_object()) + { + for (auto it = sl["expanded"].begin(); it != sl["expanded"].end(); ++it) + { + if (!it.value().is_boolean()) + continue; + + try + { + const Shape_id id = static_cast(std::stoull(it.key())); + m_shape_list_expanded[id] = it.value().get(); + } + catch (...) + { + } + } + } + + if (m_view && sl.contains("currentGroupId") && sl["currentGroupId"].is_number_unsigned()) + m_view->set_current_group_id(sl["currentGroupId"].get()); +} + +namespace gui_shp_detail +{ + +Shape_list_row_drawer::Shape_list_row_drawer(GUI& gui, const std::vector& mat_names, int nmat, + float mat_popup_w) + : m_gui(gui) + , m_view(*gui.get_view()) + , m_mat_names(mat_names) + , m_nmat(nmat) + , m_mat_popup_w(mat_popup_w) +{ + for (const AIS_Shape_ptr& ais : m_view.get_selected()) + if (!ais.IsNull()) + m_selected_in_viewer.insert(ais.get()); +} + +bool Shape_list_row_drawer::row_is_selected_(const Shp_ptr& shape) const +{ + if (shape.IsNull()) + return false; + + if (shape->is_group()) + { + for (const Shp_ptr& leaf : m_view.shape_descendant_solids(shape->get_id())) + if (m_selected_in_viewer.count(leaf.get()) != 0) + return true; + + return false; + } + + return m_selected_in_viewer.count(shape.get()) != 0; +} + +void Shape_list_row_drawer::select_row_(const Shp_ptr& shape) +{ + AIS_InteractiveContext& ctx = m_view.ctx(); + const bool ctrl = ImGui::GetIO().KeyCtrl; + if (!ctrl) + ctx.ClearSelected(false); + + if (shape->is_group()) + { + m_view.set_current_group_id(shape->get_id()); + for (const Shp_ptr& leaf : m_view.shape_descendant_solids(shape->get_id())) + ctx.AddOrRemoveSelected(leaf, true); + } + else + { + m_view.set_current_group_id(shape->get_parent_id()); + ctx.AddOrRemoveSelected(shape, true); + } + + ctx.UpdateCurrentViewer(); +} + +void Shape_list_row_drawer::apply_material_(const Shp_ptr& shape, int i) +{ + if (shape->is_group() || i < 0 || i >= m_nmat) + return; + + // OwnColor (e.g. from older wasm SetColor workarounds) overrides material presets. + if (shape->HasColor()) + shape->UnsetColor(); + + shape->SetMaterial(Graphic3d_MaterialAspect(static_cast(i))); + m_view.refresh_shape_shading_(shape); + m_view.ctx().Redisplay(shape, true); + m_view.ctx().UpdateCurrentViewer(); +} + +void Shape_list_row_drawer::draw_ctx_menu_(const Shp_ptr& shape, bool is_group) +{ + const bool can_zoom = is_group ? !m_view.shape_descendant_solids(shape->get_id()).empty() : !shape->Shape().IsNull(); + if (ImGui::MenuItem("Zoom to", nullptr, false, can_zoom)) + { + select_row_(shape); + m_view.fit_shapes_in_view(shape); + } + + if (!is_group && ImGui::MenuItem("Shape info...")) + m_gui.open_shape_info_(shape); + + if (is_group && ImGui::MenuItem("Ungroup")) + to_ungroup_id = shape->get_id(); + + if (!is_group) + { + ImGui::Separator(); + bool show_axes = shape->show_frame_axes(); + if (ImGui::MenuItem("Show axes", nullptr, show_axes)) + shape->set_show_frame_axes(!show_axes); + bool show_plane = shape->show_frame_plane(); + if (ImGui::MenuItem("Show plane", nullptr, show_plane)) + shape->set_show_frame_plane(!show_plane); + bool show_up = shape->show_frame_up(); + if (ImGui::MenuItem("Show up", nullptr, show_up)) + shape->set_show_frame_up(!show_up); + ImGui::Separator(); + if (ImGui::MenuItem("Reset frame to bbox")) + { + select_row_(shape); + m_view.set_shape_frame(shape, Shp::default_frame_for(shape->Shape())); + shape->set_show_frame_axes(true); + } + + if (ImGui::MenuItem("Set from planar face...")) + { + select_row_(shape); + m_view.shp_set_frame().begin(shape, Shp_set_frame::Pick::Planar_face); + m_gui.set_mode(Mode::Shape_set_frame); + } + + if (ImGui::MenuItem("Set from cylindrical face...")) + { + select_row_(shape); + m_view.shp_set_frame().begin(shape, Shp_set_frame::Pick::Cylindrical_face); + m_gui.set_mode(Mode::Shape_set_frame); + } + + if (ImGui::MenuItem("Flip up")) + { + select_row_(shape); + gp_Ax3 f = shape->get_frame(); + f.XReverse(); + m_view.set_shape_frame(shape, f); + } + + if (ImGui::MenuItem("Flip axis (Z)")) + { + select_row_(shape); + gp_Ax3 f = shape->get_frame(); + f.ZReverse(); + m_view.set_shape_frame(shape, f); + } + } + + if (ImGui::MenuItem("Delete")) + to_delete = shape; +} + +void Shape_list_row_drawer::accept_reparent_drop_(const Shp_ptr& shape, bool is_group) +{ + if (!ImGui::BeginDragDropTarget()) + return; + + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("EZY_SHAPE_ID")) + { + Shape_id drag_id = 0; + std::memcpy(&drag_id, payload->Data, sizeof(drag_id)); + const Shape_id new_parent = is_group ? shape->get_id() : shape->get_parent_id(); + (void)m_view.reparent_shape(drag_id, new_parent, -1, true); + } + ImGui::EndDragDropTarget(); +} + +void Shape_list_row_drawer::draw(const Shp_ptr& shape) +{ + EZY_ASSERT(shape); + if (!m_ancestors.insert(shape->get_id()).second) + return; // Parent cycle: skip rather than hang the Shape List. + + const bool is_group = shape->is_group(); + const std::vector children = m_view.shape_children(shape->get_id()); + const bool has_children = !children.empty(); + const bool is_current_group = is_group && shape->get_id() == m_view.current_group_id(); + // Selection highlight follows the 3D viewer only. Current group uses a distinct tint so + // Alt-drag / clear-selection cannot look like the group (or its children) stayed selected. + const bool row_selected = row_is_selected_(shape); + bool row_hovered = false; + + ImGui::PushID(static_cast(shape->get_id())); + ImGui::TableNextRow(); + if (row_selected) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_Header, 0.45f)); + else if (is_current_group) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_Header, 0.18f)); + + char name_buffer[1024]; + safe_cstr_copy(name_buffer, sizeof(name_buffer), shape->get_name().c_str()); + + int mat_idx = 0; + if (!is_group) + { + mat_idx = shape->Material(); + if (mat_idx < 0 || mat_idx >= m_nmat) + mat_idx = static_cast(m_view.get_default_material().Name()); + } + + // Columns 0-2: fixed actions on the left (no tree indent). + ImGui::TableSetColumnIndex(0); + // Full-row hit target under the widgets so padding / gaps between controls + // still select and open the context menu (widgets draw on top via AllowOverlap). + const ImVec2 cell0_pos = ImGui::GetCursorScreenPos(); + ImGui::Selectable("##row_hit", false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap); + row_hovered |= ImGui::IsItemHovered(); + if (ImGui::IsItemClicked()) + select_row_(shape); + ImGui::SetCursorScreenPos(cell0_pos); + + bool visible = shape->get_visible(); + if (ImGui::Checkbox("##vis", &visible)) + { + if (!visible && m_view.shape_list_hover() == shape) + m_view.set_shape_list_hover(nullptr); + + shape->set_visible(visible); + m_view.sync_sketch_shape_faint_style(); + } + row_hovered |= ImGui::IsItemHovered(); + if (m_gui.ui_show_contextual_help() && ImGui::IsItemHovered()) + ImGui::SetTooltip(is_group ? "Show/hide group subtree" : "visibility"); + + ImGui::TableSetColumnIndex(1); + if (is_group) + ImGui::TextUnformatted(""); + else + { + bool shaded = shape->get_disp_mode() == AIS_Shaded; + if (ImGui::Checkbox("##shaded", &shaded)) + shape->set_disp_mode(shaded ? AIS_Shaded : AIS_WireFrame); + + row_hovered |= ImGui::IsItemHovered(); + if (m_gui.ui_show_contextual_help() && ImGui::IsItemHovered()) + ImGui::SetTooltip("solid/wire"); + } + + ImGui::TableSetColumnIndex(2); + if (is_group) + ImGui::TextUnformatted(""); + else + { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4.0f, ImGui::GetStyle().FramePadding.y)); + if (ImGui::Button("M")) + ImGui::OpenPopup("mat_pick"); + + ImGui::PopStyleVar(); + row_hovered |= ImGui::IsItemHovered(); + if (m_gui.ui_show_contextual_help() && ImGui::IsItemHovered()) + ImGui::SetTooltip("%s\n(click: material; right-click row: menu)", m_mat_names[static_cast(mat_idx)].c_str()); + + ImGui::SetNextWindowSize(ImVec2(m_mat_popup_w, 0.0f), ImGuiCond_Appearing); + if (ImGui::BeginPopup("mat_pick")) + { + ImGui::TextUnformatted("Material"); + ImGui::Separator(); + const float max_h = ImGui::GetTextLineHeightWithSpacing() * 12.0f; + const float sc_w = std::max(1.0f, ImGui::GetContentRegionAvail().x); + if (ImGui::BeginChild("mat_sc", ImVec2(sc_w, max_h), ImGuiChildFlags_Borders, ImGuiWindowFlags_AlwaysVerticalScrollbar)) + { + for (int i = 0; i < m_nmat; ++i) + if (ImGui::Selectable(m_mat_names[static_cast(i)].c_str(), i == mat_idx)) + { + apply_material_(shape, i); + ImGui::CloseCurrentPopup(); + } + + ImGui::EndChild(); + } + + ImGui::EndPopup(); + } + } + + // Column 3: tree arrow + name (indent applies here so hierarchy stays under the name). + ImGui::TableSetColumnIndex(3); + ImGui::AlignTextToFramePadding(); + + ImGuiTreeNodeFlags node_flags = ImGuiTreeNodeFlags_FramePadding | ImGuiTreeNodeFlags_OpenOnArrow | + ImGuiTreeNodeFlags_DrawLinesToNodes | ImGuiTreeNodeFlags_NoTreePushOnOpen; + if (!has_children) + node_flags |= ImGuiTreeNodeFlags_Leaf; + + if (row_selected) + node_flags |= ImGuiTreeNodeFlags_Selected; + + bool open = true; + if (has_children) + { + const auto exp_it = m_gui.m_shape_list_expanded.find(shape->get_id()); + open = (exp_it == m_gui.m_shape_list_expanded.end()) ? true : exp_it->second; + ImGui::SetNextItemOpen(open); + } + + ImGui::SetNextItemAllowOverlap(); + const bool node_open = ImGui::TreeNodeEx("##node", node_flags); + if (has_children && node_open != open) + m_gui.m_shape_list_expanded[shape->get_id()] = node_open; + + if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) + select_row_(shape); + + row_hovered |= ImGui::IsItemHovered(); + if (m_gui.ui_show_contextual_help() && ImGui::IsItemHovered()) + { + if (row_selected) + ImGui::SetTooltip("Selected in 3D viewer"); + else if (is_current_group) + ImGui::SetTooltip("Current group (new shapes go here)"); + } + + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) + { + const Shape_id drag_id = shape->get_id(); + ImGui::SetDragDropPayload("EZY_SHAPE_ID", &drag_id, sizeof(drag_id)); + ImGui::TextUnformatted(shape->get_name().c_str()); + ImGui::EndDragDropSource(); + } + + // Drop onto group -> that group; onto solid -> solid's parent. Register on both the + // tree arrow and the name field - the name covers most of the row. + accept_reparent_drop_(shape, is_group); + + ImGui::SameLine(); + ImGui::SetNextItemWidth(std::max(1.0f, ImGui::GetContentRegionAvail().x)); + if (ImGui::InputText("##name", name_buffer, sizeof(name_buffer))) + shape->set_name(std::string(name_buffer)); + + if (ImGui::IsItemClicked()) + select_row_(shape); + + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) + { + const Shape_id drag_id = shape->get_id(); + ImGui::SetDragDropPayload("EZY_SHAPE_ID", &drag_id, sizeof(drag_id)); + ImGui::TextUnformatted(shape->get_name().c_str()); + ImGui::EndDragDropSource(); + } + accept_reparent_drop_(shape, is_group); + + row_hovered |= ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenOverlapped | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + + // Last-column clip would miss gaps between vis / shaded / M; test the full row. + if (const ImGuiTable* table = ImGui::GetCurrentTable()) + { + if (ImGui::TableGetHoveredColumn() >= 0) + { + const ImRect row_bb(ImVec2(table->WorkRect.Min.x, table->RowPosY1), ImVec2(table->WorkRect.Max.x, table->RowPosY2)); + row_hovered |= ImGui::IsMouseHoveringRect(row_bb.Min, row_bb.Max, false); + } + } + + const ImGuiPayload* dd = ImGui::GetDragDropPayload(); + const bool dragging_row = dd != nullptr && dd->IsDataType("EZY_SHAPE_ID"); + if (row_hovered && !dragging_row && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) + ImGui::OpenPopup("shape_row_ctx"); + + if (ImGui::BeginPopup("shape_row_ctx")) + { + draw_ctx_menu_(shape, is_group); + ImGui::EndPopup(); + } + + if (row_hovered && shape->get_visible() && !is_group) + hover = shape; + + if (has_children && node_open) + { + ImGui::TreePush(reinterpret_cast(static_cast(shape->get_id()))); + for (const Shp_ptr& child : children) + draw(child); + ImGui::TreePop(); + } + + ImGui::PopID(); + m_ancestors.erase(shape->get_id()); +} + +} // namespace gui_shp_detail diff --git a/src/shp_move.cpp b/src/shp_move.cpp index 748d782..45463d3 100644 --- a/src/shp_move.cpp +++ b/src/shp_move.cpp @@ -4,6 +4,7 @@ #include "gui.h" #include "gui_occt_view.h" #include "shp_delta.h" +#include "shp_transform.h" #include "utl.h" Shp_move::Shp_move(Occt_view& view) @@ -21,10 +22,8 @@ Status Shp_move::move_selected(const ScreenCoords& screen_coords) { CHK_RET(ensure_operation_shps_()); - if (!m_center.has_value()) - // Get the estimate of the center. - // TODO consider all shapes. - m_center = get_shape_bbox_center(m_shps[0]->Shape()); + const Transform_axes axes = transform_axes_for(m_shps, gui().get_transform_space()); + m_center = axes.origin; if (!m_move_pln.has_value()) // Remember, if the user can change the view via a hot key this will be invalid. @@ -34,25 +33,12 @@ Status Shp_move::move_selected(const ScreenCoords& screen_coords) if (!mouse_wc_pos) return Status::user_error("Adjust view, cannot get point on plane."); - bool no_axis_constraints = !m_opts.constr_axis_x && !m_opts.constr_axis_y && !m_opts.constr_axis_z; - - if (m_delta.override_x.has_value()) - m_delta.delta.SetX(*m_delta.override_x); - else - m_delta.delta.SetX(no_axis_constraints || m_opts.constr_axis_x ? mouse_wc_pos->X() - m_center->X() : 0); - - if (m_delta.override_y.has_value()) - m_delta.delta.SetY(*m_delta.override_y); - else - m_delta.delta.SetY(no_axis_constraints || m_opts.constr_axis_y ? mouse_wc_pos->Y() - m_center->Y() : 0); - - if (m_delta.override_z.has_value()) - m_delta.delta.SetZ(*m_delta.override_z); - else - m_delta.delta.SetZ(no_axis_constraints || m_opts.constr_axis_z ? mouse_wc_pos->Z() - m_center->Z() : 0); + const gp_Vec mouse_vec(*m_center, *mouse_wc_pos); + m_delta.delta = transform_axis_magnitudes(axes, mouse_vec, m_opts.constr_axis_x, m_opts.constr_axis_y, m_opts.constr_axis_z, + m_delta.override_x, m_delta.override_y, m_delta.override_z); gp_Trsf translation; - translation.SetTranslation(gp_Vec(m_delta.delta)); + translation.SetTranslation(transform_translation(axes, m_delta.delta)); for (const Shp_ptr& shape : m_shps) shape->SetLocalTransformation(translation); @@ -162,4 +148,10 @@ void Shp_move::reset() gui().set_mode(Mode::Normal); } -Move_options& Shp_move::get_opts() { return m_opts; } \ No newline at end of file +Move_options& Shp_move::get_opts() { return m_opts; } + +void Shp_move::on_transform_space_changed() +{ + m_center.reset(); + m_move_pln.reset(); +} \ No newline at end of file diff --git a/src/shp_move.h b/src/shp_move.h index 8ca178f..af2fec7 100644 --- a/src/shp_move.h +++ b/src/shp_move.h @@ -30,6 +30,8 @@ class Shp_move : private Shp_operation_base void cancel(); void reset(); Move_options& get_opts(); + /// Re-seed pivot after Options Local/World change. + void on_transform_space_changed(); private: struct Deltas diff --git a/src/shp_rotate.cpp b/src/shp_rotate.cpp index e08bc43..682aaea 100644 --- a/src/shp_rotate.cpp +++ b/src/shp_rotate.cpp @@ -1,9 +1,17 @@ #include "shp_rotate.h" +#include #include -#include +#include +#include +#include +#include +#include +#include #include -#include +#include +#include +#include #include "utl_geom.h" #include "gui.h" @@ -18,27 +26,53 @@ Shp_rotate::Shp_rotate(Occt_view& view) void Shp_rotate::begin(std::vector shps) { - clear_all(m_angle, m_initial_mouse_pos, m_rotate_pln, m_center); + clear_all(m_angle, m_initial_mouse_pos, m_rotate_pln, m_captured_axis_dir, m_center); clear_rotation_vis_(); set_operation_shps_(std::move(shps)); + if (!m_shps.empty()) + refresh_guides_(); } Status Shp_rotate::rotate_selected(const ScreenCoords& screen_coords) { CHK_RET(ensure_start_state_()); - std::optional mouse_wc_pos = view().pt3d_on_plane(screen_coords, *m_rotate_pln); + const gp_Dir axis_dir = current_axis_dir_(); + const gp_Pln pln = m_rotate_pln ? *m_rotate_pln : choose_rotate_pln_(axis_dir); + + std::optional mouse_wc_pos = view().pt3d_on_plane(screen_coords, pln); if (!mouse_wc_pos) return Status::user_error("Adjust view, cannot get point on plane."); - if (m_center->IsEqual(*mouse_wc_pos, Precision::Confusion())) + return update_rotate_from_world_(*mouse_wc_pos, axis_dir, pln); +} + +Status Shp_rotate::update_rotate_from_world_(const gp_Pnt& mouse_wc_pos, const gp_Dir& axis_dir, const gp_Pln& pln) +{ + if (m_center->IsEqual(mouse_wc_pos, Precision::Confusion())) return Status::user_error("Move mouse further from rotation center to start rotation."); + if (!m_rotate_pln) + { + m_rotate_pln = pln; + m_captured_axis_dir = axis_dir; + } + if (!m_initial_mouse_pos) - m_initial_mouse_pos = mouse_wc_pos; + { + // Recapture so a mid-drag Local/World (or axis) change keeps the current angle. + if (std::abs(m_angle) > Precision::Confusion()) + { + gp_Trsf back; + back.SetRotation(gp_Ax1(*m_center, m_rotate_pln->Axis().Direction()), -m_angle); + m_initial_mouse_pos = mouse_wc_pos.Transformed(back); + } + else + m_initial_mouse_pos = mouse_wc_pos; + } gp_Vec v0(*m_center, *m_initial_mouse_pos); - gp_Vec v1(*m_center, *mouse_wc_pos); + gp_Vec v1(*m_center, mouse_wc_pos); gp_Vec v0_proj = project_onto_plane(v0, *m_rotate_pln); gp_Vec v1_proj = project_onto_plane(v1, *m_rotate_pln); @@ -56,68 +90,90 @@ Status Shp_rotate::rotate_selected(const ScreenCoords& screen_coords) Status Shp_rotate::ensure_start_state_() { CHK_RET(ensure_operation_shps_()); + refresh_guides_(); + ctx().UpdateCurrentViewer(); + return Status::ok(); +} - // Get the estimate of the center. - // TODO consider all shapes. - // Use the point of the first selected object. - if (!m_center.has_value()) - m_center = get_shape_bbox_center(m_shps[0]->Shape()); +Transform_axes Shp_rotate::current_axes_() +{ + return transform_axes_for(m_shps, gui().get_transform_space()); +} - if (!m_rotate_pln.has_value()) - m_rotate_pln = view().get_view_plane(*m_center); +gp_Dir Shp_rotate::current_axis_dir_() +{ + if (m_captured_axis_dir) + return *m_captured_axis_dir; - update_rotation_axis_(); - update_rotation_center_(); - ctx().UpdateCurrentViewer(); + EZY_ASSERT(m_center.has_value()); + switch (m_rotation_axis) + { + // clang-format off + case Rotation_axis::X_axis: return current_axes_().x; + case Rotation_axis::Y_axis: return current_axes_().y; + case Rotation_axis::Z_axis: return current_axes_().z; + // clang-format on + case Rotation_axis::View_to_object: + return view().get_view_plane(*m_center).Axis().Direction(); + } - return Status::ok(); + return gp_Dir(0.0, 0.0, 1.0); } -void Shp_rotate::update_rotation_axis_() +gp_Pln Shp_rotate::choose_rotate_pln_(const gp_Dir& axis_dir) { EZY_ASSERT(m_center.has_value()); - EZY_ASSERT(m_rotate_pln.has_value()); + const gp_Pln view_pln = view().get_view_plane(*m_center); + const gp_Pln axis_pln(*m_center, axis_dir); + // Constrained X/Y/Z: use the plane perpendicular to the axis when the view faces it. + // Edge-on views stay on the view plane so the mouse still has a usable lever. + const bool use_axis_pln = + m_rotation_axis != Rotation_axis::View_to_object && std::abs(view_pln.Axis().Direction().Dot(axis_dir)) >= 0.15; + + return use_axis_pln ? axis_pln : view_pln; +} - // If View_to_object is set, remove the axis visualization - if (m_rotation_axis == Rotation_axis::View_to_object) - { - if (m_rotation_axis_vis) - { - ctx().Remove(m_rotation_axis_vis, false); - m_rotation_axis_vis = nullptr; - } +void Shp_rotate::capture_drag_frame_() +{ + if (m_rotate_pln) + return; + EZY_ASSERT(m_center.has_value()); + const gp_Dir axis_dir = current_axis_dir_(); + m_captured_axis_dir = axis_dir; + m_rotate_pln = choose_rotate_pln_(axis_dir); +} + +void Shp_rotate::refresh_guides_() +{ + if (m_shps.empty()) return; - } - gp_Dir axis_dir; - Quantity_Color axis_color; - auto set_axis_and_color = [&](double x, double y, double z) - { - axis_dir = gp_Dir(x, y, z); - axis_color = Quantity_Color(x, y, z, Quantity_TOC_RGB); - }; + m_center = current_axes_().origin; + update_rotation_axis_(); + update_rotation_center_(); +} + +void Shp_rotate::update_rotation_axis_() +{ + EZY_ASSERT(m_center.has_value()); + const gp_Dir axis_dir = current_axis_dir_(); + Quantity_Color axis_color; switch (m_rotation_axis) { // clang-format off - case Rotation_axis::X_axis: set_axis_and_color(1, 0, 0); break; - case Rotation_axis::Y_axis: set_axis_and_color(0, 1, 0); break; - case Rotation_axis::Z_axis: set_axis_and_color(0, 0, 1); break; + case Rotation_axis::X_axis: axis_color = Quantity_Color(1.0, 0.0, 0.0, Quantity_TOC_RGB); break; + case Rotation_axis::Y_axis: axis_color = Quantity_Color(0.0, 1.0, 0.0, Quantity_TOC_RGB); break; + case Rotation_axis::Z_axis: axis_color = Quantity_Color(0.0, 0.0, 1.0, Quantity_TOC_RGB); break; + case Rotation_axis::View_to_object: axis_color = Quantity_NOC_CYAN; break; // clang-format on - case Rotation_axis::View_to_object: - return; // Already handled above } - // Create a line representing the rotation axis - gp_Lin axis(*m_center, axis_dir); - - // Create points along the line by moving from center in both directions - gp_Vec axis_vec(axis_dir); - gp_Pnt p1 = m_center->Translated(axis_vec.Multiplied(-1000.0)); - gp_Pnt p2 = m_center->Translated(axis_vec.Multiplied(1000.0)); - - TopoDS_Edge axis_edge = BRepBuilderAPI_MakeEdge(p1, p2).Edge(); + const gp_Vec axis_vec(axis_dir); + const gp_Pnt p1 = m_center->Translated(axis_vec.Multiplied(-1000.0)); + const gp_Pnt p2 = m_center->Translated(axis_vec.Multiplied(1000.0)); + const TopoDS_Edge axis_edge = BRepBuilderAPI_MakeEdge(p1, p2).Edge(); if (m_rotation_axis_vis) { @@ -130,7 +186,9 @@ void Shp_rotate::update_rotation_axis_() m_rotation_axis_vis = new AIS_Shape(axis_edge); m_rotation_axis_vis->SetWidth(2.0); m_rotation_axis_vis->SetColor(axis_color); - ctx().Display(m_rotation_axis_vis, false); + m_rotation_axis_vis->SetZLayer(Graphic3d_ZLayerId_Topmost); + ctx().Display(m_rotation_axis_vis, AIS_WireFrame, -1, false); + ctx().Deactivate(m_rotation_axis_vis); } } @@ -138,46 +196,37 @@ void Shp_rotate::update_rotation_center_() { EZY_ASSERT(m_center.has_value()); - // TODO cannot see the point unless the shape is rendered as wireframe - // Create a point representing the rotation center - TopoDS_Vertex center_vertex = BRepBuilderAPI_MakeVertex(*m_center).Vertex(); + const Transform_axes axes = current_axes_(); + const double arm = 8.0; + const gp_Pnt o = *m_center; + TopoDS_Compound cross; + BRep_Builder().MakeCompound(cross); + BRep_Builder().Add(cross, BRepBuilderAPI_MakeEdge(o.Translated(gp_Vec(axes.x) * -arm), o.Translated(gp_Vec(axes.x) * arm)).Edge()); + BRep_Builder().Add(cross, BRepBuilderAPI_MakeEdge(o.Translated(gp_Vec(axes.y) * -arm), o.Translated(gp_Vec(axes.y) * arm)).Edge()); + BRep_Builder().Add(cross, BRepBuilderAPI_MakeEdge(o.Translated(gp_Vec(axes.z) * -arm), o.Translated(gp_Vec(axes.z) * arm)).Edge()); if (m_rotation_center_vis) { - m_rotation_center_vis->Set(center_vertex); + m_rotation_center_vis->Set(cross); ctx().Redisplay(m_rotation_center_vis, false); } else { - m_rotation_center_vis = new AIS_Shape(center_vertex); + m_rotation_center_vis = new AIS_Shape(cross); m_rotation_center_vis->SetWidth(3.0); m_rotation_center_vis->SetColor(Quantity_NOC_RED); - ctx().Display(m_rotation_center_vis, false); + m_rotation_center_vis->SetZLayer(Graphic3d_ZLayerId_Topmost); + ctx().Display(m_rotation_center_vis, AIS_WireFrame, -1, false); + ctx().Deactivate(m_rotation_center_vis); } } void Shp_rotate::preview_rotate_() { EZY_ASSERT(m_center.has_value()); - EZY_ASSERT(m_rotate_pln.has_value()); - gp_Dir axis_dir; - switch (m_rotation_axis) - { - // clang-format off - case Rotation_axis::X_axis: axis_dir = gp_Dir(1, 0, 0); break; - case Rotation_axis::Y_axis: axis_dir = gp_Dir(0, 1, 0); break; - case Rotation_axis::Z_axis: axis_dir = gp_Dir(0, 0, 1); break; - // clang-format on - case Rotation_axis::View_to_object: - axis_dir = m_rotate_pln->Axis().Direction(); - break; - } - - // Create rotation transformation gp_Trsf rotation; - gp_Ax1 rotation_axis(*m_center, axis_dir); - rotation.SetRotation(rotation_axis, m_angle); + rotation.SetRotation(gp_Ax1(*m_center, current_axis_dir_()), m_angle); for (const Shp_ptr& shape : m_shps) shape->SetLocalTransformation(rotation); @@ -187,7 +236,6 @@ void Shp_rotate::preview_rotate_() Status Shp_rotate::show_angle_edit(const ScreenCoords& screen_coords) { - // In case `tab` was pressed without moving the mouse CHK_RET(ensure_start_state_()); auto angle_edit = [&, screen_coords](float new_angle, bool is_final) @@ -239,7 +287,7 @@ void Shp_rotate::cancel() void Shp_rotate::reset() { - clear_all(m_angle, m_shps, m_initial_mouse_pos, m_rotate_pln, m_center); + clear_all(m_angle, m_shps, m_initial_mouse_pos, m_rotate_pln, m_captured_axis_dir, m_center); clear_rotation_vis_(); gui().set_mode(Mode::Normal); } @@ -256,10 +304,28 @@ void Shp_rotate::clear_rotation_vis_() void Shp_rotate::set_rotation_axis(Rotation_axis axis) { - if (!m_center.has_value()) + m_rotation_axis = axis; + clear_all(m_initial_mouse_pos, m_rotate_pln, m_captured_axis_dir); + if (m_shps.empty()) return; - m_rotation_axis = axis; - update_rotation_axis_(); + refresh_guides_(); + if (std::abs(m_angle) > Precision::Confusion()) + preview_rotate_(); + ctx().UpdateCurrentViewer(); -} \ No newline at end of file +} + +void Shp_rotate::on_transform_space_changed() +{ + if (m_shps.empty()) + return; + + // Drop the world-space mouse anchor; update_rotate_from_world_ recaptures so the angle stays. + clear_all(m_initial_mouse_pos, m_rotate_pln, m_captured_axis_dir); + refresh_guides_(); + if (std::abs(m_angle) > Precision::Confusion()) + preview_rotate_(); + + ctx().UpdateCurrentViewer(); +} diff --git a/src/shp_rotate.h b/src/shp_rotate.h index 1373574..37b8bb5 100644 --- a/src/shp_rotate.h +++ b/src/shp_rotate.h @@ -1,13 +1,16 @@ #pragma once #include "shp_operation.h" +#include "shp_transform.h" + +#include enum class Rotation_axis { - View_to_object, // Rotate around view axis through object center - X_axis, // Rotate around global X axis - Y_axis, // Rotate around global Y axis - Z_axis // Rotate around global Z axis + View_to_object, // Rotate around view axis through the current pivot + X_axis, // Local or world X (see Transform_space) + Y_axis, + Z_axis }; class Shp_rotate : private Shp_operation_base @@ -26,20 +29,31 @@ class Shp_rotate : private Shp_operation_base void set_rotation_axis(Rotation_axis axis); Rotation_axis get_rotation_axis() const { return m_rotation_axis; } + /// Pivot / axis vis after Options Local/World change; mid-drag re-baselines the angle. + void on_transform_space_changed(); private: - [[nodiscard]] Status ensure_start_state_(); - void preview_rotate_(); - void reset(); - void update_rotation_axis_(); - void update_rotation_center_(); - void clear_rotation_vis_(); + [[nodiscard]] Status ensure_start_state_(); + [[nodiscard]] Transform_axes current_axes_(); + [[nodiscard]] gp_Dir current_axis_dir_(); + [[nodiscard]] gp_Pln choose_rotate_pln_(const gp_Dir& axis_dir); + [[nodiscard]] Status update_rotate_from_world_(const gp_Pnt& mouse_wc_pos, const gp_Dir& axis_dir, const gp_Pln& pln); + void capture_drag_frame_(); + void refresh_guides_(); + void preview_rotate_(); + void reset(); + void update_rotation_axis_(); + void update_rotation_center_(); + void clear_rotation_vis_(); + + friend class Shp_rotate_access; std::optional m_rotate_pln; + std::optional m_captured_axis_dir; std::optional m_initial_mouse_pos; std::optional m_center; double m_angle{0}; Rotation_axis m_rotation_axis{Rotation_axis::View_to_object}; AIS_Shape_ptr m_rotation_axis_vis; AIS_Shape_ptr m_rotation_center_vis; -}; \ No newline at end of file +}; diff --git a/src/shp_scale.cpp b/src/shp_scale.cpp index f052b58..9644537 100644 --- a/src/shp_scale.cpp +++ b/src/shp_scale.cpp @@ -1,10 +1,13 @@ #include "shp_scale.h" +#include + #include "utl_geom.h" #include "gui.h" #include "mode.h" #include "gui_occt_view.h" #include "shp_delta.h" +#include "shp_transform.h" #include "utl.h" Shp_scale::Shp_scale(Occt_view& view) @@ -27,12 +30,17 @@ Status Shp_scale::scale_selected(const ScreenCoords& screen_coords) if (!mouse_wc_pos) return Status::user_error("Adjust view, cannot get point on plane."); - const double dist = m_center->Distance(*mouse_wc_pos); + return update_scale_from_distance_(m_center->Distance(*mouse_wc_pos)); +} + +Status Shp_scale::update_scale_from_distance_(double dist) +{ if (dist < Precision::Confusion()) return Status::user_error("Move mouse further from scale center to start scaling."); + // Recapture so a mid-drag Local/World pivot change keeps the current factor. if (m_initial_distance < Precision::Confusion()) - m_initial_distance = dist; + m_initial_distance = dist / m_scale_factor; m_scale_factor = dist / m_initial_distance; if (m_scale_factor < 0.01) @@ -51,7 +59,7 @@ Status Shp_scale::ensure_start_state_() CHK_RET(ensure_operation_shps_()); if (!m_center.has_value()) - m_center = get_shape_bbox_center(m_shps[0]->Shape()); + m_center = transform_axes_for(m_shps, gui().get_transform_space()).origin; if (!m_scale_pln.has_value()) m_scale_pln = view().get_view_plane(*m_center); @@ -118,3 +126,14 @@ void Shp_scale::cancel() reset(); restore_operation_selection_(); } + +void Shp_scale::on_transform_space_changed() +{ + const bool mid_drag = m_initial_distance >= Precision::Confusion(); + clear_all(m_center, m_scale_pln, m_initial_distance); + if (m_shps.empty() || !mid_drag) + return; + + if (ensure_start_state_().is_ok()) + preview_scale_(); +} diff --git a/src/shp_scale.h b/src/shp_scale.h index dc94f16..2e0e3ae 100644 --- a/src/shp_scale.h +++ b/src/shp_scale.h @@ -19,11 +19,16 @@ class Shp_scale : private Shp_operation_base void finalize(); void cancel(); void reset(); + /// Re-seed pivot after Options Local/World change; mid-drag re-baselines the factor. + void on_transform_space_changed(); private: [[nodiscard]] Status ensure_start_state_(); + [[nodiscard]] Status update_scale_from_distance_(double dist); void preview_scale_(); + friend class Shp_scale_access; + std::optional m_scale_pln; std::optional m_center; double m_initial_distance{0}; diff --git a/src/shp_transform.cpp b/src/shp_transform.cpp new file mode 100644 index 0000000..da6e52f --- /dev/null +++ b/src/shp_transform.cpp @@ -0,0 +1,80 @@ +#include "shp_transform.h" + +#include "utl_geom.h" + +namespace +{ +const Shp* first_solid_(const std::vector& shps) +{ + for (const Shp_ptr& s : shps) + if (!s.IsNull() && !s->is_group()) + return s.get(); + + return nullptr; +} +} // namespace + +Transform_axes transform_axes_for(const std::vector& shps, Transform_space space) +{ + Transform_axes axes; + axes.origin = gp_Pnt(0.0, 0.0, 0.0); + axes.x = gp_Dir(1.0, 0.0, 0.0); + axes.y = gp_Dir(0.0, 1.0, 0.0); + axes.z = gp_Dir(0.0, 0.0, 1.0); + + const Shp* s = first_solid_(shps); + if (!s) + return axes; + + if (space == Transform_space::Local) + { + const gp_Ax3& f = s->get_frame(); + axes.origin = f.Location(); + axes.x = f.XDirection(); + axes.y = f.YDirection(); + axes.z = f.Direction(); + return axes; + } + + axes.origin = get_shape_bbox_center(s->Shape()); + return axes; +} + +gp_XYZ transform_axis_magnitudes(const Transform_axes& axes, + const gp_Vec& mouse_vec, + bool constr_x, + bool constr_y, + bool constr_z, + const std::optional& override_x, + const std::optional& override_y, + const std::optional& override_z) +{ + const bool no_constr = !constr_x && !constr_y && !constr_z; + auto mag = [&](bool constr, const gp_Dir& dir, const std::optional& ov) -> double + { + if (!no_constr && !constr) + return 0.0; + + return ov.has_value() ? *ov : mouse_vec.Dot(gp_Vec(dir)); + }; + + return gp_XYZ(mag(constr_x, axes.x, override_x), mag(constr_y, axes.y, override_y), mag(constr_z, axes.z, override_z)); +} + +gp_Vec transform_translation(const Transform_axes& axes, const gp_XYZ& magnitudes) +{ + return gp_Vec(axes.x) * magnitudes.X() + gp_Vec(axes.y) * magnitudes.Y() + gp_Vec(axes.z) * magnitudes.Z(); +} + +gp_Vec transform_translation(const Transform_axes& axes, + const gp_Vec& mouse_vec, + bool constr_x, + bool constr_y, + bool constr_z, + const std::optional& override_x, + const std::optional& override_y, + const std::optional& override_z) +{ + return transform_translation( + axes, transform_axis_magnitudes(axes, mouse_vec, constr_x, constr_y, constr_z, override_x, override_y, override_z)); +} diff --git a/src/shp_transform.h b/src/shp_transform.h new file mode 100644 index 0000000..4418031 --- /dev/null +++ b/src/shp_transform.h @@ -0,0 +1,50 @@ +#pragma once + +#include "shp.h" + +#include +#include +#include +#include +#include +#include + +/// Shared by Move / Rotate / Scale. Local = first selected solid's frame. +enum class Transform_space +{ + Local = 0, + World = 1 +}; + +struct Transform_axes +{ + gp_Pnt origin; + gp_Dir x; + gp_Dir y; + gp_Dir z; +}; + +/// First non-group solid's local frame, or world XYZ at that solid's bbox center. +Transform_axes transform_axes_for(const std::vector& shps, Transform_space space); + +/// Distances along the current X/Y/Z axes (world or local), with optional locks. +gp_XYZ transform_axis_magnitudes(const Transform_axes& axes, + const gp_Vec& mouse_vec, + bool constr_x, + bool constr_y, + bool constr_z, + const std::optional& override_x, + const std::optional& override_y, + const std::optional& override_z); + +gp_Vec transform_translation(const Transform_axes& axes, const gp_XYZ& magnitudes); + +/// View-plane mouse vector expressed on the current axes, with optional locks. +gp_Vec transform_translation(const Transform_axes& axes, + const gp_Vec& mouse_vec, + bool constr_x, + bool constr_y, + bool constr_z, + const std::optional& override_x, + const std::optional& override_y, + const std::optional& override_z); diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 86dfff2..fe220a8 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -13,15 +13,23 @@ #include #include #include +#include #include +#include +#include #include #include +#include +#include #include #include "shp.h" #include "shp_create.h" #include "shp_info.h" #include "shp_cross_section.h" +#include "shp_rotate.h" +#include "shp_scale.h" +#include "shp_transform.h" #include "skt_op_recorder.h" #include "utl.h" @@ -72,6 +80,79 @@ int displayed_object_count(AIS_InteractiveContext& ctx) } } // namespace +class Shp_rotate_access +{ +public: + static void capture_drag_frame(Shp_rotate& rotate) + { + rotate.capture_drag_frame_(); + } + + static Status ensure_start(Shp_rotate& rotate) + { + return rotate.ensure_start_state_(); + } + + static Status apply_world(Shp_rotate& rotate, const gp_Pnt& mouse_wc_pos, const gp_Dir& axis_dir, const gp_Pln& pln) + { + return rotate.update_rotate_from_world_(mouse_wc_pos, axis_dir, pln); + } + + static double angle(const Shp_rotate& rotate) + { + return rotate.m_angle; + } + + static const std::optional& initial_mouse_pos(const Shp_rotate& rotate) + { + return rotate.m_initial_mouse_pos; + } + + static const std::optional& center(const Shp_rotate& rotate) + { + return rotate.m_center; + } + + static const std::optional& rotate_pln(const Shp_rotate& rotate) + { + return rotate.m_rotate_pln; + } + + static const std::optional& captured_axis_dir(const Shp_rotate& rotate) + { + return rotate.m_captured_axis_dir; + } +}; + +class Shp_scale_access +{ +public: + static Status ensure_start(Shp_scale& scale) + { + return scale.ensure_start_state_(); + } + + static Status apply_distance(Shp_scale& scale, double dist) + { + return scale.update_scale_from_distance_(dist); + } + + static double scale_factor(const Shp_scale& scale) + { + return scale.m_scale_factor; + } + + static double initial_distance(const Shp_scale& scale) + { + return scale.m_initial_distance; + } + + static const std::optional& center(const Shp_scale& scale) + { + return scale.m_center; + } +}; + // Headless Occt_view fixture shared with sketch tests. class Shp_test : public Sketch_test { @@ -1345,3 +1426,213 @@ TEST_F(Shp_test, Grouped_solids_stay_displayed_and_selectable) EXPECT_EQ(view().get_selected_shps().front()->get_id(), s->get_id()); } } + +TEST_F(Shp_test, Transform_axes_world_uses_bbox_center) +{ + view().add_box(0, 0, 0, 10, 10, 10); + Shp_ptr shp = view().get_shapes().back(); + ASSERT_FALSE(shp.IsNull()); + + const Transform_axes axes = transform_axes_for({shp}, Transform_space::World); + EXPECT_TRUE(axes.origin.IsEqual(gp_Pnt(5.0, 5.0, 5.0), 1e-9)); + EXPECT_TRUE(axes.x.IsEqual(gp_Dir(1.0, 0.0, 0.0), 1e-9)); + EXPECT_TRUE(axes.z.IsEqual(gp_Dir(0.0, 0.0, 1.0), 1e-9)); +} + +TEST_F(Shp_test, Transform_axes_local_uses_assigned_frame) +{ + view().add_box(0, 0, 0, 10, 10, 10); + Shp_ptr shp = view().get_shapes().back(); + ASSERT_FALSE(shp.IsNull()); + + const gp_Ax3 tilted(gp_Pnt(1.0, 2.0, 3.0), gp_Dir(0.0, 1.0, 0.0), gp_Dir(1.0, 0.0, 0.0)); + view().set_shape_frame(shp, tilted); + + const Transform_axes local = transform_axes_for({shp}, Transform_space::Local); + EXPECT_TRUE(local.origin.IsEqual(gp_Pnt(1.0, 2.0, 3.0), 1e-9)); + EXPECT_TRUE(local.x.IsEqual(gp_Dir(1.0, 0.0, 0.0), 1e-9)); + EXPECT_TRUE(local.z.IsEqual(gp_Dir(0.0, 1.0, 0.0), 1e-9)); + + const Transform_axes world = transform_axes_for({shp}, Transform_space::World); + EXPECT_TRUE(world.origin.IsEqual(gp_Pnt(5.0, 5.0, 5.0), 1e-9)); + EXPECT_TRUE(world.z.IsEqual(gp_Dir(0.0, 0.0, 1.0), 1e-9)); +} + +TEST_F(Shp_test, Transform_translation_local_x_constraint) +{ + Transform_axes axes; + axes.origin = gp_Pnt(0.0, 0.0, 0.0); + axes.x = gp_Dir(0.0, 1.0, 0.0); + axes.y = gp_Dir(0.0, 0.0, 1.0); + axes.z = gp_Dir(1.0, 0.0, 0.0); + + const gp_Vec mouse(1.0, 4.0, 0.0); + const gp_Vec delta = + transform_translation(axes, mouse, true, false, false, std::nullopt, std::nullopt, std::nullopt); + EXPECT_NEAR(delta.X(), 0.0, 1e-9); + EXPECT_NEAR(delta.Y(), 4.0, 1e-9); + EXPECT_NEAR(delta.Z(), 0.0, 1e-9); +} + +TEST_F(Shp_test, Rotate_axis_can_be_set_before_first_drag) +{ + gui().set_mode(Mode::Normal); + gui().set_hide_all_shapes(false); + view().add_box(0, 0, 0, 10, 10, 10); + Shp_ptr shp = view().get_shapes().back(); + ASSERT_FALSE(shp.IsNull()); + select_shapes(view(), {shp}); + + gui().set_mode(Mode::Rotate); + EXPECT_TRUE(view().shp_rotate().has_operation_shps()); + view().shp_rotate().set_rotation_axis(Rotation_axis::Z_axis); + EXPECT_EQ(view().shp_rotate().get_rotation_axis(), Rotation_axis::Z_axis); +} + +TEST_F(Shp_test, Rotate_view_to_object_keeps_drag_frame_after_orbit) +{ + gui().set_mode(Mode::Normal); + gui().set_hide_all_shapes(false); + view().add_box(0, 0, 0, 10, 10, 10); + Shp_ptr shp = view().get_shapes().back(); + ASSERT_FALSE(shp.IsNull()); + select_shapes(view(), {shp}); + + gui().set_mode(Mode::Rotate); + ASSERT_TRUE(view().shp_rotate().has_operation_shps()); + EXPECT_EQ(view().shp_rotate().get_rotation_axis(), Rotation_axis::View_to_object); + + view().view_handle()->SetProj(0, 0, 1); + Shp_rotate_access::capture_drag_frame(view().shp_rotate()); + ASSERT_TRUE(Shp_rotate_access::rotate_pln(view().shp_rotate()).has_value()); + ASSERT_TRUE(Shp_rotate_access::captured_axis_dir(view().shp_rotate()).has_value()); + + const gp_Dir frozen_axis = *Shp_rotate_access::captured_axis_dir(view().shp_rotate()); + const gp_Dir frozen_pln = Shp_rotate_access::rotate_pln(view().shp_rotate())->Axis().Direction(); + + view().view_handle()->SetProj(1, 0, 0); + const gp_Dir live_after = view().get_view_plane(gp_Pnt(5.0, 5.0, 5.0)).Axis().Direction(); + ASSERT_FALSE(live_after.IsEqual(frozen_axis, 1e-3)); + + Shp_rotate_access::capture_drag_frame(view().shp_rotate()); + EXPECT_TRUE(Shp_rotate_access::captured_axis_dir(view().shp_rotate())->IsEqual(frozen_axis, 1e-9)); + EXPECT_TRUE(Shp_rotate_access::rotate_pln(view().shp_rotate())->Axis().Direction().IsEqual(frozen_pln, 1e-9)); +} + +TEST_F(Shp_test, Rotate_constrained_keeps_axis_plane_when_facing_test_would_flip) +{ + gui().set_mode(Mode::Normal); + gui().set_hide_all_shapes(false); + view().add_box(0, 0, 0, 10, 10, 10); + Shp_ptr shp = view().get_shapes().back(); + ASSERT_FALSE(shp.IsNull()); + select_shapes(view(), {shp}); + + gui().set_mode(Mode::Rotate); + ASSERT_TRUE(view().shp_rotate().has_operation_shps()); + view().shp_rotate().set_rotation_axis(Rotation_axis::X_axis); + + // Look along +X so the view faces the rotation axis (dot ~ 1) and the axis plane is used. + view().view_handle()->SetProj(1, 0, 0); + Shp_rotate_access::capture_drag_frame(view().shp_rotate()); + ASSERT_TRUE(Shp_rotate_access::rotate_pln(view().shp_rotate()).has_value()); + EXPECT_TRUE(Shp_rotate_access::rotate_pln(view().shp_rotate())->Axis().Direction().IsEqual(gp_Dir(1.0, 0.0, 0.0), 1e-6)); + + // Edge-on to X: live facing test would fall back to the view plane (normal ~ Z). + view().view_handle()->SetProj(0, 0, 1); + const gp_Dir live_view = view().get_view_plane(gp_Pnt(5.0, 5.0, 5.0)).Axis().Direction(); + ASSERT_LT(std::abs(live_view.Dot(gp_Dir(1.0, 0.0, 0.0))), 0.15); + + Shp_rotate_access::capture_drag_frame(view().shp_rotate()); + EXPECT_TRUE(Shp_rotate_access::rotate_pln(view().shp_rotate())->Axis().Direction().IsEqual(gp_Dir(1.0, 0.0, 0.0), 1e-6)); +} + +TEST_F(Shp_test, Scale_space_change_mid_drag_keeps_factor) +{ + gui().set_mode(Mode::Normal); + gui().set_hide_all_shapes(false); + const Transform_space saved_space = gui().get_transform_space(); + struct Restore_space + { + GUI& gui; + Transform_space saved; + ~Restore_space() { GUI_access::set_transform_space(gui, saved); } + } restore{gui(), saved_space}; + GUI_access::set_transform_space(gui(), Transform_space::Local); + + view().add_box(0, 0, 0, 10, 10, 10); + Shp_ptr shp = view().get_shapes().back(); + ASSERT_FALSE(shp.IsNull()); + view().set_shape_frame(shp, gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0), gp_Dir(1.0, 0.0, 0.0))); + select_shapes(view(), {shp}); + + gui().set_mode(Mode::Scale); + ASSERT_TRUE(view().shp_scale().has_operation_shps()); + ASSERT_TRUE(Shp_scale_access::ensure_start(view().shp_scale()).is_ok()); + ASSERT_TRUE(Shp_scale_access::center(view().shp_scale())->IsEqual(gp_Pnt(0.0, 0.0, 0.0), 1e-6)); + + ASSERT_TRUE(Shp_scale_access::apply_distance(view().shp_scale(), 20.0).is_ok()); + EXPECT_NEAR(Shp_scale_access::scale_factor(view().shp_scale()), 1.0, 1e-9); + + ASSERT_TRUE(Shp_scale_access::apply_distance(view().shp_scale(), 40.0).is_ok()); + const double factor_before = Shp_scale_access::scale_factor(view().shp_scale()); + EXPECT_NEAR(factor_before, 2.0, 1e-9); + + GUI_access::set_transform_space(gui(), Transform_space::World); + ASSERT_TRUE(Shp_scale_access::center(view().shp_scale()).has_value()); + EXPECT_TRUE(Shp_scale_access::center(view().shp_scale())->IsEqual(gp_Pnt(5.0, 5.0, 5.0), 1e-6)); + EXPECT_NEAR(Shp_scale_access::scale_factor(view().shp_scale()), factor_before, 1e-9); + EXPECT_LT(Shp_scale_access::initial_distance(view().shp_scale()), 1e-9); + + const double dist_after = gp_Pnt(5.0, 5.0, 5.0).Distance(gp_Pnt(40.0, 0.0, 0.0)); + ASSERT_TRUE(Shp_scale_access::apply_distance(view().shp_scale(), dist_after).is_ok()); + EXPECT_NEAR(Shp_scale_access::scale_factor(view().shp_scale()), factor_before, 1e-6); + + gui().set_mode(Mode::Normal); +} + +TEST_F(Shp_test, Rotate_space_change_mid_drag_keeps_angle) +{ + gui().set_mode(Mode::Normal); + gui().set_hide_all_shapes(false); + const Transform_space saved_space = gui().get_transform_space(); + struct Restore_space + { + GUI& gui; + Transform_space saved; + ~Restore_space() { GUI_access::set_transform_space(gui, saved); } + } restore{gui(), saved_space}; + GUI_access::set_transform_space(gui(), Transform_space::Local); + + view().add_box(0, 0, 0, 10, 10, 10); + Shp_ptr shp = view().get_shapes().back(); + ASSERT_FALSE(shp.IsNull()); + view().set_shape_frame(shp, gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0), gp_Dir(1.0, 0.0, 0.0))); + select_shapes(view(), {shp}); + + gui().set_mode(Mode::Rotate); + ASSERT_TRUE(view().shp_rotate().has_operation_shps()); + view().shp_rotate().set_rotation_axis(Rotation_axis::Z_axis); + ASSERT_TRUE(Shp_rotate_access::ensure_start(view().shp_rotate()).is_ok()); + ASSERT_TRUE(Shp_rotate_access::center(view().shp_rotate())->IsEqual(gp_Pnt(0.0, 0.0, 0.0), 1e-6)); + + const gp_Dir axis_z(0.0, 0.0, 1.0); + const gp_Pln local_pln(gp_Pnt(0.0, 0.0, 0.0), axis_z); + ASSERT_TRUE(Shp_rotate_access::apply_world(view().shp_rotate(), gp_Pnt(20.0, 0.0, 0.0), axis_z, local_pln).is_ok()); + ASSERT_TRUE(Shp_rotate_access::apply_world(view().shp_rotate(), gp_Pnt(0.0, 20.0, 0.0), axis_z, local_pln).is_ok()); + const double angle_before = Shp_rotate_access::angle(view().shp_rotate()); + EXPECT_NEAR(angle_before, std::numbers::pi / 2.0, 1e-6); + ASSERT_TRUE(Shp_rotate_access::initial_mouse_pos(view().shp_rotate()).has_value()); + + GUI_access::set_transform_space(gui(), Transform_space::World); + ASSERT_TRUE(Shp_rotate_access::center(view().shp_rotate()).has_value()); + EXPECT_TRUE(Shp_rotate_access::center(view().shp_rotate())->IsEqual(gp_Pnt(5.0, 5.0, 5.0), 1e-6)); + EXPECT_NEAR(Shp_rotate_access::angle(view().shp_rotate()), angle_before, 1e-9); + EXPECT_FALSE(Shp_rotate_access::initial_mouse_pos(view().shp_rotate()).has_value()); + + const gp_Pln world_pln(gp_Pnt(5.0, 5.0, 5.0), axis_z); + ASSERT_TRUE(Shp_rotate_access::apply_world(view().shp_rotate(), gp_Pnt(0.0, 20.0, 0.0), axis_z, world_pln).is_ok()); + EXPECT_NEAR(Shp_rotate_access::angle(view().shp_rotate()), angle_before, 1e-6); + + gui().set_mode(Mode::Normal); +} diff --git a/tests/skt_test_fixture.cpp b/tests/skt_test_fixture.cpp index 05da379..9069551 100644 --- a/tests/skt_test_fixture.cpp +++ b/tests/skt_test_fixture.cpp @@ -40,6 +40,13 @@ void GUI_access::sketch_left_click(GUI& gui, const ScreenCoords& screen_coords) void GUI_access::mirror_selected_edges(GUI& gui) { gui.mirror_selected_sketch_edges(); } +void GUI_access::set_transform_space(GUI& gui, Transform_space space) +{ + gui.m_transform_space = space; + if (gui.m_view) + gui.m_view->on_transform_space_changed(); +} + void Sketch_access::add_edge_(Sketch& sketch, const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b) { sketch.add_edge_(pt_a, pt_b); } void Sketch_access::add_edge_(Sketch& sketch, const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, Sketch_op_recorder& rec) diff --git a/tests/skt_test_fixture.h b/tests/skt_test_fixture.h index babd72d..fd27d47 100644 --- a/tests/skt_test_fixture.h +++ b/tests/skt_test_fixture.h @@ -29,6 +29,7 @@ class GUI_access static std::string get_message(const GUI& gui); static void sketch_left_click(GUI& gui, const ScreenCoords& screen_coords); static void mirror_selected_edges(GUI& gui); + static void set_transform_space(GUI& gui, Transform_space space); }; class Sketch_access