From 45b560c22c5f7756a79f9c29c985270966edc966 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sat, 25 Jul 2026 16:34:33 -0600 Subject: [PATCH 1/5] Start --- CHANGELOG.md | 4 + docs/usage-settings.md | 4 +- docs/usage.md | 23 +-- src/doc/gui.md | 4 +- src/doc/shape.md | 4 +- src/doc/sketch.md | 2 +- src/gui.cpp | 4 +- src/gui_mode.cpp | 9 ++ src/gui_occt_view.cpp | 16 +- src/shp_extrude.cpp | 302 +++++++++++++++++++++++++++++++------ src/shp_extrude.h | 27 ++++ tests/skt_test_fixture.cpp | 29 ++++ tests/skt_test_fixture.h | 6 + tests/skt_tests.cpp | 69 +++++++++ 14 files changed, 434 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e9f7b..96080fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Extrude Twist**: Options **Twist** checkbox. Two-phase flow locks height first, then sets twist angle about the face centroid (mouse, or Shift+Tab for degrees). With **Both sides**, ends twist symmetrically by +/- half the angle. Geometry uses thru-sections when twisted; straight prism when twist is zero. Dense-face **Extrude fast preview** also applies during Twist (face copies translate and rotate; finalize builds the solid). + ## [0.4.0] - 2026-07-25 ### Performance diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 9a75788..5d232c5 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -63,7 +63,7 @@ Between those, the pane has collapsible sections. Expand a section to see its co - **Edge selection color** / **Edge highlight color** — selected edges vs mouse-over (dynamic) highlight (RGBA) - **Face fill color** / **Face selection fill** / **Face highlight fill** — normal fill, selected fill, and mouse-over fill (RGBA) - **Shapes in sketch mode** — **Enabled** checkbox (same as **Options -> Faint shapes**; default on). When enabled: *Off (hide)*, *Ghost*, or *Wire* style (default **Ghost**), and **Shape Faint Strength** (**5%** to **85%**, default **14%**; Ghost and Wire). Higher strength looks closer to normal inspection opacity. **Shape List -> Hide all** still hides every shape. Screenshot: [Sketching (2D)](usage-sketch.md#sketching-2d). - - **Extrude fast preview** — **Enabled** (default on): when extruding a sketch face with more edges than **Fast preview edge count** (default **24**), the drag preview moves face copies instead of rebuilding a solid (responsive on dense profiles such as imported cross-sections). Simple faces still show a shaded solid while dragging. With Options **Both sides**, both ends show a face copy. Finalize always builds the solid. See [Extrude](usage.md#extrude-sketch-face-tool-e). + - **Extrude fast preview** — **Enabled** (default on): when extruding a sketch face with more edges than **Fast preview edge count** (default **24**), the drag preview moves face copies instead of rebuilding a solid (responsive on dense profiles such as imported cross-sections). With Options **Twist**, those copies also rotate about the face center. Simple faces still show a shaded solid while dragging. With Options **Both sides**, both ends show a face copy. Finalize always builds the solid. See [Extrude](usage.md#extrude-sketch-face-tool-e). - **Dimension line width** (desktop only) — slider **0.5** to **8.0** - **Dimension arrow size** — slider **1.0** to **24.0** - **Dimension color** — RGB for dimension lines, arrow heads, and value text @@ -112,7 +112,7 @@ For other non-sketch Options content, see the matching tool section in the usage Sketch-related preferences are edited in the **Options** panel while you use a sketch tool, not in the **Settings** pane: - **Sketch options** (all sketch tools): **Snap guide mode** (*Traditional*, *Fullscreen*, *Both*, *None*), then **Snap dist** and **All co-axial nodes** (global co-axial grid vs closest-per-axis only; both hidden when mode is *None*), and **Faint shapes** (show document solids as ghost/wire while sketching; style and **Shape Faint Strength** in **Settings -> Sketch -> Appearance**). *None* disables snap-to-node and snap guides. See [How sketch snap works](usage-sketch.md#sketch-snapping). **Snap guide color (node)**, **Snap guide color (axis)**, **Snap guide line width**, **Snap guide mode**, and **All co-axial nodes** are also in **Settings -> Sketch -> Snap** (persisted in `gui.*` keys below). -- **Extrude sketch face**: under **Extrude**, **Both sides** and **Material** for the new solid (same document preset as **Normal** mode Options **Material**). Fast drag preview for dense faces is configured in **Settings -> Sketch -> Appearance** (**Extrude fast preview**). Other modes that still show **Material** in Options use that same preset when relevant (for example **Sketch from planar face**). +- **Extrude sketch face**: under **Extrude**, **Both sides**, **Twist**, and **Material** for the new solid (same document preset as **Normal** mode Options **Material**). With **Twist** on, the first drag sets height, then a second drag sets twist angle about the face center; Tab / Shift+Tab enter height / angle. Fast drag preview for dense faces is configured in **Settings -> Sketch -> Appearance** (**Extrude fast preview**); with **Twist**, face copies rotate as well as translate. Other modes that still show **Material** in Options use that same preset when relevant (for example **Sketch from planar face**). - **Add edge** / **Add node** (and similar): a **Shortcuts** line documents TAB / Shift+TAB typing behavior. - **Add line edge**: under **Options**, **Add midpoint nodes** ([usage-sketch.md#line-edge-option-add-midpoint-nodes](usage-sketch.md#line-edge-option-add-midpoint-nodes)) and **Place from center** ([usage-sketch.md#line-edge-option-place-from-center](usage-sketch.md#line-edge-option-place-from-center)); each has a **?** link. See also [Line edge Options](usage-sketch.md#line-edge-options). The midpoint setting is also in **Settings -> Sketch -> Nodes** (persisted). - **Sketch operation** (mirror / revolve axis): mirror, revolve, angle, and clear-axis actions (see [usage-sketch.md](usage-sketch.md#operation-axis-tool)). diff --git a/docs/usage.md b/docs/usage.md index 82919c6..dabe1bb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -493,8 +493,9 @@ The extrude tool allows you to create 3D solid shapes by extruding 2D sketch fac | **Orthographic camera** | Extrude mode forces **orthographic** projection (same as other sketch tools) so extrusion height is easier to judge without perspective foreshortening | | **Direct face selection** | Click directly on a sketch face to select it for extrusion, or use **`E`** / right-click **Extrude** on a face in the [Sketch List](#sketch-list) | | **Automatic view adjustment** | The view automatically rotates if the face plane is parallel to the view plane (within 5 degrees), providing better visibility for the extrusion operation | -| **Real-time preview** | See the extrusion update while dragging. Simple faces show a shaded solid. Dense faces (many edges) can use a fast preview that moves face copies - enable or tune this in **Settings -> Sketch -> Appearance -> Extrude fast preview**. With **Both sides**, both ends are annotated. Finalize always creates the solid | +| **Real-time preview** | See the extrusion update while dragging. Simple faces show a shaded solid. Dense faces (many edges) can use a fast preview that moves face copies (translate, and rotate when **Twist** is on) - enable or tune this in **Settings -> Sketch -> Appearance -> Extrude fast preview**. With **Both sides**, both ends are annotated. Finalize always creates the solid | | **Interactive distance control** | Drag the mouse to adjust extrusion distance, or use the distance input dialog (Tab key) for precise control | +| **Twist** | Options **Twist**: after locking height (click or Tab commit), drag to rotate the far end about the face center, or Shift+Tab for an exact angle. With **Both sides**, the mid-plane stays unrotated and each end twists by half the angle in opposite directions | | **Distance annotation** | A dimension annotation displays the current extrusion distance | | **Bidirectional extrusion** | The extrusion direction is determined by which side of the face plane you move the mouse to | @@ -507,24 +508,26 @@ The extrude tool allows you to create 3D solid shapes by extruding 2D sketch fac - **Mouse drag**: Move the mouse to adjust the extrusion distance in real-time - **Precise input**: Press Tab to open the distance input dialog and enter an exact extrusion distance - The distance annotation shows the current extrusion distance -4. **Finalize**: Click the left mouse button to confirm and create the extruded shape -5. **Cancel**: Press Esc at any time to cancel the extrusion operation +4. **Twist (optional)**: Enable **Twist** in Options. After locking height with a left click (or committing distance with Tab), move the mouse around the face center to set the twist angle, or press Shift+Tab for degrees +5. **Finalize**: Click the left mouse button to confirm and create the extruded shape (second click when Twist is on, after the height lock) +6. **Cancel**: Press Esc at any time to cancel the extrusion operation **Keyboard Shortcuts:** -| | | -| ---------------: | --------------------------------------------------------- | -| E | Activate extrude mode | -| Tab | Open distance input dialog for precise extrusion distance | -| Esc | Cancel current extrusion operation | -| Enter | Finalize extrusion (when using distance input) | +| | | +| -------------------: | ------------------------------------------------------------------------------ | +| E | Activate extrude mode | +| Tab | Open distance input for extrusion height (height phase) | +| Shift+Tab | Open angle input for twist (twist phase, when **Twist** is on) | +| Esc | Cancel current extrusion operation | +| Enter | Commit distance/angle input (height commit enters twist when **Twist** is on) | **Tips:** - Extrude uses an **orthographic** camera (projection mode). Perspective from Normal mode is restored when you leave Extrude. - Extrude works best when the view is not directly parallel to the sketch plane - the system will automatically rotate the view if needed - Use the distance input dialog for precise measurements - The extrusion direction depends on which side of the face plane you move the mouse to -- Dense faces (for example imported cross-section outlines) use a fast drag preview when **Settings -> Sketch -> Appearance -> Extrude fast preview** is enabled; turn it off to always drag a full shaded solid +- Dense faces (for example imported cross-section outlines) use a fast drag preview when **Settings -> Sketch -> Appearance -> Extrude fast preview** is enabled (face copies translate, and rotate when **Twist** is on); turn it off to always drag a full shaded solid - Multiple faces from the same sketch can be extruded separately - Extruded shapes can be used in [boolean operations](#boolean-operations) (cut, fuse, common) diff --git a/src/doc/gui.md b/src/doc/gui.md index f8d5d9c..4f65fc4 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -167,7 +167,7 @@ Always calls `m_view->on_mouse_move(screen_coords)` first. | 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 finalize already consumed the press) | +| 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 | @@ -194,7 +194,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Sketch_inspection_mode` | `options_sketch_common_` | | Each sketch tool mode | Matching `options_sketch_*_mode_` | | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | -| `Sketch_face_extrude` | Both sides, material; help mentions Settings fast preview | +| `Sketch_face_extrude` | Both sides, Twist, material; help mentions Settings fast preview | Shared sketch controls (snap, midpoint nodes, place-from-center) live in `options_sketch_common_` and helpers in `gui_mode.cpp`. diff --git a/src/doc/shape.md b/src/doc/shape.md index 05b7a54..f528dc5 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -145,7 +145,7 @@ Protected helpers used by all operation classes: | 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` | Two-click extrude of `Sketch_face_shp`. Live preview: shaded `MakePrism` for simple faces; when `gui.extrude_fast_preview` is on and edge count exceeds the threshold, translates face copies via AIS `SetLocalTransformation` (both ends when both-sides). `finalize` always bakes `MakePrism` + shaded solid; tmp length dimension reused in place; `cancel`; optional both-sides extrude. | +| `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, intermediates every ~45 deg) when twisted; 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). | @@ -167,7 +167,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::Sketch_face_extrude` | `sketch_face_extrude(..., true)` | 1st click: pick face; 2nd click (view): `m_shp_extrude.finalize()` if active (`GUI` skips `on_left_click_` so the same press does not re-pick) | `dimension_input` / `on_enter` refresh preview | `m_shp_extrude.cancel` | +| `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 | diff --git a/src/doc/sketch.md b/src/doc/sketch.md index c284415..aac7823 100644 --- a/src/doc/sketch.md +++ b/src/doc/sketch.md @@ -106,7 +106,7 @@ The viewer and `GUI` forward pointer and keyboard events to the **current** sket | Right click (finish line / multi-line) | `GUI::on_mouse_button` | `finalize_elm` | | Mode change | (via `GUI::set_mode`) | `on_mode` | -Face extrude mode (`Mode::Sketch_face_extrude`) routes mouse, Tab, and Enter through `Occt_view::sketch_face_extrude` / `Shp_extrude` instead of the sketch tool paths above. +Face extrude mode (`Mode::Sketch_face_extrude`) routes mouse, Tab (height), Shift+Tab (twist angle when Twist is on), and Enter through `Occt_view` / `Shp_extrude` instead of the sketch tool paths above. Full GLFW -> `GUI` -> view routing: [`src/doc/gui.md`](gui.md). diff --git a/src/gui.cpp b/src/gui.cpp index 02b8d97..ee95c55 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -3748,8 +3748,8 @@ void GUI::on_mouse_button(int button, int action, int mods) return; } - // Sample before the view call: Occt_view finalizes an active extrude on LMB press and clears - // the session. Skipping on_left_click_ prevents the same click from re-picking the face. + // Sample before the view call: Occt_view advances or finalizes an active extrude on LMB press. + // Skipping on_left_click_ prevents the same click from re-picking the face (or restarting height). const bool extrude_finalize_on_press = button == GLFW_MOUSE_BUTTON_LEFT && action == GLFW_PRESS && mods == 0 && m_mode == Mode::Sketch_face_extrude && m_view->shp_extrude().has_active_extrusion(); diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 57adc44..cc308c6 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -947,6 +947,14 @@ void GUI::options_sketch_face_extrude_mode_() if (ImGui::Checkbox("##extrude_both_sides", &extrude_both_sides)) m_view->shp_extrude().set_both_sides(extrude_both_sides); + bool extrude_twist = m_view->shp_extrude().get_twist(); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + options_right_aligned_label_("Twist"); + ImGui::TableSetColumnIndex(1); + if (ImGui::Checkbox("##extrude_twist", &extrude_twist)) + m_view->shp_extrude().set_twist(extrude_twist); + const std::vector& material_names = occt_material_combo_labels_(); int current_item = int(m_view->get_default_material().Name()); if (current_item < 0 || current_item >= static_cast(material_names.size())) @@ -1280,6 +1288,7 @@ float GUI::options_sketch_label_col_w_() const if (get_mode() == Mode::Sketch_face_extrude) { sketch_label_col_w = std::max(sketch_label_col_w, ImGui::CalcTextSize("Both sides").x); + sketch_label_col_w = std::max(sketch_label_col_w, ImGui::CalcTextSize("Twist").x); sketch_label_col_w = std::max(sketch_label_col_w, ImGui::CalcTextSize("Material").x); } diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 3752344..129b5ba 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -1780,6 +1780,9 @@ void Occt_view::dimension_input(const ScreenCoords& screen_coords) switch (get_mode()) { case Mode::Sketch_face_extrude: + // Tab is height-only; no-op during twist phase. + if (m_shp_extrude.is_twist_phase()) + return; m_show_dim_input = true; sketch_face_extrude(screen_coords, true); break; @@ -1790,7 +1793,15 @@ void Occt_view::dimension_input(const ScreenCoords& screen_coords) } } -void Occt_view::angle_input(const ScreenCoords& screen_coords) { curr_sketch().angle_input(screen_coords); } +void Occt_view::angle_input(const ScreenCoords& screen_coords) +{ + if (get_mode() == Mode::Sketch_face_extrude) + { + m_shp_extrude.begin_angle_input(screen_coords); + return; + } + curr_sketch().angle_input(screen_coords); +} double Occt_view::get_dimension_scale() const { return m_dimension_scale; } @@ -2412,7 +2423,8 @@ void Occt_view::on_mouse_button(int theButton, int theAction, int theMods) if (m_shp_extrude.has_active_extrusion()) { - m_shp_extrude.finalize(); + // Height phase + Twist: lock height and enter twist. Otherwise finalize. + m_shp_extrude.on_left_click(); return; } diff --git a/src/shp_extrude.cpp b/src/shp_extrude.cpp index b5b654a..22e829f 100644 --- a/src/shp_extrude.cpp +++ b/src/shp_extrude.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include #include @@ -16,6 +18,7 @@ #include "skt.h" #include "shp_delta.h" #include "utl.h" +#include "utl_occt.h" namespace { @@ -26,6 +29,30 @@ size_t count_shape_edges_(const TopoDS_Shape& shape) ++n; return n; } + +gp_Pnt centroid_of_verts_(const std::vector& verts) +{ + EZY_ASSERT(!verts.empty()); + gp_XYZ sum(0.0, 0.0, 0.0); + for (const gp_Pnt& p : verts) + sum += p.XYZ(); + sum /= static_cast(verts.size()); + return gp_Pnt(sum); +} + +TopoDS_Wire transform_wire_(const TopoDS_Wire& wire, const gp_Trsf& trsf) +{ + return TopoDS::Wire(BRepBuilderAPI_Transform(wire, trsf, true).Shape()); +} + +gp_Trsf section_trsf_(const gp_Ax1& axis, double height_along_axis, double twist_rad) +{ + gp_Trsf rot; + rot.SetRotation(axis, twist_rad); + gp_Trsf trans; + trans.SetTranslation(gp_Vec(axis.Direction()) * height_along_axis); + return trans * rot; +} } // namespace Shp_extrude::Shp_extrude(Occt_view& view) @@ -50,6 +77,11 @@ bool Shp_extrude::begin_face_extrude(const AIS_Shape_ptr& shp) m_to_extrude = shp; m_face_edge_count = count_shape_edges_(shp->Shape()); m_lite_preview_active = false; + m_phase = Phase::Height; + m_twist_angle = 0.0; + m_twist_centroid = centroid_of_verts_(face->verts_3d); + m_show_angle_input = false; + m_entered_twist_deg.reset(); const gp_Ax1& a = m_to_extrude_pln.Axis(); const gp_Ax1& b = m_curr_view_pln.Axis(); @@ -90,9 +122,9 @@ void Shp_extrude::finalize() EZY_ASSERT(m_extruded); EZY_ASSERT(m_last_preview_dist); - // Lite preview only translated face copies. Always bake a shaded MakePrism solid. + // Lite preview only moved face copies. Always bake a shaded solid. clear_lite_other_face_(); - const TopoDS_Shape body = make_prism_body_(*m_last_preview_dist, m_extrude_side); + const TopoDS_Shape body = make_body_(*m_last_preview_dist, m_extrude_side, m_twist_angle); m_extruded->ResetTransformation(); m_extruded->Set(body); m_extruded->set_disp_mode(AIS_Shaded); @@ -102,11 +134,21 @@ void Shp_extrude::finalize() ctx().Remove(m_tmp_dim, false); clear_all(m_to_extrude_pt, m_to_extrude, m_extruded, m_tmp_dim); clear_preview_(); - view().set_show_dim_input(false); - view().set_entered_dim(std::nullopt); + clear_session_inputs_(); ctx().ClearSelected(true); } +void Shp_extrude::on_left_click() +{ + EZY_ASSERT(has_active_extrusion()); + if (m_phase == Phase::Height && m_twist_enabled) + { + lock_height_begin_twist_(); + return; + } + finalize(); +} + bool Shp_extrude::cancel() { clear_lite_other_face_(); @@ -115,8 +157,7 @@ bool Shp_extrude::cancel() bool did_cancel = m_to_extrude_pt.has_value(); clear_all(m_to_extrude_pt, m_to_extrude, m_extruded, m_tmp_dim); clear_preview_(); - view().set_show_dim_input(false); - view().set_entered_dim(std::nullopt); + clear_session_inputs_(); ctx().ClearSelected(true); @@ -139,44 +180,154 @@ void Shp_extrude::set_both_sides(const bool both_sides) update_extrude_preview_(*m_last_preview_dist, m_extrude_side); } +bool Shp_extrude::get_twist() const { return m_twist_enabled; } + +void Shp_extrude::set_twist(const bool twist) +{ + if (m_twist_enabled == twist) + return; + + m_twist_enabled = twist; + + if (!twist && m_phase == Phase::Twist) + { + // Return to editable height preview; drop twist angle. + m_phase = Phase::Height; + m_twist_angle = 0.0; + m_show_angle_input = false; + m_entered_twist_deg.reset(); + view().set_entered_dim(std::nullopt); + view().set_show_dim_input(false); + gui().hide_angle_edit(false); + } + + if (!m_extruded.IsNull() && m_last_preview_dist) + update_extrude_preview_(*m_last_preview_dist, m_extrude_side); +} + +bool Shp_extrude::is_twist_phase() const { return m_phase == Phase::Twist; } + +void Shp_extrude::begin_angle_input(const ScreenCoords& screen_coords) +{ + if (m_phase != Phase::Twist || !m_last_preview_dist) + return; + + m_show_angle_input = true; + update_twist_(screen_coords); +} + +void Shp_extrude::lock_height_begin_twist_() +{ + EZY_ASSERT(m_last_preview_dist); + EZY_ASSERT(m_twist_enabled); + + m_phase = Phase::Twist; + view().set_show_dim_input(false); + // Lock height to the last preview distance (typed or mouse). + view().set_entered_dim(*m_last_preview_dist); + m_twist_angle = 0.0; + m_entered_twist_deg.reset(); + m_show_angle_input = false; + update_extrude_preview_(*m_last_preview_dist, m_extrude_side); +} + void Shp_extrude::_update_extrude(const ScreenCoords& screen_coords) { - // Extrude the face + if (m_phase == Phase::Twist) + update_twist_(screen_coords); + else + update_height_(screen_coords); +} + +void Shp_extrude::update_height_(const ScreenCoords& screen_coords) +{ std::optional p = view().pt3d_on_plane(screen_coords, m_curr_view_pln); - if (p) // TODO report error! - { - double extrude_dist = m_to_extrude_pln.Distance(*p); - if (auto entered_dim = view().get_entered_dim(); entered_dim) - extrude_dist = *entered_dim; + if (!p) + return; - if (extrude_dist <= Precision::Confusion()) - return; + double extrude_dist = m_to_extrude_pln.Distance(*p); + if (auto entered_dim = view().get_entered_dim(); entered_dim) + extrude_dist = *entered_dim; + + if (extrude_dist <= Precision::Confusion()) + return; - const Plane_side cursor_side = side_of_plane(m_to_extrude_pln, *p); - if (cursor_side != Plane_side::On) - m_extrude_side = cursor_side; + const Plane_side cursor_side = side_of_plane(m_to_extrude_pln, *p); + if (cursor_side != Plane_side::On) + m_extrude_side = cursor_side; - double scaled_dist = extrude_dist / view().get_display_to_model_scale(); + const double scaled_dist = extrude_dist / view().get_display_to_model_scale(); - if (view().get_show_dim_input()) + if (view().get_show_dim_input()) + { + auto l = [this](float new_dist, bool do_finalize) { - auto l = [this](float new_dist, bool do_finalize) + const double entered_dist = static_cast(new_dist) * view().get_display_to_model_scale(); + view().set_entered_dim(entered_dist); + if (do_finalize) { - const double entered_dist = static_cast(new_dist) * view().get_display_to_model_scale(); - view().set_entered_dim(entered_dist); - if (do_finalize) - { - view().set_show_dim_input(false); - update_extrude_preview_(entered_dist, m_extrude_side); + view().set_show_dim_input(false); + update_extrude_preview_(entered_dist, m_extrude_side); + if (m_twist_enabled) + lock_height_begin_twist_(); + else finalize(); - } - }; + } + }; - gui().set_dist_edit(float(scaled_dist), std::move(std::function(l)), screen_coords); - } + gui().set_dist_edit(float(scaled_dist), std::move(std::function(l)), screen_coords); + } + + update_extrude_preview_(extrude_dist, m_extrude_side); +} + +void Shp_extrude::update_twist_(const ScreenCoords& screen_coords) +{ + EZY_ASSERT(m_last_preview_dist); + + std::optional p = view().pt3d_on_plane(screen_coords, m_to_extrude_pln); + if (!p) + return; + + double twist_rad = m_twist_angle; + if (m_show_angle_input && m_entered_twist_deg) + twist_rad = to_radians(*m_entered_twist_deg); + else + { + const gp_Vec v(m_twist_centroid, *p); + const gp_Vec x_dir(m_to_extrude_pln.XAxis().Direction()); + const gp_Vec y_dir(m_to_extrude_pln.YAxis().Direction()); + const double vx = v.Dot(x_dir); + const double vy = v.Dot(y_dir); + if (std::hypot(vx, vy) <= Precision::Confusion()) + return; + twist_rad = std::atan2(vy, vx); + if (!m_show_angle_input) + m_entered_twist_deg.reset(); + } - update_extrude_preview_(extrude_dist, m_extrude_side); + m_twist_angle = twist_rad; + + if (m_show_angle_input) + { + auto cb = [this](float new_angle_deg, bool do_finalize) + { + m_entered_twist_deg = static_cast(new_angle_deg); + m_twist_angle = to_radians(*m_entered_twist_deg); + if (m_last_preview_dist) + update_extrude_preview_(*m_last_preview_dist, m_extrude_side); + if (do_finalize) + { + m_show_angle_input = false; + finalize(); + } + }; + + gui().set_angle_edit(float(to_degrees(m_twist_angle)), std::move(std::function(cb)), + screen_coords); } + + update_extrude_preview_(*m_last_preview_dist, m_extrude_side); } bool Shp_extrude::use_lite_preview_() @@ -238,10 +389,11 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane const bool lite = use_lite_preview_(); - // Skip redundant work when height / side / preview mode are unchanged. + // Skip redundant work when height / side / twist / preview mode are unchanged. if (!m_extruded.IsNull() && m_last_preview_dist && m_last_preview_side == side && m_last_preview_both_sides == m_extrude_both_sides && m_lite_preview_active == lite && - std::fabs(extrude_dist - *m_last_preview_dist) <= Precision::Confusion()) + std::fabs(extrude_dist - *m_last_preview_dist) <= Precision::Confusion() && + std::fabs(m_twist_angle - m_last_preview_twist) <= Precision::Angular()) return; // Switching between lite and full needs a fresh AIS object. @@ -254,16 +406,12 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane update_dim_(extrude_dist, side); - const gp_Vec normal_dir(m_to_extrude_pln.Axis().Direction()); - const double side_sign = (side == Plane_side::Front) ? 1.0 : -1.0; - const gp_Vec extrude_vec = normal_dir * (side_sign * extrude_dist); - gp_Vec face_offset(0.0, 0.0, 0.0); - if (m_extrude_both_sides) - face_offset = normal_dir * (-side_sign * (extrude_dist * 0.5)); + const double side_sign = (side == Plane_side::Front) ? 1.0 : -1.0; if (lite) { - // Dense faces: translate copies of the sketch face (Move-style cheap preview). + // Dense faces: move copies of the sketch face (translate + optional twist rotation). + // Side walls are not previewed; finalize builds the real prism / thru-sections solid. if (m_extruded.IsNull()) { m_extruded = new Shp(ctx(), m_to_extrude->Shape()); @@ -271,9 +419,10 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane ctx().Display(m_extruded, AIS_WireFrame, AIS_Shape::SelectionMode(TopAbs_SHAPE), false); } - gp_Trsf far_trsf; - far_trsf.SetTranslation(face_offset + extrude_vec); - m_extruded->SetLocalTransformation(far_trsf); + const gp_Ax1 axis = twist_axis_(); + const double h_far = m_extrude_both_sides ? (0.5 * side_sign * extrude_dist) : (side_sign * extrude_dist); + const double ang_far = m_extrude_both_sides ? (0.5 * m_twist_angle) : m_twist_angle; + m_extruded->SetLocalTransformation(section_trsf_(axis, h_far, ang_far)); if (m_extrude_both_sides) { @@ -284,9 +433,9 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane ctx().Deactivate(m_lite_face_other); } - gp_Trsf near_trsf; - near_trsf.SetTranslation(face_offset); - m_lite_face_other->SetLocalTransformation(near_trsf); + const double h_near = -0.5 * side_sign * extrude_dist; + const double ang_near = -0.5 * m_twist_angle; + m_lite_face_other->SetLocalTransformation(section_trsf_(axis, h_near, ang_near)); ctx().Redisplay(m_lite_face_other, false); } else @@ -298,8 +447,8 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane { clear_lite_other_face_(); - // Simple faces: full shaded solid prism each update. - const TopoDS_Shape body = make_prism_body_(extrude_dist, side); + // Shaded solid: prism when twist ~ 0, thru-sections when twisted. + const TopoDS_Shape body = make_body_(extrude_dist, side, m_twist_angle); if (m_extruded.IsNull()) { @@ -324,6 +473,12 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane m_last_preview_dist = extrude_dist; m_last_preview_side = side; m_last_preview_both_sides = m_extrude_both_sides; + m_last_preview_twist = m_twist_angle; +} + +gp_Ax1 Shp_extrude::twist_axis_() const +{ + return gp_Ax1(m_twist_centroid, m_to_extrude_pln.Axis().Direction()); } TopoDS_Shape Shp_extrude::make_prism_body_(const double extrude_dist, const Plane_side side) const @@ -346,6 +501,44 @@ TopoDS_Shape Shp_extrude::make_prism_body_(const double extrude_dist, const Plan return BRepPrimAPI_MakePrism(face, extrude_vec); } +TopoDS_Shape Shp_extrude::make_body_(const double extrude_dist, const Plane_side side, const double twist_rad) const +{ + EZY_ASSERT(side != Plane_side::On); + EZY_ASSERT(extrude_dist > Precision::Confusion()); + + if (std::fabs(twist_rad) <= Precision::Angular()) + return make_prism_body_(extrude_dist, side); + + const TopoDS_Face face = TopoDS::Face(m_to_extrude->Shape()); + const TopoDS_Wire outer_wire = BRepTools::OuterWire(face); + EZY_ASSERT(!outer_wire.IsNull()); + + const double side_sign = (side == Plane_side::Front) ? 1.0 : -1.0; + const gp_Ax1 axis = twist_axis_(); + + // Param t in [0, 1]: height along signed extrude axis, twist from near to far. + const double h0 = m_extrude_both_sides ? (-0.5 * side_sign * extrude_dist) : 0.0; + const double h1 = m_extrude_both_sides ? (0.5 * side_sign * extrude_dist) : (side_sign * extrude_dist); + const double ang0 = m_extrude_both_sides ? (-0.5 * twist_rad) : 0.0; + const double ang1 = m_extrude_both_sides ? (0.5 * twist_rad) : twist_rad; + + const double abs_ang = std::fabs(twist_rad); + const int n_seg = std::max(1, static_cast(std::ceil(abs_ang / to_radians(45.0)))); + + BRepOffsetAPI_ThruSections maker(true /*isSolid*/, true /*ruled*/); + for (int i = 0; i <= n_seg; ++i) + { + const double t = static_cast(i) / static_cast(n_seg); + const double height = h0 + t * (h1 - h0); + const double ang = ang0 + t * (ang1 - ang0); + maker.AddWire(transform_wire_(outer_wire, section_trsf_(axis, height, ang))); + } + + maker.Build(); + EZY_ASSERT(maker.IsDone()); + return try_make_solid(maker.Shape()); +} + void Shp_extrude::clear_lite_other_face_() { if (!m_lite_face_other.IsNull()) @@ -355,6 +548,16 @@ void Shp_extrude::clear_lite_other_face_() } } +void Shp_extrude::clear_session_inputs_() +{ + view().set_show_dim_input(false); + view().set_entered_dim(std::nullopt); + m_show_angle_input = false; + m_entered_twist_deg.reset(); + gui().hide_angle_edit(false); + gui().hide_dist_edit(false); +} + void Shp_extrude::clear_preview_() { clear_lite_other_face_(); @@ -363,6 +566,9 @@ void Shp_extrude::clear_preview_() m_last_preview_dist.reset(); m_last_preview_side = Plane_side::Front; m_last_preview_both_sides = false; + m_last_preview_twist = 0.0; + m_phase = Phase::Height; + m_twist_angle = 0.0; } void Shp_extrude::refresh_tmp_dimension_style(const Length_dimension_style& style) diff --git a/src/shp_extrude.h b/src/shp_extrude.h index b11fd0c..c5fbaf6 100644 --- a/src/shp_extrude.h +++ b/src/shp_extrude.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -21,22 +22,41 @@ class Shp_extrude : private Shp_operation_base /// Start extruding a known sketch face (list / script); returns false if `shp` is not a valid face. bool begin_face_extrude(const AIS_Shape_ptr& shp); void finalize(); + /// LMB while a preview is active: lock height and enter twist (if Twist on), else finalize. + void on_left_click(); bool cancel(); bool has_active_extrusion() const; bool get_both_sides() const; void set_both_sides(bool both_sides); + bool get_twist() const; + void set_twist(bool twist); + bool is_twist_phase() const; + /// Shift+Tab during twist phase: open angle edit; no-op in height phase. + void begin_angle_input(const ScreenCoords& screen_coords); void refresh_tmp_dimension_style(const Length_dimension_style& style); private: friend class Shp_extrude_access; + enum class Phase + { + Height, + Twist + }; + void _update_extrude(const ScreenCoords& screen_coords); + void update_height_(const ScreenCoords& screen_coords); + void update_twist_(const ScreenCoords& screen_coords); + void lock_height_begin_twist_(); void update_extrude_preview_(double extrude_dist, Plane_side side); void update_dim_(double extrude_dist, Plane_side side); void clear_preview_(); void clear_lite_other_face_(); + void clear_session_inputs_(); bool use_lite_preview_(); + gp_Ax1 twist_axis_() const; TopoDS_Shape make_prism_body_(double extrude_dist, Plane_side side) const; + TopoDS_Shape make_body_(double extrude_dist, Plane_side side, double twist_rad) const; // Face extrude related AIS_Shape_ptr m_to_extrude; @@ -49,10 +69,17 @@ class Shp_extrude : private Shp_operation_base PrsDim_LengthDimension_ptr m_tmp_dim; Plane_side m_extrude_side; bool m_extrude_both_sides{false}; + bool m_twist_enabled{false}; + Phase m_phase{Phase::Height}; + double m_twist_angle{0.0}; // radians, CCW about face centroid / plane normal + gp_Pnt m_twist_centroid; + bool m_show_angle_input{false}; + std::optional m_entered_twist_deg; // Faces with more than the settings threshold use a translated face-copy preview. size_t m_face_edge_count{0}; bool m_lite_preview_active{false}; std::optional m_last_preview_dist; Plane_side m_last_preview_side; bool m_last_preview_both_sides{false}; + double m_last_preview_twist{0.0}; }; diff --git a/tests/skt_test_fixture.cpp b/tests/skt_test_fixture.cpp index 85b6893..05da379 100644 --- a/tests/skt_test_fixture.cpp +++ b/tests/skt_test_fixture.cpp @@ -196,6 +196,35 @@ void Shp_extrude_access::begin_face_extrude(Shp_extrude& extrude, const AIS_Shap // Height plane must not be parallel to the sketch plane or Distance stays ~0. extrude.m_curr_view_pln = gp_Pln(*extrude.m_to_extrude_pt, gp_Dir(0.0, 1.0, 0.0)); extrude.m_to_extrude = face; + extrude.m_phase = Shp_extrude::Phase::Height; + extrude.m_twist_angle = 0.0; + { + gp_XYZ sum(0.0, 0.0, 0.0); + for (const gp_Pnt& p : sketch_face->verts_3d) + sum += p.XYZ(); + sum /= static_cast(sketch_face->verts_3d.size()); + extrude.m_twist_centroid = gp_Pnt(sum); + } + extrude.m_show_angle_input = false; + extrude.m_entered_twist_deg.reset(); extrude.update_extrude_preview_(extrude_dist, extrude.m_extrude_side); } + +void Shp_extrude_access::set_twist(Shp_extrude& extrude, bool twist) { extrude.set_twist(twist); } + +void Shp_extrude_access::set_both_sides(Shp_extrude& extrude, bool both_sides) { extrude.set_both_sides(both_sides); } + +void Shp_extrude_access::lock_height_begin_twist(Shp_extrude& extrude) { extrude.lock_height_begin_twist_(); } + +void Shp_extrude_access::set_twist_angle_rad(Shp_extrude& extrude, double twist_rad) +{ + extrude.m_twist_angle = twist_rad; + if (extrude.m_last_preview_dist) + extrude.update_extrude_preview_(*extrude.m_last_preview_dist, extrude.m_extrude_side); +} + +TopoDS_Shape Shp_extrude_access::make_body(Shp_extrude& extrude, double extrude_dist, Plane_side side, double twist_rad) +{ + return extrude.make_body_(extrude_dist, side, twist_rad); +} diff --git a/tests/skt_test_fixture.h b/tests/skt_test_fixture.h index a70ff24..babd72d 100644 --- a/tests/skt_test_fixture.h +++ b/tests/skt_test_fixture.h @@ -19,6 +19,7 @@ #include #include #include +#include class GUI_access { @@ -75,6 +76,11 @@ class Shp_extrude_access static void set_curr_view_pln(Shp_extrude& extrude, const gp_Pln& pln); /// Start an extrude preview from a known face (bypasses AIS screen picking). static void begin_face_extrude(Shp_extrude& extrude, const AIS_Shape_ptr& face, double extrude_dist); + static void set_twist(Shp_extrude& extrude, bool twist); + static void set_both_sides(Shp_extrude& extrude, bool both_sides); + static void lock_height_begin_twist(Shp_extrude& extrude); + static void set_twist_angle_rad(Shp_extrude& extrude, double twist_rad); + static TopoDS_Shape make_body(Shp_extrude& extrude, double extrude_dist, Plane_side side, double twist_rad); }; struct Headless_guard diff --git a/tests/skt_tests.cpp b/tests/skt_tests.cpp index bf42522..8b18e80 100644 --- a/tests/skt_tests.cpp +++ b/tests/skt_tests.cpp @@ -688,6 +688,75 @@ TEST_F(Sketch_test, ExtrudeSketchFace_EzyCad) EXPECT_EQ(shapes.back()->Shape().ShapeType(), TopAbs_SOLID); } +TEST_F(Sketch_test, ExtrudeSketchFace_Twist_solid) +{ + gp_Pln default_plane(gp::Origin(), gp::DZ()); + Sketch sketch("TwistExtrude", view(), default_plane); + + gp_Pnt2d p1(0, 0), p2(10, 0), p3(10, 5), p4(0, 5); + Sketch_access::add_edge_(sketch, p1, p2); + Sketch_access::add_edge_(sketch, p2, p3); + Sketch_access::add_edge_(sketch, p3, p4); + Sketch_access::add_edge_(sketch, p4, p1); + Sketch_access::update_faces_(sketch); + const auto& faces = Sketch_access::get_faces(sketch); + ASSERT_EQ(faces.size(), 1); + + gui().set_mode(Mode::Sketch_face_extrude); + Shp_extrude& extrude = view().shp_extrude(); + Shp_extrude_access::set_twist(extrude, true); + Shp_extrude_access::begin_face_extrude(extrude, faces[0], 8.0); + ASSERT_TRUE(extrude.has_active_extrusion()); + EXPECT_FALSE(extrude.is_twist_phase()); + + Shp_extrude_access::lock_height_begin_twist(extrude); + EXPECT_TRUE(extrude.is_twist_phase()); + + const double twist_rad = to_radians(45.0); + Shp_extrude_access::set_twist_angle_rad(extrude, twist_rad); + + const TopoDS_Shape body = + Shp_extrude_access::make_body(extrude, 8.0, Plane_side::Front, twist_rad); + EXPECT_EQ(body.ShapeType(), TopAbs_SOLID); + + extrude.finalize(); + auto& shapes = view().get_shapes(); + ASSERT_FALSE(shapes.empty()); + EXPECT_EQ(shapes.back()->Shape().ShapeType(), TopAbs_SOLID); +} + +TEST_F(Sketch_test, ExtrudeSketchFace_Twist_both_sides_solid) +{ + gp_Pln default_plane(gp::Origin(), gp::DZ()); + Sketch sketch("TwistBothSides", view(), default_plane); + + gp_Pnt2d p1(-4, -2), p2(4, -2), p3(4, 2), p4(-4, 2); + Sketch_access::add_edge_(sketch, p1, p2); + Sketch_access::add_edge_(sketch, p2, p3); + Sketch_access::add_edge_(sketch, p3, p4); + Sketch_access::add_edge_(sketch, p4, p1); + Sketch_access::update_faces_(sketch); + const auto& faces = Sketch_access::get_faces(sketch); + ASSERT_EQ(faces.size(), 1); + + gui().set_mode(Mode::Sketch_face_extrude); + Shp_extrude& extrude = view().shp_extrude(); + Shp_extrude_access::set_twist(extrude, true); + Shp_extrude_access::set_both_sides(extrude, true); + Shp_extrude_access::begin_face_extrude(extrude, faces[0], 10.0); + Shp_extrude_access::lock_height_begin_twist(extrude); + + // Symmetric both-sides: +/- half of 90 deg at the ends. + const double twist_rad = to_radians(90.0); + const TopoDS_Shape body = + Shp_extrude_access::make_body(extrude, 10.0, Plane_side::Front, twist_rad); + EXPECT_EQ(body.ShapeType(), TopAbs_SOLID); + + Shp_extrude_access::set_twist_angle_rad(extrude, twist_rad); + extrude.finalize(); + EXPECT_EQ(view().get_shapes().back()->Shape().ShapeType(), TopAbs_SOLID); +} + TEST_F(Sketch_test, AddNode_splits_linear_edge_interior) { gp_Pln default_plane(gp::Origin(), gp::DZ()); From 185ef7b774ffc4b29f6e9efe41316a2c76c006a3 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sat, 25 Jul 2026 16:44:32 -0600 Subject: [PATCH 2/5] Improve --- CHANGELOG.md | 2 +- docs/usage.md | 2 +- src/doc/utility.md | 2 +- src/shp_extrude.cpp | 129 +++++++++++++++++++++++++++++++++++--------- src/shp_extrude.h | 7 +++ src/utl_geom.cpp | 35 +++++++++++- src/utl_geom.h | 5 ++ src/utl_types.h | 2 + 8 files changed, 153 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96080fd..59e91c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Extrude Twist**: Options **Twist** checkbox. Two-phase flow locks height first, then sets twist angle about the face centroid (mouse, or Shift+Tab for degrees). With **Both sides**, ends twist symmetrically by +/- half the angle. Geometry uses thru-sections when twisted; straight prism when twist is zero. Dense-face **Extrude fast preview** also applies during Twist (face copies translate and rotate; finalize builds the solid). +- **Extrude Twist**: Options **Twist** checkbox. Two-phase flow locks height first, then sets twist angle about the face centroid (mouse, or Shift+Tab for degrees). Height length dim is cleared on lock; a temporary angle annotation shows degrees during twist. With **Both sides**, ends twist symmetrically by +/- half the angle. Geometry uses thru-sections when twisted; straight prism when twist is zero. Dense-face **Extrude fast preview** also applies during Twist (face copies translate and rotate; finalize builds the solid). ## [0.4.0] - 2026-07-25 diff --git a/docs/usage.md b/docs/usage.md index dabe1bb..4592b03 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -495,7 +495,7 @@ The extrude tool allows you to create 3D solid shapes by extruding 2D sketch fac | **Automatic view adjustment** | The view automatically rotates if the face plane is parallel to the view plane (within 5 degrees), providing better visibility for the extrusion operation | | **Real-time preview** | See the extrusion update while dragging. Simple faces show a shaded solid. Dense faces (many edges) can use a fast preview that moves face copies (translate, and rotate when **Twist** is on) - enable or tune this in **Settings -> Sketch -> Appearance -> Extrude fast preview**. With **Both sides**, both ends are annotated. Finalize always creates the solid | | **Interactive distance control** | Drag the mouse to adjust extrusion distance, or use the distance input dialog (Tab key) for precise control | -| **Twist** | Options **Twist**: after locking height (click or Tab commit), drag to rotate the far end about the face center, or Shift+Tab for an exact angle. With **Both sides**, the mid-plane stays unrotated and each end twists by half the angle in opposite directions | +| **Twist** | Options **Twist**: after locking height (click or Tab commit), the height dimension is removed and a temporary angle annotation shows twist in degrees. Drag to rotate the far end about the face center, or Shift+Tab for an exact angle. With **Both sides**, the mid-plane stays unrotated and each end twists by half the angle in opposite directions | | **Distance annotation** | A dimension annotation displays the current extrusion distance | | **Bidirectional extrusion** | The extrusion direction is determined by which side of the face plane you move the mouse to | diff --git a/src/doc/utility.md b/src/doc/utility.md index 72a634b..cfcc1fe 100644 --- a/src/doc/utility.md +++ b/src/doc/utility.md @@ -64,7 +64,7 @@ Large module; grouped by concern: | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Plane / 2D | `to_2d`, `to_3d`, `xy_plane`, `sketch_reference_plane`, `Plane_side` | | Profile wires | `make_square_wire`, `make_circle_wire`, `make_slot_wire`, `create_wire_box` | -| Sketch dimensions | `Length_dimension_style`, `create_distance_annotation`, `apply_length_dimension_style` | +| Sketch dimensions | `Length_dimension_style`, `create_distance_annotation`, `create_angle_annotation`, `apply_length_dimension_style`, `apply_angle_dimension_style` | | Analysis | `to_boost` (polygon), `to_boost_ls` (edge `linestring_2d`), `get_shape_bbox_center`, `plane_from_face`, `side_of_plane` | | Tests / debug | `to_wkt_string` (linestring / ring / polygon), `ezy_geom::area`, `is_valid`; Geometry Watch in `scripts/ezycad_graphical_debugging.xml` (`ring_2d` inherits vector as Ring; `linestring_2d` uses named `points` as Linestring). Re-select the XML path in Options after editing it. | diff --git a/src/shp_extrude.cpp b/src/shp_extrude.cpp index 22e829f..57dd8d7 100644 --- a/src/shp_extrude.cpp +++ b/src/shp_extrude.cpp @@ -131,8 +131,9 @@ void Shp_extrude::finalize() m_extruded->set_name(view().get_unique_shape_name("Shape")); add_shp_(m_extruded, true); view().push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*m_extruded)})); - ctx().Remove(m_tmp_dim, false); - clear_all(m_to_extrude_pt, m_to_extrude, m_extruded, m_tmp_dim); + clear_length_dim_(); + clear_angle_dim_(); + clear_all(m_to_extrude_pt, m_to_extrude, m_extruded); clear_preview_(); clear_session_inputs_(); ctx().ClearSelected(true); @@ -153,9 +154,10 @@ bool Shp_extrude::cancel() { clear_lite_other_face_(); ctx().Remove(m_extruded, true); - ctx().Remove(m_tmp_dim, false); + clear_length_dim_(); + clear_angle_dim_(); bool did_cancel = m_to_extrude_pt.has_value(); - clear_all(m_to_extrude_pt, m_to_extrude, m_extruded, m_tmp_dim); + clear_all(m_to_extrude_pt, m_to_extrude, m_extruded); clear_preview_(); clear_session_inputs_(); @@ -196,6 +198,7 @@ void Shp_extrude::set_twist(const bool twist) m_twist_angle = 0.0; m_show_angle_input = false; m_entered_twist_deg.reset(); + clear_angle_dim_(); view().set_entered_dim(std::nullopt); view().set_show_dim_input(false); gui().hide_angle_edit(false); @@ -228,6 +231,7 @@ void Shp_extrude::lock_height_begin_twist_() m_twist_angle = 0.0; m_entered_twist_deg.reset(); m_show_angle_input = false; + clear_length_dim_(); update_extrude_preview_(*m_last_preview_dist, m_extrude_side); } @@ -340,6 +344,8 @@ bool Shp_extrude::use_lite_preview_() void Shp_extrude::update_dim_(const double extrude_dist, const Plane_side side) { + clear_angle_dim_(); + const gp_Vec normal_dir(m_to_extrude_pln.Axis().Direction()); const double side_sign = (side == Plane_side::Front) ? 1.0 : -1.0; const gp_Vec extrude_vec = normal_dir * (side_sign * extrude_dist); @@ -360,26 +366,69 @@ void Shp_extrude::update_dim_(const double extrude_dist, const Plane_side side) if (measure.Magnitude() > Precision::Confusion()) { const gp_Vec plane_norm = measure.Crossed(gp_Vec(m_curr_view_pln.Axis().Direction())); - if (plane_norm.Magnitude() > Precision::Confusion()) - dim_pln = gp_Pln(dim_near, gp_Dir(plane_norm)); - else + if (plane_norm.Magnitude() <= Precision::Confusion()) { // Measurement nearly along the view: fall back to sketch-plane X for a readable plane. const gp_Vec alt = measure.Crossed(gp_Vec(m_to_extrude_pln.XAxis().Direction())); if (alt.Magnitude() > Precision::Confusion()) dim_pln = gp_Pln(dim_near, gp_Dir(alt)); } + else + dim_pln = gp_Pln(dim_near, gp_Dir(plane_norm)); } } // Recreate each update: SetMeasuredGeometry was unreliable when toggling both-sides // (endpoints jump from one-sided to a centered span). - ctx().Remove(m_tmp_dim, false); + clear_length_dim_(); m_tmp_dim = create_distance_annotation(dim_far, dim_near, dim_pln, gui().length_dimension_style()); m_tmp_dim->SetCustomValue(extrude_dist / view().get_display_to_model_scale()); ctx().Display(m_tmp_dim, false); } +void Shp_extrude::update_angle_dim_() +{ + clear_length_dim_(); + + const double radius = twist_dim_radius_(); + if (radius <= Precision::Confusion()) + return; + + // Geometry needs a non-zero opening; label still shows the real twist degrees. + double geom_ang = m_twist_angle; + if (std::fabs(geom_ang) < to_radians(1.0)) + geom_ang = (geom_ang < 0.0) ? -to_radians(1.0) : to_radians(1.0); + + const gp_Vec x_dir(m_to_extrude_pln.XAxis().Direction()); + const gp_Vec y_dir(m_to_extrude_pln.YAxis().Direction()); + const gp_Pnt center = m_twist_centroid; + const gp_Pnt p_ref(center.XYZ() + x_dir.XYZ() * radius); + const gp_Pnt p_cur(center.XYZ() + (x_dir * std::cos(geom_ang) + y_dir * std::sin(geom_ang)).XYZ() * radius); + + clear_angle_dim_(); + m_tmp_angle_dim = + create_angle_annotation(p_ref, center, p_cur, to_degrees(m_twist_angle), gui().length_dimension_style()); + ctx().Display(m_tmp_angle_dim, false); +} + +void Shp_extrude::clear_length_dim_() +{ + if (!m_tmp_dim.IsNull()) + { + ctx().Remove(m_tmp_dim, false); + m_tmp_dim.Nullify(); + } +} + +void Shp_extrude::clear_angle_dim_() +{ + if (!m_tmp_angle_dim.IsNull()) + { + ctx().Remove(m_tmp_angle_dim, false); + m_tmp_angle_dim.Nullify(); + } +} + void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane_side side) { if (extrude_dist <= Precision::Confusion()) @@ -387,11 +436,13 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane EZY_ASSERT(side != Plane_side::On); - const bool lite = use_lite_preview_(); + const bool lite = use_lite_preview_(); + const bool twist_phase_now = (m_phase == Phase::Twist); - // Skip redundant work when height / side / twist / preview mode are unchanged. + // Skip redundant work when height / side / twist / phase / preview mode are unchanged. if (!m_extruded.IsNull() && m_last_preview_dist && m_last_preview_side == side && m_last_preview_both_sides == m_extrude_both_sides && m_lite_preview_active == lite && + m_last_preview_was_twist_phase == twist_phase_now && std::fabs(extrude_dist - *m_last_preview_dist) <= Precision::Confusion() && std::fabs(m_twist_angle - m_last_preview_twist) <= Precision::Angular()) return; @@ -404,7 +455,10 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane m_extruded.Nullify(); } - update_dim_(extrude_dist, side); + if (twist_phase_now) + update_angle_dim_(); + else + update_dim_(extrude_dist, side); const double side_sign = (side == Plane_side::Front) ? 1.0 : -1.0; @@ -419,8 +473,8 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane ctx().Display(m_extruded, AIS_WireFrame, AIS_Shape::SelectionMode(TopAbs_SHAPE), false); } - const gp_Ax1 axis = twist_axis_(); - const double h_far = m_extrude_both_sides ? (0.5 * side_sign * extrude_dist) : (side_sign * extrude_dist); + const gp_Ax1 axis = twist_axis_(); + const double h_far = m_extrude_both_sides ? (0.5 * side_sign * extrude_dist) : (side_sign * extrude_dist); const double ang_far = m_extrude_both_sides ? (0.5 * m_twist_angle) : m_twist_angle; m_extruded->SetLocalTransformation(section_trsf_(axis, h_far, ang_far)); @@ -470,10 +524,11 @@ void Shp_extrude::update_extrude_preview_(const double extrude_dist, const Plane ctx().Redisplay(m_extruded, false); ctx().UpdateCurrentViewer(); - m_last_preview_dist = extrude_dist; - m_last_preview_side = side; - m_last_preview_both_sides = m_extrude_both_sides; - m_last_preview_twist = m_twist_angle; + m_last_preview_dist = extrude_dist; + m_last_preview_side = side; + m_last_preview_both_sides = m_extrude_both_sides; + m_last_preview_twist = m_twist_angle; + m_last_preview_was_twist_phase = twist_phase_now; } gp_Ax1 Shp_extrude::twist_axis_() const @@ -481,6 +536,19 @@ gp_Ax1 Shp_extrude::twist_axis_() const return gp_Ax1(m_twist_centroid, m_to_extrude_pln.Axis().Direction()); } +double Shp_extrude::twist_dim_radius_() const +{ + double radius = 0.0; + if (auto* face = dynamic_cast(m_to_extrude.get())) + { + for (const gp_Pnt& p : face->verts_3d) + radius = std::max(radius, m_twist_centroid.Distance(p)); + } + if (radius <= Precision::Confusion() && m_to_extrude_pt) + radius = m_twist_centroid.Distance(*m_to_extrude_pt); + return radius; +} + TopoDS_Shape Shp_extrude::make_prism_body_(const double extrude_dist, const Plane_side side) const { EZY_ASSERT(side != Plane_side::On); @@ -561,20 +629,29 @@ void Shp_extrude::clear_session_inputs_() void Shp_extrude::clear_preview_() { clear_lite_other_face_(); + clear_length_dim_(); + clear_angle_dim_(); m_face_edge_count = 0; m_lite_preview_active = false; m_last_preview_dist.reset(); - m_last_preview_side = Plane_side::Front; - m_last_preview_both_sides = false; - m_last_preview_twist = 0.0; - m_phase = Phase::Height; - m_twist_angle = 0.0; + m_last_preview_side = Plane_side::Front; + m_last_preview_both_sides = false; + m_last_preview_twist = 0.0; + m_last_preview_was_twist_phase = false; + m_phase = Phase::Height; + m_twist_angle = 0.0; } void Shp_extrude::refresh_tmp_dimension_style(const Length_dimension_style& style) { - if (m_tmp_dim.IsNull()) - return; - apply_length_dimension_style(m_tmp_dim, style); - ctx().Redisplay(m_tmp_dim, true); + if (!m_tmp_dim.IsNull()) + { + apply_length_dimension_style(m_tmp_dim, style); + ctx().Redisplay(m_tmp_dim, true); + } + if (!m_tmp_angle_dim.IsNull()) + { + apply_angle_dimension_style(m_tmp_angle_dim, style); + ctx().Redisplay(m_tmp_angle_dim, true); + } } diff --git a/src/shp_extrude.h b/src/shp_extrude.h index c5fbaf6..d880d44 100644 --- a/src/shp_extrude.h +++ b/src/shp_extrude.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -50,10 +51,14 @@ class Shp_extrude : private Shp_operation_base void lock_height_begin_twist_(); void update_extrude_preview_(double extrude_dist, Plane_side side); void update_dim_(double extrude_dist, Plane_side side); + void update_angle_dim_(); + void clear_length_dim_(); + void clear_angle_dim_(); void clear_preview_(); void clear_lite_other_face_(); void clear_session_inputs_(); bool use_lite_preview_(); + double twist_dim_radius_() const; gp_Ax1 twist_axis_() const; TopoDS_Shape make_prism_body_(double extrude_dist, Plane_side side) const; TopoDS_Shape make_body_(double extrude_dist, Plane_side side, double twist_rad) const; @@ -67,6 +72,7 @@ class Shp_extrude : private Shp_operation_base AIS_Shape_ptr m_lite_face_other; gp_Pln m_curr_view_pln; PrsDim_LengthDimension_ptr m_tmp_dim; + PrsDim_AngleDimension_ptr m_tmp_angle_dim; Plane_side m_extrude_side; bool m_extrude_both_sides{false}; bool m_twist_enabled{false}; @@ -82,4 +88,5 @@ class Shp_extrude : private Shp_operation_base Plane_side m_last_preview_side; bool m_last_preview_both_sides{false}; double m_last_preview_twist{0.0}; + bool m_last_preview_was_twist_phase{false}; }; diff --git a/src/utl_geom.cpp b/src/utl_geom.cpp index 1995ea9..e148942 100644 --- a/src/utl_geom.cpp +++ b/src/utl_geom.cpp @@ -31,7 +31,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -50,6 +52,7 @@ #include #include #include +#include #include // Function to project a 3D point onto a plane and get its 2D (u, v) coordinates @@ -571,7 +574,7 @@ namespace { constexpr double k_dim_text_height_base = 16.0; -Prs3d_DimensionAspect_ptr clone_dimension_aspect(const PrsDim_LengthDimension_ptr& dim) +Prs3d_DimensionAspect_ptr clone_dimension_aspect(const Handle(PrsDim_Dimension)& dim) { if (dim.IsNull()) return new Prs3d_DimensionAspect(); @@ -643,7 +646,7 @@ double length_dimension_auto_flyout(const double edge_len) return std::max(k_min_flyout, edge_len * k_edge_fraction); } -void apply_length_dimension_style(const PrsDim_LengthDimension_ptr& dim, const Length_dimension_style& style) +void apply_dimension_style_(const Handle(PrsDim_Dimension)& dim, const Length_dimension_style& style) { if (dim.IsNull()) return; @@ -709,6 +712,16 @@ void apply_length_dimension_style(const PrsDim_LengthDimension_ptr& dim, const L } } +void apply_length_dimension_style(const PrsDim_LengthDimension_ptr& dim, const Length_dimension_style& style) +{ + apply_dimension_style_(dim, style); +} + +void apply_angle_dimension_style(const PrsDim_AngleDimension_ptr& dim, const Length_dimension_style& style) +{ + apply_dimension_style_(dim, style); +} + void apply_length_dimension_list_hover_style(const PrsDim_LengthDimension_ptr& dim, const float hover_rgb[3], const double hover_line_width) { @@ -865,6 +878,24 @@ PrsDim_LengthDimension_ptr create_distance_annotation(const gp_Pnt2d& p1, const return create_distance_annotation(to_3d(pln, p1), to_3d(pln, p2), pln, style, interior_ref, sketch_faces_for_flyout); } +PrsDim_AngleDimension_ptr create_angle_annotation(const gp_Pnt& p_ref, const gp_Pnt& center, const gp_Pnt& p_cur, + const double degrees, const Length_dimension_style& style) +{ + EZY_ASSERT(unique(p_ref, center)); + EZY_ASSERT(unique(p_cur, center)); + + PrsDim_AngleDimension_ptr dim = new PrsDim_AngleDimension(p_ref, center, p_cur); + apply_angle_dimension_style(dim, style); + + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.2f deg", degrees); + dim->SetCustomValue(TCollection_ExtendedString(buf)); + + const double arm = std::max(center.Distance(p_ref), center.Distance(p_cur)); + dim->SetFlyout(length_dimension_auto_flyout(arm)); + return dim; +} + const gp_Pnt& closest_to_camera(const V3d_View_ptr& view, const std::vector& pnts) { EZY_ASSERT(pnts.size()); diff --git a/src/utl_geom.h b/src/utl_geom.h index a729f24..6073672 100644 --- a/src/utl_geom.h +++ b/src/utl_geom.h @@ -145,6 +145,7 @@ double length_dimension_auto_flyout(double edge_len); /// Apply full dimension aspect (line, text, arrows, extensions). Call `Redisplay` after. void apply_length_dimension_style(const PrsDim_LengthDimension_ptr& dim, const Length_dimension_style& style); +void apply_angle_dimension_style(const PrsDim_AngleDimension_ptr& dim, const Length_dimension_style& style); /// Sketch-list row hover: recolor and thicken the dimension line (call `Redisplay` after). void apply_length_dimension_list_hover_style(const PrsDim_LengthDimension_ptr& dim, const float hover_rgb[3], @@ -163,6 +164,10 @@ PrsDim_LengthDimension_ptr create_distance_annotation(const gp_Pnt2d& p1, const const std::optional& interior_ref = std::nullopt, const std::vector* sketch_faces_for_flyout = nullptr); +/// Temporary angle annotation (apex at `center`, arms through `p_ref` and `p_cur`). Label shows `degrees` with a "deg" suffix. +PrsDim_AngleDimension_ptr create_angle_annotation(const gp_Pnt& p_ref, const gp_Pnt& center, const gp_Pnt& p_cur, + double degrees, const Length_dimension_style& style); + const gp_Pnt& closest_to_camera(const V3d_View_ptr& view, const std::vector& pnts); // Function to compute the area of a face diff --git a/src/utl_types.h b/src/utl_types.h index f88c63a..52173f6 100644 --- a/src/utl_types.h +++ b/src/utl_types.h @@ -43,6 +43,7 @@ class Prs3d_LineAspect; class Prs3d_ShadingAspect; class Prs3d_TextAspect; class PrsDim_LengthDimension; +class PrsDim_AngleDimension; class StdSelect_BRepOwner; class V3d_RectangularGrid; class V3d_View; @@ -90,6 +91,7 @@ using Prs3d_LineAspect_ptr = opencascade::handle; using Prs3d_ShadingAspect_ptr = opencascade::handle; using Prs3d_TextAspect_ptr = opencascade::handle; using PrsDim_LengthDimension_ptr = opencascade::handle; +using PrsDim_AngleDimension_ptr = opencascade::handle; using Sketch_AIS_edge_ptr = opencascade::handle; using Sketch_AIS_node_mark_ptr = opencascade::handle; using Sketch_face_shp_ptr = opencascade::handle; From 82d08dd71f2af5e56e3d79050f299cb6c1ee9d19 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sat, 25 Jul 2026 16:52:00 -0600 Subject: [PATCH 3/5] Improve. --- docs/usage.md | 2 +- src/shp_extrude.cpp | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 4592b03..366a9ba 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -495,7 +495,7 @@ The extrude tool allows you to create 3D solid shapes by extruding 2D sketch fac | **Automatic view adjustment** | The view automatically rotates if the face plane is parallel to the view plane (within 5 degrees), providing better visibility for the extrusion operation | | **Real-time preview** | See the extrusion update while dragging. Simple faces show a shaded solid. Dense faces (many edges) can use a fast preview that moves face copies (translate, and rotate when **Twist** is on) - enable or tune this in **Settings -> Sketch -> Appearance -> Extrude fast preview**. With **Both sides**, both ends are annotated. Finalize always creates the solid | | **Interactive distance control** | Drag the mouse to adjust extrusion distance, or use the distance input dialog (Tab key) for precise control | -| **Twist** | Options **Twist**: after locking height (click or Tab commit), the height dimension is removed and a temporary angle annotation shows twist in degrees. Drag to rotate the far end about the face center, or Shift+Tab for an exact angle. With **Both sides**, the mid-plane stays unrotated and each end twists by half the angle in opposite directions | +| **Twist** | Options **Twist**: after locking height (click or Tab commit), the height dimension is removed and a temporary angle annotation on the extruded front face shows twist in degrees. Drag to rotate the far end about the face center, or Shift+Tab for an exact angle. With **Both sides**, the mid-plane stays unrotated and each end twists by half the angle in opposite directions | | **Distance annotation** | A dimension annotation displays the current extrusion distance | | **Bidirectional extrusion** | The extrusion direction is determined by which side of the face plane you move the mouse to | diff --git a/src/shp_extrude.cpp b/src/shp_extrude.cpp index 57dd8d7..d2a522f 100644 --- a/src/shp_extrude.cpp +++ b/src/shp_extrude.cpp @@ -389,6 +389,7 @@ void Shp_extrude::update_dim_(const double extrude_dist, const Plane_side side) void Shp_extrude::update_angle_dim_() { clear_length_dim_(); + EZY_ASSERT(m_last_preview_dist); const double radius = twist_dim_radius_(); if (radius <= Precision::Confusion()) @@ -399,9 +400,14 @@ void Shp_extrude::update_angle_dim_() if (std::fabs(geom_ang) < to_radians(1.0)) geom_ang = (geom_ang < 0.0) ? -to_radians(1.0) : to_radians(1.0); + const double side_sign = (m_extrude_side == Plane_side::Front) ? 1.0 : -1.0; + const double h_far = + m_extrude_both_sides ? (0.5 * side_sign * *m_last_preview_dist) : (side_sign * *m_last_preview_dist); + const gp_Vec normal_dir(m_to_extrude_pln.Axis().Direction()); + const gp_Pnt center(m_twist_centroid.XYZ() + normal_dir.XYZ() * h_far); + const gp_Vec x_dir(m_to_extrude_pln.XAxis().Direction()); const gp_Vec y_dir(m_to_extrude_pln.YAxis().Direction()); - const gp_Pnt center = m_twist_centroid; const gp_Pnt p_ref(center.XYZ() + x_dir.XYZ() * radius); const gp_Pnt p_cur(center.XYZ() + (x_dir * std::cos(geom_ang) + y_dir * std::sin(geom_ang)).XYZ() * radius); From e6c3f8b6875be1446aded694c47fb96cc46eddce Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sat, 25 Jul 2026 17:04:02 -0600 Subject: [PATCH 4/5] Improve. --- src/utl_geom.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utl_geom.cpp b/src/utl_geom.cpp index e148942..d41ee02 100644 --- a/src/utl_geom.cpp +++ b/src/utl_geom.cpp @@ -891,8 +891,9 @@ PrsDim_AngleDimension_ptr create_angle_annotation(const gp_Pnt& p_ref, const gp_ std::snprintf(buf, sizeof(buf), "%.2f deg", degrees); dim->SetCustomValue(TCollection_ExtendedString(buf)); + // For angle dims, flyout is the arc radius (not a length-dim offset). Match the attach arms. const double arm = std::max(center.Distance(p_ref), center.Distance(p_cur)); - dim->SetFlyout(length_dimension_auto_flyout(arm)); + dim->SetFlyout(arm); return dim; } From f214ae92b091055d6864db7b6924944fa595400b Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sat, 25 Jul 2026 17:14:24 -0600 Subject: [PATCH 5/5] Improve --- CHANGELOG.md | 2 +- src/doc/shape.md | 2 +- src/shp_extrude.cpp | 66 +++++++++++++++++++++++++++++++++++---------- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e91c7..0e6d683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Extrude Twist**: Options **Twist** checkbox. Two-phase flow locks height first, then sets twist angle about the face centroid (mouse, or Shift+Tab for degrees). Height length dim is cleared on lock; a temporary angle annotation shows degrees during twist. With **Both sides**, ends twist symmetrically by +/- half the angle. Geometry uses thru-sections when twisted; straight prism when twist is zero. Dense-face **Extrude fast preview** also applies during Twist (face copies translate and rotate; finalize builds the solid). +- **Extrude Twist**: Options **Twist** checkbox. Two-phase flow locks height first, then sets twist angle about the face centroid (mouse, or Shift+Tab for degrees). Height length dim is cleared on lock; a temporary angle annotation on the extruded front face shows degrees during twist. With **Both sides**, ends twist symmetrically by +/- half the angle. Geometry uses ruled thru-sections with compatibility off (keeps tooth pairing) and cuts twisted hole solids so face bores survive; straight prism when twist is zero. Dense-face **Extrude fast preview** also applies during Twist (face copies translate and rotate; finalize builds the solid). ## [0.4.0] - 2026-07-25 diff --git a/src/doc/shape.md b/src/doc/shape.md index f528dc5..be55f62 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -145,7 +145,7 @@ Protected helpers used by all operation classes: | 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, intermediates every ~45 deg) when twisted; 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_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). | diff --git a/src/shp_extrude.cpp b/src/shp_extrude.cpp index d2a522f..a254514 100644 --- a/src/shp_extrude.cpp +++ b/src/shp_extrude.cpp @@ -1,6 +1,7 @@ #include "shp_extrude.h" #include +#include #include #include #include @@ -10,6 +11,7 @@ #include #include #include +#include #include "utl_dbg.h" #include "utl_geom.h" @@ -53,6 +55,42 @@ gp_Trsf section_trsf_(const gp_Ax1& axis, double height_along_axis, double twist trans.SetTranslation(gp_Vec(axis.Direction()) * height_along_axis); return trans * rot; } + +/// Ruled thru-sections solid from a closed wire with height + twist along `axis`. +/// Compatibility is off so intentional twist keeps edge/vertex pairing. +TopoDS_Shape loft_twisted_wire_(const TopoDS_Wire& wire, const gp_Ax1& axis, const double h0, const double h1, + const double ang0, const double ang1, const int n_seg) +{ + EZY_ASSERT(!wire.IsNull()); + EZY_ASSERT(n_seg >= 1); + + BRepOffsetAPI_ThruSections maker(true /*isSolid*/, true /*ruled*/); + maker.CheckCompatibility(false); + for (int i = 0; i <= n_seg; ++i) + { + const double t = static_cast(i) / static_cast(n_seg); + const double height = h0 + t * (h1 - h0); + const double ang = ang0 + t * (ang1 - ang0); + maker.AddWire(transform_wire_(wire, section_trsf_(axis, height, ang))); + } + + maker.Build(); + EZY_ASSERT(maker.IsDone()); + return try_make_solid(maker.Shape()); +} + +std::vector face_hole_wires_(const TopoDS_Face& face, const TopoDS_Wire& outer_wire) +{ + std::vector holes; + for (TopExp_Explorer ex(face, TopAbs_WIRE); ex.More(); ex.Next()) + { + const TopoDS_Wire w = TopoDS::Wire(ex.Current()); + if (w.IsNull() || w.IsSame(outer_wire)) + continue; + holes.push_back(w); + } + return holes; +} } // namespace Shp_extrude::Shp_extrude(Occt_view& view) @@ -583,7 +621,7 @@ TopoDS_Shape Shp_extrude::make_body_(const double extrude_dist, const Plane_side if (std::fabs(twist_rad) <= Precision::Angular()) return make_prism_body_(extrude_dist, side); - const TopoDS_Face face = TopoDS::Face(m_to_extrude->Shape()); + const TopoDS_Face face = TopoDS::Face(m_to_extrude->Shape()); const TopoDS_Wire outer_wire = BRepTools::OuterWire(face); EZY_ASSERT(!outer_wire.IsNull()); @@ -591,26 +629,26 @@ TopoDS_Shape Shp_extrude::make_body_(const double extrude_dist, const Plane_side const gp_Ax1 axis = twist_axis_(); // Param t in [0, 1]: height along signed extrude axis, twist from near to far. - const double h0 = m_extrude_both_sides ? (-0.5 * side_sign * extrude_dist) : 0.0; - const double h1 = m_extrude_both_sides ? (0.5 * side_sign * extrude_dist) : (side_sign * extrude_dist); - const double ang0 = m_extrude_both_sides ? (-0.5 * twist_rad) : 0.0; - const double ang1 = m_extrude_both_sides ? (0.5 * twist_rad) : twist_rad; + const double h0 = m_extrude_both_sides ? (-0.5 * side_sign * extrude_dist) : 0.0; + const double h1 = m_extrude_both_sides ? (0.5 * side_sign * extrude_dist) : (side_sign * extrude_dist); + const double ang0 = m_extrude_both_sides ? (-0.5 * twist_rad) : 0.0; + const double ang1 = m_extrude_both_sides ? (0.5 * twist_rad) : twist_rad; const double abs_ang = std::fabs(twist_rad); const int n_seg = std::max(1, static_cast(std::ceil(abs_ang / to_radians(45.0)))); - BRepOffsetAPI_ThruSections maker(true /*isSolid*/, true /*ruled*/); - for (int i = 0; i <= n_seg; ++i) + TopoDS_Shape body = loft_twisted_wire_(outer_wire, axis, h0, h1, ang0, ang1, n_seg); + + // MakePrism keeps face holes; loft only the outer wire then cut matching twisted hole solids. + for (const TopoDS_Wire& hole : face_hole_wires_(face, outer_wire)) { - const double t = static_cast(i) / static_cast(n_seg); - const double height = h0 + t * (h1 - h0); - const double ang = ang0 + t * (ang1 - ang0); - maker.AddWire(transform_wire_(outer_wire, section_trsf_(axis, height, ang))); + const TopoDS_Shape hole_body = loft_twisted_wire_(hole, axis, h0, h1, ang0, ang1, n_seg); + BRepAlgoAPI_Cut cut(body, hole_body); + EZY_ASSERT(cut.IsDone()); + body = try_make_solid(cut.Shape()); } - maker.Build(); - EZY_ASSERT(maker.IsDone()); - return try_make_solid(maker.Shape()); + return body; } void Shp_extrude::clear_lite_other_face_()