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 - ![Shape Move Tool](res/icons/Assembly_AxialMove.png) [Move shapes (G)](#shape-move-tool-g) - ![Shape Rotate Tool](res/icons/Draft_Rotate.png) [Rotate objects (R)](#shape-rotate-tool-r) - ![Shape Scale Tool](res/icons/Part_Scale.png) [Scale elements (S)](#shape-scale-tool) + - ![Align Cylinders Tool](res/icons/Assembly_Move.png) [Align cylinders (J)](#align-cylinders-tool-j) - ![Polar Duplicate Tool](res/icons/Draft_PolarArray.png) [Polar duplicate](#shape-polar-duplicate-tool) - ![Cross-section Tool](res/icons/Curves_ExtractSubshape.png) [Preview a local-plane cross-section](#shape-cross-section-tool) @@ -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) + +![Align Cylinders Tool](res/icons/Assembly_Move.png) + +Place a shaft into a hole (or the reverse) by aligning two cylindrical faces, then sliding along the shared axis for insert depth. This is a one-shot transform that bakes into the solid geometry (same as Move / Rotate), not a persistent assembly mate. + +**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. ![Assembly_Move](res/icons/Assembly_Move.png) **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 - ![Assembly_AxialMove](res/icons/Assembly_AxialMove.png) - Shape move (G) - ![Draft_Rotate](res/icons/Draft_Rotate.png) - Shape rotate (R) - ![Part_Scale](res/icons/Part_Scale.png) - Shape scale (S) +- ![Assembly_Move](res/icons/Assembly_Move.png) - Align cylinders (J) ### Sketch Tools - ![Workbench_Sketcher_none](res/icons/Workbench_Sketcher_none.png) - 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