diff --git a/CHANGELOG.md b/CHANGELOG.md
index c75f243..04955a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Shape local frame**: Shape List right-click on a solid toggles **Show axes** / **Show plane** / **Show up**, **Reset frame to bbox**, **Set from planar/cylindrical face**, and **Flip up** / **Flip axis (Z)**. Frame display flags persist in `.ezy` as `frameDisplay`. Face picks use `Mode::Shape_set_frame` (no toolbar hotkey).
+
- **Shape List Zoom to**: right-click a shape or group name (or the **M** button on a solid) and choose **Zoom to** to frame that solid, or all descendant solids of a group, in the 3D view while keeping the current camera orientation.
- **Shape copy / paste** (Ctrl+C / Ctrl+V): in-app clipboard for selected solids, or a Shape List group subtree when the selection matches that group's descendant solids. Paste deep-copies under the current group (new ids, uniquified names, same pose) with one undo step. Survives **New** so you can copy, start a blank project, and paste. Remappable as **`edit.copy`** / **`edit.paste`**. Does not use the OS clipboard; sketch geometry is not copied.
diff --git a/docs/usage.md b/docs/usage.md
index 5a33d1f..666df3a 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -126,7 +126,12 @@ Each row (left to right):
- **M** - Solids only: material popup; right-click for **Zoom to**, **Shape info...**, or **Delete**.
- **Name** - Expandable tree row with an editable name. Click the row to select that solid (or all descendant solids for a group) and update the current group. **Ctrl+click** toggles multi-select. Drag to reparent (empty area below the list = document root).
- **Copy / Paste** - Ctrl+C / Ctrl+V (remappable) copy selected solids, or a whole group subtree when you clicked that group (all its descendant solids are selected). Paste inserts an independent deep copy under the current group, at the same pose. If you still have that copied group as the current group, paste places a sibling copy beside it (not nested inside it). This is an in-app clipboard (survives **New**; it does not use the system clipboard). Sketch edges are not copied.
-- **Right-click the name** - Solids: **Zoom to** / **Shape info...** / **Delete**. Groups: **Zoom to** (fits all descendant solids; disabled when the group has none) / **Ungroup** (moves **all** direct children to the group's parent, then removes the group) / **Delete** (cascade-deletes the whole subtree). **Zoom to** keeps the current camera orientation and frames the solid or group with a little padding.
+- **Right-click the name** - Solids: **Zoom to** / **Shape info...** / local-frame display and edit (below) / **Delete**. Groups: **Zoom to** (fits all descendant solids; disabled when the group has none) / **Ungroup** (moves **all** direct children to the group's parent, then removes the group) / **Delete** (cascade-deletes the whole subtree). **Zoom to** keeps the current camera orientation and frames the solid or group with a little padding.
+- **Local frame (solids)** - Each solid has a local `XYZ` frame (default: world-aligned at the bounding-box center; **Z** is the primary axis, **Y** is up). Right-click the name or **M** for:
+ - **Show axes** / **Show plane** / **Show up** - toggle 3D annotations (off by default; saved in the `.ezy` file; hidden while any sketch tool is active, when **Hide all** is on, or when the solid or an ancestor group is hidden).
+ - **Reset frame to bbox** - world-aligned frame at the bbox center (turns **Show axes** on).
+ - **Set from planar face...** / **Set from cylindrical face...** - pick a face on that solid; plane sets **Z** = normal with origin at the face area center (circle center for a disk); cylinder sets **Z** = axis with origin = solid center projected onto the axis.
+ - **Flip up** / **Flip axis (Z)** - reverse the up direction or primary axis (undoable).
Boolean results stay under the shared parent of their inputs when all inputs share one parent; otherwise they are placed at the document root. **File -> Import** STEP assemblies use **Import as** (default **Preserve hierarchy**) to keep product/assembly groups in the tree, import **Flat solids** at the root, or **Union shapes** into one solid.
diff --git a/src/doc/gui.md b/src/doc/gui.md
index 948be79..baf8bb8 100644
--- a/src/doc/gui.md
+++ b/src/doc/gui.md
@@ -40,11 +40,11 @@ Typical responsibilities:
| 3 | `sync_sketch_add_mid_pt_edges_if_applicable_()` |
| 4 | Update toolbar active state |
-`set_parent_mode()` maps each tool mode back to `Normal` or `Sketch_inspection_mode` via `GUI::parent_mode_of` (see parent map in `gui_mode.cpp`). Undo/redo uses the same map so stored `Move` / `Rotate` / `Scale` restore their parent instead of re-entering the free-drag tool (see [undo-redo.md](undo-redo.md#mode-restoration)).
+`set_parent_mode()` maps each tool mode back to `Normal` or `Sketch_inspection_mode` via `GUI::parent_mode_of` (see parent map in `gui_mode.cpp`). Undo/redo uses the same map so stored `Move` / `Rotate` / `Scale` / `Shape_shaft_align` / `Shape_set_frame` restore their parent instead of re-entering a free-drag or face-pick tool (see [undo-redo.md](undo-redo.md#mode-restoration)).
### New mode or toolbar command (hotkeys)
-When adding a `Mode` to [`mode.h`](../mode.h) (`EZY_MODE_LIST`), a toolbar button, or a one-shot `Command`, update remappable hotkeys in the **same change**. Skip only for modes that must stay toolbar-only (document that choice).
+When adding a `Mode` to [`mode.h`](../mode.h) (`EZY_MODE_LIST`), a toolbar button, or a one-shot `Command`, update remappable hotkeys in the **same change**. Skip only for modes that must stay toolbar-only (document that choice). `Mode::Shape_set_frame` is Shape List-only (no toolbar button / remappable hotkey).
| Step | Touch |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -182,6 +182,7 @@ See also [`src/doc/sketch.md`](sketch.md) and [`src/doc/shape.md`](shape.md) for
| `Rotate` | `shp_rotate().rotate_selected` |
| `Scale` | `shp_scale().scale_selected` |
| `Shape_shaft_align` | `shp_cyl_align().drag_depth` / `drag_twist` |
+| `Shape_set_frame` | `options_shape_set_frame_mode_` (Shape List only; no toolbar/hotkey) |
| `Shape_polar_duplicate` | `shp_polar_dup().move_point` |
| Sketch tool modes (line, arc, rect, dim, axis, ...) | `curr_sketch().sketch_pt_move` |
| `Sketch_face_extrude` | `sketch_face_extrude(..., true)` |
diff --git a/src/doc/shape.md b/src/doc/shape.md
index e264395..41b8652 100644
--- a/src/doc/shape.md
+++ b/src/doc/shape.md
@@ -33,7 +33,7 @@ Typical uses:
### Lifetime and ownership
- Shapes are stored in `Occt_view::m_shps` (`std::list`). Access via `get_shapes()` or internal `add_shp_()`.
-- Each solid stores a `gp_Ax3` local frame. New geometry defaults to a world-aligned frame at its bounding-box center. Baked move/rotate/scale transforms update the frame; project JSON and shape undo records preserve it.
+- Each solid stores a `gp_Ax3` local frame (**Z** = primary axis, **Y** = up). New geometry defaults to a world-aligned frame at its bounding-box center. Baked move/rotate/scale transforms update the frame; project JSON and shape undo records preserve it. Shape List right-click toggles **Show axes** / **Show plane** / **Show up** AIS annotations (`frameDisplay` in `.ezy`), and can **Reset**, **Set from planar/cylindrical face** (`Mode::Shape_set_frame`, no toolbar/hotkey; planar origin = face area centroid), or **Flip up** / **Flip axis** via `Occt_view::set_shape_frame` (frame-only `Shape_geom_delta`). Call `clear_frame_display()` before `Remove` from the interactive context (user delete via `Occt_view::remove`, fillet/chamfer `replace_picked_shape_`, load, view dtor). `~Shp` only Nullifies those handles -- the context may already be dying.
- `Shp_ptr` is `opencascade::handle`. New shapes are allocated with `new Shp(ctx(), topo_shape)` then registered through `Occt_view::add_shp_()`. Groups use `Shp::create_group` (empty compound, never displayed).
- Hierarchy: `parent_id` (0 = root) + `sibling_order`. Organizational groups only (no transform inheritance). Helpers: `shape_children`, `shape_descendant_solids`, `group_shapes`, `ungroup_shape`, `reparent_shape`, `would_reparent_create_cycle`.
- **Current group** (`Occt_view::current_group_id`, 0 = root): Shape List click sets it; empty groups are valid. Primitives / extrude / revolve / PLY / unioned STEP import (`Step_import_mode::Union_shapes`) call `add_shp_(..., use_current_group=true)` so new solids land under that group. Hierarchical and flat STEP import place roots at document root (`parent_id` 0). STEP import may prepare geometry off-thread (`prepare_step_import`) then `commit_step_import` on the UI thread. Booleans keep `assign_result_parent_`.
@@ -122,7 +122,7 @@ class Shp : public AIS_Shape {
};
```
-`set_visible` stores the user preference. `Occt_view::sync_sketch_shape_faint_style` applies effective visibility (own flag, ancestor groups, Hide all overlay, sketch faint/hide) via `apply_context_shown` so Hide all does not stomp per-shape flags. `update_display_()` re-binds selection after mode changes.
+`set_visible` stores the user preference. `Occt_view::sync_sketch_shape_faint_style` applies effective visibility (own flag, ancestor groups, Hide all overlay, sketch faint/hide) via `apply_context_shown` so Hide all does not stomp per-shape flags. Frame AIS (axes/plane/up) use the same `show` result: `set_frame_display_suppressed(sketch || !show)` and `apply_context_shown(false)` clears overlays without changing the Shape List toggle flags. `update_display_()` re-binds selection after mode changes.
`.ezy` `shapes[]` entries include `id`, `name`, `parentId`, `order`, `visible`, and either `isGroup: true` or `material` + `geom` + `frame`. Undo uses `Shape_rec` (including the local frame) plus `Shape_tree_delta` for reparent/group/ungroup.
@@ -154,6 +154,7 @@ Protected helpers used by all operation classes:
| `shp_rotate.h` | `Shp_rotate` | Rotate about view axis, global X/Y/Z, or view-to-object; angle Tab entry; optional axis/center AIS guides. |
| `shp_scale.h` | `Shp_scale` | Uniform scale from bbox center vs mouse distance; clamped factor 0.01..100. |
| `shp_cyl_align.h` | `Shp_cyl_align` | Pick two cylindrical faces (first moves); coaxial `cyl_align_trsf`; drag axial depth; Options **Clock rotation** (default off) then LMB / Shift+Tab about shared axis; Options flip; bake like Move. |
+| `shp_set_frame.h` | `Shp_set_frame` | Shape List-only: pick planar or cylindrical face to redefine `Shp` local frame; enables Show axes. |
| `shp_fillet.h` | `Shp_fillet` | `add_fillet(..., Fillet_mode)` -- `BRepFilletAPI_MakeFillet`; modes: Shape, Face, Wire, Edge (`mode.h`). |
| `shp_chamfer.h` | `Shp_chamfer` | `add_chamfer(..., Chamfer_mode)` -- diagonal distance converted to setback (`dist/sqrt(2)`). |
| `shp_polar_dup.h` | `Shp_polar_dup` | Arm on sketch plane; `dup()` copies selection at polar steps; options: rotate copies, combine into one solid. |
diff --git a/src/doc/undo-redo.md b/src/doc/undo-redo.md
index 9e44074..897f04f 100644
--- a/src/doc/undo-redo.md
+++ b/src/doc/undo-redo.md
@@ -50,7 +50,7 @@ This prevents re-entrant history pushes while applying a step.
### Mode restoration
-After applying a delta or reloading a snapshot, `Occt_view` restores a UI mode via `GUI::set_mode`. Stored `Move` / `Rotate` / `Scale` are mapped to their parent mode (`GUI::parent_mode_of`, normally `Normal`) so undo/redo does not re-enter a free-drag tool that would immediately move the current selection under the cursor. Other stored modes are restored as-is. If the restored mode is `Sketch_inspection_mode`, the sketch list pane is shown again.
+After applying a delta or reloading a snapshot, `Occt_view` restores a UI mode via `GUI::set_mode`. Stored `Move` / `Rotate` / `Scale` / `Shape_shaft_align` / `Shape_set_frame` are mapped to their parent mode (`GUI::parent_mode_of`, normally `Normal`) so undo/redo does not re-enter a free-drag tool or face-pick with no target. Other stored modes are restored as-is. If the restored mode is `Sketch_inspection_mode`, the sketch list pane is shown again.
### View camera
diff --git a/src/gui.cpp b/src/gui.cpp
index 4f101da..43cf7ac 100644
--- a/src/gui.cpp
+++ b/src/gui.cpp
@@ -2735,6 +2735,53 @@ void GUI::shape_list_()
if (ImGui::MenuItem("Shape info..."))
open_shape_info_(shape);
+ if (true)
+ {
+ ImGui::Separator();
+ bool show_axes = shape->show_frame_axes();
+ if (ImGui::MenuItem("Show axes", nullptr, show_axes))
+ shape->set_show_frame_axes(!show_axes);
+ bool show_plane = shape->show_frame_plane();
+ if (ImGui::MenuItem("Show plane", nullptr, show_plane))
+ shape->set_show_frame_plane(!show_plane);
+ bool show_up = shape->show_frame_up();
+ if (ImGui::MenuItem("Show up", nullptr, show_up))
+ shape->set_show_frame_up(!show_up);
+ ImGui::Separator();
+ if (ImGui::MenuItem("Reset frame to bbox"))
+ {
+ select_shape_row(shape);
+ m_view->set_shape_frame(shape, Shp::default_frame_for(shape->Shape()));
+ shape->set_show_frame_axes(true);
+ }
+ if (ImGui::MenuItem("Set from planar face..."))
+ {
+ select_shape_row(shape);
+ m_view->shp_set_frame().begin(shape, Shp_set_frame::Pick::Planar_face);
+ set_mode(Mode::Shape_set_frame);
+ }
+ if (ImGui::MenuItem("Set from cylindrical face..."))
+ {
+ select_shape_row(shape);
+ m_view->shp_set_frame().begin(shape, Shp_set_frame::Pick::Cylindrical_face);
+ set_mode(Mode::Shape_set_frame);
+ }
+ if (ImGui::MenuItem("Flip up"))
+ {
+ select_shape_row(shape);
+ gp_Ax3 f = shape->get_frame();
+ f.XReverse();
+ m_view->set_shape_frame(shape, f);
+ }
+ if (ImGui::MenuItem("Flip axis (Z)"))
+ {
+ select_shape_row(shape);
+ gp_Ax3 f = shape->get_frame();
+ f.ZReverse();
+ m_view->set_shape_frame(shape, f);
+ }
+ }
+
if (ImGui::MenuItem("Delete"))
shape_to_delete = shape;
@@ -2838,6 +2885,53 @@ void GUI::shape_list_()
if (is_group && ImGui::MenuItem("Ungroup"))
shape_to_ungroup_id = shape->get_id();
+ if (!is_group)
+ {
+ ImGui::Separator();
+ bool show_axes = shape->show_frame_axes();
+ if (ImGui::MenuItem("Show axes", nullptr, show_axes))
+ shape->set_show_frame_axes(!show_axes);
+ bool show_plane = shape->show_frame_plane();
+ if (ImGui::MenuItem("Show plane", nullptr, show_plane))
+ shape->set_show_frame_plane(!show_plane);
+ bool show_up = shape->show_frame_up();
+ if (ImGui::MenuItem("Show up", nullptr, show_up))
+ shape->set_show_frame_up(!show_up);
+ ImGui::Separator();
+ if (ImGui::MenuItem("Reset frame to bbox"))
+ {
+ select_shape_row(shape);
+ m_view->set_shape_frame(shape, Shp::default_frame_for(shape->Shape()));
+ shape->set_show_frame_axes(true);
+ }
+ if (ImGui::MenuItem("Set from planar face..."))
+ {
+ select_shape_row(shape);
+ m_view->shp_set_frame().begin(shape, Shp_set_frame::Pick::Planar_face);
+ set_mode(Mode::Shape_set_frame);
+ }
+ if (ImGui::MenuItem("Set from cylindrical face..."))
+ {
+ select_shape_row(shape);
+ m_view->shp_set_frame().begin(shape, Shp_set_frame::Pick::Cylindrical_face);
+ set_mode(Mode::Shape_set_frame);
+ }
+ if (ImGui::MenuItem("Flip up"))
+ {
+ select_shape_row(shape);
+ gp_Ax3 f = shape->get_frame();
+ f.XReverse();
+ m_view->set_shape_frame(shape, f);
+ }
+ if (ImGui::MenuItem("Flip axis (Z)"))
+ {
+ select_shape_row(shape);
+ gp_Ax3 f = shape->get_frame();
+ f.ZReverse();
+ m_view->set_shape_frame(shape, f);
+ }
+ }
+
if (ImGui::MenuItem("Delete"))
shape_to_delete = shape;
@@ -3924,6 +4018,10 @@ void GUI::on_left_click_(const ScreenCoords& screen_coords)
else if (Status s = m_view->shp_cyl_align().pick(screen_coords); !s.is_ok())
show_message(s.message());
break;
+ case Mode::Shape_set_frame:
+ if (Status s = m_view->shp_set_frame().pick(screen_coords); !s.is_ok())
+ show_message(s.message());
+ break;
case Mode::Sketch_face_extrude: m_view->sketch_face_extrude(screen_coords, false); break;
// clang-format on
diff --git a/src/gui.h b/src/gui.h
index 31a596a..e7949fa 100644
--- a/src/gui.h
+++ b/src/gui.h
@@ -457,6 +457,7 @@ class GUI
void options_shape_polar_duplicate_mode_();
void options_shape_cross_section_mode_();
void options_shape_shaft_align_mode_();
+ void options_shape_set_frame_mode_();
void options_sketch_from_planer_face_mode_();
void options_sketch_operation_axis_mode_();
void options_sketch_face_extrude_mode_();
diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp
index 8e9b5ed..0c00b65 100644
--- a/src/gui_mode.cpp
+++ b/src/gui_mode.cpp
@@ -66,6 +66,7 @@ std::string GUI::get_doc_url_for_mode(Mode mode)
{Mode::Sketch_add_slot, "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#slot-creation-tool"},
{Mode::Sketch_dim_anno, "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#dimension-tool"},
{Mode::Shape_cross_section, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-cross-section-tool"},
+ {Mode::Shape_set_frame, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-list"},
// clang-format on
};
@@ -87,6 +88,10 @@ const char* GUI::current_mode_description_() const
if (std::get(b.data) == m_mode)
return b.tooltip.c_str();
+ // Modes entered only from Shape List / menus (no toolbar button).
+ if (m_mode == Mode::Shape_set_frame)
+ return "Set local frame";
+
EZY_ASSERT_MSG(false, "Current mode not found in toolbar buttons");
return "";
}
@@ -145,6 +150,7 @@ Mode GUI::parent_mode_of(Mode mode)
{Mode::Sketch_add_slot, Mode::Sketch_inspection_mode},
{Mode::Sketch_dim_anno, Mode::Sketch_inspection_mode},
{Mode::Shape_cross_section, Mode::Normal},
+ {Mode::Shape_set_frame, Mode::Normal},
// clang-format on
};
@@ -510,6 +516,7 @@ void GUI::options_()
case Mode::Shape_fillet: options_shape_fillet_mode_(); break;
case Mode::Shape_polar_duplicate: options_shape_polar_duplicate_mode_(); break;
case Mode::Shape_cross_section: options_shape_cross_section_mode_(); break;
+ case Mode::Shape_set_frame: options_shape_set_frame_mode_(); break;
// Sketch related modes:
case Mode::Sketch_inspection_mode: options_sketch_inspection_mode_(); break;
@@ -658,6 +665,25 @@ void GUI::options_shape_shaft_align_mode_()
options_orthographic_projection_();
}
+void GUI::options_shape_set_frame_mode_()
+{
+ EZY_ASSERT(get_mode() == Mode::Shape_set_frame);
+
+ ImGui::TextUnformatted("Set local frame");
+ options_doc_help_button_();
+ ImGui::Separator();
+
+ const auto pick = m_view->shp_set_frame().get_pick();
+ if (pick == Shp_set_frame::Pick::Planar_face)
+ ImGui::TextWrapped("Click a planar face on the selected solid. Z becomes the face normal; origin is the face area center (circle center for a disk).");
+ else
+ ImGui::TextWrapped("Click a cylindrical face on the selected solid. Z becomes the cylinder axis; origin is the solid center projected onto the axis.");
+
+ ImGui::Separator();
+ options_orthographic_projection_();
+}
+
+
void GUI::options_rotate_mode_()
{
EZY_ASSERT(get_mode() == Mode::Rotate);
diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp
index 498c805..c213b11 100644
--- a/src/gui_occt_view.cpp
+++ b/src/gui_occt_view.cpp
@@ -96,6 +96,7 @@ Occt_view::Occt_view(GUI& gui)
, m_shp_rotate(*this)
, m_shp_scale(*this)
, m_shp_cyl_align(*this)
+ , m_shp_set_frame(*this)
, m_shp_chamfer(*this)
, m_shp_fillet(*this)
, m_shp_cut(*this)
@@ -107,7 +108,15 @@ Occt_view::Occt_view(GUI& gui)
{
}
-Occt_view::~Occt_view() {}
+Occt_view::~Occt_view()
+{
+ // Remove frame AIS while the interactive context is still alive.
+ for (Shp_ptr& shp : m_shps)
+ if (!shp.IsNull())
+ shp->clear_frame_display();
+
+ m_shps.clear();
+}
// Initialization related.
void Occt_view::init_window(GLFWwindow* GlfwWindow)
@@ -536,6 +545,8 @@ void Occt_view::bake_transform_into_geometry(AIS_Shape_ptr& shape, bool update_v
// Reset the local transformation to identity
gp_Trsf identity_transform;
shape->SetLocalTransformation(identity_transform);
+ if (Shp_ptr document_shape = Shp_ptr::DownCast(shape); !document_shape.IsNull())
+ document_shape->update_frame_display();
// Redisplay to update the viewer and selection
m_ctx->Redisplay(shape, update_viewer);
@@ -591,6 +602,11 @@ void Occt_view::cancel(Set_parent_mode set_parent_mode)
operation_canceled = true;
break;
+ case Mode::Shape_set_frame:
+ shp_set_frame().cancel();
+ operation_canceled = true;
+ break;
+
case Mode::Shape_cross_section:
shp_cross_section().clear();
gui().set_mode(Mode::Normal);
@@ -1408,6 +1424,12 @@ void Occt_view::insert_shape_rec(const Shape_rec& rec)
adopt_shape_id(rec.id);
shp->set_name(rec.name);
shp->set_frame(rec.frame);
+ if (!shp->is_group())
+ {
+ shp->set_show_frame_axes(rec.show_frame_axes);
+ shp->set_show_frame_plane(rec.show_frame_plane);
+ shp->set_show_frame_up(rec.show_frame_up);
+ }
shp->set_parent_id(rec.parent_id);
shp->set_sibling_order(rec.sibling_order);
// Prefer writing the flag without relying on Display order; sync applies context.
@@ -1430,7 +1452,10 @@ void Occt_view::remove_shape_by_id(Shape_id id)
set_shape_list_hover(nullptr);
if (!shp->is_group())
+ {
+ shp->clear_frame_display();
m_ctx->Remove(shp, false);
+ }
m_shps.erase(it);
ensure_current_group_valid_();
@@ -1449,10 +1474,22 @@ void Occt_view::set_shape_geom_by_id(Shape_id id, const TopoDS_Shape& geom, cons
shp->set_frame(frame);
gp_Trsf identity;
shp->SetLocalTransformation(identity);
+ shp->sync_frame_display_trsf();
m_ctx->Redisplay(shp, true);
m_ctx->UpdateCurrentViewer();
}
+void Occt_view::set_shape_frame(const Shp_ptr& shp, const gp_Ax3& frame)
+{
+ if (shp.IsNull() || shp->is_group())
+ return;
+
+ std::vector changes;
+ changes.push_back(Shape_geom_delta::Geom_change{shp->get_id(), shp->Shape(), shp->Shape(), shp->get_frame(), frame});
+ shp->set_frame(frame);
+ push_undo_delta(std::make_unique(std::move(changes)));
+}
+
void Occt_view::undo_insert_sketch(const nlohmann::json& sketch_json, bool make_current)
{
Sketch_ptr sketch = Sketch_json::from_json(*this, sketch_json);
@@ -3525,6 +3562,7 @@ void Occt_view::on_mode()
case Mode::Rotate: set_shp_selection_mode(TopAbs_SHAPE); break;
case Mode::Scale: set_shp_selection_mode(TopAbs_SHAPE); break;
case Mode::Shape_shaft_align: set_shp_selection_mode(TopAbs_FACE); break;
+ case Mode::Shape_set_frame: set_shp_selection_mode(TopAbs_FACE); break;
case Mode::Shape_cross_section: set_shp_selection_mode(TopAbs_COMPOUND); break;
default:
if(m_modes_selection_mode_map.count(get_mode()))
@@ -3607,6 +3645,10 @@ void Occt_view::sync_sketch_shape_faint_style()
const bool hide_overlay = hide_all || (sketch && hide_in_sketch);
const bool show = own_ok && !hide_overlay;
+ // Frame AIS follow effective visibility, not only get_visible(): sketch tools, Hide all,
+ // and a hidden ancestor group all keep axes/plane/up off (flags unchanged).
+ shp->set_frame_display_suppressed(sketch || !show);
+
if (faint_active && show)
{
// Ghost (1) or Wire (2) while sketching; strength drives transparency for both.
@@ -3808,6 +3850,8 @@ Shp_move& Occt_view::shp_move() { return m_shp_move; }
Shp_rotate& Occt_view::shp_rotate() { return m_shp_rotate; }
Shp_scale& Occt_view::shp_scale() { return m_shp_scale; }
Shp_cyl_align& Occt_view::shp_cyl_align() { return m_shp_cyl_align; }
+
+Shp_set_frame& Occt_view::shp_set_frame() { return m_shp_set_frame; }
Shp_chamfer& Occt_view::shp_chamfer() { return m_shp_chamfer; }
Shp_fillet& Occt_view::shp_fillet() { return m_shp_fillet; }
Shp_cut& Occt_view::shp_cut() { return m_shp_cut; }
@@ -3829,8 +3873,8 @@ void Occt_view::set_dynamic_highlight_enabled(bool enabled)
// Undo / redo: interactive edits use typed deltas; JSON snapshots for mixed delete / file open.
namespace
{
-/// Move/Rotate/Scale follow the mouse while active. Restoring those modes on undo/redo would
-/// immediately drag whatever is selected; use the tool's parent mode instead.
+/// Move/Rotate/Scale/shaft-align follow the mouse while active; Shape_set_frame needs a
+/// Shape List target that undo does not restore. Map those modes to their parent instead.
Mode mode_for_history_restore_(Mode mode);
} // namespace
@@ -3989,6 +4033,17 @@ std::string Occt_view::to_json() const
shp_json["material"] = s->Material();
shp_json["geom"] = oss.str();
shp_json["frame"] = ::to_json(gp_Pln(s->get_frame()));
+ if (s->show_frame_axes() || s->show_frame_plane() || s->show_frame_up())
+ {
+ json fd;
+ if (s->show_frame_axes())
+ fd["axes"] = true;
+ if (s->show_frame_plane())
+ fd["plane"] = true;
+ if (s->show_frame_up())
+ fd["up"] = true;
+ shp_json["frameDisplay"] = fd;
+ }
}
shps.push_back(shp_json);
}
@@ -4028,7 +4083,11 @@ void Occt_view::load(const std::string& json_str, bool restore_view)
{
using namespace nlohmann;
for (AIS_Shape_ptr& s : m_shps)
+ {
+ if (Shp_ptr shp = Shp_ptr::DownCast(s); !shp.IsNull())
+ shp->clear_frame_display();
m_ctx->Remove(s, false);
+ }
clear_all(m_sketches, m_cur_sketch, m_shps);
@@ -4081,6 +4140,16 @@ void Occt_view::load(const std::string& json_str, bool restore_view)
shp = new Shp(*m_ctx, shape);
if (s.contains("frame") && s["frame"].is_object())
shp->set_frame(from_json_pln(s["frame"]).Position());
+ if (s.contains("frameDisplay") && s["frameDisplay"].is_object())
+ {
+ const json& fd = s["frameDisplay"];
+ if (fd.contains("axes") && fd["axes"].is_boolean())
+ shp->set_show_frame_axes(fd["axes"].get());
+ if (fd.contains("plane") && fd["plane"].is_boolean())
+ shp->set_show_frame_plane(fd["plane"].get());
+ if (fd.contains("up") && fd["up"].is_boolean())
+ shp->set_show_frame_up(fd["up"].get());
+ }
int mat_idx = static_cast(m_default_material.Name());
if (s.contains("material") && s["material"].is_number_integer())
mat_idx = s["material"].get();
@@ -4618,6 +4687,7 @@ Mode mode_for_history_restore_(Mode mode)
case Mode::Rotate:
case Mode::Scale:
case Mode::Shape_shaft_align:
+ case Mode::Shape_set_frame:
return GUI::parent_mode_of(mode);
default:
return mode;
diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h
index d28df86..bb5bbba 100644
--- a/src/gui_occt_view.h
+++ b/src/gui_occt_view.h
@@ -20,6 +20,7 @@
#include "shp_common.h"
#include "shp_cut.h"
#include "shp_cyl_align.h"
+#include "shp_set_frame.h"
#include "shp_extrude.h"
#include "shp_fillet.h"
#include "shp_fuse.h"
@@ -150,6 +151,8 @@ class Occt_view : protected AIS_ViewController
void remove_shape_by_id(Shape_id id);
/// Replace BREP of an existing shape (identity local transform).
void set_shape_geom_by_id(Shape_id id, const TopoDS_Shape& geom, const gp_Ax3& frame);
+ /// Frame-only edit with undo (geometry unchanged). Also used by Shape_set_frame.
+ void set_shape_frame(const Shp_ptr& shp, const gp_Ax3& frame);
/// Next sibling_order among children of \a parent_id (0 = document root).
int next_sibling_order(Shape_id parent_id) const;
@@ -263,6 +266,7 @@ class Occt_view : protected AIS_ViewController
Shp_rotate& shp_rotate();
Shp_scale& shp_scale();
Shp_cyl_align& shp_cyl_align();
+ Shp_set_frame& shp_set_frame();
Shp_chamfer& shp_chamfer();
Shp_fillet& shp_fillet();
Shp_cut& shp_cut();
@@ -574,6 +578,7 @@ class Occt_view : protected AIS_ViewController
Shp_rotate m_shp_rotate;
Shp_scale m_shp_scale;
Shp_cyl_align m_shp_cyl_align;
+ Shp_set_frame m_shp_set_frame;
// --------------------------------------------------------------------
// Commands
Shp_chamfer m_shp_chamfer;
diff --git a/src/gui_occt_view.inl b/src/gui_occt_view.inl
index 6c951e2..83ef6f3 100644
--- a/src/gui_occt_view.inl
+++ b/src/gui_occt_view.inl
@@ -2,7 +2,15 @@
// Member function to delete variable arguments
template void Occt_view::remove(Args&&... args)
{
- for_each_flat([&](AIS_Shape_ptr& s) { m_ctx->Remove(s, true); }, std::forward(args)...);
+ for_each_flat(
+ [&](AIS_Shape_ptr& s)
+ {
+ if (Shp_ptr shp = Shp_ptr::DownCast(s); !shp.IsNull())
+ shp->clear_frame_display();
+
+ m_ctx->Remove(s, true);
+ },
+ std::forward(args)...);
}
template void show(AIS_InteractiveContext& ctx, Shp_ptr_t& shp, const T& obj, bool redraw)
diff --git a/src/mode.h b/src/mode.h
index 058b645..6d1ff60 100644
--- a/src/mode.h
+++ b/src/mode.h
@@ -30,7 +30,8 @@
X(Sketch_add_slot) \
X(Sketch_dim_anno) \
X(Shape_cross_section) \
- X(Shape_shaft_align)
+ X(Shape_shaft_align) \
+ X(Shape_set_frame) /* Shape List only: pick face for local frame */
enum class Mode
{
diff --git a/src/shp.cpp b/src/shp.cpp
index b31b741..f93478b 100644
--- a/src/shp.cpp
+++ b/src/shp.cpp
@@ -2,14 +2,21 @@
#include
#include
+#include
+#include
+#include
#include
#include
+#include
+#include
#include
+#include
namespace
{
gp_Ax3 default_shape_frame_(const TopoDS_Shape& shape);
-}
+double frame_arm_length_(const TopoDS_Shape& shape);
+} // namespace
Shp::Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp)
: AIS_Shape(shp)
@@ -23,7 +30,17 @@ Shp::Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp)
{
}
-Shp::~Shp() {}
+Shp::~Shp()
+{
+ // Do not call m_ctx.Remove here: on app exit AIS may release Shp after the context
+ // is already dying (shapes stay alive via Display handles). Nullify only.
+ m_frame_axis_x_ais.Nullify();
+ m_frame_axis_y_ais.Nullify();
+ m_frame_axis_z_ais.Nullify();
+ m_frame_plane_fill_ais.Nullify();
+ m_frame_plane_lines_ais.Nullify();
+ m_frame_up_ais.Nullify();
+}
Shp_ptr Shp::create_group(AIS_InteractiveContext& ctx, const std::string& name)
{
@@ -64,8 +81,6 @@ void Shp::set_visible(const bool visible)
if (m_is_group)
return;
- // Immediate context update for leaf solids when no overlay is applied by the view.
- // Occt_view::sync_sketch_shape_faint_style() recomputes effective visibility afterward.
if (visible)
redisplay_();
else
@@ -73,6 +88,8 @@ void Shp::set_visible(const bool visible)
m_ctx.Unhilight(this, false);
m_ctx.Erase(this, true);
}
+
+ update_frame_display();
}
void Shp::apply_context_shown(bool shown)
@@ -87,6 +104,13 @@ void Shp::apply_context_shown(bool shown)
m_ctx.Unhilight(this, false);
m_ctx.Erase(this, false);
}
+
+ // Overlay hide (Hide all, hidden ancestor, sketch-hide) must drop frame AIS even when
+ // get_visible() is still true. update_frame_display() keys off m_visible + suppressed.
+ if (shown)
+ update_frame_display();
+ else
+ clear_frame_display();
}
void Shp::set_selection_mode(const TopAbs_ShapeEnum mode)
@@ -115,7 +139,6 @@ void Shp::redisplay_()
return;
m_ctx.Unhilight(this, false);
- // Faint sketch-mode shapes are display-only (no pick/hover highlight).
if (m_sketch_faint_active)
{
m_ctx.Display(this, effective_disp_mode_(), -1, false);
@@ -133,13 +156,170 @@ void Shp::update_display_()
if (m_is_group || !get_visible())
return;
- // Required to update selection mode in some cases.
- // E.G. after changing from create sketch from face to normal mode.
m_ctx.Erase(this, false);
redisplay_();
m_ctx.UpdateCurrentViewer();
}
+void Shp::set_frame(const gp_Ax3& frame)
+{
+ m_frame = frame;
+ update_frame_display();
+}
+
+void Shp::transform_frame(const gp_Trsf& transform)
+{
+ m_frame.Transform(transform);
+ update_frame_display();
+}
+
+gp_Ax3 Shp::default_frame_for(const TopoDS_Shape& shape) { return default_shape_frame_(shape); }
+
+void Shp::set_show_frame_axes(bool show)
+{
+ if (m_show_frame_axes == show)
+ return;
+ m_show_frame_axes = show;
+ update_frame_display();
+}
+
+void Shp::set_show_frame_plane(bool show)
+{
+ if (m_show_frame_plane == show)
+ return;
+ m_show_frame_plane = show;
+ update_frame_display();
+}
+
+void Shp::set_show_frame_up(bool show)
+{
+ if (m_show_frame_up == show)
+ return;
+ m_show_frame_up = show;
+ update_frame_display();
+}
+
+void Shp::set_frame_display_suppressed(bool suppressed)
+{
+ if (m_frame_display_suppressed == suppressed)
+ return;
+ m_frame_display_suppressed = suppressed;
+ update_frame_display();
+}
+
+void Shp::clear_frame_display()
+{
+ auto remove = [&](AIS_Shape_ptr& ais)
+ {
+ if (!ais.IsNull())
+ m_ctx.Remove(ais, false);
+
+ ais.Nullify();
+ };
+
+ remove(m_frame_axis_x_ais);
+ remove(m_frame_axis_y_ais);
+ remove(m_frame_axis_z_ais);
+ remove(m_frame_plane_fill_ais);
+ remove(m_frame_plane_lines_ais);
+ remove(m_frame_up_ais);
+}
+
+void Shp::sync_frame_display_trsf()
+{
+ const gp_Trsf& trsf = LocalTransformation();
+ auto apply = [&](const AIS_Shape_ptr& ais)
+ {
+ if (!ais.IsNull())
+ ais->SetLocalTransformation(trsf);
+ };
+
+ apply(m_frame_axis_x_ais);
+ apply(m_frame_axis_y_ais);
+ apply(m_frame_axis_z_ais);
+ apply(m_frame_plane_fill_ais);
+ apply(m_frame_plane_lines_ais);
+ apply(m_frame_up_ais);
+}
+
+void Shp::update_frame_display()
+{
+ clear_frame_display();
+ if (m_is_group || !m_visible || m_frame_display_suppressed)
+ return;
+
+ if (!m_show_frame_axes && !m_show_frame_plane && !m_show_frame_up)
+ return;
+
+ const double arm = frame_arm_length_(Shape());
+ if (arm <= 0.0)
+ return;
+
+ const gp_Pnt o = m_frame.Location();
+ const gp_Vec x(m_frame.XDirection());
+ const gp_Vec y(m_frame.YDirection());
+ const gp_Vec z(m_frame.Direction());
+ const gp_Trsf trsf = LocalTransformation();
+
+ auto display_wire = [&](const TopoDS_Shape& geom, Quantity_NameOfColor color, double width) -> AIS_Shape_ptr
+ {
+ AIS_Shape_ptr ais = new AIS_Shape(geom);
+ ais->SetColor(color);
+ ais->SetWidth(width);
+ ais->SetZLayer(Graphic3d_ZLayerId_Topmost);
+ ais->SetLocalTransformation(trsf);
+ m_ctx.Display(ais, AIS_WireFrame, -1, false);
+ m_ctx.Deactivate(ais);
+ return ais;
+ };
+
+ if (m_show_frame_axes)
+ {
+ // Standard CAD triad: X red, Y green, Z blue.
+ m_frame_axis_x_ais = display_wire(BRepBuilderAPI_MakeEdge(o, o.Translated(x * arm)).Edge(), Quantity_NOC_RED, 2.0);
+ m_frame_axis_y_ais = display_wire(BRepBuilderAPI_MakeEdge(o, o.Translated(y * arm)).Edge(), Quantity_NOC_GREEN, 2.0);
+ m_frame_axis_z_ais =
+ display_wire(BRepBuilderAPI_MakeEdge(o, o.Translated(z * (arm * 1.15))).Edge(), Quantity_NOC_BLUE1, 2.5);
+ }
+
+ if (m_show_frame_plane)
+ {
+ const double half = arm * 0.75;
+ const gp_Pln pln(m_frame);
+ const TopoDS_Face face = BRepBuilderAPI_MakeFace(pln, -half, half, -half, half).Face();
+ m_frame_plane_fill_ais = new AIS_Shape(face);
+ m_frame_plane_fill_ais->SetColor(Quantity_NOC_CYAN);
+ m_frame_plane_fill_ais->SetTransparency(0.85);
+ m_frame_plane_fill_ais->SetLocalTransformation(trsf);
+ m_ctx.Display(m_frame_plane_fill_ais, AIS_Shaded, -1, false);
+ m_ctx.Deactivate(m_frame_plane_fill_ais);
+
+ BRepBuilderAPI_MakePolygon outline;
+ outline.Add(o.Translated(x * (-half) + y * (-half)));
+ outline.Add(o.Translated(x * half + y * (-half)));
+ outline.Add(o.Translated(x * half + y * half));
+ outline.Add(o.Translated(x * (-half) + y * half));
+ outline.Close();
+ m_frame_plane_lines_ais = display_wire(outline.Wire(), Quantity_NOC_CYAN, 1.5);
+ }
+
+ if (m_show_frame_up)
+ {
+ const gp_Pnt tip = o.Translated(y * arm);
+ const gp_Pnt arrow_left = tip.Translated(y * (-arm * 0.2) + x * (arm * 0.1));
+ const gp_Pnt arrow_right = tip.Translated(y * (-arm * 0.2) - x * (arm * 0.1));
+ TopoDS_Compound up;
+ BRep_Builder builder;
+ builder.MakeCompound(up);
+ builder.Add(up, BRepBuilderAPI_MakeEdge(o, tip).Edge());
+ builder.Add(up, BRepBuilderAPI_MakeEdge(arrow_left, tip).Edge());
+ builder.Add(up, BRepBuilderAPI_MakeEdge(arrow_right, tip).Edge());
+ m_frame_up_ais = display_wire(up, Quantity_NOC_GREEN, 3.0);
+ }
+
+ m_ctx.UpdateCurrentViewer();
+}
+
namespace
{
gp_Ax3 default_shape_frame_(const TopoDS_Shape& shape)
@@ -156,4 +336,23 @@ gp_Ax3 default_shape_frame_(const TopoDS_Shape& shape)
bounds.Get(x_min, y_min, z_min, x_max, y_max, z_max);
return gp_Ax3(gp_Pnt((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, (z_min + z_max) * 0.5), gp::DZ(), gp::DX());
}
+
+double frame_arm_length_(const TopoDS_Shape& shape)
+{
+ if (shape.IsNull())
+ return 1.0;
+
+ Bnd_Box bounds;
+ BRepBndLib::Add(shape, bounds);
+ if (bounds.IsVoid())
+ return 1.0;
+
+ double x_min, y_min, z_min, x_max, y_max, z_max;
+ bounds.Get(x_min, y_min, z_min, x_max, y_max, z_max);
+ const double dx = x_max - x_min;
+ const double dy = y_max - y_min;
+ const double dz = z_max - z_min;
+ const double diag = std::sqrt(dx * dx + dy * dy + dz * dz);
+ return std::max(diag * 0.35, 1e-3);
+}
} // namespace
diff --git a/src/shp.h b/src/shp.h
index 758a3ac..bc76f03 100644
--- a/src/shp.h
+++ b/src/shp.h
@@ -3,6 +3,7 @@
#include
#include
#include
+#include
#include
#include "utl.h"
@@ -41,10 +42,29 @@ class Shp : public AIS_Shape
void set_sibling_order(int order) { m_sibling_order = order; }
/// Shape-local frame metadata. New shapes default to a world-aligned frame
- /// centered on their bounding box.
+ /// centered on their bounding box. Z is the primary axis; Y is "up".
const gp_Ax3& get_frame() const { return m_frame; }
- void set_frame(const gp_Ax3& frame) { m_frame = frame; }
- void transform_frame(const gp_Trsf& transform) { m_frame.Transform(transform); }
+ void set_frame(const gp_Ax3& frame);
+ void transform_frame(const gp_Trsf& transform);
+
+ /// World-aligned frame at the shape bounding-box center (same as ctor default).
+ static gp_Ax3 default_frame_for(const TopoDS_Shape& shape);
+
+ bool show_frame_axes() const { return m_show_frame_axes; }
+ bool show_frame_plane() const { return m_show_frame_plane; }
+ bool show_frame_up() const { return m_show_frame_up; }
+ void set_show_frame_axes(bool show);
+ void set_show_frame_plane(bool show);
+ void set_show_frame_up(bool show);
+
+ /// Rebuild axis/plane/up AIS from get_frame() (and current LocalTransformation).
+ void update_frame_display();
+ /// Match frame AIS LocalTransformation to this shape (live move/rotate preview).
+ void sync_frame_display_trsf();
+ void clear_frame_display();
+ /// When true, frame AIS stay cleared (sketch tools, Hide all, hidden ancestor). Flags unchanged.
+ void set_frame_display_suppressed(bool suppressed);
+ bool frame_display_suppressed() const { return m_frame_display_suppressed; }
/// Show or erase in the interactive context without changing get_visible().
/// No-op for group nodes.
@@ -72,6 +92,16 @@ class Shp : public AIS_Shape
Shape_id m_parent_id{0};
int m_sibling_order{0};
gp_Ax3 m_frame;
+ bool m_show_frame_axes{false};
+ bool m_show_frame_plane{false};
+ bool m_show_frame_up{false};
+ bool m_frame_display_suppressed{false};
+ AIS_Shape_ptr m_frame_axis_x_ais; // red
+ AIS_Shape_ptr m_frame_axis_y_ais; // green
+ AIS_Shape_ptr m_frame_axis_z_ais; // blue
+ AIS_Shape_ptr m_frame_plane_fill_ais;
+ AIS_Shape_ptr m_frame_plane_lines_ais;
+ AIS_Shape_ptr m_frame_up_ais;
};
using Shp_rslt = Result;
diff --git a/src/shp_delta.cpp b/src/shp_delta.cpp
index 542be01..ef47404 100644
--- a/src/shp_delta.cpp
+++ b/src/shp_delta.cpp
@@ -19,8 +19,11 @@ Shape_rec capture_shape_rec(const Shp& shp)
rec.frame = shp.get_frame();
rec.parent_id = shp.get_parent_id();
rec.sibling_order = shp.get_sibling_order();
- rec.is_group = shp.is_group();
- rec.visible = shp.get_visible();
+ rec.is_group = shp.is_group();
+ rec.visible = shp.get_visible();
+ rec.show_frame_axes = shp.show_frame_axes();
+ rec.show_frame_plane = shp.show_frame_plane();
+ rec.show_frame_up = shp.show_frame_up();
return rec;
}
diff --git a/src/shp_delta.h b/src/shp_delta.h
index 6b6239b..3993175 100644
--- a/src/shp_delta.h
+++ b/src/shp_delta.h
@@ -21,6 +21,9 @@ struct Shape_rec
int sibling_order{0};
bool is_group{false};
bool visible{true};
+ bool show_frame_axes{false};
+ bool show_frame_plane{false};
+ bool show_frame_up{false};
};
Shape_rec capture_shape_rec(const Shp& shp);
diff --git a/src/shp_operation.cpp b/src/shp_operation.cpp
index 0bbd3da..26b403d 100644
--- a/src/shp_operation.cpp
+++ b/src/shp_operation.cpp
@@ -139,6 +139,7 @@ void Shp_operation_base::replace_picked_shape_(Shp_ptr& old_shp, Shp_ptr& new_sh
ctx().ClearSelected(false);
ctx().Unhilight(old_shp, false);
+ old_shp->clear_frame_display();
ctx().Remove(old_shp, false);
v.get_shapes().remove(old_shp);
@@ -173,5 +174,9 @@ void Shp_operation_base::redisplay_operation_shps_after_transform_()
// from the B-Rep (re-triangulate faces, rebuild sensitive BVH) - very slow for dense shapes.
// Dynamic highlight is disabled for Move/Rotate/Scale in Occt_view::on_mode() so stale
// selection BVHs cannot paint a wireframe ghost at the original pose.
+ for (Shp_ptr& shape : m_shps)
+ if (!shape.IsNull())
+ shape->sync_frame_display_trsf();
+
ctx().UpdateCurrentViewer();
}
\ No newline at end of file
diff --git a/src/shp_set_frame.cpp b/src/shp_set_frame.cpp
new file mode 100644
index 0000000..feb4ae0
--- /dev/null
+++ b/src/shp_set_frame.cpp
@@ -0,0 +1,122 @@
+#include "shp_set_frame.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include "gui.h"
+#include "gui_occt_view.h"
+#include "mode.h"
+#include "utl_geom.h"
+
+namespace
+{
+gp_Pnt bbox_center_(const TopoDS_Shape& shape)
+{
+ Bnd_Box bounds;
+ BRepBndLib::Add(shape, bounds);
+ if (bounds.IsVoid())
+ return gp_Pnt();
+
+ double x_min, y_min, z_min, x_max, y_max, z_max;
+ bounds.Get(x_min, y_min, z_min, x_max, y_max, z_max);
+ return gp_Pnt((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, (z_min + z_max) * 0.5);
+}
+
+gp_Pnt project_onto_axis_(const gp_Ax1& axis, const gp_Pnt& p)
+{
+ const gp_Vec to_p(axis.Location(), p);
+ const double t = to_p.Dot(gp_Vec(axis.Direction()));
+ return axis.Location().Translated(gp_Vec(axis.Direction()) * t);
+}
+
+gp_Pnt project_onto_plane_(const gp_Pln& pln, const gp_Pnt& p)
+{
+ const gp_Vec n(pln.Axis().Direction());
+ const gp_Vec v(pln.Location(), p);
+ return p.Translated(-n * v.Dot(n));
+}
+
+/// Area centroid of a planar face (circle center for a disk); falls back to face AABB center.
+gp_Pnt planar_face_origin_(const TopoDS_Face& face, const gp_Pln& pln)
+{
+ GProp_GProps props;
+ BRepGProp::SurfaceProperties(face, props);
+ if (props.Mass() > 0.0)
+ return project_onto_plane_(pln, props.CentreOfMass());
+
+ return project_onto_plane_(pln, bbox_center_(face));
+}
+} // namespace
+
+Shp_set_frame::Shp_set_frame(Occt_view& view)
+ : Shp_operation_base(view)
+{
+}
+
+void Shp_set_frame::begin(const Shp_ptr& target, Pick pick)
+{
+ m_target = target;
+ m_pick = pick;
+ if (m_target.IsNull() || m_target->is_group())
+ {
+ clear_all(m_target);
+ return;
+ }
+
+ if (m_pick == Pick::Planar_face)
+ gui().show_message("Pick a planar face to set the local frame (Z = normal).");
+ else
+ gui().show_message("Pick a cylindrical face to set the local frame (Z = axis).");
+}
+
+Status Shp_set_frame::pick(const ScreenCoords& screen_coords)
+{
+ if (m_target.IsNull() || m_target->is_group())
+ return Status::user_error("No target shape for set frame.");
+
+ Shp_ptr shp = Shp_ptr::DownCast(get_shape_(screen_coords));
+ if (shp.IsNull() || shp != m_target)
+ return Status::user_error("Pick a face on the selected shape.");
+
+ const TopoDS_Face* face = get_face_(screen_coords);
+ if (!face)
+ return Status::user_error("Click a face (selection filter is Face).");
+
+ std::optional frame;
+ if (m_pick == Pick::Planar_face)
+ {
+ const std::optional pln = plane_from_face(*face);
+ if (!pln)
+ return Status::user_error("Selected face is not planar.");
+
+ const gp_Pnt origin = planar_face_origin_(*face, *pln);
+ frame = gp_Ax3(origin, pln->Axis().Direction(), pln->XAxis().Direction());
+ }
+ else
+ {
+ const std::optional cyl = cylinder_from_face(*face);
+ if (!cyl)
+ return Status::user_error("Selected face is not cylindrical.");
+
+ const gp_Pnt origin = project_onto_axis_(cyl->axis, bbox_center_(m_target->Shape()));
+ frame = gp_Ax3(gp_Ax2(origin, cyl->axis.Direction()));
+ }
+
+ // Leave pick mode before the undo push so history stores Normal, not Shape_set_frame.
+ const Shp_ptr target = m_target;
+ clear_all(m_target);
+ gui().set_mode(Mode::Normal);
+ view().set_shape_frame(target, *frame);
+ target->set_show_frame_axes(true);
+ gui().show_message("Local frame updated.");
+ return Status::ok();
+}
+
+void Shp_set_frame::cancel()
+{
+ clear_all(m_target);
+ gui().set_mode(Mode::Normal);
+}
diff --git a/src/shp_set_frame.h b/src/shp_set_frame.h
new file mode 100644
index 0000000..e210835
--- /dev/null
+++ b/src/shp_set_frame.h
@@ -0,0 +1,26 @@
+#pragma once
+
+#include "shp_operation.h"
+
+/// Pick a planar or cylindrical face to redefine a solid's local frame (Shape List only).
+class Shp_set_frame : private Shp_operation_base
+{
+public:
+ enum class Pick
+ {
+ Planar_face,
+ Cylindrical_face
+ };
+
+ explicit Shp_set_frame(Occt_view& view);
+
+ void begin(const Shp_ptr& target, Pick pick);
+ [[nodiscard]] Status pick(const ScreenCoords& screen_coords);
+ void cancel();
+ [[nodiscard]] Pick get_pick() const { return m_pick; }
+ [[nodiscard]] bool has_target() const { return !m_target.IsNull(); }
+
+private:
+ Pick m_pick{Pick::Planar_face};
+ Shp_ptr m_target;
+};
diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp
index ee84a87..86dfff2 100644
--- a/tests/shp_tests.cpp
+++ b/tests/shp_tests.cpp
@@ -63,6 +63,13 @@ void select_shapes(Occt_view& view, const std::vector& shapes)
for (const Shp_ptr& shp : shapes)
cctx.AddOrRemoveSelected(shp, true);
}
+
+int displayed_object_count(AIS_InteractiveContext& ctx)
+{
+ NCollection_List displayed;
+ ctx.DisplayedObjects(displayed);
+ return displayed.Extent();
+}
} // namespace
// Headless Occt_view fixture shared with sketch tests.
@@ -637,6 +644,34 @@ TEST_F(Shp_test, Undo_delete_shape_restores_brep)
EXPECT_NEAR(volume_of(view().get_shapes().back()->Shape()), 1000.0, 1e-6);
}
+TEST_F(Shp_test, Delete_shape_clears_frame_ais)
+{
+ // Static GUI can be left in a sketch mode by an earlier test; that suppresses frame AIS.
+ gui().set_mode(Mode::Normal);
+
+ view().add_box(0, 0, 0, 10, 10, 10);
+ ASSERT_EQ(view().get_shapes().size(), 1u);
+ const Shp_ptr shp = view().get_shapes().back();
+ const int after_box = displayed_object_count(view().ctx());
+
+ shp->set_show_frame_axes(true);
+ shp->set_show_frame_plane(true);
+ shp->set_show_frame_up(true);
+ const int with_frame = displayed_object_count(view().ctx());
+ ASSERT_GT(with_frame, after_box);
+
+ view().delete_shapes({shp});
+ EXPECT_EQ(view().get_shapes().size(), 0u);
+ EXPECT_EQ(displayed_object_count(view().ctx()), after_box - 1);
+
+ EXPECT_TRUE(view().undo());
+ ASSERT_EQ(view().get_shapes().size(), 1u);
+ EXPECT_TRUE(view().get_shapes().back()->show_frame_axes());
+ EXPECT_TRUE(view().get_shapes().back()->show_frame_plane());
+ EXPECT_TRUE(view().get_shapes().back()->show_frame_up());
+ EXPECT_EQ(displayed_object_count(view().ctx()), with_frame);
+}
+
TEST_F(Shp_test, Undo_fuse_restores_inputs)
{
view().add_box(0, 0, 0, 10, 10, 10);
@@ -688,6 +723,39 @@ TEST_F(Shp_test, Undo_interleaves_sketch_delta_and_shape_add)
EXPECT_EQ(view().get_shapes().size(), 1u);
}
+TEST_F(Shp_test, Set_frame_undo_stays_in_normal)
+{
+ gui().set_mode(Mode::Normal);
+ gui().set_hide_all_shapes(false);
+
+ view().add_box(0, 0, 0, 10, 10, 10);
+ Shp_ptr shp = view().get_shapes().back();
+ ASSERT_FALSE(shp.IsNull());
+ const gp_Ax3 before = shp->get_frame();
+ gp_Ax3 after = before;
+ after.ZReverse();
+
+ gui().set_mode(Mode::Shape_set_frame);
+ view().shp_set_frame().begin(shp, Shp_set_frame::Pick::Planar_face);
+ ASSERT_TRUE(view().shp_set_frame().has_target());
+
+ // Same order as the old pick() bug: push undo while still in Shape_set_frame, then leave.
+ view().set_shape_frame(shp, after);
+ shp->set_show_frame_axes(true);
+ view().shp_set_frame().cancel();
+ EXPECT_EQ(gui().get_mode(), Mode::Normal);
+ EXPECT_FALSE(view().shp_set_frame().has_target());
+
+ EXPECT_TRUE(view().undo());
+ EXPECT_EQ(gui().get_mode(), Mode::Normal);
+ EXPECT_FALSE(view().shp_set_frame().has_target());
+ EXPECT_TRUE(shp->get_frame().Direction().IsEqual(before.Direction(), 1e-9));
+
+ EXPECT_TRUE(view().redo());
+ EXPECT_EQ(gui().get_mode(), Mode::Normal);
+ EXPECT_TRUE(shp->get_frame().Direction().IsEqual(after.Direction(), 1e-9));
+}
+
TEST_F(Shp_test, Shape_ids_persist_in_json)
{
view().add_box(0, 0, 0, 3, 4, 5);
@@ -891,6 +959,69 @@ TEST_F(Shp_test, Hide_all_preserves_per_shape_visibility)
EXPECT_FALSE(shp->get_visible());
}
+TEST_F(Shp_test, Hide_all_clears_frame_ais)
+{
+ gui().set_mode(Mode::Normal);
+ gui().set_hide_all_shapes(false);
+
+ view().add_box(0, 0, 0, 10, 10, 10);
+ ASSERT_EQ(view().get_shapes().size(), 1u);
+ const Shp_ptr shp = view().get_shapes().back();
+ const int after_box = displayed_object_count(view().ctx());
+
+ shp->set_show_frame_axes(true);
+ shp->set_show_frame_plane(true);
+ shp->set_show_frame_up(true);
+ const int with_frame = displayed_object_count(view().ctx());
+ ASSERT_GT(with_frame, after_box);
+
+ gui().set_hide_all_shapes(true);
+ view().sync_sketch_shape_faint_style();
+ EXPECT_TRUE(shp->get_visible());
+ EXPECT_TRUE(shp->show_frame_axes());
+ EXPECT_EQ(displayed_object_count(view().ctx()), after_box - 1);
+
+ shp->set_show_frame_axes(false);
+ shp->set_show_frame_axes(true);
+ EXPECT_EQ(displayed_object_count(view().ctx()), after_box - 1);
+
+ gui().set_hide_all_shapes(false);
+ view().sync_sketch_shape_faint_style();
+ EXPECT_EQ(displayed_object_count(view().ctx()), with_frame);
+}
+
+TEST_F(Shp_test, Hidden_group_clears_child_frame_ais)
+{
+ gui().set_mode(Mode::Normal);
+ gui().set_hide_all_shapes(false);
+
+ view().add_box(0, 0, 0, 10, 10, 10);
+ Shp_ptr box = view().get_shapes().back();
+ box->set_show_frame_axes(true);
+ box->set_show_frame_plane(true);
+ box->set_show_frame_up(true);
+ const int with_frame = displayed_object_count(view().ctx());
+
+ ASSERT_TRUE(view().group_shapes({box}).is_ok());
+ Shp_ptr grp;
+ for (const Shp_ptr& s : view().get_shapes())
+ if (s->is_group())
+ grp = s;
+
+ ASSERT_FALSE(grp.IsNull());
+ EXPECT_EQ(displayed_object_count(view().ctx()), with_frame);
+
+ grp->set_visible(false);
+ view().sync_sketch_shape_faint_style();
+ EXPECT_TRUE(box->get_visible());
+ EXPECT_TRUE(box->show_frame_axes());
+ EXPECT_LT(displayed_object_count(view().ctx()), with_frame);
+
+ grp->set_visible(true);
+ view().sync_sketch_shape_faint_style();
+ EXPECT_EQ(displayed_object_count(view().ctx()), with_frame);
+}
+
TEST_F(Shp_test, Fuse_keeps_shared_parent)
{
view().add_box(0, 0, 0, 10, 10, 10);