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
-  [Move shapes (G)](#shape-move-tool-g)
-  [Rotate objects (R)](#shape-rotate-tool-r)
-  [Scale elements (S)](#shape-scale-tool)
- -  [Align cylinders (J)](#align-cylinders-tool-j)
+ -  [Align shafts (J)](#align-shafts-tool-j)
-  [Polar duplicate](#shape-polar-duplicate-tool)
-  [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)
-
+
-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
-  - Shape move (G)
-  - Shape rotate (R)
-  - Shape scale (S)
--  - Align cylinders (J)
+-  - Align shafts (J)
### Sketch Tools
-  - 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);