diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c6f04..8e18795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Shape copy / paste** (Ctrl+C / Ctrl+V): in-app clipboard for selected solids, or a Shape List group subtree when the selection matches that group's descendant solids. Paste deep-copies under the current group (new ids, uniquified names, same pose) with one undo step. Survives **New** so you can copy, start a blank project, and paste. Remappable as **`edit.copy`** / **`edit.paste`**. Does not use the OS clipboard; sketch geometry is not copied. -- **Align cylinders** (J): pick two cylindrical faces (first moves, second is fixed), coaxially align, then drag insert depth along the shared axis (or Tab for an exact depth). Options **Flip direction** reverses axis sense. Bakes like Move/Rotate (`Shape_geom_delta`); radius mismatch logs a warning but still places. Remappable as **`mode.cyl_align`**. +- **Align shafts** (J): pick two cylindrical faces (first moves, second is fixed), coaxially align, drag axial insert depth; with Options **Clock rotation** (default off), LMB or Shift+Tab rotates about the shared axis for tooth clocking (Enter during depth skips clocking). Tab edits depth or angle by phase. Options **Flip direction** reverses axis sense. Bakes like Move/Rotate (`Shape_geom_delta`); radius mismatch logs a warning but still places. Remappable as **`mode.cyl_align`**. - **STEP Import as**: the Import dialog replaces the **Union shapes** checkbox with an **Import as** combo (**Preserve hierarchy** default, **Flat solids**, **Union shapes**). Hierarchy keeps XCAF assembly groups and product/instance names; flat adds leaf solids at the document root; union fuses into one solid. diff --git a/agents/drafts/issues/active/gh-246-align-shafts-clock-rotation.md b/agents/drafts/issues/active/gh-246-align-shafts-clock-rotation.md new file mode 100644 index 0000000..5e555cc --- /dev/null +++ b/agents/drafts/issues/active/gh-246-align-shafts-clock-rotation.md @@ -0,0 +1,29 @@ +--- +github_issue: 246 +github_pr: 247 +status: open +--- + +# Align shafts: clock rotation for tooth clocking + +**Suggested labels:** `enhancement` + +## Title (GitHub) + +Align shafts: clock rotation for tooth clocking + +## Body (GitHub) + +Filed: https://github.com/trailcode/EzyCad/issues/246 + +### Summary + +Extend Align shafts (formerly Align cylinders) with optional Clock rotation after coaxial depth; rename user-facing tool. + +### Follow-ups + +- Deterministic Clocking pick (planar tooth flanks) — revisited later. + +## Related + +- PR: https://github.com/trailcode/EzyCad/pull/247 diff --git a/agents/drafts/prs/active/gh-247-align-shafts-clock-rotation.md b/agents/drafts/prs/active/gh-247-align-shafts-clock-rotation.md new file mode 100644 index 0000000..fc77690 --- /dev/null +++ b/agents/drafts/prs/active/gh-247-align-shafts-clock-rotation.md @@ -0,0 +1,27 @@ +--- +github_issue: 246 +github_pr: 247 +status: open +--- + +# PR - Align shafts: clock rotation and rename + +## Title + +Align shafts: clock rotation and rename + +## Summary + +- Rename Align cylinders -> Align shafts (user-facing; `mode.cyl_align` unchanged). +- Options Clock rotation (default off) for roll about shared axis after depth. +- Docs + CHANGELOG. + +## Related + +- Issue: https://github.com/trailcode/EzyCad/issues/246 +- PR: https://github.com/trailcode/EzyCad/pull/247 +- Branch: `Trailcode/allign_shafts` + +## Test Plan + +- [ ] Build Release; exercise Clock rotation off/on, Flip, Tab/Shift+Tab, rename in UI/docs. diff --git a/agents/plans/assembly-inspection-mode.md b/agents/plans/assembly-inspection-mode.md index 3b4c611..8090932 100644 --- a/agents/plans/assembly-inspection-mode.md +++ b/agents/plans/assembly-inspection-mode.md @@ -53,7 +53,7 @@ flowchart TB Esc ladder (see [docs/usage.md](../../docs/usage.md)): sketch tool → sketch inspection → Normal. -**Not first-class yet:** Parts, instances, mates, inherited parent transforms. Shape List **groups** are organizational (STEP XCAF hierarchy on import). Move / Rotate / Scale / Align cylinders **bake** into leaf BREP — one-shot geometry edits, not assembly placement. Toolbar icons named `Assembly_*.png` are FreeCAD-style assets for those shape tools, not an assembly workbench. +**Not first-class yet:** Parts, instances, mates, inherited parent transforms. Shape List **groups** are organizational (STEP XCAF hierarchy on import). Move / Rotate / Scale / Align shafts **bake** into leaf BREP — one-shot geometry edits, not assembly placement. Toolbar icons named `Assembly_*.png` are FreeCAD-style assets for those shape tools, not an assembly workbench. Planned model: [shape-list-hierarchy-phase3.md](shape-list-hierarchy-phase3.md) (#214) — relative placement, typed nodes (Body, Group, Part, …), non-baking transforms. diff --git a/docs/usage.md b/docs/usage.md index b46b7c7..37ceff2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -233,7 +233,7 @@ The typical modeling workflow in EzyCad follows these steps: 3. **Create a Sketch from a Planar Face**: Once you have 3D shapes, use [Create sketch from planar face](usage-sketch.md#create-sketch-from-planar-face-tool) to pick a flat face on a solid. EzyCad extracts the face boundary into a new sketch aligned with that face (Origin at the face bounding-box center). Edit the sketch, then extrude again to add or cut features on the existing model. -4. **Modify 3D Shapes**: Use [3D Modeling tools](#3d-modeling) to transform shapes ([move](#shape-move-tool-g), [rotate](#shape-rotate-tool-r), [scale](#shape-scale-tool-s), [align cylinders](#align-cylinders-tool-j)) or create patterns ([polar duplicate](#shape-polar-duplicate-tool)). +4. **Modify 3D Shapes**: Use [3D Modeling tools](#3d-modeling) to transform shapes ([move](#shape-move-tool-g), [rotate](#shape-rotate-tool-r), [scale](#shape-scale-tool-s), [align shafts](#align-shafts-tool-j)) or create patterns ([polar duplicate](#shape-polar-duplicate-tool)). 5. **Apply Feature Operations**: Use [boolean operations](#boolean-operations) (cut, fuse, common) or edge-based feature operations (chamfer with C, fillet with F) to refine your 3D model. @@ -367,7 +367,7 @@ More detail: [Sketch snapping](usage-sketch.md#sketch-snapping) in the sketch gu - ![Shape Move Tool](res/icons/Assembly_AxialMove.png) [Move shapes (G)](#shape-move-tool-g) - ![Shape Rotate Tool](res/icons/Draft_Rotate.png) [Rotate objects (R)](#shape-rotate-tool-r) - ![Shape Scale Tool](res/icons/Part_Scale.png) [Scale elements (S)](#shape-scale-tool) - - ![Align Cylinders Tool](res/icons/Assembly_Move.png) [Align cylinders (J)](#align-cylinders-tool-j) + - ![Align Shafts Tool](res/icons/Assembly_Move.png) [Align shafts (J)](#align-shafts-tool-j) - ![Polar Duplicate Tool](res/icons/Draft_PolarArray.png) [Polar duplicate](#shape-polar-duplicate-tool) - ![Cross-section Tool](res/icons/Curves_ExtractSubshape.png) [Preview a local-plane cross-section](#shape-cross-section-tool) @@ -477,21 +477,22 @@ The shape scale tool allows you to uniformly scale selected shapes around a comp - Scale works best when the view direction makes the movement relative to the center easy to see (avoid looking exactly edge-on at the objects). - Because the operation is undoable, you can experiment with different scale amounts and step back with Ctrl+Z if needed. -#### Align Cylinders Tool (J) +#### Align Shafts Tool (J) -![Align Cylinders Tool](res/icons/Assembly_Move.png) +![Align Shafts Tool](res/icons/Assembly_Move.png) -Place a shaft into a hole (or the reverse) by aligning two cylindrical faces, then sliding along the shared axis for insert depth. This is a one-shot transform that bakes into the solid geometry (same as Move / Rotate), not a persistent assembly mate. +Place a shaft into a hole (or the reverse) by aligning two cylindrical faces, sliding along the shared axis for insert depth, and optionally clocking rotation about that axis (splines, keyways). This is a one-shot transform that bakes into the solid geometry (same as Move / Rotate), not a persistent assembly mate. **Features:** -| | | -| ----------------------: | ---------------------------------------------------------------------------------------------------- | -| **Face picks** | Click a cylindrical face on the shape to move, then a cylindrical face on the fixed shape. | -| **Coaxial align** | The moving shape snaps so the two cylinder axes coincide. | -| **Axial depth drag** | After align, drag to set how far the moving shape slides along the shared axis. | -| **Flip direction** | Options **Flip direction** reverses which way the moving axis points along the fixed axis. | -| **Radius mismatch** | Placement is still allowed when radii differ (clearance / press fits); a log warning is recorded. | +| | | +| -------------------: | -------------------------------------------------------------------------------------------------- | +| **Face picks** | Click a cylindrical face on the shape to move, then a cylindrical face on the fixed shape. | +| **Coaxial align** | The moving shape snaps so the two cylinder axes coincide. | +| **Axial depth drag** | After align, drag to set how far the moving shape slides along the shared axis. | +| **Clock rotation** | Options **Clock rotation** (default off): after depth, rotate about the shared axis to mesh teeth. | +| **Flip direction** | Options **Flip direction** reverses which way the moving axis points along the fixed axis. | +| **Radius mismatch** | Placement is still allowed when radii differ (clearance / press fits); a log warning is recorded. | **How to Use:** @@ -499,12 +500,14 @@ Place a shaft into a hole (or the reverse) by aligning two cylindrical faces, th 2. **Pick moving face:** Click a cylindrical face on the body that should move (for example the outer face of a rod). 3. **Pick fixed face:** Click a cylindrical face on a different body (for example the inner face of a hole). 4. **Set depth:** Drag along the axis. Optionally press Tab to type an exact depth. -5. **Finalize or cancel:** left mouse button or Enter to bake; Esc to cancel. +5. **Clock rotation (optional):** Enable Options **Clock rotation**. Then left mouse button or Shift+Tab locks depth and enters clocking; drag about the axis (or type an angle). With **Clock rotation** off, LMB finalizes after depth. +6. **Finalize or cancel:** left mouse button or Enter to bake; Esc to cancel. Enter during the depth phase finalizes immediately without clocking. **Tips:** - The first face you pick is the body that moves. To move the hole body onto the shaft, pick the hole face first. - Use **Flip direction** in Options if the shaft points the wrong way after align. +- Pick cylindrical faces (root / major diameter or smooth lands), not spline tooth flanks. - This tool only places solids; use [Cut](#boolean-operations) afterward if you need a boolean. ## Feature Operations @@ -737,38 +740,38 @@ Mode, file, and edit chords in the **General Operations** and **Modeling Shortcu | Shift+Tab | Angle input (for line edges with angle constraint) | | Shift+D, Delete, or Backspace | Remove selected elements | | Ctrl+C | Copy selected shapes (or current group subtree) to the in-app clipboard | -| Ctrl+V | Paste clipboard shapes under the current group (same pose; undoable) | +| Ctrl+V | Paste clipboard shapes under the current group (same pose; undoable) | ### Modeling Shortcuts -| | | -| ------------------------------------------------:| ------------------------------ | -| G | Move mode | -| R | Rotate mode | -| S | Scale mode | -| J | Align cylinders mode | -| E | Extrude mode | -| C | Chamfer mode | -| F | Fillet mode | -| D | Dimension tool (sketch) | -| I | Sketch inspection | -| P | Sketch from planar face | -| Shift+A | Operation axis | -| N | Add node | -| L | Add line edge | -| Shift+L | Add multi-line edge | -| A | Add arc | -| Q | Add square | -| B | Add rectangle (two points) | -| Shift+B | Add rectangle (center) | -| O | Add circle | -| Shift+O | Add circle (three points) | -| U | Add slot | -| Shift+P | Polar duplicate | -| Shift+X | Cross-section | -| Ctrl+Shift+C | Shape cut | -| Ctrl+Shift+F | Shape fuse | -| Ctrl+Shift+M | Shape common | +| | | +| --------------------------------------------: | -------------------------- | +| G | Move mode | +| R | Rotate mode | +| S | Scale mode | +| J | Align shafts mode | +| E | Extrude mode | +| C | Chamfer mode | +| F | Fillet mode | +| D | Dimension tool (sketch) | +| I | Sketch inspection | +| P | Sketch from planar face | +| Shift+A | Operation axis | +| N | Add node | +| L | Add line edge | +| Shift+L | Add multi-line edge | +| A | Add arc | +| Q | Add square | +| B | Add rectangle (two points) | +| Shift+B | Add rectangle (center) | +| O | Add circle | +| Shift+O | Add circle (three points) | +| U | Add slot | +| Shift+P | Polar duplicate | +| Shift+X | Cross-section | +| Ctrl+Shift+C | Shape cut | +| Ctrl+Shift+F | Shape fuse | +| Ctrl+Shift+M | Shape common | ### View navigation @@ -901,7 +904,7 @@ Contributors should follow **[ezycad_code_style.md](ezycad_code_style.md)** for - ![Assembly_AxialMove](res/icons/Assembly_AxialMove.png) - Shape move (G) - ![Draft_Rotate](res/icons/Draft_Rotate.png) - Shape rotate (R) - ![Part_Scale](res/icons/Part_Scale.png) - Shape scale (S) -- ![Assembly_Move](res/icons/Assembly_Move.png) - Align cylinders (J) +- ![Assembly_Move](res/icons/Assembly_Move.png) - Align shafts (J) ### Sketch Tools - ![Workbench_Sketcher_none](res/icons/Workbench_Sketcher_none.png) - Sketch inspection mode diff --git a/src/doc/gui.md b/src/doc/gui.md index 8cb653b..3f213a5 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -46,12 +46,12 @@ Typical responsibilities: When adding a `Mode` to [`mode.h`](../mode.h) (`EZY_MODE_LIST`), a toolbar button, or a one-shot `Command`, update remappable hotkeys in the **same change**. Skip only for modes that must stay toolbar-only (document that choice). -| Step | Touch | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `Gui_action` + default chord in [`gui_hotkeys.h`](../gui_hotkeys.h) / [`gui_hotkeys.cpp`](../gui_hotkeys.cpp) (`c_actions`; enum order) | -| 2 | `GUI::dispatch_hotkey_action_` in [`gui_mode.cpp`](../gui_mode.cpp) | -| 3 | `sync_toolbar_hotkey_tooltips_` in [`gui.cpp`](../gui.cpp) | -| 4 | `gui.hotkeys` entry in [`res/ezycad_settings.json`](../../res/ezycad_settings.json) | +| Step | Touch | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `Gui_action` + default chord in [`gui_hotkeys.h`](../gui_hotkeys.h) / [`gui_hotkeys.cpp`](../gui_hotkeys.cpp) (`c_actions`; enum order) | +| 2 | `GUI::dispatch_hotkey_action_` in [`gui_mode.cpp`](../gui_mode.cpp) | +| 3 | `sync_toolbar_hotkey_tooltips_` in [`gui.cpp`](../gui.cpp) | +| 4 | `gui.hotkeys` entry in [`res/ezycad_settings.json`](../../res/ezycad_settings.json) | | 5 | User docs: [usage.md](../../docs/usage.md#hotkeys) Modeling table; sketch tools also [usage-sketch.md](../../docs/usage-sketch.md#hotkeys); [usage-settings.md](../../docs/usage-settings.md) if labels change; `CHANGELOG.md` | Pick a default that does not collide with existing `c_actions` chords or fixed keys (Esc, Enter, Tab, digits, unmodified X/Y/Z axis toggles — reserved via `is_reserved_chord`). Also wire parent-mode / Options / doc URL maps as usual for new modes. @@ -145,24 +145,24 @@ Overlay popups (`FloatEdit`, `AngleEdit`, `MessageStatus`, modals) keep `NoSaved Remappable chords live in `Gui_hotkeys` (`gui_hotkeys.h` / `.cpp`), owned by `GUI::m_hotkeys`. Stable action ids (e.g. `mode.move`, `mode.add_edge`, `cmd.shape_cut`, `edit.undo`) map to `Key_chord { key, mods }`. Persistence: `gui.hotkeys` in `ezycad_settings.json` as human-readable strings (`"G"`, `"Shift+L"`, `"Ctrl+Shift+C"`); missing keys merge to built-in defaults. On load, `merge_from_json` drops reserved/invalid chords, then resolves duplicate chords: later actions reset to factory; if that factory chord is still held by an earlier remap, that earlier row is also restored to factory (defaults are unique, so `action_for` never keeps a silent collision). Settings **Keyboard shortcuts** captures the next `GLFW_PRESS` (Esc cancels; `set_chord` rejects conflicts and **reserved** fixed chords via `is_reserved_chord`). Per-row **Reset** calls `reset_action` (factory chord via `set_chord`, so duplicates are rejected with the same inline conflict message). Capture is cleared when Settings closes. Toolbar tooltips for remappable modes and boolean commands are rebuilt via `sync_toolbar_hotkey_tooltips_()`. -| Input | Condition | Handler | -| --------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------- | -| `+` / `-` / numpad +/- | No Ctrl/Alt | `Occt_view::zoom_view_wheel_notches` | -| Shift + 4/6 / arrows / numpad 4/6 | No Ctrl/Alt | `Occt_view::roll_view_z_deg` | -| Numpad 5 | No modifiers | `Occt_view::snap_view_to_nearest_standard_axis` | -| Numpad 2/4/6/8 | No modifiers | `Occt_view::orbit_view_screen_step_deg` | -| Hotkey capture active | Settings | `try_capture_hotkey_press_` (assign / Esc cancel / conflict message) | -| `1`-`9` / numpad `1`-`9` | `Mode::Normal` only | `set_shp_selection_mode` (TopAbs enum index); fixed | -| Esc | | cancel capture if listening; else `cancel_underlay_calib_`, `Occt_view::cancel`, hide dist/angle edit | -| Tab | not Move/Rotate | `Occt_view::dimension_input`; Move/Rotate: `break` into mode handlers | -| Shift+Tab | not Move/Rotate | `Occt_view::angle_input`; fixed | -| Enter | not Rotate | hide edits, `Occt_view::on_enter`; Rotate: `break` into `on_key_rotate_mode_` (finalize) | -| Delete / Backspace | | `Occt_view::delete_selected` (fixed aliases; remapping `edit.delete` does not remove these) | -| Ctrl+Shift+Z | | `Occt_view::redo` (fixed second redo; remappable `edit.redo` defaults to Ctrl+Y) | -| Remappable chord | `m_hotkeys` hit | `dispatch_hotkey_action_` (`Gui_action`: sketch/shape modes, booleans, delete, copy/paste, file, undo/redo) | -| Move-mode keys | `Mode::Move` | `on_key_move_mode_` (axis constraints X/Y/Z); hardcoded | -| Rotate-mode keys | `Mode::Rotate` | `on_key_rotate_mode_` (axis pick, Tab angle); hardcoded | -| Cyl-align keys | `Mode::Shape_cyl_align` | `on_key_cyl_align_mode_` (Tab depth, Enter finalize); hardcoded | +| Input | Condition | Handler | +| --------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------- | +| `+` / `-` / numpad +/- | No Ctrl/Alt | `Occt_view::zoom_view_wheel_notches` | +| Shift + 4/6 / arrows / numpad 4/6 | No Ctrl/Alt | `Occt_view::roll_view_z_deg` | +| Numpad 5 | No modifiers | `Occt_view::snap_view_to_nearest_standard_axis` | +| Numpad 2/4/6/8 | No modifiers | `Occt_view::orbit_view_screen_step_deg` | +| Hotkey capture active | Settings | `try_capture_hotkey_press_` (assign / Esc cancel / conflict message) | +| `1`-`9` / numpad `1`-`9` | `Mode::Normal` only | `set_shp_selection_mode` (TopAbs enum index); fixed | +| Esc | | cancel capture if listening; else `cancel_underlay_calib_`, `Occt_view::cancel`, hide dist/angle edit | +| Tab | not Move/Rotate | `Occt_view::dimension_input`; Move/Rotate: `break` into mode handlers | +| Shift+Tab | not Move/Rotate | `Occt_view::angle_input`; fixed | +| Enter | not Rotate | hide edits, `Occt_view::on_enter`; Rotate: `break` into `on_key_rotate_mode_` (finalize) | +| Delete / Backspace | | `Occt_view::delete_selected` (fixed aliases; remapping `edit.delete` does not remove these) | +| Ctrl+Shift+Z | | `Occt_view::redo` (fixed second redo; remappable `edit.redo` defaults to Ctrl+Y) | +| Remappable chord | `m_hotkeys` hit | `dispatch_hotkey_action_` (`Gui_action`: sketch/shape modes, booleans, delete, copy/paste, file, undo/redo) | +| Move-mode keys | `Mode::Move` | `on_key_move_mode_` (axis constraints X/Y/Z); hardcoded | +| Rotate-mode keys | `Mode::Rotate` | `on_key_rotate_mode_` (axis pick, Tab angle); hardcoded | +| Align-shafts keys | `Mode::Shape_cyl_align` | `on_key_cyl_align_mode_` (Tab depth, Shift+Tab clock/angle, Enter finalize); hardcoded | Default remappable chords include G/R/S/J/E/C/F/D shape tools; sketch tools N/L/A/Q/B/O/U/I/P and Shift variants; Shift+P polar, Shift+X cross-section; Ctrl+Shift+C/F/M booleans; Shift+D delete; Ctrl+C / Ctrl+V copy/paste (in-app shape clipboard); Ctrl+N/O/S; Ctrl+Z / Ctrl+Y. Unmodified X/Y/Z are reserved for Move/Rotate axis toggles (`is_reserved_chord`); Shift+X remains free for cross-section. Remappable keys must pass `is_bindable_key` (letters, digits, Space, and named keys that round-trip in settings JSON); punctuation such as `,` / `.` and numpad keys are rejected. Settings **Keyboard shortcuts** has a `?` to `doc_urls::k_hotkeys` ([usage-settings.md#keyboard-shortcuts](../../docs/usage-settings.md#keyboard-shortcuts)). @@ -177,7 +177,7 @@ See also [`src/doc/sketch.md`](sketch.md) and [`src/doc/shape.md`](shape.md) for | `Move` | `shp_move().move_selected` | | `Rotate` | `shp_rotate().rotate_selected` | | `Scale` | `shp_scale().scale_selected` | -| `Shape_cyl_align` | `shp_cyl_align().drag_depth` | +| `Shape_cyl_align` | `drag_depth` / `drag_twist` | | `Shape_polar_duplicate` | `shp_polar_dup().move_point` | | Sketch tool modes (line, arc, rect, dim, axis, ...) | `curr_sketch().sketch_pt_move` | | `Sketch_face_extrude` | `sketch_face_extrude(..., true)` | @@ -186,12 +186,12 @@ Always calls `m_view->on_mouse_move(screen_coords)` first. ### Mouse buttons (`GUI::on_mouse_button` + `on_left_click_`) -| Event | Handler | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| LMB (underlay calib active) | `try_underlay_calib_click_` (early return) | -| LMB | `m_view->on_mouse_button` then `on_left_click_` (skipped when extrude LMB already advanced/finalized the session) | -| RMB press | `finalize_elm` for line / multi-line sketch modes | -| LMB in `on_left_click_` | Mode-specific: transform finalize, cyl-align face pick / finalize, sketch `add_sketch_pt`, fillet/chamfer click, polar dup `add_point`, extrude pick | +| Event | Handler | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| LMB (underlay calib active) | `try_underlay_calib_click_` (early return) | +| LMB | `m_view->on_mouse_button` then `on_left_click_` (skipped when extrude LMB already advanced/finalized the session) | +| RMB press | `finalize_elm` for line / multi-line sketch modes | +| LMB in `on_left_click_` | Mode-specific: transform finalize, cyl-align face pick / depth->clock / finalize, sketch `add_sketch_pt`, fillet/chamfer click, polar dup `add_point`, extrude pick | Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse position. @@ -210,7 +210,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | -------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `Normal` | `options_normal_mode_` (selection filter, orthographic) | | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | -| `Shape_cyl_align` | Flip direction; short pick/drag help | +| `Shape_cyl_align` | Flip direction, Clock rotation; pick / depth / clock help | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | | `Shape_cross_section` | local XY/XZ/YZ, invert normal, hide back side, show section outline, bbox-ranged offset, Clip, Cross section sketch | diff --git a/src/doc/shape.md b/src/doc/shape.md index 4d6ff36..a565a06 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -143,42 +143,42 @@ Protected helpers used by all operation classes: ## Operation modules -| File | Type | Behavior | -| --------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `shp_create.h` | `namespace shp_create` | Pure functions: `create_box`, `create_pyramid`, `create_sphere`, `create_cylinder`, `create_cone`, `create_torus` -> `TopoDS_Shape`. Called from `Occt_view::add_*` helpers. | +| File | Type | Behavior | +| --------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `shp_create.h` | `namespace shp_create` | Pure functions: `create_box`, `create_pyramid`, `create_sphere`, `create_cylinder`, `create_cone`, `create_torus` -> `TopoDS_Shape`. Called from `Occt_view::add_*` helpers. | | `shp_extrude.h` | `Shp_extrude` | Extrude of `Sketch_face_shp`. Optional Options **Twist**: two-phase (lock height, then twist angle about face centroid). Live preview: shaded `MakePrism` when twist ~0; `BRepOffsetAPI_ThruSections` (ruled, `CheckCompatibility(false)`, intermediates every ~45 deg) when twisted; face holes lofted and cut so the bore survives; both-sides + twist uses mid-plane unrotated and ends at +/- half angle. Dense faces can use lite face-copy preview (`gui.extrude_fast_preview`): AIS translate, plus rotate about centroid when Twist is on. `finalize` bakes solid + `try_make_solid`; tmp length dimension; optional both-sides. | -| `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_cyl_align.h` | `Shp_cyl_align` | Pick two cylindrical faces (first moves); coaxial `cyl_align_trsf`; drag axial depth; Options flip; bake like Move. | -| `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. | -| `shp_cross_section.h` | `Shp_cross_section` | Shared cutting-plane preview: immediate yellow plane AIS; cyan section wires via async job (desktop `std::async` + per-solid pool; WASM one-solid-per-`poll` chunks); running+latest-pending cancel/coalesce; optional hide-back AIS clip; **Show section outline** (default off) toggles cyan wires without recompute; **Clip** half-space-commons and replaces inputs (fully discarded solids are removed only); **Cross section sketch** imports cached section line/circle edges into a new sketch. | -| `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | +| `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_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_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. | +| `shp_cross_section.h` | `Shp_cross_section` | Shared cutting-plane preview: immediate yellow plane AIS; cyan section wires via async job (desktop `std::async` + per-solid pool; WASM one-solid-per-`poll` chunks); running+latest-pending cancel/coalesce; optional hide-back AIS clip; **Show section outline** (default off) toggles cyan wires without recompute; **Clip** half-space-commons and replaces inputs (fully discarded solids are removed only); **Cross section sketch** imports cached section line/circle edges into a new sketch. | +| `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | ## Input routing (from UI / `Occt_view`) `GUI` and `Occt_view` dispatch by `Mode` and toolbar actions: -| Mode / action | Mouse move (`GUI::on_mouse_pos`) | Left click | Tab / Enter | Esc (`Occt_view::cancel`) | -| ----------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------- | -| `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_cyl_align` | `shp_cyl_align().drag_depth` | Pick faces, then `finalize` while dragging | `show_depth_edit` / 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)` | -- | -- | -| `Mode::Shape_polar_duplicate` | `shp_polar_dup().move_point` | `shp_polar_dup().add_point` | -- | `shp_polar_dup().reset` on mode change | -| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter / selection / Options; Clip replaces solids | Preview cleared; Clip commits | -| Fuse / cut / common (toolbar) | -- | `selected_fuse` / `selected_cut` / `selected_common` (one-shot) | -- | -- | -| Primitives (menu / script) | -- | `Occt_view::add_box`, `add_sphere`, ... | -- | -- | -| Revolve (sketch Options) | -- | `Occt_view::revolve_selected` -> `add_shp_` | -- | -- | -| Polar duplicate commit | -- | Options **Dup** button -> `shp_polar_dup().dup()` | -- | -- | +| Mode / action | Mouse move (`GUI::on_mouse_pos`) | Left click | Tab / Enter | Esc (`Occt_view::cancel`) | +| ----------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------- | +| `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_cyl_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)` | -- | -- | +| `Mode::Shape_polar_duplicate` | `shp_polar_dup().move_point` | `shp_polar_dup().add_point` | -- | `shp_polar_dup().reset` on mode change | +| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter / selection / Options; Clip replaces solids | Preview cleared; Clip commits | +| Fuse / cut / common (toolbar) | -- | `selected_fuse` / `selected_cut` / `selected_common` (one-shot) | -- | -- | +| Primitives (menu / script) | -- | `Occt_view::add_box`, `add_sphere`, ... | -- | -- | +| Revolve (sketch Options) | -- | `Occt_view::revolve_selected` -> `add_shp_` | -- | -- | +| Polar duplicate commit | -- | Options **Dup** button -> `shp_polar_dup().dup()` | -- | -- | Pre-selection: entering Move / Rotate / Scale / cross-section snapshots selected solids in `Occt_view::on_mode` and restores AIS selection via `set_selected_shps` after selection-mode / faint redisplay. Transform tools are also seeded with `begin(enter_selection)` so multi-select operands do not depend on AIS alone (`ensure_operation_shps_()` still falls back to the AIS selection when the seed was empty). @@ -254,11 +254,11 @@ auto lines = shp_info::collect(shp->Shape(), &meta); ## Related code outside `src/shp_*` -| Location | Role | -| ----------------------------------- | -------------------------------------------------------------------------------- | -| `occt_view.h` / `gui_occt_view.cpp` | Shape list, `add_shp_`, primitives, I/O, operation member objects | -| `skt_ais.h` | `Sketch_face_shp` extrusion source | -| `skt_operations.cpp` | `Sketch::revolve_selected` -> `Shp_rslt` | -| `mode.h` | `Fillet_mode`, `Chamfer_mode`, tool modes | -| `gui.h` / `gui.cpp` | Toolbar, Shape List, fillet/chamfer mode, material UI | -| `utl_geom.h` | Plane projection, bbox center, `cylinder_from_face` / `cyl_align_trsf`, rotation helpers | +| Location | Role | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `occt_view.h` / `gui_occt_view.cpp` | Shape list, `add_shp_`, primitives, I/O, operation member objects | +| `skt_ais.h` | `Sketch_face_shp` extrusion source | +| `skt_operations.cpp` | `Sketch::revolve_selected` -> `Shp_rslt` | +| `mode.h` | `Fillet_mode`, `Chamfer_mode`, tool modes | +| `gui.h` / `gui.cpp` | Toolbar, Shape List, fillet/chamfer mode, material UI | +| `utl_geom.h` | Plane projection, bbox center, `cylinder_from_face` / `cyl_align_trsf` (coaxial + clock rotation), rotation helpers | diff --git a/src/gui.cpp b/src/gui.cpp index a699670..2c5ccf9 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -184,7 +184,7 @@ void GUI::initialize_toolbar_() {load_texture("res/icons/Assembly_AxialMove.png"), false, "Shape move", Mode::Move}, {load_texture("res/icons/Draft_Rotate.png"), false, "Shape rotate", Mode::Rotate}, {load_texture("res/icons/Part_Scale.png"), false, "Shape Scale", Mode::Scale}, - {load_texture("res/icons/Assembly_Move.png"), false, "Align cylinders", Mode::Shape_cyl_align}, + {load_texture("res/icons/Assembly_Move.png"), false, "Align shafts", Mode::Shape_cyl_align}, {load_texture("res/icons/Macro_FaceToSketch_48.png"), false, "Create a sketch from planar face", Mode::Sketch_from_planar_face}, {load_texture("res/icons/Sketcher_MirrorSketch.png"), false, "Operational axis", Mode::Sketch_operation_axis}, {load_texture("res/icons/Sketcher_CreatePoint.png"), false, "Add node", Mode::Sketch_add_node}, @@ -240,7 +240,7 @@ void GUI::sync_toolbar_hotkey_tooltips_() tip_mode(Mode::Move, "Shape move", Gui_action::Mode_move); tip_mode(Mode::Rotate, "Shape rotate", Gui_action::Mode_rotate); tip_mode(Mode::Scale, "Shape Scale", Gui_action::Mode_scale); - tip_mode(Mode::Shape_cyl_align, "Align cylinders", Gui_action::Mode_cyl_align); + tip_mode(Mode::Shape_cyl_align, "Align shafts", Gui_action::Mode_cyl_align); tip_mode(Mode::Sketch_dim_anno, "Length dimension", Gui_action::Mode_dimension); tip_mode(Mode::Sketch_face_extrude, "Extrude sketch face", Gui_action::Mode_extrude); tip_mode(Mode::Shape_chamfer, "Chamfer", Gui_action::Mode_chamfer); @@ -3849,7 +3849,12 @@ void GUI::on_mouse_pos(const ScreenCoords& screen_coords) break; case Mode::Shape_cyl_align: - if (Status s = m_view->shp_cyl_align().drag_depth(screen_coords); !s.is_ok()) + if (m_view->shp_cyl_align().is_twist_phase()) + { + if (Status s = m_view->shp_cyl_align().drag_twist(screen_coords); !s.is_ok()) + show_message(s.message()); + } + else if (Status s = m_view->shp_cyl_align().drag_depth(screen_coords); !s.is_ok()) show_message(s.message()); break; @@ -3891,7 +3896,7 @@ void GUI::on_left_click_(const ScreenCoords& screen_coords) case Mode::Scale: m_view->shp_scale().finalize(); break; case Mode::Shape_cyl_align: if (m_view->shp_cyl_align().is_dragging()) - m_view->shp_cyl_align().finalize(); + m_view->shp_cyl_align().on_left_click(); else if (Status s = m_view->shp_cyl_align().pick(screen_coords); !s.is_ok()) show_message(s.message()); break; diff --git a/src/gui.h b/src/gui.h index 26329c8..4bad3f6 100644 --- a/src/gui.h +++ b/src/gui.h @@ -488,7 +488,7 @@ class GUI void on_key_move_mode_(int key); void on_key_rotate_mode_(int key); - void on_key_cyl_align_mode_(int key); + void on_key_cyl_align_mode_(int key, int mods); void dispatch_hotkey_action_(Gui_action action); void sync_toolbar_hotkey_tooltips_(); bool try_capture_hotkey_press_(int key, int mods); diff --git a/src/gui_hotkeys.cpp b/src/gui_hotkeys.cpp index 30d3e58..3d5ac88 100644 --- a/src/gui_hotkeys.cpp +++ b/src/gui_hotkeys.cpp @@ -44,7 +44,7 @@ constexpr Action_meta c_actions[] = { {Gui_action::Mode_add_slot, "mode.add_slot", "Add slot", {GLFW_KEY_U, 0}}, {Gui_action::Mode_polar_duplicate, "mode.polar_duplicate", "Polar duplicate", {GLFW_KEY_P, GLFW_MOD_SHIFT}}, {Gui_action::Mode_cross_section, "mode.cross_section", "Cross-section", {GLFW_KEY_X, GLFW_MOD_SHIFT}}, - {Gui_action::Mode_cyl_align, "mode.cyl_align", "Align cylinders", {GLFW_KEY_J, 0}}, + {Gui_action::Mode_cyl_align, "mode.cyl_align", "Align shafts", {GLFW_KEY_J, 0}}, {Gui_action::Cmd_shape_cut, "cmd.shape_cut", "Shape cut", {GLFW_KEY_C, GLFW_MOD_CONTROL | GLFW_MOD_SHIFT}}, {Gui_action::Cmd_shape_fuse, "cmd.shape_fuse", "Shape fuse", {GLFW_KEY_F, GLFW_MOD_CONTROL | GLFW_MOD_SHIFT}}, {Gui_action::Cmd_shape_common, "cmd.shape_common", "Shape common", {GLFW_KEY_M, GLFW_MOD_CONTROL | GLFW_MOD_SHIFT}}, diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 5abf888..d77e8e9 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -46,7 +46,7 @@ std::string GUI::get_doc_url_for_mode(Mode mode) {Mode::Move, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-move-tool-g"}, {Mode::Rotate, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-rotate-tool-r"}, {Mode::Scale, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-scale-tool-s"}, - {Mode::Shape_cyl_align, "https://ezycad.readthedocs.io/en/latest/usage.html#align-cylinders-tool-j"}, + {Mode::Shape_cyl_align, "https://ezycad.readthedocs.io/en/latest/usage.html#align-shafts-tool-j"}, {Mode::Sketch_inspection_mode, "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#sketch-origin"}, {Mode::Sketch_from_planar_face, "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#create-sketch-from-planar-face-tool"}, {Mode::Sketch_face_extrude, "https://ezycad.readthedocs.io/en/latest/usage.html#extrude-sketch-face-tool-e"}, @@ -304,7 +304,7 @@ void GUI::on_key(int key, int scancode, int action, int mods) case GLFW_KEY_TAB: { - // Move / Rotate / Align cylinders handle Tab in their mode key handlers (distance / angle / depth). + // Move / Rotate / Align shafts handle Tab in their mode key handlers (distance / angle / depth / twist). const Mode mode = get_mode(); if (mode == Mode::Move || mode == Mode::Rotate || mode == Mode::Shape_cyl_align) break; @@ -318,7 +318,7 @@ void GUI::on_key(int key, int scancode, int action, int mods) } case GLFW_KEY_ENTER: - // Rotate / Align cylinders finalize on Enter in their mode key handlers. + // Rotate / Align shafts finalize on Enter in their mode key handlers. if (get_mode() == Mode::Rotate || get_mode() == Mode::Shape_cyl_align) break; hide_sketch_origin_set_edit(true); @@ -362,7 +362,7 @@ void GUI::on_key(int key, int scancode, int action, int mods) break; case Mode::Shape_cyl_align: - on_key_cyl_align_mode_(key); + on_key_cyl_align_mode_(key, mods); break; default: @@ -643,12 +643,17 @@ void GUI::options_shape_cyl_align_mode_() ImGui::TextWrapped( "Pick a cylindrical face on the shape to move, then a cylindrical face on the fixed shape. " - "Drag along the shared axis for insert depth. First pick moves; pick the hole first to move the hole onto the shaft."); + "Drag insert depth. With Clock rotation on, LMB or Shift+Tab rotates about the shared axis; Enter finalizes. " + "First pick moves; pick the hole first to move the hole onto the shaft."); Cyl_align_options& opts = m_view->shp_cyl_align().get_opts(); if (ImGui::Checkbox("Flip direction", &opts.flip_direction)) m_view->shp_cyl_align().apply_preview(); + bool clock_rotation = opts.clock_rotation; + if (ImGui::Checkbox("Clock rotation", &clock_rotation)) + m_view->shp_cyl_align().set_clock_rotation_enabled(clock_rotation); + ImGui::Separator(); options_orthographic_projection_(); } @@ -1416,7 +1421,7 @@ float GUI::options_sketch_label_col_w_() const return sketch_label_col_w; } -void GUI::on_key_cyl_align_mode_(int key) +void GUI::on_key_cyl_align_mode_(int key, int mods) { const ScreenCoords screen_coords = cursor_screen_coords(); @@ -1428,12 +1433,18 @@ void GUI::on_key_cyl_align_mode_(int key) case GLFW_KEY_ENTER: case GLFW_KEY_KP_ENTER: + // Enter finalizes immediately (skips clock rotation if still in depth phase). if (m_view->shp_cyl_align().is_dragging()) m_view->shp_cyl_align().finalize(); break; case GLFW_KEY_TAB: - m_view->shp_cyl_align().show_depth_edit(screen_coords); + if ((mods & GLFW_MOD_SHIFT) != 0) + m_view->shp_cyl_align().begin_twist_input(screen_coords); + else if (m_view->shp_cyl_align().is_twist_phase()) + m_view->shp_cyl_align().show_twist_edit(screen_coords); + else + m_view->shp_cyl_align().show_depth_edit(screen_coords); break; default: diff --git a/src/shp_cyl_align.cpp b/src/shp_cyl_align.cpp index a5a1ac9..f627594 100644 --- a/src/shp_cyl_align.cpp +++ b/src/shp_cyl_align.cpp @@ -1,6 +1,8 @@ #include "shp_cyl_align.h" #include +#include +#include #include "gui.h" #include "gui_occt_view.h" @@ -16,14 +18,30 @@ Shp_cyl_align::Shp_cyl_align(Occt_view& view) void Shp_cyl_align::begin() { - clear_all(m_phase, m_opts, m_axial_offset, m_moving_radius, m_fixed_radius, m_depth_override, m_moving_shp, m_fixed_shp, - m_moving_axis, m_fixed_axis, m_drag_pln, m_shps); + // Keep m_opts (Flip / Clock rotation) across sessions, like Extrude Twist. + clear_all(m_phase, m_axial_offset, m_moving_radius, m_fixed_radius, m_depth_override, m_moving_shp, m_fixed_shp, + m_moving_axis, m_fixed_axis, m_drag_pln, m_twist_angle, m_twist_override, m_twist_angle0, m_twist_pln, m_shps); } -bool Shp_cyl_align::is_dragging() const { return m_phase == Phase::Drag_depth && !m_shps.empty(); } +bool Shp_cyl_align::is_dragging() const +{ + return (m_phase == Phase::Drag_depth || m_phase == Phase::Drag_twist) && !m_shps.empty(); +} + +bool Shp_cyl_align::is_twist_phase() const { return m_phase == Phase::Drag_twist && !m_shps.empty(); } Cyl_align_options& Shp_cyl_align::get_opts() { return m_opts; } +void Shp_cyl_align::set_clock_rotation_enabled(bool enabled) +{ + if (m_opts.clock_rotation == enabled) + return; + + m_opts.clock_rotation = enabled; + if (!enabled && m_phase == Phase::Drag_twist) + exit_twist_to_depth_(); +} + void Shp_cyl_align::apply_preview() { if (is_dragging()) @@ -32,7 +50,7 @@ void Shp_cyl_align::apply_preview() Status Shp_cyl_align::pick(const ScreenCoords& screen_coords) { - if (m_phase == Phase::Drag_depth) + if (is_dragging()) return Status::ok(); Shp_ptr shp = Shp_ptr::DownCast(get_shape_(screen_coords)); @@ -66,11 +84,15 @@ Status Shp_cyl_align::pick(const ScreenCoords& screen_coords) m_fixed_radius = cyl->radius; if (std::abs(m_moving_radius - m_fixed_radius) > Precision::Confusion()) - gui().log_message("Align cylinders: radii differ (moving " + std::to_string(m_moving_radius) + ", fixed " + + gui().log_message("Align shafts: radii differ (moving " + std::to_string(m_moving_radius) + ", fixed " + std::to_string(m_fixed_radius) + "); placement still allowed."); enter_drag_(); - gui().show_message("Drag along the axis for insert depth, then LMB or Enter to confirm."); + if (m_opts.clock_rotation) + gui().show_message("Drag along the axis for insert depth, then LMB or Shift+Tab for clock rotation (or Enter to finish)."); + else + gui().show_message("Drag along the axis for insert depth, then LMB or Enter to confirm."); + return Status::ok(); } @@ -79,20 +101,55 @@ void Shp_cyl_align::enter_drag_() EZY_ASSERT(!m_moving_shp.IsNull()); EZY_ASSERT(m_moving_axis.has_value() && m_fixed_axis.has_value()); - clear_all(m_axial_offset, m_depth_override, m_drag_pln); + clear_all(m_axial_offset, m_depth_override, m_drag_pln, m_twist_angle, m_twist_override, m_twist_angle0, m_twist_pln); set_operation_shps_({m_moving_shp}); m_phase = Phase::Drag_depth; view().set_dynamic_highlight_enabled(false); apply_preview_(); } +void Shp_cyl_align::enter_twist_() +{ + EZY_ASSERT(m_phase == Phase::Drag_depth); + EZY_ASSERT(m_opts.clock_rotation); + EZY_ASSERT(m_fixed_axis.has_value() && m_moving_axis.has_value()); + + // Commit any typed depth override into the live offset, then clear overrides. + if (m_depth_override.has_value()) + m_axial_offset = *m_depth_override; + + clear_all(m_depth_override, m_twist_angle, m_twist_override, m_twist_angle0, m_twist_pln); + gui().hide_dist_edit(false); + + const gp_Dir& fixed_dir = m_fixed_axis->Direction(); + const gp_Vec to_moving(m_fixed_axis->Location(), m_moving_axis->Location()); + const double param0 = to_moving.Dot(gp_Vec(fixed_dir)); + const gp_Pnt seed = m_fixed_axis->Location().Translated(gp_Vec(fixed_dir) * (param0 + m_axial_offset)); + m_twist_pln = gp_Pln(seed, fixed_dir); + + m_phase = Phase::Drag_twist; + gui().show_message("Drag to clock about the axis, then LMB or Enter to confirm."); + apply_preview_(); +} + +void Shp_cyl_align::exit_twist_to_depth_() +{ + EZY_ASSERT(m_phase == Phase::Drag_twist); + clear_all(m_twist_angle, m_twist_override, m_twist_angle0, m_twist_pln); + gui().hide_angle_edit(false); + m_phase = Phase::Drag_depth; + apply_preview_(); + gui().show_message("Drag along the axis for insert depth, then LMB or Enter to confirm."); +} + void Shp_cyl_align::apply_preview_() { EZY_ASSERT(m_moving_axis.has_value() && m_fixed_axis.has_value()); EZY_ASSERT(!m_shps.empty()); const double offset = m_depth_override.value_or(m_axial_offset); - const gp_Trsf trsf = cyl_align_trsf(*m_moving_axis, *m_fixed_axis, m_opts.flip_direction, offset); + const double twist = m_twist_override.value_or(m_twist_angle); + const gp_Trsf trsf = cyl_align_trsf(*m_moving_axis, *m_fixed_axis, m_opts.flip_direction, offset, twist); for (const Shp_ptr& shape : m_shps) shape->SetLocalTransformation(trsf); @@ -102,7 +159,7 @@ void Shp_cyl_align::apply_preview_() Status Shp_cyl_align::drag_depth(const ScreenCoords& screen_coords) { - if (!is_dragging()) + if (m_phase != Phase::Drag_depth) return Status::ok(); EZY_ASSERT(m_fixed_axis.has_value() && m_moving_axis.has_value()); @@ -113,8 +170,8 @@ Status Shp_cyl_align::drag_depth(const ScreenCoords& screen_coords) return Status::ok(); } - const gp_Dir& fixed_dir = m_fixed_axis->Direction(); - const gp_Vec to_moving(m_fixed_axis->Location(), m_moving_axis->Location()); + const gp_Dir& fixed_dir = m_fixed_axis->Direction(); + const gp_Vec to_moving = {m_fixed_axis->Location(), m_moving_axis->Location()}; const double param0 = to_moving.Dot(gp_Vec(fixed_dir)); const gp_Pnt seed_on_ax = m_fixed_axis->Location().Translated(gp_Vec(fixed_dir) * param0); @@ -132,9 +189,69 @@ Status Shp_cyl_align::drag_depth(const ScreenCoords& screen_coords) return Status::ok(); } +Status Shp_cyl_align::drag_twist(const ScreenCoords& screen_coords) +{ + if (m_phase != Phase::Drag_twist) + return Status::ok(); + + EZY_ASSERT(m_fixed_axis.has_value()); + EZY_ASSERT(m_twist_pln.has_value()); + + if (m_twist_override.has_value()) + { + apply_preview_(); + return Status::ok(); + } + + const std::optional mouse_wc = view().pt3d_on_plane(screen_coords, *m_twist_pln); + if (!mouse_wc) + return Status::user_error("Adjust view, cannot get point on plane."); + + const gp_Ax2 ax2(m_twist_pln->Location(), m_fixed_axis->Direction()); + const gp_Vec v(m_twist_pln->Location(), *mouse_wc); + const double vx = v.Dot(gp_Vec(ax2.XDirection())); + const double vy = v.Dot(gp_Vec(ax2.YDirection())); + if (std::hypot(vx, vy) <= Precision::Confusion()) + return Status::ok(); + + const double ang = std::atan2(vy, vx); + if (!m_twist_angle0.has_value()) + m_twist_angle0 = ang; + + m_twist_angle = ang - *m_twist_angle0; + apply_preview_(); + + return Status::ok(); +} + +void Shp_cyl_align::on_left_click() +{ + if (m_phase == Phase::Drag_depth) + { + if (m_opts.clock_rotation) + enter_twist_(); + else + finalize(); + } + else if (m_phase == Phase::Drag_twist) + finalize(); +} + +void Shp_cyl_align::begin_twist_input(const ScreenCoords& screen_coords) +{ + if (!m_opts.clock_rotation || !is_dragging()) + return; + + if (m_phase == Phase::Drag_depth) + enter_twist_(); + + if (m_phase == Phase::Drag_twist) + show_twist_edit(screen_coords); +} + void Shp_cyl_align::show_depth_edit(const ScreenCoords& screen_coords) { - if (!is_dragging()) + if (m_phase != Phase::Drag_depth) return; auto depth_edit = [&, screen_coords](float new_dist, bool is_final) @@ -142,7 +259,12 @@ void Shp_cyl_align::show_depth_edit(const ScreenCoords& screen_coords) m_depth_override = new_dist * view().get_display_to_model_scale(); EZY_ASSERT(drag_depth(screen_coords).is_ok()); if (is_final) - finalize(); + { + if (m_opts.clock_rotation) + enter_twist_(); + else + finalize(); + } }; const double cur = m_depth_override.value_or(m_axial_offset); @@ -150,11 +272,39 @@ void Shp_cyl_align::show_depth_edit(const ScreenCoords& screen_coords) std::move(std::function(depth_edit))); } +void Shp_cyl_align::show_twist_edit(const ScreenCoords& screen_coords) +{ + if (m_phase != Phase::Drag_twist) + return; + + auto twist_edit = [&, screen_coords](float new_angle_deg, bool is_final) + { + m_twist_override = to_radians(static_cast(new_angle_deg)); + m_twist_angle = *m_twist_override; + EZY_ASSERT(drag_twist(screen_coords).is_ok()); + if (is_final) + finalize(); + }; + + const double cur = m_twist_override.value_or(m_twist_angle); + gui().set_angle_edit(float(to_degrees(cur)), std::move(std::function(twist_edit)), screen_coords); +} + void Shp_cyl_align::finalize() { if (!is_dragging()) return; + // Commit typed overrides before bake. + if (m_depth_override.has_value()) + m_axial_offset = *m_depth_override; + + if (m_twist_override.has_value()) + m_twist_angle = *m_twist_override; + + clear_all(m_depth_override, m_twist_override); + apply_preview_(); + std::vector changes; changes.reserve(m_shps.size()); for (const Shp_ptr& shape : m_shps) diff --git a/src/shp_cyl_align.h b/src/shp_cyl_align.h index 003aa26..6b38d32 100644 --- a/src/shp_cyl_align.h +++ b/src/shp_cyl_align.h @@ -9,25 +9,37 @@ struct Cyl_align_options { bool flip_direction{false}; + /// When true, LMB after depth enters clock rotation; when false, LMB finalizes. + bool clock_rotation{false}; }; -/// One-shot cylindrical insert align: pick moving face, pick fixed face, drag depth, bake. +/// One-shot cylindrical insert align: pick moving face, pick fixed face, drag depth, +/// optional clock rotation about the shared axis, bake. class Shp_cyl_align : private Shp_operation_base { public: Shp_cyl_align(Occt_view& view); void begin(); - /// True while depth-drag preview is active (LMB finalizes). + /// True while depth or clock-rotation preview is active (LMB advances / finalizes). [[nodiscard]] bool is_dragging() const; + [[nodiscard]] bool is_twist_phase() const; [[nodiscard]] Status pick(const ScreenCoords& screen_coords); [[nodiscard]] Status drag_depth(const ScreenCoords& screen_coords); + [[nodiscard]] Status drag_twist(const ScreenCoords& screen_coords); + /// Depth + Clock rotation on: enter clock phase. Depth + off: finalize. Clock phase: finalize. + void on_left_click(); + /// Depth -> clock rotation (requires Options Clock rotation), then open angle edit. + void begin_twist_input(const ScreenCoords& screen_coords); void show_depth_edit(const ScreenCoords& screen_coords); + void show_twist_edit(const ScreenCoords& screen_coords); void finalize(); void cancel(); void reset(); Cyl_align_options& get_opts(); + /// Apply Options Clock rotation toggle (exits clock phase if turned off). + void set_clock_rotation_enabled(bool enabled); /// Re-apply preview after Options flip toggle (no-op unless dragging). void apply_preview(); @@ -36,11 +48,14 @@ class Shp_cyl_align : private Shp_operation_base { Pick_moving, Pick_fixed, - Drag_depth + Drag_depth, + Drag_twist }; void apply_preview_(); void enter_drag_(); + void enter_twist_(); + void exit_twist_to_depth_(); Phase m_phase{Phase::Pick_moving}; Cyl_align_options m_opts; @@ -53,4 +68,8 @@ class Shp_cyl_align : private Shp_operation_base double m_axial_offset{0}; std::optional m_depth_override; std::optional m_drag_pln; + double m_twist_angle{0}; // radians about fixed axis after coaxial + std::optional m_twist_override; + std::optional m_twist_angle0; // first-drag reference for relative twist + std::optional m_twist_pln; }; diff --git a/src/utl_geom.cpp b/src/utl_geom.cpp index e8cf2b5..538de95 100644 --- a/src/utl_geom.cpp +++ b/src/utl_geom.cpp @@ -474,7 +474,11 @@ std::optional cylinder_from_face(const TopoDS_Face& face) return info; } -gp_Trsf cyl_align_trsf(const gp_Ax1& moving_axis, const gp_Ax1& fixed_axis, bool flip, double axial_offset) +gp_Trsf cyl_align_trsf(const gp_Ax1& moving_axis, + const gp_Ax1& fixed_axis, + bool flip, + double axial_offset, + double twist_rad) { const gp_Dir from_dir = moving_axis.Direction(); const gp_Dir to_dir = flip ? fixed_axis.Direction().Reversed() : fixed_axis.Direction(); @@ -504,7 +508,14 @@ gp_Trsf cyl_align_trsf(const gp_Ax1& moving_axis, const gp_Ax1& fixed_axis, bool gp_Trsf trans; trans.SetTranslation(gp_Vec(moved_loc, target)); - return trans * rot; + const gp_Trsf coaxial = trans * rot; + if (std::fabs(twist_rad) <= Precision::Angular()) + return coaxial; + + // Twist about the fixed axis after coaxial placement (clock rotation; keeps axes coincident). + gp_Trsf twist; + twist.SetRotation(fixed_axis, twist_rad); + return twist * coaxial; } bool planes_equal(const gp_Pln& plane1, const gp_Pln& plane2) diff --git a/src/utl_geom.h b/src/utl_geom.h index 152eca7..76c80d0 100644 --- a/src/utl_geom.h +++ b/src/utl_geom.h @@ -106,8 +106,13 @@ std::optional cylinder_from_face(const TopoDS_Face& face); /// Rigid transform that maps \a moving_axis onto \a fixed_axis. /// Offset 0 places the moving origin on its projection onto the fixed axis; /// \a axial_offset then slides along the fixed direction. \a flip reverses the -/// moving direction sense (180 deg about a perpendicular). -gp_Trsf cyl_align_trsf(const gp_Ax1& moving_axis, const gp_Ax1& fixed_axis, bool flip, double axial_offset); +/// moving direction sense (180 deg about a perpendicular). \a twist_rad is an +/// extra rotation about the fixed axis after coaxial placement (clock rotation). +gp_Trsf cyl_align_trsf(const gp_Ax1& moving_axis, + const gp_Ax1& fixed_axis, + bool flip, + double axial_offset, + double twist_rad = 0.0); bool planes_equal(const gp_Pln& plane1, const gp_Pln& plane2);