diff --git a/CHANGELOG.md b/CHANGELOG.md
index 686aeb61..5a1391ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Align cylinders** (J): pick two cylindrical faces (first moves, second is fixed), coaxially align, then drag insert depth along the shared axis (or Tab for an exact depth). Options **Flip direction** reverses axis sense. Bakes like Move/Rotate (`Shape_geom_delta`); radius mismatch logs a warning but still places. Remappable as **`mode.cyl_align`**.
+
- **STEP Import as**: the Import dialog replaces the **Union shapes** checkbox with an **Import as** combo (**Preserve hierarchy** default, **Flat solids**, **Union shapes**). Hierarchy keeps XCAF assembly groups and product/instance names; flat adds leaf solids at the document root; union fuses into one solid.
- **STEP import progress**: on **Import into project**, an **Importing...** modal appears while transferring. Desktop shows stage/progress and Cancel (OCCT transfer on a background thread). Web shows a simple wait dialog (no Cancel; transfer runs on the main thread after the dialog paints). The Import dialog no longer shows a file metadata summary (opens immediately after file pick).
diff --git a/docs/usage.md b/docs/usage.md
index ce005d25..c9206f51 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -232,7 +232,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)) 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 cylinders](#align-cylinders-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.
@@ -366,6 +366,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)
-  [Polar duplicate](#shape-polar-duplicate-tool)
-  [Preview a local-plane cross-section](#shape-cross-section-tool)
@@ -475,6 +476,36 @@ 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)
+
+
+
+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.
+
+**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. |
+
+**How to Use:**
+
+1.  **Activate:** Press J or click the toolbar icon.
+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.
+
+**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.
+- This tool only places solids; use [Cut](#boolean-operations) afterward if you need a boolean.
+
## Feature Operations
### Extrude Sketch Face Tool (E)
@@ -712,6 +743,7 @@ Mode, file, and edit chords in the **General Operations** and **Modeling Shortcu
| G | Move mode |
| R | Rotate mode |
| S | Scale mode |
+| J | Align cylinders mode |
| E | Extrude mode |
| C | Chamfer mode |
| F | Fillet mode |
@@ -866,6 +898,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)
### Sketch Tools
-  - Sketch inspection mode
diff --git a/res/ezycad_settings.json b/res/ezycad_settings.json
index 65650149..e0b29f92 100644
--- a/res/ezycad_settings.json
+++ b/res/ezycad_settings.json
@@ -55,6 +55,7 @@
"mode.add_slot": "U",
"mode.polar_duplicate": "Shift+P",
"mode.cross_section": "Shift+X",
+ "mode.cyl_align": "J",
"cmd.shape_cut": "Ctrl+Shift+C",
"cmd.shape_fuse": "Ctrl+Shift+F",
"cmd.shape_common": "Ctrl+Shift+M",
diff --git a/src/doc/gui.md b/src/doc/gui.md
index 78b0ee22..873634a9 100644
--- a/src/doc/gui.md
+++ b/src/doc/gui.md
@@ -162,8 +162,9 @@ Remappable chords live in `Gui_hotkeys` (`gui_hotkeys.h` / `.cpp`), owned by `GU
| Remappable chord | `m_hotkeys` hit | `dispatch_hotkey_action_` (`Gui_action`: sketch/shape modes, booleans, delete, file, undo/redo) |
| Move-mode keys | `Mode::Move` | `on_key_move_mode_` (axis constraints X/Y/Z); hardcoded |
| Rotate-mode keys | `Mode::Rotate` | `on_key_rotate_mode_` (axis pick, Tab angle); hardcoded |
+| Cyl-align keys | `Mode::Shape_cyl_align` | `on_key_cyl_align_mode_` (Tab depth, Enter finalize); hardcoded |
-Default remappable chords include G/R/S/E/C/F/D shape tools; sketch tools N/L/A/Q/B/O/U/I/P and Shift variants; Shift+P polar, Shift+X cross-section; Ctrl+Shift+C/F/M booleans; Shift+D delete; Ctrl+N/O/S; Ctrl+Z / Ctrl+Y. Unmodified X/Y/Z are reserved for Move/Rotate axis toggles (`is_reserved_chord`); Shift+X remains free for cross-section. Remappable keys must pass `is_bindable_key` (letters, digits, Space, and named keys that round-trip in settings JSON); punctuation such as `,` / `.` and numpad keys are rejected. Settings **Keyboard shortcuts** has a `?` to `doc_urls::k_hotkeys` ([usage-settings.md#keyboard-shortcuts](../../docs/usage-settings.md#keyboard-shortcuts)).
+Default remappable chords include G/R/S/J/E/C/F/D shape tools; sketch tools N/L/A/Q/B/O/U/I/P and Shift variants; Shift+P polar, Shift+X cross-section; Ctrl+Shift+C/F/M booleans; Shift+D delete; Ctrl+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)).
See also [`src/doc/sketch.md`](sketch.md) and [`src/doc/shape.md`](shape.md) for per-mode mouse routing after `GUI` delegates to `Occt_view`.
@@ -174,6 +175,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_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)` |
@@ -187,7 +189,7 @@ Always calls `m_view->on_mouse_move(screen_coords)` first.
| 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, sketch `add_sketch_pt`, fillet/chamfer click, polar dup `add_point`, extrude pick |
+| 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 |
Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse position.
@@ -206,6 +208,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_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 c68e0303..92197b74 100644
--- a/src/doc/shape.md
+++ b/src/doc/shape.md
@@ -22,7 +22,7 @@ Typical uses:
- Create primitives (box, sphere, cylinder, ...) and add them to the document.
- Extrude a sketch face into a solid; revolve sketch geometry (revolve lives in `skt_operations.cpp`, returns `Shp_rslt`).
- Boolean fuse/cut/common on selected shapes.
-- Interactive move, rotate, and scale with preview transforms.
+- Interactive move, rotate, scale, and cylindrical align with preview transforms.
- Preview cross-sections on a shape-local XY, XZ, or YZ plane (optional hide-back preview; **Clip** commits a half-space cut).
- Fillet/chamfer by shape, face, wire, or edge pick mode.
- Polar duplicate selected shapes about an arm on the current sketch plane.
@@ -95,7 +95,7 @@ Do not confuse this vector with `Occt_view::m_shps` (the document list).
Occt_view
|
+-- std::list m_shps document shapes
- +-- Shp_move / Shp_rotate / Shp_scale interactive transforms
+ +-- Shp_move / Shp_rotate / Shp_scale / Shp_cyl_align interactive transforms
+-- Shp_cross_section temporary local-plane section preview
+-- Shp_extrude sketch face extrude session
+-- Shp_fuse / Shp_cut / Shp_common booleans
@@ -152,6 +152,7 @@ Protected helpers used by all operation classes:
| `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. |
@@ -167,6 +168,7 @@ Protected helpers used by all operation classes:
| `Mode::Move` | `shp_move().move_selected` | `shp_move().finalize` | `shp_move().show_dist_edit` (`gui_mode`) | `shp_move().cancel` -> `Normal` |
| `Mode::Rotate` | `shp_rotate().rotate_selected` | `shp_rotate().finalize` | `shp_rotate().show_angle_edit` | `shp_rotate().cancel` -> `Normal` |
| `Mode::Scale` | `shp_scale().scale_selected` | `shp_scale().finalize` | -- | `shp_scale().cancel` -> `Normal` |
+| `Mode::Shape_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)` | -- | -- |
@@ -258,4 +260,4 @@ auto lines = shp_info::collect(shp->Shape(), &meta);
| `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, rotation helpers used by transforms and polar dup |
+| `utl_geom.h` | Plane projection, bbox center, `cylinder_from_face` / `cyl_align_trsf`, rotation helpers |
diff --git a/src/gui.cpp b/src/gui.cpp
index ad234a61..4572659e 100644
--- a/src/gui.cpp
+++ b/src/gui.cpp
@@ -184,6 +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/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},
@@ -239,6 +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::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);
@@ -3866,6 +3868,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())
+ show_message(s.message());
+
+ break;
+
case Mode::Shape_polar_duplicate:
if (Status s = m_view->shp_polar_dup().move_point(screen_coords); !s.is_ok())
show_message(s.message());
@@ -3901,6 +3909,12 @@ void GUI::on_left_click_(const ScreenCoords& screen_coords)
case Mode::Move: m_view->shp_move().finalize(); break;
case Mode::Rotate: m_view->shp_rotate().finalize(); break;
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();
+ else if (Status s = m_view->shp_cyl_align().pick(screen_coords); !s.is_ok())
+ show_message(s.message());
+ break;
case Mode::Sketch_face_extrude: m_view->sketch_face_extrude(screen_coords, false); break;
// clang-format on
diff --git a/src/gui.h b/src/gui.h
index 43b69cfd..e40d26ad 100644
--- a/src/gui.h
+++ b/src/gui.h
@@ -456,6 +456,7 @@ class GUI
void options_shape_fillet_mode_();
void options_shape_polar_duplicate_mode_();
void options_shape_cross_section_mode_();
+ void options_shape_cyl_align_mode_();
void options_sketch_from_planer_face_mode_();
void options_sketch_operation_axis_mode_();
void options_sketch_face_extrude_mode_();
@@ -487,6 +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 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 c4bc0498..42daf19f 100644
--- a/src/gui_hotkeys.cpp
+++ b/src/gui_hotkeys.cpp
@@ -44,6 +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::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_hotkeys.h b/src/gui_hotkeys.h
index eae01da4..d5bed7d3 100644
--- a/src/gui_hotkeys.h
+++ b/src/gui_hotkeys.h
@@ -32,6 +32,7 @@ enum class Gui_action
Mode_add_slot,
Mode_polar_duplicate,
Mode_cross_section,
+ Mode_cyl_align,
Cmd_shape_cut,
Cmd_shape_fuse,
Cmd_shape_common,
diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp
index 908bd951..8070a5ca 100644
--- a/src/gui_mode.cpp
+++ b/src/gui_mode.cpp
@@ -46,6 +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::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"},
@@ -124,6 +125,7 @@ Mode GUI::parent_mode_of(Mode mode)
{Mode::Move, Mode::Normal},
{Mode::Scale, Mode::Normal},
{Mode::Rotate, Mode::Normal},
+ {Mode::Shape_cyl_align, Mode::Normal},
{Mode::Sketch_inspection_mode, Mode::Normal},
{Mode::Sketch_from_planar_face, Mode::Normal},
{Mode::Sketch_face_extrude, Mode::Normal},
@@ -302,9 +304,9 @@ void GUI::on_key(int key, int scancode, int action, int mods)
case GLFW_KEY_TAB:
{
- // Move / Rotate handle Tab in their mode key handlers (distance / angle).
+ // Move / Rotate / Align cylinders handle Tab in their mode key handlers (distance / angle / depth).
const Mode mode = get_mode();
- if (mode == Mode::Move || mode == Mode::Rotate)
+ if (mode == Mode::Move || mode == Mode::Rotate || mode == Mode::Shape_cyl_align)
break;
bool shift_pressed = (mods & GLFW_MOD_SHIFT) != 0;
@@ -316,8 +318,8 @@ void GUI::on_key(int key, int scancode, int action, int mods)
}
case GLFW_KEY_ENTER:
- // Rotate finalizes on Enter in on_key_rotate_mode_.
- if (get_mode() == Mode::Rotate)
+ // Rotate / Align cylinders 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);
hide_dist_edit();
@@ -359,6 +361,10 @@ void GUI::on_key(int key, int scancode, int action, int mods)
on_key_rotate_mode_(key);
break;
+ case Mode::Shape_cyl_align:
+ on_key_cyl_align_mode_(key);
+ break;
+
default:
break;
}
@@ -372,6 +378,7 @@ void GUI::dispatch_hotkey_action_(Gui_action action)
case Gui_action::Mode_move: set_mode(Mode::Move); break;
case Gui_action::Mode_rotate: set_mode(Mode::Rotate); break;
case Gui_action::Mode_scale: set_mode(Mode::Scale); break;
+ case Gui_action::Mode_cyl_align: set_mode(Mode::Shape_cyl_align); break;
case Gui_action::Mode_extrude: set_mode(Mode::Sketch_face_extrude); break;
case Gui_action::Mode_chamfer: set_mode(Mode::Shape_chamfer); break;
case Gui_action::Mode_fillet: set_mode(Mode::Shape_fillet); break;
@@ -490,6 +497,7 @@ void GUI::options_()
case Mode::Move: options_move_mode_(); break;
case Mode::Rotate: options_rotate_mode_(); break;
case Mode::Scale: options_scale_mode_(); break;
+ case Mode::Shape_cyl_align: options_shape_cyl_align_mode_(); break;
case Mode::Shape_chamfer: options_shape_chamfer_mode_(); break;
case Mode::Shape_fillet: options_shape_fillet_mode_(); break;
case Mode::Shape_polar_duplicate: options_shape_polar_duplicate_mode_(); break;
@@ -617,6 +625,26 @@ void GUI::options_scale_mode_()
options_orthographic_projection_();
}
+void GUI::options_shape_cyl_align_mode_()
+{
+ EZY_ASSERT(get_mode() == Mode::Shape_cyl_align);
+
+ ImGui::TextUnformatted(current_mode_description_());
+ options_doc_help_button_();
+ ImGui::Separator();
+
+ 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.");
+
+ 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();
+
+ ImGui::Separator();
+ options_orthographic_projection_();
+}
+
void GUI::options_rotate_mode_()
{
EZY_ASSERT(get_mode() == Mode::Rotate);
@@ -1380,6 +1408,31 @@ float GUI::options_sketch_label_col_w_() const
return sketch_label_col_w;
}
+void GUI::on_key_cyl_align_mode_(int key)
+{
+ const ScreenCoords screen_coords = cursor_screen_coords();
+
+ switch (key)
+ {
+ case GLFW_KEY_ESCAPE:
+ m_view->shp_cyl_align().cancel();
+ break;
+
+ case GLFW_KEY_ENTER:
+ case GLFW_KEY_KP_ENTER:
+ 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);
+ break;
+
+ default:
+ break;
+ }
+}
+
void GUI::on_key_rotate_mode_(int key)
{
const ScreenCoords screen_coords = cursor_screen_coords();
diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp
index 5fdc6c6c..48245fa1 100644
--- a/src/gui_occt_view.cpp
+++ b/src/gui_occt_view.cpp
@@ -92,6 +92,7 @@ Occt_view::Occt_view(GUI& gui)
, m_shp_move(*this)
, m_shp_rotate(*this)
, m_shp_scale(*this)
+ , m_shp_cyl_align(*this)
, m_shp_chamfer(*this)
, m_shp_fillet(*this)
, m_shp_cut(*this)
@@ -580,6 +581,11 @@ void Occt_view::cancel(Set_parent_mode set_parent_mode)
operation_canceled = true;
break;
+ case Mode::Shape_cyl_align:
+ shp_cyl_align().cancel();
+ operation_canceled = true;
+ break;
+
case Mode::Shape_cross_section:
shp_cross_section().clear();
gui().set_mode(Mode::Normal);
@@ -2411,7 +2417,8 @@ void Occt_view::on_mouse_button(int theButton, int theAction, int theMods)
{
const bool finalize_transform = (get_mode() == Mode::Move && shp_move().has_operation_shps()) ||
(get_mode() == Mode::Rotate && shp_rotate().has_operation_shps()) ||
- (get_mode() == Mode::Scale && shp_scale().has_operation_shps());
+ (get_mode() == Mode::Scale && shp_scale().has_operation_shps()) ||
+ (get_mode() == Mode::Shape_cyl_align && shp_cyl_align().is_dragging());
if (finalize_transform)
{
m_transform_finalize_lmb_skipped_view_controller = true;
@@ -3103,6 +3110,7 @@ void Occt_view::on_mode()
case Mode::Move: set_shp_selection_mode(TopAbs_SHAPE); break;
case Mode::Rotate: set_shp_selection_mode(TopAbs_SHAPE); break;
case Mode::Scale: set_shp_selection_mode(TopAbs_SHAPE); break;
+ case Mode::Shape_cyl_align: set_shp_selection_mode(TopAbs_FACE); break;
case Mode::Shape_cross_section: set_shp_selection_mode(TopAbs_COMPOUND); break;
default:
if(m_modes_selection_mode_map.count(get_mode()))
@@ -3141,6 +3149,9 @@ void Occt_view::on_mode()
}
}
+ if (mode == Mode::Shape_cyl_align)
+ shp_cyl_align().begin();
+
if (mode == Mode::Shape_cross_section && !enter_selection.empty())
{
const Status status = shp_cross_section().preview(enter_selection);
@@ -3370,6 +3381,7 @@ std::list& Occt_view::get_shapes() { return m_shps; }
Shp_move& Occt_view::shp_move() { return m_shp_move; }
Shp_rotate& Occt_view::shp_rotate() { return m_shp_rotate; }
Shp_scale& Occt_view::shp_scale() { return m_shp_scale; }
+Shp_cyl_align& Occt_view::shp_cyl_align() { return m_shp_cyl_align; }
Shp_chamfer& Occt_view::shp_chamfer() { return m_shp_chamfer; }
Shp_fillet& Occt_view::shp_fillet() { return m_shp_fillet; }
Shp_cut& Occt_view::shp_cut() { return m_shp_cut; }
@@ -3380,6 +3392,13 @@ Shp_extrude& Occt_view::shp_extrude() { return m_shp_extrude; }
Shp_cross_section& Occt_view::shp_cross_section() { return m_shp_cross_section; }
// clang-format on
+void Occt_view::set_dynamic_highlight_enabled(bool enabled)
+{
+ SetAllowHighlight(enabled);
+ if (!enabled && !m_ctx.IsNull())
+ m_ctx->ClearDetected(false);
+}
+
// ---------------------------------------------------------------------------
// Undo / redo: interactive edits use typed deltas; JSON snapshots for mixed delete / file open.
namespace
@@ -3393,6 +3412,7 @@ Mode mode_for_history_restore_(Mode mode)
case Mode::Move:
case Mode::Rotate:
case Mode::Scale:
+ case Mode::Shape_cyl_align:
return GUI::parent_mode_of(mode);
default:
return mode;
diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h
index 216ed496..7ed7d623 100644
--- a/src/gui_occt_view.h
+++ b/src/gui_occt_view.h
@@ -19,6 +19,7 @@
#include "shp_chamfer.h"
#include "shp_common.h"
#include "shp_cut.h"
+#include "shp_cyl_align.h"
#include "shp_extrude.h"
#include "shp_fillet.h"
#include "shp_fuse.h"
@@ -247,6 +248,7 @@ class Occt_view : protected AIS_ViewController
Shp_move& shp_move();
Shp_rotate& shp_rotate();
Shp_scale& shp_scale();
+ Shp_cyl_align& shp_cyl_align();
Shp_chamfer& shp_chamfer();
Shp_fillet& shp_fillet();
Shp_cut& shp_cut();
@@ -256,6 +258,9 @@ class Occt_view : protected AIS_ViewController
Shp_extrude& shp_extrude();
Shp_cross_section& shp_cross_section();
+ /// Enable/disable AIS_ViewController dynamic highlight (MoveTo hover). Off during transform drag.
+ void set_dynamic_highlight_enabled(bool enabled);
+
// Revolve related
void revolve_selected(const double angle);
@@ -533,9 +538,10 @@ class Occt_view : protected AIS_ViewController
double m_zoom_scroll_scale{4.0};
// --------------------------------------------------------------------
// Operations
- Shp_move m_shp_move;
- Shp_rotate m_shp_rotate;
- Shp_scale m_shp_scale;
+ Shp_move m_shp_move;
+ Shp_rotate m_shp_rotate;
+ Shp_scale m_shp_scale;
+ Shp_cyl_align m_shp_cyl_align;
// --------------------------------------------------------------------
// Commands
Shp_chamfer m_shp_chamfer;
diff --git a/src/main.cpp b/src/main.cpp
index 1a3deb24..26dd02bf 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -417,10 +417,8 @@ int main(int argc, char** argv)
#if !defined(__EMSCRIPTEN__) && defined(EZYCAD_HAVE_PYTHON)
if (py_queue)
- {
if (Python_console* console = gui.get_python_console())
py_queue->process_pending(*console);
- }
#endif
if (glfwGetWindowAttrib(window, GLFW_ICONIFIED) != 0)
diff --git a/src/mode.h b/src/mode.h
index 18572322..7779c6ee 100644
--- a/src/mode.h
+++ b/src/mode.h
@@ -29,7 +29,8 @@
X(Sketch_add_circle_3_pts) \
X(Sketch_add_slot) \
X(Sketch_dim_anno) \
- X(Shape_cross_section)
+ X(Shape_cross_section) \
+ X(Shape_cyl_align)
enum class Mode
{
diff --git a/src/shp_cyl_align.cpp b/src/shp_cyl_align.cpp
new file mode 100644
index 00000000..116647f7
--- /dev/null
+++ b/src/shp_cyl_align.cpp
@@ -0,0 +1,210 @@
+#include "shp_cyl_align.h"
+
+#include
+
+#include "gui.h"
+#include "gui_occt_view.h"
+#include "mode.h"
+#include "shp_delta.h"
+#include "utl.h"
+#include "utl_geom.h"
+
+Shp_cyl_align::Shp_cyl_align(Occt_view& view)
+ : Shp_operation_base(view)
+{
+}
+
+void Shp_cyl_align::begin()
+{
+ m_phase = Phase::Pick_moving;
+ m_opts = {};
+ m_axial_offset = 0;
+ m_moving_radius = 0;
+ m_fixed_radius = 0;
+ m_depth_override = std::nullopt;
+ m_moving_shp.Nullify();
+ m_fixed_shp.Nullify();
+ clear_all(m_moving_axis, m_fixed_axis, m_drag_pln, m_shps);
+}
+
+bool Shp_cyl_align::is_dragging() const { return m_phase == Phase::Drag_depth && !m_shps.empty(); }
+
+Cyl_align_options& Shp_cyl_align::get_opts() { return m_opts; }
+
+void Shp_cyl_align::apply_preview()
+{
+ if (is_dragging())
+ apply_preview_();
+}
+
+Status Shp_cyl_align::pick(const ScreenCoords& screen_coords)
+{
+ if (m_phase == Phase::Drag_depth)
+ return Status::ok();
+
+ Shp_ptr shp = Shp_ptr::DownCast(get_shape_(screen_coords));
+ if (shp.IsNull() || shp->is_group())
+ return Status::user_error("Click a cylindrical face on a solid.");
+
+ const TopoDS_Face* face = get_face_(screen_coords);
+ if (!face)
+ return Status::user_error("Click a face (selection filter is Face).");
+
+ const std::optional cyl = cylinder_from_face(*face);
+ if (!cyl)
+ return Status::user_error("Selected face is not cylindrical.");
+
+ if (m_phase == Phase::Pick_moving)
+ {
+ m_moving_shp = shp;
+ m_moving_axis = cyl->axis;
+ m_moving_radius = cyl->radius;
+ m_phase = Phase::Pick_fixed;
+ gui().show_message("Pick the fixed cylindrical face (hole or shaft).");
+ return Status::ok();
+ }
+
+ // Pick_fixed
+ if (shp == m_moving_shp)
+ return Status::user_error("Pick a cylindrical face on a different shape.");
+
+ m_fixed_shp = shp;
+ m_fixed_axis = cyl->axis;
+ 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 " +
+ 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.");
+ return Status::ok();
+}
+
+void Shp_cyl_align::enter_drag_()
+{
+ EZY_ASSERT(!m_moving_shp.IsNull());
+ EZY_ASSERT(m_moving_axis.has_value() && m_fixed_axis.has_value());
+
+ m_axial_offset = 0;
+ m_depth_override = std::nullopt;
+ clear_all(m_drag_pln);
+ set_operation_shps_({m_moving_shp});
+ m_phase = Phase::Drag_depth;
+ view().set_dynamic_highlight_enabled(false);
+ apply_preview_();
+}
+
+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);
+
+ for (const Shp_ptr& shape : m_shps)
+ shape->SetLocalTransformation(trsf);
+
+ redisplay_operation_shps_after_transform_();
+}
+
+Status Shp_cyl_align::drag_depth(const ScreenCoords& screen_coords)
+{
+ if (!is_dragging())
+ return Status::ok();
+
+ EZY_ASSERT(m_fixed_axis.has_value() && m_moving_axis.has_value());
+
+ if (m_depth_override.has_value())
+ {
+ apply_preview_();
+ 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 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);
+
+ if (!m_drag_pln.has_value())
+ m_drag_pln = view().get_view_plane(seed_on_ax);
+
+ const std::optional mouse_wc = view().pt3d_on_plane(screen_coords, *m_drag_pln);
+ if (!mouse_wc)
+ return Status::user_error("Adjust view, cannot get point on plane.");
+
+ const gp_Vec from_seed(seed_on_ax, *mouse_wc);
+ m_axial_offset = from_seed.Dot(gp_Vec(fixed_dir));
+ apply_preview_();
+
+ return Status::ok();
+}
+
+void Shp_cyl_align::show_depth_edit(const ScreenCoords& screen_coords)
+{
+ if (!is_dragging())
+ return;
+
+ auto depth_edit = [&, screen_coords](float new_dist, bool is_final)
+ {
+ m_depth_override = new_dist * view().get_display_to_model_scale();
+ EZY_ASSERT(drag_depth(screen_coords).is_ok());
+ if (is_final)
+ finalize();
+ };
+
+ const double cur = m_depth_override.value_or(m_axial_offset);
+ gui().set_dist_edit(float(cur / view().get_display_to_model_scale()),
+ std::move(std::function(depth_edit)));
+}
+
+void Shp_cyl_align::finalize()
+{
+ if (!is_dragging())
+ return;
+
+ std::vector changes;
+ changes.reserve(m_shps.size());
+ for (const Shp_ptr& shape : m_shps)
+ changes.push_back(Shape_geom_delta::Geom_change{shape->get_id(), shape->Shape(), {}, shape->get_frame(), {}});
+
+ operation_shps_finalize_();
+
+ for (Shape_geom_delta::Geom_change& ch : changes)
+ {
+ Shp_ptr shp = view().find_shape_by_id(ch.id);
+ if (!shp.IsNull())
+ {
+ ch.after_geom = shp->Shape();
+ ch.after_frame = shp->get_frame();
+ }
+ }
+
+ view().push_undo_delta(std::make_unique(std::move(changes)));
+ reset();
+ restore_operation_selection_();
+}
+
+void Shp_cyl_align::cancel()
+{
+ if (is_dragging())
+ operation_shps_cancel_();
+
+ reset();
+ restore_operation_selection_();
+}
+
+void Shp_cyl_align::reset()
+{
+ m_phase = Phase::Pick_moving;
+ m_opts = {};
+ m_axial_offset = 0;
+ m_moving_radius = 0;
+ m_fixed_radius = 0;
+ m_depth_override = std::nullopt;
+ m_moving_shp.Nullify();
+ m_fixed_shp.Nullify();
+ clear_all(m_moving_axis, m_fixed_axis, m_drag_pln, m_shps);
+ gui().set_mode(Mode::Normal);
+}
diff --git a/src/shp_cyl_align.h b/src/shp_cyl_align.h
new file mode 100644
index 00000000..003aa265
--- /dev/null
+++ b/src/shp_cyl_align.h
@@ -0,0 +1,56 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include "shp_operation.h"
+
+struct Cyl_align_options
+{
+ bool flip_direction{false};
+};
+
+/// One-shot cylindrical insert align: pick moving face, pick fixed face, drag depth, 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).
+ [[nodiscard]] bool is_dragging() const;
+ [[nodiscard]] Status pick(const ScreenCoords& screen_coords);
+ [[nodiscard]] Status drag_depth(const ScreenCoords& screen_coords);
+ void show_depth_edit(const ScreenCoords& screen_coords);
+ void finalize();
+ void cancel();
+ void reset();
+
+ Cyl_align_options& get_opts();
+ /// Re-apply preview after Options flip toggle (no-op unless dragging).
+ void apply_preview();
+
+private:
+ enum class Phase
+ {
+ Pick_moving,
+ Pick_fixed,
+ Drag_depth
+ };
+
+ void apply_preview_();
+ void enter_drag_();
+
+ Phase m_phase{Phase::Pick_moving};
+ Cyl_align_options m_opts;
+ Shp_ptr m_moving_shp;
+ Shp_ptr m_fixed_shp;
+ std::optional m_moving_axis;
+ std::optional m_fixed_axis;
+ double m_moving_radius{0};
+ double m_fixed_radius{0};
+ double m_axial_offset{0};
+ std::optional m_depth_override;
+ std::optional m_drag_pln;
+};
diff --git a/src/utl_geom.cpp b/src/utl_geom.cpp
index 9e1d870a..4c1d78cd 100644
--- a/src/utl_geom.cpp
+++ b/src/utl_geom.cpp
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#include
#include
@@ -10,6 +11,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -46,11 +48,15 @@
#include
#include
#include
+#include
+#include
#include
#include
#include
#include
+#include
#include
+#include
#include
#include
#include
@@ -452,6 +458,55 @@ std::optional plane_from_face(const TopoDS_Face& face)
return plane_surface->Pln();
}
+std::optional cylinder_from_face(const TopoDS_Face& face)
+{
+ if (face.IsNull())
+ return std::nullopt;
+
+ BRepAdaptor_Surface surf(face);
+ if (surf.GetType() != GeomAbs_Cylinder)
+ return std::nullopt;
+
+ const gp_Cylinder cyl = surf.Cylinder();
+ Cyl_face_info info;
+ info.axis = cyl.Axis();
+ info.radius = cyl.Radius();
+ return info;
+}
+
+gp_Trsf cyl_align_trsf(const gp_Ax1& moving_axis, const gp_Ax1& fixed_axis, bool flip, double axial_offset)
+{
+ const gp_Dir from_dir = moving_axis.Direction();
+ const gp_Dir to_dir = flip ? fixed_axis.Direction().Reversed() : fixed_axis.Direction();
+ const gp_Dir fixed_dir = fixed_axis.Direction();
+
+ const gp_Vec to_moving(fixed_axis.Location(), moving_axis.Location());
+ const double param0 = to_moving.Dot(gp_Vec(fixed_dir));
+ const gp_Pnt target = fixed_axis.Location().Translated(gp_Vec(fixed_dir) * (param0 + axial_offset));
+
+ gp_Trsf rot;
+ if (from_dir.IsParallel(to_dir, Precision::Angular()))
+ {
+ if (from_dir.Dot(to_dir) < 0.0)
+ {
+ // 180 deg: rotate about a stable perpendicular through the moving origin.
+ const gp_Ax2 ax2(moving_axis.Location(), from_dir);
+ rot.SetRotation(gp_Ax1(moving_axis.Location(), ax2.XDirection()), std::numbers::pi);
+ }
+ }
+ else
+ {
+ const gp_Dir rot_axis(gp_Vec(from_dir).Crossed(gp_Vec(to_dir)));
+ rot.SetRotation(gp_Ax1(moving_axis.Location(), rot_axis), from_dir.Angle(to_dir));
+ }
+
+ const gp_Pnt moved_loc = moving_axis.Location().Transformed(rot);
+ gp_Trsf trans;
+ trans.SetTranslation(gp_Vec(moved_loc, target));
+
+ return trans * rot;
+}
+
bool planes_equal(const gp_Pln& plane1, const gp_Pln& plane2)
{
// Get the position and orientation of both planes
diff --git a/src/utl_geom.h b/src/utl_geom.h
index 6073672e..152eca78 100644
--- a/src/utl_geom.h
+++ b/src/utl_geom.h
@@ -4,8 +4,10 @@
#include
#include
#include
+#include
#include
#include
+#include
#include // For Pi
#include
#include
@@ -92,6 +94,21 @@ Plane_side side_of_plane(const gp_Pln& plane, const gp_Pnt& point);
// Function to get gp_Pln from a TopoDS_Face
std::optional plane_from_face(const TopoDS_Face& face);
+/// Cylinder axis and radius from a cylindrical face (location applied).
+struct Cyl_face_info
+{
+ gp_Ax1 axis;
+ double radius{0};
+};
+
+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);
+
bool planes_equal(const gp_Pln& plane1, const gp_Pln& plane2);
// Projects a vector onto a plane defined by its normal