diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93e9f7b..0e6d683 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). 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
### 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..366a9ba 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), 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 |
@@ -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..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` | 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, `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). |
@@ -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/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/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..a254514 100644
--- a/src/shp_extrude.cpp
+++ b/src/shp_extrude.cpp
@@ -1,13 +1,17 @@
#include "shp_extrude.h"
#include
+#include
#include
+#include
#include
+#include
#include
#include
#include
#include
#include
+#include
#include "utl_dbg.h"
#include "utl_geom.h"
@@ -16,6 +20,7 @@
#include "skt.h"
#include "shp_delta.h"
#include "utl.h"
+#include "utl_occt.h"
namespace
{
@@ -26,6 +31,66 @@ 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;
+}
+
+/// 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)
@@ -50,6 +115,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,33 +160,44 @@ 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);
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_();
- 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_();
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_();
- view().set_show_dim_input(false);
- view().set_entered_dim(std::nullopt);
+ clear_session_inputs_();
ctx().ClearSelected(true);
@@ -139,44 +220,156 @@ 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();
+ clear_angle_dim_();
+ 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;
+ clear_length_dim_();
+ 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();
+ }
+
+ 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();
+ }
+ };
- update_extrude_preview_(extrude_dist, m_extrude_side);
+ 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_()
@@ -189,6 +382,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);
@@ -209,26 +404,75 @@ 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_();
+ EZY_ASSERT(m_last_preview_dist);
+
+ 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 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 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())
@@ -236,12 +480,15 @@ 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 / 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 &&
- std::fabs(extrude_dist - *m_last_preview_dist) <= Precision::Confusion())
+ 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;
// Switching between lite and full needs a fresh AIS object.
@@ -252,18 +499,17 @@ 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 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 +517,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 +531,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 +545,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())
{
@@ -321,9 +568,29 @@ 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_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
+{
+ 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
@@ -346,6 +613,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))));
+
+ 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 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());
+ }
+
+ return body;
+}
+
void Shp_extrude::clear_lite_other_face_()
{
if (!m_lite_face_other.IsNull())
@@ -355,20 +660,42 @@ 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_();
+ 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_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 b11fd0c..d880d44 100644
--- a/src/shp_extrude.h
+++ b/src/shp_extrude.h
@@ -1,6 +1,8 @@
#pragma once
+#include
#include
+#include
#include
#include
#include
@@ -21,22 +23,45 @@ 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 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;
// Face extrude related
AIS_Shape_ptr m_to_extrude;
@@ -47,12 +72,21 @@ 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};
+ 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};
+ bool m_last_preview_was_twist_phase{false};
};
diff --git a/src/utl_geom.cpp b/src/utl_geom.cpp
index 1995ea9..d41ee02 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,25 @@ 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));
+
+ // 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(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;
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());