From 28b7b3139b2a09bcdb650088f78f2394f5dc4cb3 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Tue, 21 Jul 2026 17:21:18 -0600 Subject: [PATCH 01/17] Start --- CHANGELOG.md | 5 + agents/plans/cross-section-tool.md | 23 ++- docs/usage-settings.md | 30 +-- docs/usage.md | 43 ++-- src/doc/gui.md | 61 +++--- src/doc/shape.md | 7 +- src/doc_delta.cpp | 5 +- src/gui.cpp | 80 ++++---- src/gui.h | 173 ++++++++-------- src/gui_mode.cpp | 60 ++++++ src/gui_occt_view.cpp | 47 +++-- src/gui_occt_view.h | 48 ++--- src/gui_settings.cpp | 32 ++- src/main.cpp | 5 +- src/mode.h | 3 +- src/scr_lua_console.cpp | 2 +- src/shp.cpp | 35 +++- src/shp.h | 12 +- src/shp_chamfer.cpp | 2 +- src/shp_common.cpp | 3 +- src/shp_cut.cpp | 3 +- src/shp_delta.cpp | 13 +- src/shp_delta.h | 7 +- src/shp_fillet.cpp | 4 +- src/shp_fuse.cpp | 3 +- src/shp_move.cpp | 7 +- src/shp_operation.cpp | 10 +- src/shp_operation.h | 4 +- src/shp_rotate.cpp | 7 +- src/shp_scale.cpp | 7 +- src/shp_section.cpp | 303 +++++++++++++++++++++++++++++ src/shp_section.h | 52 +++++ src/utl_cad_file_info.cpp | 19 +- src/utl_cad_file_info.h | 2 +- tests/shp_tests.cpp | 157 ++++++++++++++- 35 files changed, 958 insertions(+), 316 deletions(-) create mode 100644 src/shp_section.cpp create mode 100644 src/shp_section.h diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c7341..77cbc27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Cross-section + +- Added a temporary **Shape cross-section** preview tool for selected solids. Options choose each shape's local **XY**, **XZ**, or **YZ** plane and an offset in project units; a translucent yellow plane and normal arrow annotate the cut, while empty or invalid sections clear the preview and report status/curve counts. +- Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. + ### Shape List - **Shape List** is a hierarchical outliner: organizational **groups**, expand/collapse, drag-and-drop reparent, **Group** / **New group** / **Ungroup**, cascade delete, and Ctrl+click multi-select. Click a group (including empty) to set the **current group**; new primitives/extrudes/revolves are added there. Drop on empty space below the list to move a node to the document root. Group visibility hides the subtree. **Hide all** no longer clears per-shape visibility flags. Boolean results inherit a shared parent when inputs share one. STEP import preserves XCAF assembly groups (unless **Union shapes**). diff --git a/agents/plans/cross-section-tool.md b/agents/plans/cross-section-tool.md index 2284778..767c80f 100644 --- a/agents/plans/cross-section-tool.md +++ b/agents/plans/cross-section-tool.md @@ -1,5 +1,5 @@ --- -status: planning +status: implemented-v0 topic: cross-section-tool depends_on: shp-origin-orientation blocks: @@ -32,12 +32,12 @@ Fail closed on empty section (status message). No requirement to create a `Sketc ## Experiment checklist -- [ ] Box mid-plane → rectangle-like wires. -- [ ] Cylinder mid-plane → circle (or dense curve). -- [ ] Offset along normal; miss solid → clear failure. -- [ ] Multi-face / boolean solids: junk edges? duplicates? -- [ ] Curve types returned (`GeomAbs_*`) — informs sketch import strategy. -- [ ] WASM vs desktop parity ([occt-wasm-dual-version](../conventions/occt-wasm-dual-version.md)). +- [x] Box mid-plane -> four line edges (focused unit test). +- [x] Cylinder mid-plane -> circular edge (focused unit test). +- [x] Offset along normal; miss solid -> clear failure (focused unit test). +- [x] Multi-face / boolean solids: junk edges? duplicates? (fused two-box mid-plane returns **8** line edges, not a clean 4-edge rectangle — sketch import will need weld/dedup). +- [x] Curve types returned (`GeomAbs_*`) -> preview status counts lines, circles, ellipses, B-splines, and other curves. +- [x] WASM vs desktop parity ([occt-wasm-dual-version](../conventions/occt-wasm-dual-version.md)). ## Likely touch points @@ -58,3 +58,12 @@ Enough evidence to either: Import into `Sketch_edges`; undoable new sketch; script binding (unless useful for tests). **Related UX:** [sketch-mode-shape-faint.md](sketch-mode-shape-faint.md) — keep the solid visible but faint under section preview. + +## Implemented v0 findings + +- `Shp` now has a persisted `gp_Ax3` local frame. It defaults to a world-aligned frame at the shape bounding-box center and follows baked move/rotate/scale transforms. +- `Shp_section` computes one local plane per selected solid with `BRepAlgoAPI_Section` (`Approximation(false)` for exact curve types), aggregates the edges into a temporary topmost cyan AIS wire preview, and adds a translucent yellow plane outline with a positive-normal arrow. None of this temporary geometry enters the document or undo history. +- Preview updates fail closed: the old AIS is removed first; plane/offset edits clear a stale preview; pending `LocalTransformation` is applied before sectioning; non-solid / empty / OCCT failure leaves no preview. +- The first curve evidence supports line and circle handling. Ellipse/B-spline/other counts are exposed for further manual experiments before sketch import rules are chosen. +- Boolean solids are not yet sketch-ready: a fused two-box mid-plane yields 8 line edges (likely unmerged/overlapping segments) instead of one 4-edge outer loop. +- Desktop Release build and focused section tests pass with OCCT 8.0.0. The Emscripten Release build also passes with OCCT 7.9.3. diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 8877bf0..9ed677c 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -104,7 +104,7 @@ Under **Material**: - Document preset for new solids that do not inherit from a clicked shape (toolbar **Box**, **polar duplicate** output, and similar). To change material on an existing solid, use the [Shape List](usage.md#shape-list). -For other non-sketch Options content (for example **Polar duplicate**), see [usage.md -> User Interface](usage.md#user-interface) (Options Panel). +For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Offset**, **Update preview**, and **Clear** controls; see [Shape cross-section tool](usage.md#shape-cross-section-tool). ### Sketch tools @@ -223,7 +223,7 @@ If saved layout text has no `[Docking]` section (older installs), a default dock | `view_roll_step_deg` | number | Degrees per **NumPad 8**/**2**/**4**/**6** orbit and **Shift+NumPad 4**/**6** roll (allowed range **0.1** to **180** in code; default **45**). | | `view_zoom_scroll_scale` | number | Multiplier for `UpdateZoom` scroll delta from wheel and keyboard zoom (allowed range **0.25** to **64** in code; default **4**). With **Shift** held, the effective step is multiplied by **0.1** (Blender-style finer zoom). | | `default_project_unit` | string | Default **File -> New** project unit: `"inch"` or `"millimeter"` (default **`inch`**). Edited under **Settings -> New project defaults**. | -| `default_2d_view_width` | number | Horizontal sketch-plane span for **File -> New** / projects with no saved camera, stored in **inches** (allowed range **0.1** to **1000**; default **3**). Settings UI shows this in **`default_project_unit`**. | +| `default_2d_view_width` | number | Horizontal sketch-plane span for **File -> New** / projects with no saved camera, stored in **inches** (allowed range **0.1** to **1000**; default **3**). Settings UI shows this in **`default_project_unit`**. | | `default_2d_view_height` | number | Vertical sketch-plane span, stored in **inches** (allowed range **0.1** to **1000**; default **3**). Settings UI shows this in **`default_project_unit`**. | | `load_last_opened_on_startup` | boolean | Desktop: open the last `.ezy` on launch. **Legacy:** `load_last_saved_on_startup` is read as a fallback if the newer key is absent. | | `last_opened_project_path` | string | Path of the last opened project for the option above. **Legacy:** `last_saved_project_path` is accepted if the newer key is missing. | @@ -244,20 +244,20 @@ Each **`imgui_style_dark`** / **`imgui_style_light`** object may contain: Each **`settings_headers`** object may contain (default: only nested Sketch **Appearance** / **Dimensions** expanded): -| Key | Type | Meaning | -| ------------------- | ------- | ---------------------------------------- | -| `view_nav` | boolean | **3D view navigation** section expanded. | +| Key | Type | Meaning | +| ------------------- | ------- | ------------------------------------------ | +| `view_nav` | boolean | **3D view navigation** section expanded. | | `new_project` | boolean | **New project defaults** section expanded. | -| `ui` | boolean | **UI** section expanded. | -| `view_presentation` | boolean | **View presentation** section expanded. | -| `grid` | boolean | **3D view grid** section expanded. | -| `sketch` | boolean | **Sketch** section expanded. | -| `sketch_appearance` | boolean | Nested **Appearance** under Sketch. | -| `sketch_dimensions` | boolean | Nested **Dimensions** under Sketch. | -| `sketch_nodes` | boolean | Nested **Nodes** under Sketch. | -| `sketch_snap` | boolean | Nested **Snap** under Sketch. | -| `sketch_underlay` | boolean | Nested **Underlay** under Sketch. | -| `startup` | boolean | **Startup project** section expanded. | +| `ui` | boolean | **UI** section expanded. | +| `view_presentation` | boolean | **View presentation** section expanded. | +| `grid` | boolean | **3D view grid** section expanded. | +| `sketch` | boolean | **Sketch** section expanded. | +| `sketch_appearance` | boolean | Nested **Appearance** under Sketch. | +| `sketch_dimensions` | boolean | Nested **Dimensions** under Sketch. | +| `sketch_nodes` | boolean | Nested **Nodes** under Sketch. | +| `sketch_snap` | boolean | Nested **Snap** under Sketch. | +| `sketch_underlay` | boolean | Nested **Underlay** under Sketch. | +| `startup` | boolean | **Startup project** section expanded. | Scripting API **`ezy.occt_view_settings_json()`** returns a JSON string with **`occt_view`** plus selected **`gui`** keys (including dimension and snap keys above, **`gui.permanent_node_anno_scale`**, **`gui.inspection_orthographic`**, **`gui.view_roll_step_deg`**, **`gui.view_zoom_scroll_scale`**, **`gui.default_project_unit`**, **`gui.default_2d_view_width`**, **`gui.default_2d_view_height`** when saved). See [scripting.md](scripting.md). diff --git a/docs/usage.md b/docs/usage.md index 79258b0..51ec88c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -251,10 +251,10 @@ The typical modeling workflow in EzyCad follows these steps: **File -> Import** opens an **Import** window for STEP or PLY. Review metadata, optionally enable **Union shapes** (STEP), then click **Import into project**. The window closes after a successful import. -| Format | What the Import dialog shows | -| -------------------------- | -------------------------------------------------------------- | -| **STEP** (`.step`, `.stp`) | Roots/shapes, import bodies, named bodies, topology, bbox | -| **PLY** (`.ply`) | Encoding, vertex and face counts from the header | +| Format | What the Import dialog shows | +| -------------------------- | --------------------------------------------------------- | +| **STEP** (`.step`, `.stp`) | Roots/shapes, import bodies, named bodies, topology, bbox | +| **PLY** (`.ply`) | Encoding, vertex and face counts from the header | **How to use:** 1. Choose **File -> Import** @@ -290,10 +290,10 @@ In addition to creating 3D shapes from sketches, EzyCad supports importing exist **Units:** Sketch and display lengths follow **File -> Project units** (**Inches** or **Millimeters**). Internal model coordinates stay inch-scaled (`model = inches * dimension_scale`, default **100**). Changing project units only remaps the UI; it does not rewrite geometry. Import scaling: -| Format | File units | Into EzyCad | -| -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------ | -| **STEP** (`.step`, `.stp`) | Declared in the file (often mm); OCCT reads them as mm, then converted | Scaled so lengths match the inch-scaled model | -| **PLY** (`.ply`) | No standard unit metadata | Vertex coords are treated as **inches** | +| Format | File units | Into EzyCad | +| -------------------------- | ---------------------------------------------------------------------- | --------------------------------------------- | +| **STEP** (`.step`, `.stp`) | Declared in the file (often mm); OCCT reads them as mm, then converted | Scaled so lengths match the inch-scaled model | +| **PLY** (`.ply`) | No standard unit metadata | Vertex coords are treated as **inches** | **PLY import notes:** - Supported: **ASCII** PLY and **binary little-endian** PLY. @@ -324,10 +324,10 @@ Use **File -> Export** to save the current model for other CAD tools, CAM, or 3D **Units:** After you choose a format, an **Export units** dialog asks for **Inches** or **Millimeters** (defaults: millimeters for STEP/IGES, inches for STL/PLY). -| Format | How units are applied | -| ------------------- | --------------------------------------------------------------------------- | -| **STEP** / **IGES** | Geometry scaled to the chosen unit; that unit is written into the CAD file | -| **STL** / **PLY** | Geometry scaled to the chosen unit; files have **no** unit metadata | +| Format | How units are applied | +| ------------------- | -------------------------------------------------------------------------- | +| **STEP** / **IGES** | Geometry scaled to the chosen unit; that unit is written into the CAD file | +| **STL** / **PLY** | Geometry scaled to the chosen unit; files have **no** unit metadata | **Mesh exports (STL and PLY):** Surfaces are **tessellated** with a fixed linear deflection (same idea as typical STL export). Very complex B-rep models produce large mesh files. @@ -370,6 +370,7 @@ More detail: [Sketch snapping](usage-sketch.md#sketch-snapping) in the sketch gu - ![Shape Rotate Tool](res/icons/Draft_Rotate.png) [Rotate objects (R)](#shape-rotate-tool-r) - ![Shape Scale Tool](res/icons/Part_Scale.png) [Scale elements (S)](#shape-scale-tool) - ![Polar Duplicate Tool](res/icons/Draft_PolarArray.png) [Polar duplicate](#shape-polar-duplicate-tool) + - ![Cross-section Tool](res/icons/Curves_ExtractSubshape.png) [Preview a local-plane cross-section](#shape-cross-section-tool) #### Shape Move Tool (G) @@ -490,7 +491,7 @@ 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) | +| **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 extruded shape update in real-time as you move the mouse | | **Interactive distance control** | Drag the mouse to adjust extrusion distance, or use the distance input dialog (Tab key) for precise control | @@ -594,6 +595,22 @@ The polar duplicate tool allows you to create multiple copies of selected shapes - Creating symmetric designs with rotational symmetry - Duplicating features around a center point +#### Shape Cross-section Tool + +![Cross-section Tool](res/icons/Curves_ExtractSubshape.png) + +Use the cross-section tool to inspect where a plane cuts one or more selected solids. The result is a temporary cyan wire preview. A translucent yellow rectangle outlines the cutting plane, with an arrow showing its positive normal direction. These annotations do not modify the solids or create a sketch. + +1. Select one or more solids. +2. Click **Shape cross-section** in the toolbar. +3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. +4. Enter an **Offset** along that plane's local normal, in the current project unit. +5. Click **Update preview**. Changing the plane or offset clears a stale preview; click **Update preview** again to recompute it. Use **Clear** or leave the tool to remove the preview. + +Each solid uses its own local frame. New solids start with a world-aligned frame at the center of their bounding box; moving, rotating, or scaling a solid updates that frame. With multiple selected solids, the same local plane and offset are evaluated separately for each solid. + +If any selected item is not a solid, the plane misses a selected solid, or Open CASCADE cannot compute the section, EzyCad clears the previous preview and shows a status message. Section edges may be lines, circles, ellipses, B-splines, or other OCCT curves. The status message reports the curve counts. + ### Other Feature Operations - Create chamfers (C) diff --git a/src/doc/gui.md b/src/doc/gui.md index ee4cffe..638c4ee 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -124,24 +124,24 @@ Overlay popups (`FloatEdit`, `AngleEdit`, `MessageStatus`, modals) keep `NoSaved ### Keyboard (`GUI::on_key` in `gui_mode.cpp`) -| Input | Condition | Handler | -| --------------------------------- | ------------------- | ------------------------------------------------------------------- | -| `+` / `-` / numpad +/- | No Ctrl/Alt | `Occt_view::zoom_view_wheel_notches` | -| Shift + 4/6 / arrows / numpad 4/6 | No Ctrl/Alt | `Occt_view::roll_view_z_deg` | -| Numpad 5 | No modifiers | `Occt_view::snap_view_to_nearest_standard_axis` | -| Numpad 2/4/6/8 | No modifiers | `Occt_view::orbit_view_screen_step_deg` | +| Input | Condition | Handler | +| --------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `+` / `-` / numpad +/- | No Ctrl/Alt | `Occt_view::zoom_view_wheel_notches` | +| Shift + 4/6 / arrows / numpad 4/6 | No Ctrl/Alt | `Occt_view::roll_view_z_deg` | +| Numpad 5 | No modifiers | `Occt_view::snap_view_to_nearest_standard_axis` | +| Numpad 2/4/6/8 | No modifiers | `Occt_view::orbit_view_screen_step_deg` | | Ctrl+N/O/S | | `new_project_` / `open_file_dialog_` / `save_file_dialog_` (save failures: `show_error_dialog` / `error_modal_dialog_`) | -| Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y | | `Occt_view::undo` / `redo` | -| `1`-`9` / numpad `1`-`9` | `Mode::Normal` only | `set_shp_selection_mode` (TopAbs enum index) | -| Esc | | `cancel_underlay_calib_`, `Occt_view::cancel`, hide dist/angle edit | -| Tab | | `Occt_view::dimension_input` | -| Shift+Tab | | `Occt_view::angle_input` | -| Enter | | hide edits, `Occt_view::on_enter` | -| D | | `Mode::Sketch_dim_anno` | -| Shift+D / Delete / Backspace | | `Occt_view::delete_selected` | -| G / R / E / S / C / F | | Move / Rotate / Extrude / Scale / Chamfer / Fillet modes | -| Move-mode keys | `Mode::Move` | `on_key_move_mode_` (axis constraints X/Y/Z) | -| Rotate-mode keys | `Mode::Rotate` | `on_key_rotate_mode_` (axis pick, Tab angle) | +| Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y | | `Occt_view::undo` / `redo` | +| `1`-`9` / numpad `1`-`9` | `Mode::Normal` only | `set_shp_selection_mode` (TopAbs enum index) | +| Esc | | `cancel_underlay_calib_`, `Occt_view::cancel`, hide dist/angle edit | +| Tab | | `Occt_view::dimension_input` | +| Shift+Tab | | `Occt_view::angle_input` | +| Enter | | hide edits, `Occt_view::on_enter` | +| D | | `Mode::Sketch_dim_anno` | +| Shift+D / Delete / Backspace | | `Occt_view::delete_selected` | +| G / R / E / S / C / F | | Move / Rotate / Extrude / Scale / Chamfer / Fillet modes | +| Move-mode keys | `Mode::Move` | `on_key_move_mode_` (axis constraints X/Y/Z) | +| Rotate-mode keys | `Mode::Rotate` | `on_key_rotate_mode_` (axis pick, Tab angle) | See also [`src/doc/sketch.md`](sketch.md) and [`src/doc/shape.md`](shape.md) for per-mode mouse routing after `GUI` delegates to `Occt_view`. @@ -186,6 +186,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | +| `Shape_section` | local XY/XZ/YZ plane, offset, **Update preview**, clear | | `Sketch_inspection_mode` | `options_sketch_common_` | | Each sketch tool mode | Matching `options_sketch_*_mode_` | | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | @@ -195,17 +196,17 @@ Shared sketch controls (snap, midpoint nodes, place-from-center) live in `option ## ImGui frame order (`render_gui`) -| Order | Function | Purpose | -| ----- | -------------------------------------------- | ------------------------------------- | -| 1 | `flush_view_events` | Sync camera before UI uses projection | -| 2 | `menu_bar_`, `toolbar_` | File / View / mode tools | -| 3 | `dist_edit_`, `angle_edit_` | Floating numeric entry | -| 4 | `sketch_list_`, `sketch_properties_dialog_` | Sketch List + underlay/properties | -| 5 | `shape_list_`, `shape_info_dialog_`, `file_inspector_dialog_` | Shape List + info + Import dialog | -| 6 | `options_` | Mode-specific Options pane | -| 7 | `message_status_window_`, `about_dialog_` | Status + About | -| 8 | `add_*_dialog_` | Primitive / sketch creation popups | -| 9 | `log_window_`, consoles, `settings_`, `dbg_` | Log, Lua/Python, Settings | +| Order | Function | Purpose | +| ----- | ------------------------------------------------------------- | ------------------------------------- | +| 1 | `flush_view_events` | Sync camera before UI uses projection | +| 2 | `menu_bar_`, `toolbar_` | File / View / mode tools | +| 3 | `dist_edit_`, `angle_edit_` | Floating numeric entry | +| 4 | `sketch_list_`, `sketch_properties_dialog_` | Sketch List + underlay/properties | +| 5 | `shape_list_`, `shape_info_dialog_`, `file_inspector_dialog_` | Shape List + info + Import dialog | +| 6 | `options_` | Mode-specific Options pane | +| 7 | `message_status_window_`, `about_dialog_` | Status + About | +| 8 | `add_*_dialog_` | Primitive / sketch creation popups | +| 9 | `log_window_`, consoles, `settings_`, `dbg_` | Log, Lua/Python, Settings | Sketch List expand **Faces**: each face row supports **`E`** and right-click **Extrude** via `GUI::sketch_list_extrude_face_` (`set_mode(Sketch_face_extrude)` + `Occt_view::begin_sketch_face_extrude` / `Shp_extrude::begin_face_extrude`). Hovering a **Faces**, **Edges**, or **Nodes** row calls `Occt_view::set_sketch_list_hover_{face,edge,node}` (temporarily displays the AIS when hidden outside sketch modes; uses `Graphic3d_ZLayerId_Topmost` so solids do not occlude the highlight). @@ -234,6 +235,8 @@ Toolbar buttons hold `std::variant`. `Command` (`Shape_cut`, `Sha Mode buttons call `set_mode`. Active state tracks `m_mode`. +The cross-section toolbar button enters `Mode::Shape_section`. Its Options controls call `Shp_section::preview_selected`; the temporary AIS wire result is cleared when the mode is left, when the user presses **Clear**, or before a failed/updated preview. + ## Typical developer usage ### Drive sketch input from tests @@ -276,4 +279,4 @@ Occt_view* view = gui.get_view(); | [`src/doc/shape.md`](shape.md) | Shape operations invoked from toolbar, Options, mouse | | [`scr_lua_console.cpp`](../scr_lua_console.cpp) / [`scr_python_console.cpp`](../scr_python_console.cpp) | Script consoles embedded in `render_gui` | | [`utl_settings.cpp`](../utl_settings.cpp) | User settings file path and I/O helpers | -| [`utl_cad_file_info.h`](../utl_cad_file_info.h) | CAD/mesh file metadata for **File -> Import** | +| [`utl_cad_file_info.h`](../utl_cad_file_info.h) | CAD/mesh file metadata for **File -> Import** | diff --git a/src/doc/shape.md b/src/doc/shape.md index 2ccbd47..b11f0b3 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -23,6 +23,7 @@ Typical uses: - Extrude a sketch face into a solid; revolve sketch geometry (revolve lives in `skt_operations.cpp`, returns `Shp_rslt`). - Boolean fuse/cut/common on selected shapes. - Interactive move, rotate, and scale with preview transforms. +- Preview cross-sections on a shape-local XY, XZ, or YZ plane. - Fillet/chamfer by shape, face, wire, or edge pick mode. - Polar duplicate selected shapes about an arm on the current sketch plane. @@ -31,6 +32,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. - `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 call `add_shp_(..., use_current_group=true)` so new solids land under that group. Booleans keep `assign_result_parent_`. @@ -90,6 +92,7 @@ Occt_view | +-- std::list m_shps document shapes +-- Shp_move / Shp_rotate / Shp_scale interactive transforms + +-- Shp_section temporary local-plane section preview +-- Shp_extrude sketch face extrude session +-- Shp_fuse / Shp_cut / Shp_common booleans +-- Shp_fillet / Shp_chamfer edge modifiers (replace in place) @@ -116,7 +119,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. -`.ezy` `shapes[]` entries include `id`, `name`, `parentId`, `order`, `visible`, and either `isGroup: true` or `material` + `geom`. Undo uses `Shape_rec` (same fields) plus `Shape_tree_delta` for reparent/group/ungroup. +`.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. ## `Shp_operation_base` @@ -148,6 +151,7 @@ Protected helpers used by all operation classes: | `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. | +| `shp_section.h` | `Shp_section` | `BRepAlgoAPI_Section` preview on each selected solid's local XY/XZ/YZ plane plus offset; temporary cyan section wires plus a translucent yellow plane/normal annotation. | | `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | ## Input routing (from UI / `Occt_view`) @@ -163,6 +167,7 @@ Protected helpers used by all operation classes: | `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 | +| `Mode::Shape_section` | -- | -- | Options **Update preview** | Preview cleared | | Fuse / cut / common (toolbar) | -- | `selected_fuse` / `selected_cut` / `selected_common` (one-shot) | -- | -- | | Primitives (menu / script) | -- | `Occt_view::add_box`, `add_sphere`, ... | -- | -- | | Revolve (sketch Options) | -- | `Occt_view::revolve_selected` -> `add_shp_` | -- | -- | diff --git a/src/doc_delta.cpp b/src/doc_delta.cpp index 0256df4..0a13314 100644 --- a/src/doc_delta.cpp +++ b/src/doc_delta.cpp @@ -19,10 +19,7 @@ void Sketch_struct_delta::add_sketch_(Occt_view& view, const nlohmann::json& ske view.undo_insert_sketch(sketch_json, make_current); } -void Sketch_struct_delta::remove_sketch_by_id_(Occt_view& view, size_t sketch_id) const -{ - view.undo_remove_sketch(sketch_id); -} +void Sketch_struct_delta::remove_sketch_by_id_(Occt_view& view, size_t sketch_id) const { view.undo_remove_sketch(sketch_id); } void Sketch_struct_delta::apply_forward(Occt_view& view) { diff --git a/src/gui.cpp b/src/gui.cpp index cee6adb..b25243e 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -196,6 +196,7 @@ void GUI::initialize_toolbar_() {load_texture("res/icons/PartDesign_Chamfer.png"), false, "Chamfer (c)", Mode::Shape_chamfer}, {load_texture("res/icons/PartDesign_Fillet.png"), false, "Fillet (f)", Mode::Shape_fillet}, {load_texture("res/icons/Draft_PolarArray.png"), false, "Shape polar duplicate", Mode::Shape_polar_duplicate}, + {load_texture("res/icons/Curves_ExtractSubshape.png"), false, "Shape cross-section", Mode::Shape_section}, {load_texture("res/icons/Part_Cut.png"), false, "Shape cut", Command::Shape_cut}, {load_texture("res/icons/Part_Fuse.png"), false, "Shape fuse", Command::Shape_fuse}, {load_texture("res/icons/Part_Common.png"), false, "Shape common", Command::Shape_common}, @@ -1121,9 +1122,8 @@ const std::vector& GUI::occt_material_combo_labels_() } void GUI::sketch_list_inspector_(const Sketch::sptr& sketch, int index, Sketch_list_row_ui& row_ui, - Sketch::sptr& hover_dim_sketch, size_t& hover_dim_index, - Sketch::sptr& hover_face_sketch, size_t& hover_face_index, - Sketch::sptr& hover_edge_sketch, size_t& hover_edge_index, + Sketch::sptr& hover_dim_sketch, size_t& hover_dim_index, Sketch::sptr& hover_face_sketch, + size_t& hover_face_index, Sketch::sptr& hover_edge_sketch, size_t& hover_edge_index, Sketch::sptr& hover_node_sketch, size_t& hover_node_index) { ImGui::Indent(); @@ -1492,10 +1492,9 @@ void GUI::sketch_list_() sketch_list_hover = sketch; if (expanded && ui_show_sketch_list_expand()) - sketch_list_inspector_(sketch, index, row_ui, sketch_list_measurement_hover_sketch, - sketch_list_measurement_hover_index, sketch_list_hover_face_sketch, - sketch_list_hover_face_index, sketch_list_hover_edge_sketch, sketch_list_hover_edge_index, - sketch_list_hover_node_sketch, sketch_list_hover_node_index); + sketch_list_inspector_(sketch, index, row_ui, sketch_list_measurement_hover_sketch, sketch_list_measurement_hover_index, + sketch_list_hover_face_sketch, sketch_list_hover_face_index, sketch_list_hover_edge_sketch, + sketch_list_hover_edge_index, sketch_list_hover_node_sketch, sketch_list_hover_node_index); ++index; } @@ -2137,8 +2136,8 @@ void GUI::cancel_underlay_calib_() void GUI::begin_underlay_undo_(Sketch& sk) { - m_underlay_undo_before = sk.underlay().to_json(m_view->asset_store()); - m_underlay_undo_sketch_id = sk.get_id(); + m_underlay_undo_before = sk.underlay().to_json(m_view->asset_store()); + m_underlay_undo_sketch_id = sk.get_id(); } void GUI::commit_underlay_undo_(Sketch& sk) @@ -2764,15 +2763,15 @@ void GUI::shape_list_() if (ImGui::BeginTable("##shape_outliner", 4, table_flags, ImVec2(0.f, 0.f))) { // Fixed actions on the left; stretch name on the right (tree indent on name only). - ImGui::TableSetupColumn("vis", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | - ImGuiTableColumnFlags_IndentDisable, - check_col_w); - ImGui::TableSetupColumn("disp", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | - ImGuiTableColumnFlags_IndentDisable, - check_col_w); - ImGui::TableSetupColumn("mat", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | - ImGuiTableColumnFlags_IndentDisable, - mat_col_w); + ImGui::TableSetupColumn( + "vis", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_IndentDisable, + check_col_w); + ImGui::TableSetupColumn( + "disp", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_IndentDisable, + check_col_w); + ImGui::TableSetupColumn( + "mat", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_IndentDisable, + mat_col_w); ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_IndentEnable, 1.0f); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, @@ -2788,15 +2787,14 @@ void GUI::shape_list_() { const float min_pad_h = ImGui::GetFrameHeight(); const ImGuiWindow* inner = ImGui::GetCurrentWindow(); - const float visible_remain = - inner->InnerClipRect.Max.y - ImGui::GetCursorScreenPos().y - ImGui::GetStyle().CellPadding.y; - const float pad_h = std::max(min_pad_h, visible_remain); + const float visible_remain = inner->InnerClipRect.Max.y - ImGui::GetCursorScreenPos().y - ImGui::GetStyle().CellPadding.y; + const float pad_h = std::max(min_pad_h, visible_remain); ImGui::TableNextRow(ImGuiTableRowFlags_None, pad_h); ImGui::TableSetColumnIndex(0); const ImGuiPayload* active_payload = ImGui::GetDragDropPayload(); - const bool dragging_shape = - active_payload != nullptr && active_payload->IsDataType("EZY_SHAPE_ID") && active_payload->DataSize == sizeof(Shape_id); + const bool dragging_shape = active_payload != nullptr && active_payload->IsDataType("EZY_SHAPE_ID") && + active_payload->DataSize == sizeof(Shape_id); if (dragging_shape) ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_Header, 0.20f)); @@ -2808,8 +2806,7 @@ void GUI::shape_list_() bool root_drop_hovered = false; if (ImGui::BeginDragDropTarget()) { - const ImGuiPayload* payload = - ImGui::AcceptDragDropPayload("EZY_SHAPE_ID", ImGuiDragDropFlags_AcceptBeforeDelivery); + const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("EZY_SHAPE_ID", ImGuiDragDropFlags_AcceptBeforeDelivery); if (payload != nullptr) { root_drop_hovered = payload->Preview; @@ -2832,9 +2829,9 @@ void GUI::shape_list_() const char* hint = "Move to root"; const ImVec2 ts = ImGui::CalcTextSize(hint); - ImGui::GetWindowDrawList()->AddText(ImVec2(rmin.x + (rmax.x - rmin.x - ts.x) * 0.5f, - rmin.y + (rmax.y - rmin.y - ts.y) * 0.5f), - ImGui::GetColorU32(ImGuiCol_TextDisabled), hint); + ImGui::GetWindowDrawList()->AddText( + ImVec2(rmin.x + (rmax.x - rmin.x - ts.x) * 0.5f, rmin.y + (rmax.y - rmin.y - ts.y) * 0.5f), + ImGui::GetColorU32(ImGuiCol_TextDisabled), hint); } } @@ -3471,7 +3468,7 @@ void GUI::apply_shape_list_ui_from_json_(const nlohmann::json& j) try { - const Shape_id id = static_cast(std::stoull(it.key())); + const Shape_id id = static_cast(std::stoull(it.key())); m_shape_list_expanded[id] = it.value().get(); } catch (...) @@ -3548,7 +3545,7 @@ void GUI::apply_sketch_list_ui_from_json_(const nlohmann::json& j) if (!row.is_object() || key.empty()) continue; - char* end = nullptr; + char* end = nullptr; const unsigned long long parsed = std::strtoull(key.c_str(), &end, 10); if (!end || *end != '\0') continue; @@ -3574,8 +3571,8 @@ std::string GUI::serialized_project_json_() const std::string project_json = m_view->to_json(); json j = json::parse(project_json); j["mode"] = static_cast(get_mode()); - j["ui"]["sketchList"] = sketch_list_ui_to_json_(); - j["ui"]["shapeList"] = shape_list_ui_to_json_(); + j["ui"]["sketchList"] = sketch_list_ui_to_json_(); + j["ui"]["shapeList"] = shape_list_ui_to_json_(); return j.dump(2); } @@ -4057,8 +4054,8 @@ void GUI::save_file_dialog_() { auto describe_save_failure = [&](const char* stage) -> std::string { - const int err = errno; - const std::string name = std::filesystem::path(file).filename().string(); + const int err = errno; + const std::string name = std::filesystem::path(file).filename().string(); std::ostringstream msg; msg << "Could not save \"" << name << "\".\n\n"; msg << "Path:\n" << file << "\n\n"; @@ -4271,8 +4268,7 @@ void GUI::import_file_dialog_async() var contentsPtr = _malloc(length); HEAPU8.set(contents, contentsPtr); // File -> Import opens the Import dialog (metadata, then Import into project). - Module.ccall('on_inspector_file_selected', null, [ 'string', 'number', 'number' ], - [ fileName, contentsPtr, length ]); + Module.ccall('on_inspector_file_selected', null, [ 'string', 'number', 'number' ], [ fileName, contentsPtr, length ]); _free(contentsPtr); }; reader.readAsArrayBuffer(file); @@ -4322,10 +4318,14 @@ void GUI::save_file_dialog_async(const char* title, const std::string& default_f try { var data = HEAPU8.subarray($0, $0 + $1); - var blob = new Blob([data], {type : 'application/octet-stream'}); - var url = URL.createObjectURL(blob); - var a = document.createElement('a'); - a.href = url; + var blob = new Blob([data], + { + type: + 'application/octet-stream' + }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; a.download = UTF8ToString($2); document.body.appendChild(a); a.click(); diff --git a/src/gui.h b/src/gui.h index 48fc544..e065dc0 100644 --- a/src/gui.h +++ b/src/gui.h @@ -105,8 +105,7 @@ inline constexpr float k_gui_sketch_face_selection_color_default[4] = {0.799043f /// Dynamic (hover) highlight RGBA for sketch faces (`gui.sketch_face_highlight_color`). inline constexpr float k_gui_sketch_face_highlight_color_default[4] = {0.822967f, 0.0f, 1.0f, 1.0f}; /// 3D shape selection (AIS SelectionStyle) RGBA (`gui.shape_selection_color`). -inline constexpr float k_gui_shape_selection_color_default[4] = { - 0.754312f, 0.072938f, 0.846890f, 1.0f}; +inline constexpr float k_gui_shape_selection_color_default[4] = {0.754312f, 0.072938f, 0.846890f, 1.0f}; /// `gui.sketch_shape_faint_style`: 0 = Off (hide shapes in sketch mode), 1 = Ghost, 2 = Wire. inline constexpr int k_gui_sketch_shape_faint_style_min = 0; inline constexpr int k_gui_sketch_shape_faint_style_max = 2; @@ -285,32 +284,32 @@ class GUI float sketch_shape_faint_opacity() const { return m_sketch_shape_faint_opacity; } /// Master on/off for faint shapes in all sketch modes (`gui.sketch_shape_faint_enabled`). bool sketch_shape_faint_enabled() const { return m_sketch_shape_faint_enabled; } - bool get_add_mid_pt_line_edges() const { return m_add_mid_pt_line_edges; } - bool get_add_mid_pt_rect_edges() const { return m_add_mid_pt_rect_edges; } - bool get_add_mid_pt_slot_edges() const { return m_add_mid_pt_slot_edges; } - bool get_edge_from_center() const { return m_edge_from_center; } - bool get_hide_all_shapes() const { return m_hide_all_shapes; } - void set_hide_all_shapes(bool hide) { m_hide_all_shapes = hide; } + bool get_add_mid_pt_line_edges() const { return m_add_mid_pt_line_edges; } + bool get_add_mid_pt_rect_edges() const { return m_add_mid_pt_rect_edges; } + bool get_add_mid_pt_slot_edges() const { return m_add_mid_pt_slot_edges; } + bool get_edge_from_center() const { return m_edge_from_center; } + bool get_hide_all_shapes() const { return m_hide_all_shapes; } + void set_hide_all_shapes(bool hide) { m_hide_all_shapes = hide; } /// Orthographic camera toggle for non-sketch modes (forces ortho in sketch modes); persisted as /// `gui.inspection_orthographic`. - bool inspection_orthographic() const { return m_inspection_orthographic; } - void set_inspection_orthographic(bool v) { m_inspection_orthographic = v; } + bool inspection_orthographic() const { return m_inspection_orthographic; } + void set_inspection_orthographic(bool v) { m_inspection_orthographic = v; } /// Default top-view framing width/height in inches (`gui.default_2d_view_width` / `_height`). double default_2d_view_width() const { return m_default_2d_view_width; } double default_2d_view_height() const { return m_default_2d_view_height; } /// Unit applied by File -> New (`gui.default_project_unit`). Project_unit default_project_unit() const { return m_default_project_unit; } - bool get_dark_mode() const { return m_dark_mode; } - ImVec4 get_clear_color() const; - void set_mode(Mode mode); // gui_mode.cpp - void set_parent_mode(); // gui_mode.cpp - void set_dist_edit(float dist, std::function&& callback, - const std::optional screen_coords = std::nullopt); - void hide_dist_edit(bool apply = true); - void set_angle_edit(float angle, std::function&& callback, - const std::optional screen_coords = std::nullopt); - void hide_angle_edit(bool apply = true); - void hide_sketch_origin_set_edit(bool apply = true); + bool get_dark_mode() const { return m_dark_mode; } + ImVec4 get_clear_color() const; + void set_mode(Mode mode); // gui_mode.cpp + void set_parent_mode(); // gui_mode.cpp + void set_dist_edit(float dist, std::function&& callback, + const std::optional screen_coords = std::nullopt); + void hide_dist_edit(bool apply = true); + void set_angle_edit(float angle, std::function&& callback, + const std::optional screen_coords = std::nullopt); + void hide_angle_edit(bool apply = true); + void hide_sketch_origin_set_edit(bool apply = true); /// True when dist or angle edit is visible; Tab should be routed to on_key() instead of ImGui. bool is_dist_or_angle_edit_active() const; bool is_sketch_origin_set_edit_active() const; @@ -364,9 +363,9 @@ class GUI void note_saved_project_filename(const std::string& filename); #endif - void on_file(const std::string& file_path, const std::string& file_bytes, bool announce_load = true); + void on_file(const std::string& file_path, const std::string& file_bytes, bool announce_load = true); [[nodiscard]] bool on_import_file(const std::string& file_path, const std::string& file_data, bool union_shapes = false); - void on_inspector_file(const std::string& file_path, const std::string& file_data); + void on_inspector_file(const std::string& file_path, const std::string& file_data); /// Emscripten `on_sketch_underlay_selected` routes here (must be public for C callback). void on_sketch_underlay_file(const std::string& file_path, const std::string& file_bytes); @@ -410,14 +409,14 @@ class GUI [[nodiscard]] nlohmann::json sketch_list_ui_to_json_() const; [[nodiscard]] nlohmann::json shape_list_ui_to_json_() const; void apply_shape_list_ui_from_json_(const nlohmann::json& j); - void sketch_properties_dialog_(); - void sketch_origin_panel_settings_(const std::shared_ptr& sk); - void shape_list_(); - void shape_info_dialog_(); - void open_shape_info_(const Shp_ptr& shape); - void file_inspector_dialog_(); - void open_file_inspector_(const std::string& file_path, const std::string& file_bytes); - void close_file_inspector_(); + void sketch_properties_dialog_(); + void sketch_origin_panel_settings_(const std::shared_ptr& sk); + void shape_list_(); + void shape_info_dialog_(); + void open_shape_info_(const Shp_ptr& shape); + void file_inspector_dialog_(); + void open_file_inspector_(const std::string& file_path, const std::string& file_bytes); + void close_file_inspector_(); // Mode + Options panel (gui_mode.cpp) void options_(); @@ -429,6 +428,7 @@ class GUI void options_shape_chamfer_mode_(); void options_shape_fillet_mode_(); void options_shape_polar_duplicate_mode_(); + void options_shape_section_mode_(); void options_sketch_from_planer_face_mode_(); void options_sketch_operation_axis_mode_(); void options_sketch_face_extrude_mode_(); @@ -612,18 +612,18 @@ class GUI int m_sketch_shape_faint_style = k_gui_sketch_shape_faint_style_default; float m_sketch_shape_faint_opacity = k_gui_sketch_shape_faint_opacity_default; bool m_sketch_shape_faint_enabled = k_gui_sketch_shape_faint_enabled_default; - bool m_add_mid_pt_line_edges = false; - bool m_add_mid_pt_rect_edges = true; - bool m_add_mid_pt_slot_edges = false; - bool m_edge_from_center = false; + bool m_add_mid_pt_line_edges = false; + bool m_add_mid_pt_rect_edges = true; + bool m_add_mid_pt_slot_edges = false; + bool m_edge_from_center = false; /// Degrees per numpad orbit (8/2/4/6) and Blender-style roll (Shift+NumPad 4/6); persisted in `gui.view_roll_step_deg`. double m_view_roll_step_deg = k_gui_view_roll_step_deg_default; /// Multiplier for `UpdateZoom(Aspect_ScrollDelta(..., int(y * scale)))`; persisted in `gui.view_zoom_scroll_scale`. - double m_view_zoom_scroll_scale = k_gui_view_zoom_scroll_scale_default; + double m_view_zoom_scroll_scale = k_gui_view_zoom_scroll_scale_default; /// Sketch-plane framing for New Project / default camera (`gui.default_2d_view_width` / `_height`, inches). - double m_default_2d_view_width = k_gui_default_2d_view_size_default; - double m_default_2d_view_height = k_gui_default_2d_view_size_default; - Project_unit m_default_project_unit = Project_unit::Inch; + double m_default_2d_view_width = k_gui_default_2d_view_size_default; + double m_default_2d_view_height = k_gui_default_2d_view_size_default; + Project_unit m_default_project_unit = Project_unit::Inch; bool m_inspection_orthographic = false; std::vector m_toolbar_buttons; @@ -668,52 +668,52 @@ class GUI std::unordered_map m_sketch_list_ui; /// Shape List group expand state (project `ui.shapeList.expanded`); missing id defaults to open. std::unordered_map m_shape_list_expanded; - float m_sketch_list_scroll_y{0.f}; - bool m_sketch_list_scroll_restore{false}; - - bool m_show_sketch_list{true}; - bool m_show_shape_list{true}; - bool m_show_options{true}; - bool m_show_settings_dialog{false}; - bool m_open_about_popup{false}; - bool m_about_popup_open{false}; - bool m_shape_info_open{false}; - Shp_ptr m_shape_info_shp; - std::vector m_shape_info_lines; - bool m_file_inspector_open{false}; - bool m_file_inspector_union{false}; - std::string m_file_inspector_path; - std::string m_file_inspector_bytes; - utl_cad_file_info::Format m_file_inspector_fmt{utl_cad_file_info::Format::Unknown}; + float m_sketch_list_scroll_y{0.f}; + bool m_sketch_list_scroll_restore{false}; + + bool m_show_sketch_list{true}; + bool m_show_shape_list{true}; + bool m_show_options{true}; + bool m_show_settings_dialog{false}; + bool m_open_about_popup{false}; + bool m_about_popup_open{false}; + bool m_shape_info_open{false}; + Shp_ptr m_shape_info_shp; + std::vector m_shape_info_lines; + bool m_file_inspector_open{false}; + bool m_file_inspector_union{false}; + std::string m_file_inspector_path; + std::string m_file_inspector_bytes; + utl_cad_file_info::Format m_file_inspector_fmt{utl_cad_file_info::Format::Unknown}; std::vector m_file_inspector_lines; - std::string m_about_markdown; - uint32_t m_about_splash_gl{0}; - glm::ivec2 m_about_splash_size{512, 512}; - bool m_about_assets_loaded{false}; - bool m_open_add_box_popup{false}; - glm::dvec3 m_add_box_origin{0.0, 0.0, 0.0}; - glm::dvec3 m_add_box_size{1.0, 1.0, 1.0}; - bool m_open_add_pyramid_popup{false}; - glm::dvec3 m_add_pyramid_origin{0.0, 0.0, 0.0}; - double m_add_pyramid_side{1}; - bool m_open_add_sphere_popup{false}; - glm::dvec3 m_add_sphere_origin{0.0, 0.0, 0.0}; - double m_add_sphere_radius{1}; - bool m_open_add_cylinder_popup{false}; - glm::dvec3 m_add_cylinder_origin{0.0, 0.0, 0.0}; - double m_add_cylinder_radius{1}, m_add_cylinder_height{1}; - bool m_open_add_cone_popup{false}; - glm::dvec3 m_add_cone_origin{0.0, 0.0, 0.0}; - double m_add_cone_R1{1}, m_add_cone_R2{0}, m_add_cone_height{1}; - bool m_open_add_torus_popup{false}; - glm::dvec3 m_add_torus_origin{0.0, 0.0, 0.0}; - double m_add_torus_R1{1}, m_add_torus_R2{0.5}; - bool m_open_add_sketch_popup{false}; - int m_new_sketch_plane{0}; // 0=XY, 1=XZ, 2=YZ - double m_new_sketch_offset{}; - bool m_hide_all_shapes{false}; - int m_ui_verbosity{k_gui_ui_verbosity_default}; - bool m_dark_mode{false}; + std::string m_about_markdown; + uint32_t m_about_splash_gl{0}; + glm::ivec2 m_about_splash_size{512, 512}; + bool m_about_assets_loaded{false}; + bool m_open_add_box_popup{false}; + glm::dvec3 m_add_box_origin{0.0, 0.0, 0.0}; + glm::dvec3 m_add_box_size{1.0, 1.0, 1.0}; + bool m_open_add_pyramid_popup{false}; + glm::dvec3 m_add_pyramid_origin{0.0, 0.0, 0.0}; + double m_add_pyramid_side{1}; + bool m_open_add_sphere_popup{false}; + glm::dvec3 m_add_sphere_origin{0.0, 0.0, 0.0}; + double m_add_sphere_radius{1}; + bool m_open_add_cylinder_popup{false}; + glm::dvec3 m_add_cylinder_origin{0.0, 0.0, 0.0}; + double m_add_cylinder_radius{1}, m_add_cylinder_height{1}; + bool m_open_add_cone_popup{false}; + glm::dvec3 m_add_cone_origin{0.0, 0.0, 0.0}; + double m_add_cone_R1{1}, m_add_cone_R2{0}, m_add_cone_height{1}; + bool m_open_add_torus_popup{false}; + glm::dvec3 m_add_torus_origin{0.0, 0.0, 0.0}; + double m_add_torus_R1{1}, m_add_torus_R2{0.5}; + bool m_open_add_sketch_popup{false}; + int m_new_sketch_plane{0}; // 0=XY, 1=XZ, 2=YZ + double m_new_sketch_offset{}; + bool m_hide_all_shapes{false}; + int m_ui_verbosity{k_gui_ui_verbosity_default}; + bool m_dark_mode{false}; #ifndef NDEBUG bool m_show_dbg{false}; #endif @@ -774,9 +774,8 @@ class GUI /// Shape List hover highlight in the OCCT view (0-1, persisted in ezycad_settings.json). glm::vec4 m_elm_list_hover_color{0.402064f, 0.102557f, 0.474576f, 1.f}; /// AIS SelectionStyle for selected 3D shapes (0-1 RGBA; Settings -> View presentation). - float m_shape_selection_color[4] = { - k_gui_shape_selection_color_default[0], k_gui_shape_selection_color_default[1], - k_gui_shape_selection_color_default[2], k_gui_shape_selection_color_default[3]}; + float m_shape_selection_color[4] = {k_gui_shape_selection_color_default[0], k_gui_shape_selection_color_default[1], + k_gui_shape_selection_color_default[2], k_gui_shape_selection_color_default[3]}; std::unique_ptr m_lua_console; bool m_show_python_console{false}; diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index b3b9497..7512554 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -62,6 +62,7 @@ std::string GUI::get_doc_url_for_mode(Mode mode) {Mode::Sketch_add_circle_3_pts, ""}, // planned feature - no specific section in the docs yet; falls back to main guide {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_section, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-cross-section-tool"}, // clang-format on }; @@ -130,6 +131,7 @@ void GUI::set_parent_mode() {Mode::Sketch_add_circle_3_pts, Mode::Sketch_inspection_mode}, {Mode::Sketch_add_slot, Mode::Sketch_inspection_mode}, {Mode::Sketch_dim_anno, Mode::Sketch_inspection_mode}, + {Mode::Shape_section, Mode::Normal}, // clang-format on }; @@ -392,6 +394,7 @@ void GUI::options_() case Mode::Shape_chamfer: options_shape_chamfer_mode_(); break; case Mode::Shape_fillet: options_shape_fillet_mode_(); break; case Mode::Shape_polar_duplicate: options_shape_polar_duplicate_mode_(); break; + case Mode::Shape_section: options_shape_section_mode_(); break; // Sketch related modes: case Mode::Sketch_inspection_mode: options_sketch_inspection_mode_(); break; @@ -753,6 +756,63 @@ void GUI::options_shape_polar_duplicate_mode_() options_orthographic_projection_(); } +void GUI::options_shape_section_mode_() +{ + EZY_ASSERT(get_mode() == Mode::Shape_section); + + Shp_section& section = m_view->shp_section(); + int plane = static_cast(section.get_plane()); + double offset = section.get_offset_display(); + + ImGui::TextUnformatted(current_mode_description_()); + options_doc_help_button_(); + ImGui::Separator(); + ImGui::TextUnformatted("Section plane"); + + if (ImGui::RadioButton("Local XY", &plane, static_cast(Section_plane::XY))) + { + section.set_plane(Section_plane::XY); + section.clear(); + } + ImGui::SameLine(); + if (ImGui::RadioButton("Local XZ", &plane, static_cast(Section_plane::XZ))) + { + section.set_plane(Section_plane::XZ); + section.clear(); + } + ImGui::SameLine(); + if (ImGui::RadioButton("Local YZ", &plane, static_cast(Section_plane::YZ))) + { + section.set_plane(Section_plane::YZ); + section.clear(); + } + + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("Offset", &offset, 0.0, 0.0, "%.6g")) + { + section.set_offset_display(offset); + section.clear(); + } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::TextUnformatted(m_view->project_unit_suffix()); + + if (ImGui::Button("Update preview")) + { + const Status status = section.preview_selected(); + show_message(status.message()); + } + if (section.has_preview()) + { + ImGui::SameLine(); + if (ImGui::Button("Clear")) + section.clear(); + } + + ImGui::TextWrapped( + "Each selected solid uses its own local frame. The yellow plane and arrow show the cut plane and positive normal."); + options_orthographic_projection_(); +} + void GUI::options_sketch_from_planer_face_mode_() { EZY_ASSERT(get_mode() == Mode::Sketch_from_planar_face); diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index f37a40a..d09f0da 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -95,6 +95,7 @@ Occt_view::Occt_view(GUI& gui) , m_shp_common(*this) , m_shp_polar_dup(*this) , m_shp_extrude(*this) + , m_shp_section(*this) { } @@ -519,6 +520,8 @@ void Occt_view::bake_transform_into_geometry(AIS_Shape_ptr& shape) // Update the AIS_Shape with the new geometry shape->Set(transformed_shape); + if (Shp_ptr document_shape = Shp_ptr::DownCast(shape); !document_shape.IsNull()) + document_shape->transform_frame(current_transform); // Reset the local transformation to identity gp_Trsf identity_transform; @@ -571,6 +574,11 @@ void Occt_view::cancel(Set_parent_mode set_parent_mode) gui().set_mode(Mode::Normal); break; + case Mode::Shape_section: + shp_section().clear(); + gui().set_mode(Mode::Normal); + break; + default: operation_canceled |= cancel_sketch_extrude_(); operation_canceled |= curr_sketch().cancel_elm(); @@ -1056,7 +1064,8 @@ std::vector Occt_view::shape_children(Shape_id parent_id) const if (!s.IsNull() && s->get_parent_id() == parent_id) kids.push_back(s); - std::sort(kids.begin(), kids.end(), [](const Shp_ptr& a, const Shp_ptr& b) + std::sort(kids.begin(), kids.end(), + [](const Shp_ptr& a, const Shp_ptr& b) { if (a->get_sibling_order() != b->get_sibling_order()) return a->get_sibling_order() < b->get_sibling_order(); @@ -1192,8 +1201,8 @@ Status Occt_view::group_shapes(const std::vector& nodes) apply_shape_link(ch.id, ch.new_parent, ch.new_order); m_current_group_id = grp->get_id(); - push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*grp)}, - std::vector{}, std::move(links))); + push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*grp)}, std::vector{}, + std::move(links))); sync_sketch_shape_faint_style(); return Status::ok(); } @@ -1304,7 +1313,7 @@ void Occt_view::insert_shape_rec(const Shape_rec& rec) } else { - shp = new Shp(*m_ctx, rec.geom); + shp = new Shp(*m_ctx, rec.geom); const int nmat = Graphic3d_MaterialAspect::NumberOfMaterials(); int mat_idx = rec.material; if (mat_idx < 0 || mat_idx >= nmat) @@ -1318,6 +1327,7 @@ void Occt_view::insert_shape_rec(const Shape_rec& rec) shp->set_id(rec.id); adopt_shape_id(rec.id); shp->set_name(rec.name); + shp->set_frame(rec.frame); 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. @@ -1349,13 +1359,14 @@ void Occt_view::remove_shape_by_id(Shape_id id) } } -void Occt_view::set_shape_geom_by_id(Shape_id id, const TopoDS_Shape& geom) +void Occt_view::set_shape_geom_by_id(Shape_id id, const TopoDS_Shape& geom, const gp_Ax3& frame) { Shp_ptr shp = find_shape_by_id(id); if (shp.IsNull()) return; shp->Set(geom); + shp->set_frame(frame); gp_Trsf identity; shp->SetLocalTransformation(identity); m_ctx->Redisplay(shp, true); @@ -1689,10 +1700,7 @@ void Occt_view::set_project_unit(Project_unit unit) refresh_sketch_annotations({.length_dimensions = true}); } -const char* Occt_view::project_unit_suffix() const -{ - return m_project_unit == Project_unit::Millimeter ? "mm" : "in"; -} +const char* Occt_view::project_unit_suffix() const { return m_project_unit == Project_unit::Millimeter ? "mm" : "in"; } bool Occt_view::get_show_dim_input() const { return m_show_dim_input; } @@ -2851,6 +2859,8 @@ void Occt_view::on_mode() DBG_MSG(c_mode_strs[int(get_mode())]); shp_polar_dup().reset(); + if (get_mode() != Mode::Shape_section) + shp_section().clear(); for (Sketch_ptr& s : m_sketches) s->on_mode(); @@ -2912,6 +2922,7 @@ void Occt_view::on_mode() case Mode::Move: set_shp_selection_mode(TopAbs_COMPOUND); break; case Mode::Rotate: set_shp_selection_mode(TopAbs_COMPOUND); break; case Mode::Scale: set_shp_selection_mode(TopAbs_COMPOUND); break; + case Mode::Shape_section: set_shp_selection_mode(TopAbs_COMPOUND); break; default: if(m_modes_selection_mode_map.count(get_mode())) set_shp_selection_mode(m_modes_selection_mode_map.at(get_mode())); @@ -2955,7 +2966,7 @@ void Occt_view::sync_sketch_shape_faint_style() continue; // Hide all / sketch-hide are overlays: do not write get_visible(). - const bool own_ok = shp->get_visible() && shape_ancestors_visible(*shp); + const bool own_ok = shp->get_visible() && shape_ancestors_visible(*shp); const bool hide_overlay = hide_all || (sketch && hide_in_sketch); const bool show = own_ok && !hide_overlay; @@ -3154,6 +3165,7 @@ Shp_fuse& Occt_view::shp_fuse() { return m_shp_fuse; } Shp_common& Occt_view::shp_common() { return m_shp_common; } Shp_polar_dup& Occt_view::shp_polar_dup() { return m_shp_polar_dup; } Shp_extrude& Occt_view::shp_extrude() { return m_shp_extrude; } +Shp_section& Occt_view::shp_section() { return m_shp_section; } // clang-format on // --------------------------------------------------------------------------- @@ -3276,7 +3288,7 @@ std::string Occt_view::to_json() const { using namespace nlohmann; json j; - j["ezyFormat"] = k_ezy_file_format_version; + j["ezyFormat"] = k_ezy_file_format_version; j["projectUnit"] = (m_project_unit == Project_unit::Millimeter) ? "millimeter" : "inch"; json& sketches = j["sketches"] = json::array(); json& shps = j["shapes"] = json::array(); @@ -3310,6 +3322,7 @@ std::string Occt_view::to_json() const BRepTools::Write(shape, oss, false, false, TopTools_FormatVersion_CURRENT); shp_json["material"] = s->Material(); shp_json["geom"] = oss.str(); + shp_json["frame"] = ::to_json(gp_Pln(s->get_frame())); } shps.push_back(shp_json); } @@ -3400,6 +3413,8 @@ void Occt_view::load(const std::string& json_str, bool restore_view) iss.str(s["geom"]); BRepTools::Read(shape, iss, BRep_Builder()); shp = new Shp(*m_ctx, shape); + if (s.contains("frame") && s["frame"].is_object()) + shp->set_frame(from_json_pln(s["frame"]).Position()); int mat_idx = static_cast(m_default_material.Name()); if (s.contains("material") && s["material"].is_number_integer()) mat_idx = s["material"].get(); @@ -3710,13 +3725,13 @@ Status Occt_view::import_step(const std::string& step_data, const bool union_sha return Status::ok(); } - std::vector id_by_index(named.size(), 0); + std::vector id_by_index(named.size(), 0); std::vector added; added.reserve(named.size()); for (size_t i = 0; i < named.size(); ++i) { - utl_cad_file_info::Named_node& node = named[i]; + utl_cad_file_info::Named_node& node = named[i]; Shape_id parent_id = 0; if (node.parent_index >= 0 && static_cast(node.parent_index) < id_by_index.size()) parent_id = id_by_index[static_cast(node.parent_index)]; @@ -3783,10 +3798,10 @@ void Occt_view::new_file() remove(m_shps); clear_all(m_shps, m_sketches, m_cur_sketch); m_assets.clear(); - m_next_sketch_id = 1; - m_next_shape_id = 1; + m_next_sketch_id = 1; + m_next_shape_id = 1; m_current_group_id = 0; - m_project_unit = m_gui.default_project_unit(); + m_project_unit = m_gui.default_project_unit(); create_default_sketch_(); refresh_viewer_grid_(); diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h index c027916..220c2ca 100644 --- a/src/gui_occt_view.h +++ b/src/gui_occt_view.h @@ -26,6 +26,7 @@ #include "shp_polar_dup.h" #include "shp_rotate.h" #include "shp_scale.h" +#include "shp_section.h" #include "utl_types.h" #include "utl_asset_store.h" #include "utl_geom.h" @@ -98,7 +99,7 @@ class Occt_view : protected AIS_ViewController /// When \a union_shapes is true and the file has multiple roots, fuse them into one solid first. [[nodiscard]] Status import_step(const std::string& step_data, bool union_shapes = false); /// Import PLY (coords treated as inches) scaled into model space (* dimension_scale). - bool import_ply(const std::string& ply_bytes); + bool import_ply(const std::string& ply_bytes); /// Writes STEP/IGES/STL/PLY from model space in \a unit. Selected document shapes if any, else all. [[nodiscard]] Status export_document(Export_format fmt, Export_unit unit, const std::string& file_path); @@ -125,7 +126,7 @@ class Occt_view : protected AIS_ViewController /// Remove a shape by stable id (viewer + document list). 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); + void set_shape_geom_by_id(Shape_id id, const TopoDS_Shape& geom, 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; @@ -191,9 +192,9 @@ class Occt_view : protected AIS_ViewController // Sketch related Sketch_list& get_sketches(); const Sketch_list& get_sketches() const; - size_t allocate_sketch_id(); - void adopt_sketch_id(size_t id); - void remove_sketch(const Sketch_ptr& sketch); + size_t allocate_sketch_id(); + void adopt_sketch_id(size_t id); + void remove_sketch(const Sketch_ptr& sketch); /// Empty sketch on \a pln; \a base_name is uniquified (e.g. Sketch_xy, Sketch_xy.001). void add_sketch(const gp_Pln& pln, const std::string& base_name); /// Like add_sketch; \a offset_display is multiplied by get_display_to_model_scale(). @@ -207,7 +208,7 @@ class Occt_view : protected AIS_ViewController void set_curr_sketch(const Sketch_ptr& sketch); void sketch_face_extrude(const ScreenCoords& screen_coords, bool is_mouse_move); /// Enter face-extrude drag for a Sketch List face (after mode is already Sketch_face_extrude). - bool begin_sketch_face_extrude(const AIS_Shape_ptr& face); + bool begin_sketch_face_extrude(const AIS_Shape_ptr& face); std::list& get_shapes(); std::string get_unique_shape_name(const char* base_name) const; @@ -229,6 +230,7 @@ class Occt_view : protected AIS_ViewController Shp_common& shp_common(); Shp_polar_dup& shp_polar_dup(); Shp_extrude& shp_extrude(); + Shp_section& shp_section(); // Revolve related void revolve_selected(const double angle); @@ -244,19 +246,19 @@ class Occt_view : protected AIS_ViewController bool fit_face_in_view(const TopoDS_Face& face); // Dimension related - void dimension_input(const ScreenCoords& screen_coords); - void angle_input(const ScreenCoords& screen_coords); - void refresh_sketch_annotations(const Sketch_annotation_refresh& refresh); - void apply_sketch_dimensions_visibility(); + void dimension_input(const ScreenCoords& screen_coords); + void angle_input(const ScreenCoords& screen_coords); + void refresh_sketch_annotations(const Sketch_annotation_refresh& refresh); + void apply_sketch_dimensions_visibility(); /// Inch-based model scale (`model = inches * scale`). Used for STEP/PLY import/export. - double get_dimension_scale() const; + double get_dimension_scale() const; /// Display (project unit) -> model scale. Prefer for UI length I/O. - double get_display_to_model_scale() const; - double to_model(double display) const; - double to_display(double model) const; - Project_unit get_project_unit() const; + double get_display_to_model_scale() const; + double to_model(double display) const; + double to_display(double model) const; + Project_unit get_project_unit() const; /// Sets project unit, refreshes length dimensions; does not rewrite geometry. - void set_project_unit(Project_unit unit); + void set_project_unit(Project_unit unit); /// Short label for length fields: "in" or "mm". const char* project_unit_suffix() const; bool get_show_dim_input() const; @@ -313,8 +315,8 @@ class Occt_view : protected AIS_ViewController std::vector get_selected() const; /// Document `Shp` objects in the current viewer selection (deduped; ignores non-Shp AIS). std::vector get_selected_shps() const; - TopAbs_ShapeEnum get_shp_selection_mode() const; - void set_shp_selection_mode(const TopAbs_ShapeEnum selection_mode); + TopAbs_ShapeEnum get_shp_selection_mode() const; + void set_shp_selection_mode(const TopAbs_ShapeEnum selection_mode); /// Highlight \a shp in the 3D viewer while the Shape List row is hovered (null clears). void set_shape_list_hover(const Shp_ptr& shp); @@ -480,13 +482,12 @@ class Occt_view : protected AIS_ViewController void restore_sketch_list_measurement_hover_style_(); void refresh_sketch_list_measurement_hover_highlight_(); void clear_sketch_list_hover_ais_state_(Sketch_list_hover_ais& hover); - void apply_sketch_list_hover_ais_state_(Sketch_list_hover_ais& hover, const Prs3d_Drawer_ptr& drawer, - int display_mode); - void set_sketch_list_hover_ais_state_(Sketch_list_hover_ais& hover, const AIS_Shape_ptr& ais, - const Prs3d_Drawer_ptr& drawer, int display_mode); + void apply_sketch_list_hover_ais_state_(Sketch_list_hover_ais& hover, const Prs3d_Drawer_ptr& drawer, int display_mode); + void set_sketch_list_hover_ais_state_(Sketch_list_hover_ais& hover, const AIS_Shape_ptr& ais, const Prs3d_Drawer_ptr& drawer, + int display_mode); [[nodiscard]] static Sketch* sketch_owner_of_list_ais_(const AIS_Shape_ptr& ais); Graphic3d_MaterialAspect m_default_material; - bool m_headless_view{false}; + bool m_headless_view{false}; /// True when LMB press was handled by planar-face sketch creation without AIS_ViewController::PressMouseButton (pair with /// release skip). bool m_planar_face_lmb_skipped_view_controller{false}; @@ -514,6 +515,7 @@ class Occt_view : protected AIS_ViewController Shp_common m_shp_common; Shp_polar_dup m_shp_polar_dup; Shp_extrude m_shp_extrude; + Shp_section m_shp_section; // -------------------------------------------------------------------- // Selection related std::map m_modes_selection_mode_map; diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index c963a71..8ca911e 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -214,9 +214,9 @@ std::string GUI::occt_view_settings_json() const {"annotate_all_coaxial_nodes", Sketch_nodes::get_annotate_all_coaxial_nodes()}, {"ui_verbosity", m_ui_verbosity}, {"elm_list_hover_color", - {m_elm_list_hover_color[0], m_elm_list_hover_color[1], m_elm_list_hover_color[2], m_elm_list_hover_color[3]}}, + {m_elm_list_hover_color[0], m_elm_list_hover_color[1], m_elm_list_hover_color[2], m_elm_list_hover_color[3]}}, {"shape_selection_color", - {m_shape_selection_color[0], m_shape_selection_color[1], m_shape_selection_color[2], m_shape_selection_color[3]}}, + {m_shape_selection_color[0], m_shape_selection_color[1], m_shape_selection_color[2], m_shape_selection_color[3]}}, {"sketch_shape_faint_style", m_sketch_shape_faint_style}, {"sketch_shape_faint_opacity", m_sketch_shape_faint_opacity}, {"sketch_shape_faint_enabled", m_sketch_shape_faint_enabled}, @@ -1019,17 +1019,15 @@ void GUI::settings_() "Does not change the open project's File -> Project units. Click ? for the guide.", doc_urls::k_view_navigation); - const double to_ui = - (m_default_project_unit == Project_unit::Millimeter) ? k_mm_per_inch : 1.0; - const char* unit_sfx = - (m_default_project_unit == Project_unit::Millimeter) ? "mm" : "in"; - double w_ui = m_default_2d_view_width * to_ui; - double h_ui = m_default_2d_view_height * to_ui; - const double min_ui = k_gui_default_2d_view_size_min * to_ui; - const double max_ui = k_gui_default_2d_view_size_max * to_ui; + const double to_ui = (m_default_project_unit == Project_unit::Millimeter) ? k_mm_per_inch : 1.0; + const char* unit_sfx = (m_default_project_unit == Project_unit::Millimeter) ? "mm" : "in"; + double w_ui = m_default_2d_view_width * to_ui; + double h_ui = m_default_2d_view_height * to_ui; + const double min_ui = k_gui_default_2d_view_size_min * to_ui; + const double max_ui = k_gui_default_2d_view_size_max * to_ui; // Drag (not a 0.1..1000 slider): default 3 in sits near the left of a linear slider and is easy to // accidentally set to the minimum. - const float drag_spd = (m_default_project_unit == Project_unit::Millimeter) ? 1.0f : 0.05f; + const float drag_spd = (m_default_project_unit == Project_unit::Millimeter) ? 1.0f : 0.05f; ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); @@ -1039,8 +1037,7 @@ void GUI::settings_() ImGui::SetNextItemWidth(120.0f); if (ImGui::DragScalar("##default_2d_view_width", ImGuiDataType_Double, &w_ui, drag_spd, &min_ui, &max_ui, "%.2f")) { - m_default_2d_view_width = - std::clamp(w_ui / to_ui, k_gui_default_2d_view_size_min, k_gui_default_2d_view_size_max); + m_default_2d_view_width = std::clamp(w_ui / to_ui, k_gui_default_2d_view_size_min, k_gui_default_2d_view_size_max); save_occt_view_settings(); } m_default_2d_view_width = @@ -1061,8 +1058,7 @@ void GUI::settings_() ImGui::SetNextItemWidth(120.0f); if (ImGui::DragScalar("##default_2d_view_height", ImGuiDataType_Double, &h_ui, drag_spd, &min_ui, &max_ui, "%.2f")) { - m_default_2d_view_height = - std::clamp(h_ui / to_ui, k_gui_default_2d_view_size_min, k_gui_default_2d_view_size_max); + m_default_2d_view_height = std::clamp(h_ui / to_ui, k_gui_default_2d_view_size_min, k_gui_default_2d_view_size_max); save_occt_view_settings(); } m_default_2d_view_height = @@ -1461,9 +1457,9 @@ void GUI::settings_() k_gui_sketch_shape_faint_opacity_min * 100.0f, k_gui_sketch_shape_faint_opacity_max * 100.0f, "%.0f%%")) { - m_sketch_shape_faint_opacity = - std::clamp(strength_pct / 100.0f, k_gui_sketch_shape_faint_opacity_min, k_gui_sketch_shape_faint_opacity_max); - appear_changed = true; + m_sketch_shape_faint_opacity = std::clamp(strength_pct / 100.0f, k_gui_sketch_shape_faint_opacity_min, + k_gui_sketch_shape_faint_opacity_max); + appear_changed = true; } } ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); diff --git a/src/main.cpp b/src/main.cpp index 908535f..1a3deb2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -360,9 +360,8 @@ int main(int argc, char** argv) { ImGui_ImplGlfw_MouseButtonCallback(window, button, action, mods); - const unsigned button_bit = - (button >= 0 && button < 32) ? (1u << static_cast(button)) : 0u; - const bool in_view = forward_mouse_click_to_gui(); + const unsigned button_bit = (button >= 0 && button < 32) ? (1u << static_cast(button)) : 0u; + const bool in_view = forward_mouse_click_to_gui(); if (action == GLFW_PRESS) { diff --git a/src/mode.h b/src/mode.h index e9d045f..86f4303 100644 --- a/src/mode.h +++ b/src/mode.h @@ -28,7 +28,8 @@ X(Sketch_add_circle) \ X(Sketch_add_circle_3_pts) \ X(Sketch_add_slot) \ - X(Sketch_dim_anno) + X(Sketch_dim_anno) \ + X(Shape_section) enum class Mode { diff --git a/src/scr_lua_console.cpp b/src/scr_lua_console.cpp index 59bb8fc..07e864b 100644 --- a/src/scr_lua_console.cpp +++ b/src/scr_lua_console.cpp @@ -386,7 +386,7 @@ int l_view_get_selected_indices(lua_State* L) const std::vector selected = view->get_selected_shps(); std::list& shapes = view->get_shapes(); - int out_i = 1; + int out_i = 1; for (const Shp_ptr& sel : selected) { lua_Integer idx = 1; diff --git a/src/shp.cpp b/src/shp.cpp index 204bdde..b31b741 100644 --- a/src/shp.cpp +++ b/src/shp.cpp @@ -1,9 +1,16 @@ #include "shp.h" #include +#include #include +#include #include +namespace +{ +gp_Ax3 default_shape_frame_(const TopoDS_Shape& shape); +} + Shp::Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp) : AIS_Shape(shp) , m_ctx(ctx) @@ -12,6 +19,7 @@ Shp::Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp) , m_disp_mode(AIS_Shaded) , m_visible(true) , m_selection_mode(TopAbs_SHAPE) + , m_frame(default_shape_frame_(shp)) { } @@ -21,10 +29,10 @@ Shp_ptr Shp::create_group(AIS_InteractiveContext& ctx, const std::string& name) { TopoDS_Compound comp; BRep_Builder().MakeCompound(comp); - Shp_ptr grp = new Shp(ctx, comp); + Shp_ptr grp = new Shp(ctx, comp); grp->m_is_group = true; grp->set_name(name); - grp->m_visible = true; + grp->m_visible = true; return grp; } @@ -99,10 +107,7 @@ void Shp::set_sketch_faint(bool enabled, AIS_DisplayMode faint_mode, float trans update_display_(); } -AIS_DisplayMode Shp::effective_disp_mode_() const -{ - return m_sketch_faint_active ? m_faint_disp_mode : m_disp_mode; -} +AIS_DisplayMode Shp::effective_disp_mode_() const { return m_sketch_faint_active ? m_faint_disp_mode : m_disp_mode; } void Shp::redisplay_() { @@ -134,3 +139,21 @@ void Shp::update_display_() redisplay_(); m_ctx.UpdateCurrentViewer(); } + +namespace +{ +gp_Ax3 default_shape_frame_(const TopoDS_Shape& shape) +{ + if (shape.IsNull()) + return gp_Ax3(); + + Bnd_Box bounds; + BRepBndLib::Add(shape, bounds); + if (bounds.IsVoid()) + return gp_Ax3(); + + 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_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()); +} +} // namespace diff --git a/src/shp.h b/src/shp.h index 3c6324a..758a3ac 100644 --- a/src/shp.h +++ b/src/shp.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "utl.h" @@ -30,8 +31,8 @@ class Shp : public AIS_Shape void set_disp_mode(const AIS_DisplayMode mode); bool get_visible() const; /// User visibility preference (does not alone control viewer Erase/Display when overlays apply). - void set_visible(const bool visible); - void set_selection_mode(const TopAbs_ShapeEnum mode); + void set_visible(const bool visible); + void set_selection_mode(const TopAbs_ShapeEnum mode); bool is_group() const { return m_is_group; } Shape_id get_parent_id() const { return m_parent_id; } @@ -39,6 +40,12 @@ class Shp : public AIS_Shape int get_sibling_order() const { return m_sibling_order; } 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. + 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); } + /// Show or erase in the interactive context without changing get_visible(). /// No-op for group nodes. void apply_context_shown(bool shown); @@ -64,6 +71,7 @@ class Shp : public AIS_Shape bool m_is_group{false}; Shape_id m_parent_id{0}; int m_sibling_order{0}; + gp_Ax3 m_frame; }; using Shp_rslt = Result; diff --git a/src/shp_chamfer.cpp b/src/shp_chamfer.cpp index de3efee..c21b685 100644 --- a/src/shp_chamfer.cpp +++ b/src/shp_chamfer.cpp @@ -100,7 +100,7 @@ Status Shp_chamfer::add_chamfer(const ScreenCoords& screen_coords, const Chamfer Shp_ptr chamfer_shp = new Shp(ctx(), chamfer_maker.Shape()); replace_picked_shape_(chamfer_src_shp, chamfer_shp, "Chamfered shape"); view().push_undo_delta(std::make_unique(std::vector{removed}, - std::vector{capture_shape_rec(*chamfer_shp)})); + std::vector{capture_shape_rec(*chamfer_shp)})); } catch (const Standard_Failure& e) { diff --git a/src/shp_common.cpp b/src/shp_common.cpp index 86bf1da..ed940a9 100644 --- a/src/shp_common.cpp +++ b/src/shp_common.cpp @@ -54,7 +54,8 @@ Shp_rslt Shp_common::common(std::vector shps) assign_result_parent_(shp, m_shps); delete_operation_shps_(); add_shp_(shp); - view().push_undo_delta(std::make_unique(std::move(removed), std::vector{capture_shape_rec(*shp)})); + view().push_undo_delta( + std::make_unique(std::move(removed), std::vector{capture_shape_rec(*shp)})); return Shp_rslt(shp); } diff --git a/src/shp_cut.cpp b/src/shp_cut.cpp index 16969f1..8a2c76f 100644 --- a/src/shp_cut.cpp +++ b/src/shp_cut.cpp @@ -54,7 +54,8 @@ Shp_rslt Shp_cut::cut(std::vector shps) assign_result_parent_(shp, m_shps); delete_operation_shps_(); add_shp_(shp); - view().push_undo_delta(std::make_unique(std::move(removed), std::vector{capture_shape_rec(*shp)})); + view().push_undo_delta( + std::make_unique(std::move(removed), std::vector{capture_shape_rec(*shp)})); return Shp_rslt(shp); } diff --git a/src/shp_delta.cpp b/src/shp_delta.cpp index 9e3d67a..3640b50 100644 --- a/src/shp_delta.cpp +++ b/src/shp_delta.cpp @@ -9,6 +9,7 @@ Shape_rec capture_shape_rec(const Shp& shp) rec.name = shp.get_name(); rec.material = shp.Material(); rec.geom = shp.Shape(); + 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(); @@ -85,13 +86,13 @@ Shape_geom_delta::Shape_geom_delta(std::vector changes) void Shape_geom_delta::apply_forward(Occt_view& view) { for (const Geom_change& ch : m_changes) - view.set_shape_geom_by_id(ch.id, ch.after_geom); + view.set_shape_geom_by_id(ch.id, ch.after_geom, ch.after_frame); } void Shape_geom_delta::apply_reverse(Occt_view& view) { for (const Geom_change& ch : m_changes) - view.set_shape_geom_by_id(ch.id, ch.before_geom); + view.set_shape_geom_by_id(ch.id, ch.before_geom, ch.before_frame); } std::unique_ptr Shape_geom_delta::clone() const { return std::make_unique(m_changes); } @@ -114,13 +115,9 @@ void Shape_replace_delta::apply_reverse(Occt_view& view) insert_recs_(view, m_removed); } -std::unique_ptr Shape_replace_delta::clone() const -{ - return std::make_unique(m_removed, m_added); -} +std::unique_ptr Shape_replace_delta::clone() const { return std::make_unique(m_removed, m_added); } -Shape_tree_delta::Shape_tree_delta(std::vector added, std::vector removed, - std::vector links) +Shape_tree_delta::Shape_tree_delta(std::vector added, std::vector removed, std::vector links) : m_added(std::move(added)) , m_removed(std::move(removed)) , m_links(std::move(links)) diff --git a/src/shp_delta.h b/src/shp_delta.h index 058325e..6b6239b 100644 --- a/src/shp_delta.h +++ b/src/shp_delta.h @@ -16,6 +16,7 @@ struct Shape_rec std::string name; int material{0}; TopoDS_Shape geom; + gp_Ax3 frame; Shape_id parent_id{0}; int sibling_order{0}; bool is_group{false}; @@ -61,6 +62,8 @@ class Shape_geom_delta : public Delta Shape_id id{0}; TopoDS_Shape before_geom; TopoDS_Shape after_geom; + gp_Ax3 before_frame; + gp_Ax3 after_frame; }; explicit Shape_geom_delta(std::vector changes); @@ -108,7 +111,7 @@ class Shape_tree_delta : public Delta std::unique_ptr clone() const override; private: - std::vector m_added; - std::vector m_removed; + std::vector m_added; + std::vector m_removed; std::vector m_links; }; diff --git a/src/shp_fillet.cpp b/src/shp_fillet.cpp index 6f08f2c..e23137c 100644 --- a/src/shp_fillet.cpp +++ b/src/shp_fillet.cpp @@ -95,8 +95,8 @@ Status Shp_fillet::add_fillet(const ScreenCoords& screen_coords, const Fillet_mo fillet_maker.Build(); Shp_ptr fillet_shp = new Shp(ctx(), fillet_maker.Shape()); replace_picked_shape_(fillet_src_shp, fillet_shp, "Filleted shape"); - view().push_undo_delta( - std::make_unique(std::vector{removed}, std::vector{capture_shape_rec(*fillet_shp)})); + view().push_undo_delta(std::make_unique(std::vector{removed}, + std::vector{capture_shape_rec(*fillet_shp)})); } catch (const Standard_Failure& e) { diff --git a/src/shp_fuse.cpp b/src/shp_fuse.cpp index 2135687..2f99096 100644 --- a/src/shp_fuse.cpp +++ b/src/shp_fuse.cpp @@ -54,7 +54,8 @@ Shp_rslt Shp_fuse::fuse(std::vector shps) assign_result_parent_(shp, m_shps); delete_operation_shps_(); add_shp_(shp); - view().push_undo_delta(std::make_unique(std::move(removed), std::vector{capture_shape_rec(*shp)})); + view().push_undo_delta( + std::make_unique(std::move(removed), std::vector{capture_shape_rec(*shp)})); return Shp_rslt(shp); } diff --git a/src/shp_move.cpp b/src/shp_move.cpp index ed75fd5..219cfe6 100644 --- a/src/shp_move.cpp +++ b/src/shp_move.cpp @@ -127,7 +127,7 @@ void Shp_move::finalize() std::vector changes; changes.reserve(m_shps.size()); for (const Shp_ptr& shape : m_shps) - changes.push_back(Shape_geom_delta::Geom_change{shape->get_id(), shape->Shape(), {}}); + changes.push_back(Shape_geom_delta::Geom_change{shape->get_id(), shape->Shape(), {}, shape->get_frame(), {}}); operation_shps_finalize_(); @@ -135,7 +135,10 @@ void Shp_move::finalize() { Shp_ptr shp = view().find_shape_by_id(ch.id); if (!shp.IsNull()) - ch.after_geom = shp->Shape(); + { + ch.after_geom = shp->Shape(); + ch.after_frame = shp->get_frame(); + } } view().push_undo_delta(std::make_unique(std::move(changes))); diff --git a/src/shp_operation.cpp b/src/shp_operation.cpp index 2736abd..917c27f 100644 --- a/src/shp_operation.cpp +++ b/src/shp_operation.cpp @@ -28,10 +28,7 @@ std::vector Shp_operation_base::get_selected_shps_() const return ret; } -Shape_id Shp_operation_base::result_parent_from_operands_() const -{ - return Occt_view::result_parent_id(m_shps); -} +Shape_id Shp_operation_base::result_parent_from_operands_() const { return Occt_view::result_parent_id(m_shps); } void Shp_operation_base::assign_result_parent_(Shp_ptr& result, const std::vector& operands) { @@ -109,10 +106,7 @@ const TopoDS_Edge* Shp_operation_base::get_edge_(const ScreenCoords& screen_coor return m_view.get_edge_(screen_coords); } -void Shp_operation_base::add_shp_(Shp_ptr& shp, bool use_current_group) -{ - m_view.add_shp_(shp, use_current_group); -} +void Shp_operation_base::add_shp_(Shp_ptr& shp, bool use_current_group) { m_view.add_shp_(shp, use_current_group); } void Shp_operation_base::replace_picked_shape_(Shp_ptr& old_shp, Shp_ptr& new_shp, const std::string& name) { diff --git a/src/shp_operation.h b/src/shp_operation.h index 1711c30..c137690 100644 --- a/src/shp_operation.h +++ b/src/shp_operation.h @@ -20,8 +20,8 @@ class Shp_operation_base std::vector get_selected_shps_() const; /// Parent id for new shapes from multi-selection (see Occt_view::result_parent_id). - Shape_id result_parent_from_operands_() const; - void assign_result_parent_(Shp_ptr& result, const std::vector& operands); + Shape_id result_parent_from_operands_() const; + void assign_result_parent_(Shp_ptr& result, const std::vector& operands); [[nodiscard]] Status ensure_operation_shps_(); [[nodiscard]] Status ensure_operation_multi_shps_(); void delete_operation_shps_(); diff --git a/src/shp_rotate.cpp b/src/shp_rotate.cpp index 4824c62..5a79e03 100644 --- a/src/shp_rotate.cpp +++ b/src/shp_rotate.cpp @@ -202,7 +202,7 @@ void Shp_rotate::finalize() std::vector changes; changes.reserve(m_shps.size()); for (const Shp_ptr& shape : m_shps) - changes.push_back(Shape_geom_delta::Geom_change{shape->get_id(), shape->Shape(), {}}); + changes.push_back(Shape_geom_delta::Geom_change{shape->get_id(), shape->Shape(), {}, shape->get_frame(), {}}); operation_shps_finalize_(); @@ -210,7 +210,10 @@ void Shp_rotate::finalize() { Shp_ptr shp = view().find_shape_by_id(ch.id); if (!shp.IsNull()) - ch.after_geom = shp->Shape(); + { + ch.after_geom = shp->Shape(); + ch.after_frame = shp->get_frame(); + } } view().push_undo_delta(std::make_unique(std::move(changes))); diff --git a/src/shp_scale.cpp b/src/shp_scale.cpp index 7417bd7..23ea129 100644 --- a/src/shp_scale.cpp +++ b/src/shp_scale.cpp @@ -73,7 +73,7 @@ void Shp_scale::finalize() std::vector changes; changes.reserve(m_shps.size()); for (const Shp_ptr& shape : m_shps) - changes.push_back(Shape_geom_delta::Geom_change{shape->get_id(), shape->Shape(), {}}); + changes.push_back(Shape_geom_delta::Geom_change{shape->get_id(), shape->Shape(), {}, shape->get_frame(), {}}); operation_shps_finalize_(); @@ -81,7 +81,10 @@ void Shp_scale::finalize() { Shp_ptr shp = view().find_shape_by_id(ch.id); if (!shp.IsNull()) - ch.after_geom = shp->Shape(); + { + ch.after_geom = shp->Shape(); + ch.after_frame = shp->get_frame(); + } } view().push_undo_delta(std::make_unique(std::move(changes))); diff --git a/src/shp_section.cpp b/src/shp_section.cpp new file mode 100644 index 0000000..1176bc4 --- /dev/null +++ b/src/shp_section.cpp @@ -0,0 +1,303 @@ +#include "shp_section.h" + +#include "gui_occt_view.h" +#include "utl_occt.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace +{ +gp_Pln section_plane_(const gp_Ax3& frame, Section_plane plane, double offset); +void count_curve_type_(const TopoDS_Edge& edge, Section_geometry& result); +bool contains_solid_(const TopoDS_Shape& shape); +void add_plane_annotation_(const TopoDS_Shape& shape, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, + TopoDS_Compound& lines); +} // namespace + +Result section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Section_plane plane, double offset) +{ + if (shape.IsNull()) + return {Result_status::User_error, "Cannot section a null shape."}; + + if (!contains_solid_(shape)) + return {Result_status::User_error, "Cross-section supports solid shapes only."}; + + try + { + BRepAlgoAPI_Section section(shape, section_plane_(frame, plane, offset), false); + section.Approximation(false); + section.Build(); + if (!section.IsDone()) + return {Result_status::Topo_error, "Open CASCADE could not compute the section."}; + + Section_geometry result; + result.shape = section.Shape(); + for (TopExp_Explorer it(result.shape, TopAbs_EDGE); it.More(); it.Next()) + { + ++result.edge_count; + count_curve_type_(TopoDS::Edge(it.Current()), result); + } + + if (result.shape.IsNull() || result.edge_count == 0) + return {Result_status::User_error, "The section plane does not intersect the shape."}; + + return result; + } + catch (const Standard_Failure& e) + { + return {Result_status::Topo_error, std::string("Section failed: ") + standard_failure_message(e)}; + } +} + +Shp_section::Shp_section(Occt_view& view) + : Shp_operation_base(view) +{ +} + +Status Shp_section::preview_selected() +{ + clear(); + if (!std::isfinite(m_offset_display)) + return Status::user_error("Section offset must be a finite number."); + + const std::vector selected = get_selected_shps_(); + if (selected.empty()) + return Status::user_error("Select one or more solid shapes."); + + TopoDS_Compound compound; + TopoDS_Compound plane_fill; + TopoDS_Compound plane_lines; + BRep_Builder builder; + builder.MakeCompound(compound); + builder.MakeCompound(plane_fill); + builder.MakeCompound(plane_lines); + + Section_geometry totals; + const double offset_model = view().to_model(m_offset_display); + for (const Shp_ptr& shp : selected) + { + TopoDS_Shape section_source = shp->Shape(); + gp_Ax3 section_frame = shp->get_frame(); + const gp_Trsf& local_transform = shp->LocalTransformation(); + if (local_transform.Form() != gp_Identity) + { + section_source = BRepBuilderAPI_Transform(section_source, local_transform, true).Shape(); + section_frame.Transform(local_transform); + } + + const gp_Pln plane = section_plane_(section_frame, m_plane, offset_model); + Result result = section_shape(section_source, section_frame, m_plane, offset_model); + if (!result.has_value()) + return Status(result.status(), shp->get_name() + ": " + result.message()); + + add_plane_annotation_(section_source, plane, builder, plane_fill, plane_lines); + + const Section_geometry& geometry = *result; + builder.Add(compound, geometry.shape); + totals.edge_count += geometry.edge_count; + totals.line_count += geometry.line_count; + totals.circle_count += geometry.circle_count; + totals.ellipse_count += geometry.ellipse_count; + totals.bspline_count += geometry.bspline_count; + totals.other_curve_count += geometry.other_curve_count; + } + + if (totals.edge_count == 0) + return Status::user_error("The section is empty."); + + m_preview = new AIS_Shape(compound); + m_preview->SetColor(Quantity_NOC_CYAN); + m_preview->SetWidth(3.0); + m_preview->SetZLayer(Graphic3d_ZLayerId_Topmost); + ctx().Display(m_preview, AIS_WireFrame, -1, false); + ctx().Deactivate(m_preview); + + m_plane_fill = new AIS_Shape(plane_fill); + m_plane_fill->SetColor(Quantity_NOC_YELLOW); + m_plane_fill->SetTransparency(0.85); + ctx().Display(m_plane_fill, AIS_Shaded, -1, false); + ctx().Deactivate(m_plane_fill); + + m_plane_lines = new AIS_Shape(plane_lines); + m_plane_lines->SetColor(Quantity_NOC_YELLOW); + m_plane_lines->SetWidth(1.5); + m_plane_lines->SetZLayer(Graphic3d_ZLayerId_Topmost); + ctx().Display(m_plane_lines, AIS_WireFrame, -1, false); + ctx().Deactivate(m_plane_lines); + + ctx().UpdateCurrentViewer(); + + std::ostringstream msg; + msg << "Section preview: " << totals.edge_count << " edge"; + if (totals.edge_count != 1) + msg << "s"; + msg << " (" << totals.line_count << " line, " << totals.circle_count << " circle, " << totals.ellipse_count << " ellipse, " + << totals.bspline_count << " B-spline, " << totals.other_curve_count << " other)."; + return Status::ok(msg.str()); +} + +void Shp_section::clear() +{ + if (!m_preview.IsNull()) + ctx().Remove(m_preview, false); + + if (!m_plane_fill.IsNull()) + ctx().Remove(m_plane_fill, false); + + if (!m_plane_lines.IsNull()) + ctx().Remove(m_plane_lines, false); + + m_preview.Nullify(); + m_plane_fill.Nullify(); + m_plane_lines.Nullify(); + ctx().UpdateCurrentViewer(); +} + +namespace +{ +gp_Pln section_plane_(const gp_Ax3& frame, Section_plane plane, double offset) +{ + gp_Dir normal; + gp_Dir x_dir; + switch (plane) + { + case Section_plane::XY: + normal = frame.Direction(); + x_dir = frame.XDirection(); + break; + case Section_plane::XZ: + normal = frame.YDirection(); + x_dir = frame.XDirection(); + break; + case Section_plane::YZ: + normal = frame.XDirection(); + x_dir = frame.YDirection(); + break; + } + + const gp_Pnt location = frame.Location().Translated(gp_Vec(normal) * offset); + return gp_Pln(gp_Ax3(location, normal, x_dir)); +} + +void count_curve_type_(const TopoDS_Edge& edge, Section_geometry& result) +{ + switch (BRepAdaptor_Curve(edge).GetType()) + { + case GeomAbs_Line: + ++result.line_count; + break; + case GeomAbs_Circle: + ++result.circle_count; + break; + case GeomAbs_Ellipse: + ++result.ellipse_count; + break; + case GeomAbs_BSplineCurve: + ++result.bspline_count; + break; + default: + ++result.other_curve_count; + break; + } +} + +bool contains_solid_(const TopoDS_Shape& shape) +{ + return !shape.IsNull() && (shape.ShapeType() == TopAbs_SOLID || TopExp_Explorer(shape, TopAbs_SOLID).More()); +} + +void add_plane_annotation_(const TopoDS_Shape& shape, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, + TopoDS_Compound& lines) +{ + Bnd_Box bounds; + BRepBndLib::Add(shape, bounds); + if (bounds.IsVoid()) + return; + + 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 std::array corners{ + gp_Pnt(x_min, y_min, z_min), gp_Pnt(x_min, y_min, z_max), gp_Pnt(x_min, y_max, z_min), gp_Pnt(x_min, y_max, z_max), + gp_Pnt(x_max, y_min, z_min), gp_Pnt(x_max, y_min, z_max), gp_Pnt(x_max, y_max, z_min), gp_Pnt(x_max, y_max, z_max), + }; + + const gp_Pnt origin = plane.Location(); + const gp_Vec x_axis(plane.XAxis().Direction()); + const gp_Vec y_axis(plane.YAxis().Direction()); + double u_min = std::numeric_limits::max(); + double u_max = std::numeric_limits::lowest(); + double v_min = std::numeric_limits::max(); + double v_max = std::numeric_limits::lowest(); + for (const gp_Pnt& corner : corners) + { + const gp_Vec relative(origin, corner); + const double u = relative.Dot(x_axis); + const double v = relative.Dot(y_axis); + u_min = std::min(u_min, u); + u_max = std::max(u_max, u); + v_min = std::min(v_min, v); + v_max = std::max(v_max, v); + } + + const double width = u_max - u_min; + const double height = v_max - v_min; + const double margin = std::max(width, height) * 0.1; + u_min -= margin; + u_max += margin; + v_min -= margin; + v_max += margin; + + const TopoDS_Face plane_face = BRepBuilderAPI_MakeFace(plane, u_min, u_max, v_min, v_max).Face(); + builder.Add(fill, plane_face); + + auto point_on_plane = [&](double u, double v) + { + return origin.Translated(x_axis * u + y_axis * v); + }; + + BRepBuilderAPI_MakePolygon outline; + outline.Add(point_on_plane(u_min, v_min)); + outline.Add(point_on_plane(u_max, v_min)); + outline.Add(point_on_plane(u_max, v_max)); + outline.Add(point_on_plane(u_min, v_max)); + outline.Close(); + builder.Add(lines, outline.Wire()); + + const double center_u = (u_min + u_max) * 0.5; + const double center_v = (v_min + v_max) * 0.5; + const gp_Pnt center = point_on_plane(center_u, center_v); + const double normal_length = std::max(u_max - u_min, v_max - v_min) * 0.25; + const gp_Vec normal(plane.Axis().Direction()); + const gp_Pnt tip = center.Translated(normal * normal_length); + builder.Add(lines, BRepBuilderAPI_MakeEdge(center, tip).Edge()); + + const gp_Pnt arrow_left = tip.Translated(normal * (-normal_length * 0.2) + x_axis * (normal_length * 0.1)); + const gp_Pnt arrow_right = tip.Translated(normal * (-normal_length * 0.2) - x_axis * (normal_length * 0.1)); + builder.Add(lines, BRepBuilderAPI_MakeEdge(arrow_left, tip).Edge()); + builder.Add(lines, BRepBuilderAPI_MakeEdge(arrow_right, tip).Edge()); +} +} // namespace diff --git a/src/shp_section.h b/src/shp_section.h new file mode 100644 index 0000000..d52bc13 --- /dev/null +++ b/src/shp_section.h @@ -0,0 +1,52 @@ +#pragma once + +#include "shp_operation.h" + +#include +#include + +#include + +enum class Section_plane +{ + XY, + XZ, + YZ +}; + +struct Section_geometry +{ + TopoDS_Shape shape; + std::size_t edge_count{0}; + std::size_t line_count{0}; + std::size_t circle_count{0}; + std::size_t ellipse_count{0}; + std::size_t bspline_count{0}; + std::size_t other_curve_count{0}; +}; + +/// Compute a section in \a frame local coordinates. Offset is in model units +/// along the selected local plane normal. +Result section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Section_plane plane, double offset); + +class Shp_section : private Shp_operation_base +{ +public: + explicit Shp_section(Occt_view& view); + + [[nodiscard]] Status preview_selected(); + void clear(); + + Section_plane get_plane() const { return m_plane; } + void set_plane(Section_plane plane) { m_plane = plane; } + double get_offset_display() const { return m_offset_display; } + void set_offset_display(double offset) { m_offset_display = offset; } + bool has_preview() const { return !m_preview.IsNull(); } + +private: + Section_plane m_plane{Section_plane::XY}; + double m_offset_display{0.0}; + AIS_Shape_ptr m_preview; + AIS_Shape_ptr m_plane_fill; + AIS_Shape_ptr m_plane_lines; +}; diff --git a/src/utl_cad_file_info.cpp b/src/utl_cad_file_info.cpp index 6e00736..d17f6a2 100644 --- a/src/utl_cad_file_info.cpp +++ b/src/utl_cad_file_info.cpp @@ -33,10 +33,7 @@ namespace utl_cad_file_info { namespace { -void add_line(std::vector& out, const char* label, const std::string& value) -{ - out.push_back({label, value}); -} +void add_line(std::vector& out, const char* label, const std::string& value) { out.push_back({label, value}); } void add_blank(std::vector& out) { out.push_back({"", ""}); } @@ -154,16 +151,14 @@ void append_shape_summary(std::vector& lines, const TopoDS_Shape& shape) add_line(lines, "BBox X", fmt_double(xmin) + " .. " + fmt_double(xmax)); add_line(lines, "BBox Y", fmt_double(ymin) + " .. " + fmt_double(ymax)); add_line(lines, "BBox Z", fmt_double(zmin) + " .. " + fmt_double(zmax)); - add_line(lines, "BBox size", - fmt_double(xmax - xmin) + " x " + fmt_double(ymax - ymin) + " x " + fmt_double(zmax - zmin)); + add_line(lines, "BBox size", fmt_double(xmax - xmin) + " x " + fmt_double(ymax - ymin) + " x " + fmt_double(zmax - zmin)); } void append_common(std::vector& lines, const std::string& file_path, const std::string& file_bytes, Format fmt) { const std::string name = std::filesystem::path(file_path).filename().string(); add_line(lines, "File", name.empty() ? file_path : name); - if (!file_path.empty() && - (file_path.find('/') != std::string::npos || file_path.find('\\') != std::string::npos)) + if (!file_path.empty() && (file_path.find('/') != std::string::npos || file_path.find('\\') != std::string::npos)) add_line(lines, "Path", file_path); add_line(lines, "Format", format_label(fmt)); @@ -180,8 +175,8 @@ std::vector collect_step(const std::string& file_path, const std::string& Interface_Static::SetCVal("xstep.cascade.unit", "MM"); - STEPControl_Reader reader; - std::istringstream stream(file_bytes); + STEPControl_Reader reader; + std::istringstream stream(file_bytes); const IFSelect_ReturnStatus read_st = reader.ReadStream("", stream); if (read_st != IFSelect_RetDone) { @@ -228,8 +223,8 @@ std::vector collect_iges(const std::string& file_path, const std::string& append_common(lines, file_path, file_bytes, Format::Iges); add_blank(lines); - IGESControl_Reader reader; - std::istringstream stream(file_bytes); + IGESControl_Reader reader; + std::istringstream stream(file_bytes); const IFSelect_ReturnStatus read_st = reader.ReadStream("", stream); if (read_st != IFSelect_RetDone) { diff --git a/src/utl_cad_file_info.h b/src/utl_cad_file_info.h index 6e1ba94..33ea032 100644 --- a/src/utl_cad_file_info.h +++ b/src/utl_cad_file_info.h @@ -37,7 +37,7 @@ struct Named_body /// STEP XCAF node: assemblies become groups (`is_group`); leaves carry geometry. struct Named_node { - TopoDS_Shape shape; // null/empty when is_group + TopoDS_Shape shape; // null/empty when is_group std::string name; bool is_group{false}; int parent_index{-1}; // index in the output vector, or -1 for root diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 11993f5..a5c619c 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -1,17 +1,25 @@ #include "skt_test_fixture.h" #include +#include #include +#include #include #include +#include #include #include #include +#include +#include +#include +#include #include #include "shp.h" #include "shp_create.h" #include "shp_info.h" +#include "shp_section.h" #include "skt_op_recorder.h" #include "utl.h" @@ -79,8 +87,8 @@ TEST(Shp_create, Box_solid_volume_and_origin) TEST(Shp_create, Sphere_volume_centered_at_origin) { - const double r = 2.0; - const TopoDS_Shape sphere = shp_create::create_sphere(r); + const double r = 2.0; + const TopoDS_Shape sphere = shp_create::create_sphere(r); ASSERT_FALSE(sphere.IsNull()); EXPECT_EQ(sphere.ShapeType(), TopAbs_SOLID); EXPECT_TRUE(BRepCheck_Analyzer(sphere).IsValid()); @@ -162,6 +170,123 @@ TEST(Shp_create, Pyramid_solid_non_null) EXPECT_NEAR(zmax, side / 2.0, 1e-6); } +// --------------------------------------------------------------------------- +// Cross-section geometry (no viewer) +// --------------------------------------------------------------------------- + +TEST(Shp_section, Box_midplane_is_four_lines) +{ + const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 4, 6, 8); + const gp_Ax3 frame = gp_Ax3(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); + + const Result result = section_shape(box, frame, Section_plane::XY, 0.0); + ASSERT_TRUE(result.has_value()) << result.message(); + EXPECT_EQ((*result).edge_count, 4u); + EXPECT_EQ((*result).line_count, 4u); +} + +TEST(Shp_section, Cylinder_midplane_contains_circle) +{ + const TopoDS_Shape cylinder = shp_create::create_cylinder(2.0, 6.0); + const gp_Ax3 frame(gp::Origin(), gp::DZ(), gp::DX()); + + const Result result = section_shape(cylinder, frame, Section_plane::XY, 0.0); + ASSERT_TRUE(result.has_value()) << result.message(); + EXPECT_GE((*result).edge_count, 1u); + EXPECT_GE((*result).circle_count, 1u); +} + +TEST(Shp_section, Offset_outside_box_fails_closed) +{ + const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 4, 6, 8); + const gp_Ax3 frame = gp_Ax3(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); + + const Result result = section_shape(box, frame, Section_plane::XY, 5.0); + EXPECT_FALSE(result.has_value()); +} + +TEST(Shp_section, Compound_with_solid_is_supported) +{ + TopoDS_Compound compound; + BRep_Builder builder; + builder.MakeCompound(compound); + builder.Add(compound, shp_create::create_box(0, 0, 0, 4, 6, 8)); + + const gp_Ax3 frame(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); + const Result result = section_shape(compound, frame, Section_plane::XY, 0.0); + ASSERT_TRUE(result.has_value()) << result.message(); + EXPECT_EQ((*result).line_count, 4u); +} + +TEST(Shp_section, Empty_compound_is_rejected) +{ + TopoDS_Compound compound; + BRep_Builder().MakeCompound(compound); + + const Result result = section_shape(compound, gp_Ax3(), Section_plane::XY, 0.0); + EXPECT_FALSE(result.has_value()); +} + +TEST(Shp_section, Face_only_compound_is_rejected) +{ + const TopoDS_Shape face = BRepBuilderAPI_MakeFace(gp_Pln(gp::Origin(), gp::DZ()), -1.0, 1.0, -1.0, 1.0).Face(); + TopoDS_Compound compound; + BRep_Builder builder; + builder.MakeCompound(compound); + builder.Add(compound, face); + + const Result result = section_shape(compound, gp_Ax3(), Section_plane::XY, 0.0); + EXPECT_FALSE(result.has_value()); +} + +TEST(Shp_section, Box_local_xz_and_yz_midplanes_are_four_lines) +{ + const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 4, 6, 8); + const gp_Ax3 frame = gp_Ax3(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); + + const Result xz = section_shape(box, frame, Section_plane::XZ, 0.0); + ASSERT_TRUE(xz.has_value()) << xz.message(); + EXPECT_EQ((*xz).edge_count, 4u); + EXPECT_EQ((*xz).line_count, 4u); + + const Result yz = section_shape(box, frame, Section_plane::YZ, 0.0); + ASSERT_TRUE(yz.has_value()) << yz.message(); + EXPECT_EQ((*yz).edge_count, 4u); + EXPECT_EQ((*yz).line_count, 4u); +} + +TEST(Shp_section, Rotated_frame_midplane_is_four_lines) +{ + const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 4, 6, 8); + gp_Ax3 frame(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); + gp_Trsf rotate; + rotate.SetRotation(gp_Ax1(frame.Location(), gp::DX()), std::numbers::pi / 2.0); + frame.Transform(rotate); + + const Result result = section_shape(box, frame, Section_plane::XY, 0.0); + ASSERT_TRUE(result.has_value()) << result.message(); + EXPECT_EQ((*result).edge_count, 4u); + EXPECT_EQ((*result).line_count, 4u); +} + +TEST(Shp_section, Fused_boxes_midplane_returns_extra_line_edges) +{ + const TopoDS_Shape a = shp_create::create_box(0, 0, 0, 10, 10, 10); + const TopoDS_Shape b = shp_create::create_box(5, 0, 0, 10, 10, 10); + BRepAlgoAPI_Fuse fuse(a, b); + fuse.Build(); + ASSERT_TRUE(fuse.IsDone()); + + const gp_Ax3 frame(gp_Pnt(7.5, 5, 5), gp::DZ(), gp::DX()); + const Result result = section_shape(fuse.Shape(), frame, Section_plane::XY, 0.0); + ASSERT_TRUE(result.has_value()) << result.message(); + // Ideal outer section is a 15x10 rectangle (4 lines), but BRepAlgoAPI_Section currently + // returns 8 line edges here. Keep this expectation as discovery evidence for sketch import. + EXPECT_EQ((*result).edge_count, 8u); + EXPECT_EQ((*result).line_count, 8u); + EXPECT_EQ((*result).other_curve_count, 0u); +} + // --------------------------------------------------------------------------- // shp_info // --------------------------------------------------------------------------- @@ -174,7 +299,7 @@ TEST(Shp_info, Collect_null_shape) TEST(Shp_info, Collect_box_with_display_meta) { - const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 2, 3, 4); + const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 2, 3, 4); shp_info::Display_meta meta{"Box1", "Steel", "Shaded", true}; const auto lines = shp_info::collect(box, &meta); @@ -205,6 +330,7 @@ TEST_F(Shp_test, AddBox_registers_named_solid) EXPECT_EQ(shp->get_disp_mode(), AIS_Shaded); EXPECT_EQ(shp->Shape().ShapeType(), TopAbs_SOLID); EXPECT_NEAR(volume_of(shp->Shape()), 10.0 * 20.0 * 30.0, 1e-6); + EXPECT_TRUE(shp->get_frame().Location().IsEqual(gp_Pnt(5, 10, 15), 1e-6)); } TEST_F(Shp_test, UniqueShapeNames_increment) @@ -428,6 +554,27 @@ TEST_F(Shp_test, Shape_ids_persist_in_json) EXPECT_EQ(view().get_shapes().back()->get_id(), id); } +TEST_F(Shp_test, Shape_frame_persists_in_json_and_undo) +{ + view().add_box(10, 20, 30, 4, 6, 8); + ASSERT_EQ(view().get_shapes().size(), 1u); + const gp_Ax3 expected = view().get_shapes().back()->get_frame(); + + EXPECT_TRUE(view().undo()); + EXPECT_TRUE(view().get_shapes().empty()); + EXPECT_TRUE(view().redo()); + ASSERT_EQ(view().get_shapes().size(), 1u); + EXPECT_TRUE(view().get_shapes().back()->get_frame().Location().IsEqual(expected.Location(), 1e-6)); + + const std::string json = view().to_json(); + view().new_file(); + view().load(json, false); + ASSERT_EQ(view().get_shapes().size(), 1u); + EXPECT_TRUE(view().get_shapes().back()->get_frame().Location().IsEqual(expected.Location(), 1e-6)); + EXPECT_TRUE(view().get_shapes().back()->get_frame().Direction().IsEqual(expected.Direction(), 1e-9)); + EXPECT_TRUE(view().get_shapes().back()->get_frame().XDirection().IsEqual(expected.XDirection(), 1e-9)); +} + // --------------------------------------------------------------------------- // Shape hierarchy (organizational groups) // --------------------------------------------------------------------------- @@ -532,8 +679,8 @@ TEST_F(Shp_test, Hierarchy_json_round_trip) view().new_file(); view().load(json, false); - size_t groups = 0; - size_t leaves = 0; + size_t groups = 0; + size_t leaves = 0; Shape_id group_id = 0; for (const Shp_ptr& s : view().get_shapes()) { From f57757ab5ce130e689201cf29ac94e24b506e5d8 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Tue, 21 Jul 2026 17:48:32 -0600 Subject: [PATCH 02/17] Improve --- CHANGELOG.md | 2 +- agents/plans/cross-section-tool.md | 4 +- docs/usage-settings.md | 2 +- docs/usage.md | 8 +- src/doc/gui.md | 2 +- src/doc/shape.md | 2 +- src/gui_mode.cpp | 59 +++++-- src/shp_section.cpp | 264 ++++++++++++++++++++++------- src/shp_section.h | 8 +- tests/shp_tests.cpp | 15 ++ 10 files changed, 282 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77cbc27..da21612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Cross-section -- Added a temporary **Shape cross-section** preview tool for selected solids. Options choose each shape's local **XY**, **XZ**, or **YZ** plane and an offset in project units; a translucent yellow plane and normal arrow annotate the cut, while empty or invalid sections clear the preview and report status/curve counts. +- Added a temporary **Shape cross-section** preview tool for selected solids. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center) and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. Status reports curve counts and any missed solids. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. ### Shape List diff --git a/agents/plans/cross-section-tool.md b/agents/plans/cross-section-tool.md index 767c80f..5293348 100644 --- a/agents/plans/cross-section-tool.md +++ b/agents/plans/cross-section-tool.md @@ -62,8 +62,8 @@ Import into `Sketch_edges`; undoable new sketch; script binding (unless useful f ## Implemented v0 findings - `Shp` now has a persisted `gp_Ax3` local frame. It defaults to a world-aligned frame at the shape bounding-box center and follows baked move/rotate/scale transforms. -- `Shp_section` computes one local plane per selected solid with `BRepAlgoAPI_Section` (`Approximation(false)` for exact curve types), aggregates the edges into a temporary topmost cyan AIS wire preview, and adds a translucent yellow plane outline with a positive-normal arrow. None of this temporary geometry enters the document or undo history. -- Preview updates fail closed: the old AIS is removed first; plane/offset edits clear a stale preview; pending `LocalTransformation` is applied before sectioning; non-solid / empty / OCCT failure leaves no preview. +- `Shp_section` computes one shared cutting plane for the selection with `BRepAlgoAPI_Section` (`Approximation(false)` for exact curve types): orientation from the first selected solid's local axes, origin at the selection bbox center, plus offset. Section edges aggregate into a temporary topmost cyan AIS wire preview, with one translucent yellow plane outline and positive-normal arrow. None of this temporary geometry enters the document or undo history. +- Preview updates fail closed only when the shared plane misses every selected solid, or a hard OCCT failure occurs. Solids the plane does not intersect are skipped and counted in the status message; the yellow plane annotation remains. - The first curve evidence supports line and circle handling. Ellipse/B-spline/other counts are exposed for further manual experiments before sketch import rules are chosen. - Boolean solids are not yet sketch-ready: a fused two-box mid-plane yields 8 line edges (likely unmerged/overlapping segments) instead of one 4-edge outer loop. - Desktop Release build and focused section tests pass with OCCT 8.0.0. The Emscripten Release build also passes with OCCT 7.9.3. diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 9ed677c..67497e2 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -104,7 +104,7 @@ Under **Material**: - Document preset for new solids that do not inherit from a clicked shape (toolbar **Box**, **polar duplicate** output, and similar). To change material on an existing solid, use the [Shape List](usage.md#shape-list). -For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Offset**, **Update preview**, and **Clear** controls; see [Shape cross-section tool](usage.md#shape-cross-section-tool). +For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, a bbox-ranged **Offset** slider (Ctrl+click to type), **Update preview**, and **Clear** controls; see [Shape cross-section tool](usage.md#shape-cross-section-tool). ### Sketch tools diff --git a/docs/usage.md b/docs/usage.md index 51ec88c..cf65a09 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -604,12 +604,12 @@ Use the cross-section tool to inspect where a plane cuts one or more selected so 1. Select one or more solids. 2. Click **Shape cross-section** in the toolbar. 3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. -4. Enter an **Offset** along that plane's local normal, in the current project unit. -5. Click **Update preview**. Changing the plane or offset clears a stale preview; click **Update preview** again to recompute it. Use **Clear** or leave the tool to remove the preview. +4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit, and the preview updates as you change it. +5. If you change the selection, click **Update preview**. Use **Clear** or leave the tool to remove the preview. -Each solid uses its own local frame. New solids start with a world-aligned frame at the center of their bounding box; moving, rotating, or scaling a solid updates that frame. With multiple selected solids, the same local plane and offset are evaluated separately for each solid. +Each solid has a local frame used for orientation. New solids start with a world-aligned frame at the center of their bounding box; moving, rotating, or scaling a solid updates that frame. With multiple selected solids, all of them share one cutting plane: orientation from the first selected solid's local axes, and origin at the selection bounding-box center. Offset moves that shared plane along its normal. -If any selected item is not a solid, the plane misses a selected solid, or Open CASCADE cannot compute the section, EzyCad clears the previous preview and shows a status message. Section edges may be lines, circles, ellipses, B-splines, or other OCCT curves. The status message reports the curve counts. +If any selected item is not a solid, or Open CASCADE cannot compute a section for a solid, that solid is skipped. If the plane misses some solids but still intersects others, the preview continues for the solids it cuts and the status notes how many were missed. Only when the plane misses the entire selection is the preview cleared. Section edges may be lines, circles, ellipses, B-splines, or other OCCT curves. The status message reports the curve counts. ### Other Feature Operations diff --git a/src/doc/gui.md b/src/doc/gui.md index 638c4ee..08b99f6 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -186,7 +186,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | -| `Shape_section` | local XY/XZ/YZ plane, offset, **Update preview**, clear | +| `Shape_section` | local XY/XZ/YZ plane, bbox-ranged offset slider (auto preview), **Update preview**, clear | | `Sketch_inspection_mode` | `options_sketch_common_` | | Each sketch tool mode | Matching `options_sketch_*_mode_` | | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | diff --git a/src/doc/shape.md b/src/doc/shape.md index b11f0b3..df4a64a 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -151,7 +151,7 @@ Protected helpers used by all operation classes: | `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. | -| `shp_section.h` | `Shp_section` | `BRepAlgoAPI_Section` preview on each selected solid's local XY/XZ/YZ plane plus offset; temporary cyan section wires plus a translucent yellow plane/normal annotation. | +| `shp_section.h` | `Shp_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation. | | `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | ## Input routing (from UI / `Occt_view`) diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 7512554..58745f2 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -764,6 +765,12 @@ void GUI::options_shape_section_mode_() int plane = static_cast(section.get_plane()); double offset = section.get_offset_display(); + auto update_preview = [&]() + { + const Status status = section.preview_selected(); + show_message(status.message()); + }; + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -772,35 +779,63 @@ void GUI::options_shape_section_mode_() if (ImGui::RadioButton("Local XY", &plane, static_cast(Section_plane::XY))) { section.set_plane(Section_plane::XY); - section.clear(); + update_preview(); } ImGui::SameLine(); if (ImGui::RadioButton("Local XZ", &plane, static_cast(Section_plane::XZ))) { section.set_plane(Section_plane::XZ); - section.clear(); + update_preview(); } ImGui::SameLine(); if (ImGui::RadioButton("Local YZ", &plane, static_cast(Section_plane::YZ))) { section.set_plane(Section_plane::YZ); - section.clear(); + update_preview(); } - ImGui::SetNextItemWidth(120.0f); - if (ImGui::InputDouble("Offset", &offset, 0.0, 0.0, "%.6g")) + double offset_min = -1.0; + double offset_max = 1.0; + const bool have_range = section.try_get_offset_range_display(offset_min, offset_max); + if (have_range) + { + if (offset < offset_min) + { + offset = offset_min; + section.set_offset_display(offset); + } + else if (offset > offset_max) + { + offset = offset_max; + section.set_offset_display(offset); + } + } + else + { + offset_min = std::min(offset, -1.0); + offset_max = std::max(offset, 1.0); + if (!(offset_max > offset_min)) + { + offset_min = offset - 1.0; + offset_max = offset + 1.0; + } + } + + ImGui::SetNextItemWidth(180.0f); + ImGui::BeginDisabled(!have_range); + if (ImGui::SliderScalar("Offset", ImGuiDataType_Double, &offset, &offset_min, &offset_max, "%.6g")) { section.set_offset_display(offset); - section.clear(); + update_preview(); } + ImGui::EndDisabled(); ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::TextUnformatted(m_view->project_unit_suffix()); + if (!have_range) + ImGui::TextDisabled("Select solids to enable the offset slider."); if (ImGui::Button("Update preview")) - { - const Status status = section.preview_selected(); - show_message(status.message()); - } + update_preview(); if (section.has_preview()) { ImGui::SameLine(); @@ -809,7 +844,9 @@ void GUI::options_shape_section_mode_() } ImGui::TextWrapped( - "Each selected solid uses its own local frame. The yellow plane and arrow show the cut plane and positive normal."); + "All selected solids share one cutting plane. Orientation follows the first selected solid's local axes; the " + "plane is centered on the selection bounding box. Offset slides across that box along the plane normal " + "(Ctrl+click the slider to type a value). The yellow plane and arrow show the cut plane and positive normal."); options_orthographic_projection_(); } diff --git a/src/shp_section.cpp b/src/shp_section.cpp index 1176bc4..ed4e08d 100644 --- a/src/shp_section.cpp +++ b/src/shp_section.cpp @@ -28,49 +28,26 @@ #include #include #include +#include namespace { -gp_Pln section_plane_(const gp_Ax3& frame, Section_plane plane, double offset); -void count_curve_type_(const TopoDS_Edge& edge, Section_geometry& result); -bool contains_solid_(const TopoDS_Shape& shape); -void add_plane_annotation_(const TopoDS_Shape& shape, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, - TopoDS_Compound& lines); +gp_Pln section_plane_(const gp_Ax3& frame, Section_plane plane, double offset); +void count_curve_type_(const TopoDS_Edge& edge, Section_geometry& result); +bool contains_solid_(const TopoDS_Shape& shape); +TopoDS_Shape shape_world_(const Shp& shp); +gp_Ax3 frame_world_(const Shp& shp); +bool append_bounds_(const TopoDS_Shape& shape, Bnd_Box& bounds); +std::array bbox_corners_(const Bnd_Box& bounds); +void project_bbox_offsets_(const Bnd_Box& bounds, const gp_Pnt& origin, const gp_Dir& normal, double& out_min, double& out_max); +void add_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, + TopoDS_Compound& lines); +Result section_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane); } // namespace Result section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Section_plane plane, double offset) { - if (shape.IsNull()) - return {Result_status::User_error, "Cannot section a null shape."}; - - if (!contains_solid_(shape)) - return {Result_status::User_error, "Cross-section supports solid shapes only."}; - - try - { - BRepAlgoAPI_Section section(shape, section_plane_(frame, plane, offset), false); - section.Approximation(false); - section.Build(); - if (!section.IsDone()) - return {Result_status::Topo_error, "Open CASCADE could not compute the section."}; - - Section_geometry result; - result.shape = section.Shape(); - for (TopExp_Explorer it(result.shape, TopAbs_EDGE); it.More(); it.Next()) - { - ++result.edge_count; - count_curve_type_(TopoDS::Edge(it.Current()), result); - } - - if (result.shape.IsNull() || result.edge_count == 0) - return {Result_status::User_error, "The section plane does not intersect the shape."}; - - return result; - } - catch (const Standard_Failure& e) - { - return {Result_status::Topo_error, std::string("Section failed: ") + standard_failure_message(e)}; - } + return section_shape_on_plane_(shape, section_plane_(frame, plane, offset)); } Shp_section::Shp_section(Occt_view& view) @@ -88,6 +65,40 @@ Status Shp_section::preview_selected() if (selected.empty()) return Status::user_error("Select one or more solid shapes."); + std::vector world_shapes; + world_shapes.reserve(selected.size()); + Bnd_Box combined_bounds; + gp_Ax3 shared_axes; + bool have_axes = false; + + for (const Shp_ptr& shp : selected) + { + TopoDS_Shape world_shape = shape_world_(*shp); + if (!contains_solid_(world_shape)) + return Status::user_error(shp->get_name() + ": Cross-section supports solid shapes only."); + + if (!append_bounds_(world_shape, combined_bounds)) + return Status::user_error(shp->get_name() + ": Shape has empty bounds."); + + if (!have_axes) + { + shared_axes = frame_world_(*shp); + have_axes = true; + } + + world_shapes.push_back(std::move(world_shape)); + } + + if (combined_bounds.IsVoid() || !have_axes) + return Status::user_error("Select one or more solid shapes."); + + double x_min, y_min, z_min, x_max, y_max, z_max; + combined_bounds.Get(x_min, y_min, z_min, x_max, y_max, z_max); + shared_axes.SetLocation(gp_Pnt((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, (z_min + z_max) * 0.5)); + + const double offset_model = view().to_model(m_offset_display); + const gp_Pln shared_plane = section_plane_(shared_axes, m_plane, offset_model); + TopoDS_Compound compound; TopoDS_Compound plane_fill; TopoDS_Compound plane_lines; @@ -97,25 +108,22 @@ Status Shp_section::preview_selected() builder.MakeCompound(plane_lines); Section_geometry totals; - const double offset_model = view().to_model(m_offset_display); - for (const Shp_ptr& shp : selected) + size_t missed = 0; + for (size_t i = 0; i < selected.size(); ++i) { - TopoDS_Shape section_source = shp->Shape(); - gp_Ax3 section_frame = shp->get_frame(); - const gp_Trsf& local_transform = shp->LocalTransformation(); - if (local_transform.Form() != gp_Identity) + Result result = section_shape_on_plane_(world_shapes[i], shared_plane); + if (!result.has_value()) { - section_source = BRepBuilderAPI_Transform(section_source, local_transform, true).Shape(); - section_frame.Transform(local_transform); + // A miss on one solid should not kill the shared preview for the others. + if (result.status() == Result_status::User_error) + { + ++missed; + continue; + } + + return Status(result.status(), selected[i]->get_name() + ": " + result.message()); } - const gp_Pln plane = section_plane_(section_frame, m_plane, offset_model); - Result result = section_shape(section_source, section_frame, m_plane, offset_model); - if (!result.has_value()) - return Status(result.status(), shp->get_name() + ": " + result.message()); - - add_plane_annotation_(section_source, plane, builder, plane_fill, plane_lines); - const Section_geometry& geometry = *result; builder.Add(compound, geometry.shape); totals.edge_count += geometry.edge_count; @@ -127,7 +135,9 @@ Status Shp_section::preview_selected() } if (totals.edge_count == 0) - return Status::user_error("The section is empty."); + return Status::user_error("The section plane does not intersect the selection."); + + add_plane_annotation_(combined_bounds, shared_plane, builder, plane_fill, plane_lines); m_preview = new AIS_Shape(compound); m_preview->SetColor(Quantity_NOC_CYAN); @@ -156,7 +166,14 @@ Status Shp_section::preview_selected() if (totals.edge_count != 1) msg << "s"; msg << " (" << totals.line_count << " line, " << totals.circle_count << " circle, " << totals.ellipse_count << " ellipse, " - << totals.bspline_count << " B-spline, " << totals.other_curve_count << " other)."; + << totals.bspline_count << " B-spline, " << totals.other_curve_count << " other)"; + if (missed > 0) + { + msg << "; missed " << missed << " solid"; + if (missed != 1) + msg << "s"; + } + msg << "."; return Status::ok(msg.str()); } @@ -177,6 +194,51 @@ void Shp_section::clear() ctx().UpdateCurrentViewer(); } +bool Shp_section::try_get_offset_range_display(double& out_min, double& out_max) +{ + const std::vector selected = get_selected_shps_(); + if (selected.empty()) + return false; + + Bnd_Box combined_bounds; + gp_Ax3 shared_axes; + bool have_axes = false; + + for (const Shp_ptr& shp : selected) + { + TopoDS_Shape world_shape = shape_world_(*shp); + if (!contains_solid_(world_shape) || !append_bounds_(world_shape, combined_bounds)) + continue; + + if (!have_axes) + { + shared_axes = frame_world_(*shp); + have_axes = true; + } + } + + if (!have_axes || combined_bounds.IsVoid()) + return false; + + double x_min, y_min, z_min, x_max, y_max, z_max; + combined_bounds.Get(x_min, y_min, z_min, x_max, y_max, z_max); + shared_axes.SetLocation(gp_Pnt((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, (z_min + z_max) * 0.5)); + + const gp_Pln plane = section_plane_(shared_axes, m_plane, 0.0); + double model_min; + double model_max; + project_bbox_offsets_(combined_bounds, plane.Location(), plane.Axis().Direction(), model_min, model_max); + if (!(model_max > model_min)) + return false; + + out_min = view().to_display(model_min); + out_max = view().to_display(model_max); + if (out_min > out_max) + std::swap(out_min, out_max); + + return true; +} + namespace { gp_Pln section_plane_(const gp_Ax3& frame, Section_plane plane, double offset) @@ -203,6 +265,41 @@ gp_Pln section_plane_(const gp_Ax3& frame, Section_plane plane, double offset) return gp_Pln(gp_Ax3(location, normal, x_dir)); } +Result section_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane) +{ + if (shape.IsNull()) + return {Result_status::User_error, "Cannot section a null shape."}; + + if (!contains_solid_(shape)) + return {Result_status::User_error, "Cross-section supports solid shapes only."}; + + try + { + BRepAlgoAPI_Section section(shape, plane, false); + section.Approximation(false); + section.Build(); + if (!section.IsDone()) + return {Result_status::Topo_error, "Open CASCADE could not compute the section."}; + + Section_geometry result; + result.shape = section.Shape(); + for (TopExp_Explorer it(result.shape, TopAbs_EDGE); it.More(); it.Next()) + { + ++result.edge_count; + count_curve_type_(TopoDS::Edge(it.Current()), result); + } + + if (result.shape.IsNull() || result.edge_count == 0) + return {Result_status::User_error, "The section plane does not intersect the shape."}; + + return result; + } + catch (const Standard_Failure& e) + { + return {Result_status::Topo_error, std::string("Section failed: ") + standard_failure_message(e)}; + } +} + void count_curve_type_(const TopoDS_Edge& edge, Section_geometry& result) { switch (BRepAdaptor_Curve(edge).GetType()) @@ -230,20 +327,65 @@ bool contains_solid_(const TopoDS_Shape& shape) return !shape.IsNull() && (shape.ShapeType() == TopAbs_SOLID || TopExp_Explorer(shape, TopAbs_SOLID).More()); } -void add_plane_annotation_(const TopoDS_Shape& shape, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, - TopoDS_Compound& lines) +TopoDS_Shape shape_world_(const Shp& shp) { - Bnd_Box bounds; - BRepBndLib::Add(shape, bounds); - if (bounds.IsVoid()) - return; + TopoDS_Shape shape = shp.Shape(); + const gp_Trsf& local_transform = shp.LocalTransformation(); + if (local_transform.Form() != gp_Identity) + shape = BRepBuilderAPI_Transform(shape, local_transform, true).Shape(); + + return shape; +} + +gp_Ax3 frame_world_(const Shp& shp) +{ + gp_Ax3 frame = shp.get_frame(); + const gp_Trsf& local_transform = shp.LocalTransformation(); + if (local_transform.Form() != gp_Identity) + frame.Transform(local_transform); + + return frame; +} +bool append_bounds_(const TopoDS_Shape& shape, Bnd_Box& bounds) +{ + Bnd_Box local; + BRepBndLib::Add(shape, local); + if (local.IsVoid()) + return false; + + bounds.Add(local); + return true; +} + +std::array bbox_corners_(const Bnd_Box& bounds) +{ 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 std::array corners{ + return { gp_Pnt(x_min, y_min, z_min), gp_Pnt(x_min, y_min, z_max), gp_Pnt(x_min, y_max, z_min), gp_Pnt(x_min, y_max, z_max), gp_Pnt(x_max, y_min, z_min), gp_Pnt(x_max, y_min, z_max), gp_Pnt(x_max, y_max, z_min), gp_Pnt(x_max, y_max, z_max), }; +} + +void project_bbox_offsets_(const Bnd_Box& bounds, const gp_Pnt& origin, const gp_Dir& normal, double& out_min, double& out_max) +{ + out_min = std::numeric_limits::max(); + out_max = std::numeric_limits::lowest(); + const gp_Vec normal_vec(normal); + for (const gp_Pnt& corner : bbox_corners_(bounds)) + { + const double t = gp_Vec(origin, corner).Dot(normal_vec); + out_min = std::min(out_min, t); + out_max = std::max(out_max, t); + } +} + +void add_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, + TopoDS_Compound& lines) +{ + if (bounds.IsVoid()) + return; const gp_Pnt origin = plane.Location(); const gp_Vec x_axis(plane.XAxis().Direction()); @@ -252,7 +394,7 @@ void add_plane_annotation_(const TopoDS_Shape& shape, const gp_Pln& plane, BRep_ double u_max = std::numeric_limits::lowest(); double v_min = std::numeric_limits::max(); double v_max = std::numeric_limits::lowest(); - for (const gp_Pnt& corner : corners) + for (const gp_Pnt& corner : bbox_corners_(bounds)) { const gp_Vec relative(origin, corner); const double u = relative.Dot(x_axis); diff --git a/src/shp_section.h b/src/shp_section.h index d52bc13..9465805 100644 --- a/src/shp_section.h +++ b/src/shp_section.h @@ -25,8 +25,9 @@ struct Section_geometry std::size_t other_curve_count{0}; }; -/// Compute a section in \a frame local coordinates. Offset is in model units -/// along the selected local plane normal. +/// Compute a section with a plane from \a frame local coordinates. Offset is in +/// model units along the selected local plane normal. Interactive preview uses one +/// shared plane for the whole selection (first-shape axes, selection bbox center). Result section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Section_plane plane, double offset); class Shp_section : private Shp_operation_base @@ -42,6 +43,9 @@ class Shp_section : private Shp_operation_base double get_offset_display() const { return m_offset_display; } void set_offset_display(double offset) { m_offset_display = offset; } bool has_preview() const { return !m_preview.IsNull(); } + /// Projected extent of the current selection along the shared section-plane normal, + /// in display units. False when nothing solid is selected or bounds are empty. + [[nodiscard]] bool try_get_offset_range_display(double& out_min, double& out_max); private: Section_plane m_plane{Section_plane::XY}; diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index a5c619c..aad1000 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -287,6 +287,21 @@ TEST(Shp_section, Fused_boxes_midplane_returns_extra_line_edges) EXPECT_EQ((*result).other_curve_count, 0u); } +TEST(Shp_section, Shared_plane_cuts_two_separated_boxes) +{ + // Mimic multi-select: one plane through the combined bbox center must hit both solids. + const TopoDS_Shape left = shp_create::create_box(0, 0, 0, 4, 4, 4); + const TopoDS_Shape right = shp_create::create_box(6, 0, 0, 4, 4, 4); + const gp_Ax3 shared_frame(gp_Pnt(5, 2, 2), gp::DZ(), gp::DX()); + + const Result left_result = section_shape(left, shared_frame, Section_plane::XY, 0.0); + const Result right_result = section_shape(right, shared_frame, Section_plane::XY, 0.0); + ASSERT_TRUE(left_result.has_value()) << left_result.message(); + ASSERT_TRUE(right_result.has_value()) << right_result.message(); + EXPECT_EQ((*left_result).line_count, 4u); + EXPECT_EQ((*right_result).line_count, 4u); +} + // --------------------------------------------------------------------------- // shp_info // --------------------------------------------------------------------------- From d7da0c2c34a020937210ca79bfdfecfe8780073e Mon Sep 17 00:00:00 2001 From: Trailcode Date: Tue, 21 Jul 2026 17:55:49 -0600 Subject: [PATCH 03/17] Rename --- agents/plans/cross-section-tool.md | 2 +- src/doc/gui.md | 4 +- src/doc/shape.md | 6 +- src/gui.cpp | 2 +- src/gui.h | 2 +- src/gui_mode.cpp | 24 ++++---- src/gui_occt_view.cpp | 14 ++--- src/gui_occt_view.h | 6 +- src/mode.h | 2 +- ...{shp_section.cpp => shp_cross_section.cpp} | 44 +++++++------- src/shp_cross_section.h | 57 +++++++++++++++++++ src/shp_section.h | 56 ------------------ tests/shp_tests.cpp | 46 +++++++-------- 13 files changed, 133 insertions(+), 132 deletions(-) rename src/{shp_section.cpp => shp_cross_section.cpp} (89%) create mode 100644 src/shp_cross_section.h delete mode 100644 src/shp_section.h diff --git a/agents/plans/cross-section-tool.md b/agents/plans/cross-section-tool.md index 5293348..0a373b1 100644 --- a/agents/plans/cross-section-tool.md +++ b/agents/plans/cross-section-tool.md @@ -62,7 +62,7 @@ Import into `Sketch_edges`; undoable new sketch; script binding (unless useful f ## Implemented v0 findings - `Shp` now has a persisted `gp_Ax3` local frame. It defaults to a world-aligned frame at the shape bounding-box center and follows baked move/rotate/scale transforms. -- `Shp_section` computes one shared cutting plane for the selection with `BRepAlgoAPI_Section` (`Approximation(false)` for exact curve types): orientation from the first selected solid's local axes, origin at the selection bbox center, plus offset. Section edges aggregate into a temporary topmost cyan AIS wire preview, with one translucent yellow plane outline and positive-normal arrow. None of this temporary geometry enters the document or undo history. +- `Shp_cross_section` computes one shared cutting plane for the selection with `BRepAlgoAPI_Section` (`Approximation(false)` for exact curve types): orientation from the first selected solid's local axes, origin at the selection bbox center, plus offset. Section edges aggregate into a temporary topmost cyan AIS wire preview, with one translucent yellow plane outline and positive-normal arrow. None of this temporary geometry enters the document or undo history. - Preview updates fail closed only when the shared plane misses every selected solid, or a hard OCCT failure occurs. Solids the plane does not intersect are skipped and counted in the status message; the yellow plane annotation remains. - The first curve evidence supports line and circle handling. Ellipse/B-spline/other counts are exposed for further manual experiments before sketch import rules are chosen. - Boolean solids are not yet sketch-ready: a fused two-box mid-plane yields 8 line edges (likely unmerged/overlapping segments) instead of one 4-edge outer loop. diff --git a/src/doc/gui.md b/src/doc/gui.md index 08b99f6..affe67b 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -186,7 +186,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | -| `Shape_section` | local XY/XZ/YZ plane, bbox-ranged offset slider (auto preview), **Update preview**, clear | +| `Shape_cross_section` | local XY/XZ/YZ plane, bbox-ranged offset slider (auto preview), **Update preview**, clear | | `Sketch_inspection_mode` | `options_sketch_common_` | | Each sketch tool mode | Matching `options_sketch_*_mode_` | | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | @@ -235,7 +235,7 @@ Toolbar buttons hold `std::variant`. `Command` (`Shape_cut`, `Sha Mode buttons call `set_mode`. Active state tracks `m_mode`. -The cross-section toolbar button enters `Mode::Shape_section`. Its Options controls call `Shp_section::preview_selected`; the temporary AIS wire result is cleared when the mode is left, when the user presses **Clear**, or before a failed/updated preview. +The cross-section toolbar button enters `Mode::Shape_cross_section`. Its Options controls call `Shp_cross_section::preview_selected`; the temporary AIS wire result is cleared when the mode is left, when the user presses **Clear**, or before a failed/updated preview. ## Typical developer usage diff --git a/src/doc/shape.md b/src/doc/shape.md index df4a64a..5c13302 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -92,7 +92,7 @@ Occt_view | +-- std::list m_shps document shapes +-- Shp_move / Shp_rotate / Shp_scale interactive transforms - +-- Shp_section temporary local-plane section preview + +-- Shp_cross_section temporary local-plane section preview +-- Shp_extrude sketch face extrude session +-- Shp_fuse / Shp_cut / Shp_common booleans +-- Shp_fillet / Shp_chamfer edge modifiers (replace in place) @@ -151,7 +151,7 @@ Protected helpers used by all operation classes: | `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. | -| `shp_section.h` | `Shp_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation. | +| `shp_cross_section.h` | `Shp_cross_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation. | | `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | ## Input routing (from UI / `Occt_view`) @@ -167,7 +167,7 @@ Protected helpers used by all operation classes: | `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 | -| `Mode::Shape_section` | -- | -- | Options **Update preview** | Preview cleared | +| `Mode::Shape_cross_section` | -- | -- | Options **Update preview** | Preview cleared | | Fuse / cut / common (toolbar) | -- | `selected_fuse` / `selected_cut` / `selected_common` (one-shot) | -- | -- | | Primitives (menu / script) | -- | `Occt_view::add_box`, `add_sphere`, ... | -- | -- | | Revolve (sketch Options) | -- | `Occt_view::revolve_selected` -> `add_shp_` | -- | -- | diff --git a/src/gui.cpp b/src/gui.cpp index b25243e..701f18d 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -196,7 +196,7 @@ void GUI::initialize_toolbar_() {load_texture("res/icons/PartDesign_Chamfer.png"), false, "Chamfer (c)", Mode::Shape_chamfer}, {load_texture("res/icons/PartDesign_Fillet.png"), false, "Fillet (f)", Mode::Shape_fillet}, {load_texture("res/icons/Draft_PolarArray.png"), false, "Shape polar duplicate", Mode::Shape_polar_duplicate}, - {load_texture("res/icons/Curves_ExtractSubshape.png"), false, "Shape cross-section", Mode::Shape_section}, + {load_texture("res/icons/Curves_ExtractSubshape.png"), false, "Shape cross-section", Mode::Shape_cross_section}, {load_texture("res/icons/Part_Cut.png"), false, "Shape cut", Command::Shape_cut}, {load_texture("res/icons/Part_Fuse.png"), false, "Shape fuse", Command::Shape_fuse}, {load_texture("res/icons/Part_Common.png"), false, "Shape common", Command::Shape_common}, diff --git a/src/gui.h b/src/gui.h index e065dc0..07a73ee 100644 --- a/src/gui.h +++ b/src/gui.h @@ -428,7 +428,7 @@ class GUI void options_shape_chamfer_mode_(); void options_shape_fillet_mode_(); void options_shape_polar_duplicate_mode_(); - void options_shape_section_mode_(); + void options_shape_cross_section_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 58745f2..e58cbf4 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -63,7 +63,7 @@ std::string GUI::get_doc_url_for_mode(Mode mode) {Mode::Sketch_add_circle_3_pts, ""}, // planned feature - no specific section in the docs yet; falls back to main guide {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_section, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-cross-section-tool"}, + {Mode::Shape_cross_section, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-cross-section-tool"}, // clang-format on }; @@ -132,7 +132,7 @@ void GUI::set_parent_mode() {Mode::Sketch_add_circle_3_pts, Mode::Sketch_inspection_mode}, {Mode::Sketch_add_slot, Mode::Sketch_inspection_mode}, {Mode::Sketch_dim_anno, Mode::Sketch_inspection_mode}, - {Mode::Shape_section, Mode::Normal}, + {Mode::Shape_cross_section, Mode::Normal}, // clang-format on }; @@ -395,7 +395,7 @@ void GUI::options_() case Mode::Shape_chamfer: options_shape_chamfer_mode_(); break; case Mode::Shape_fillet: options_shape_fillet_mode_(); break; case Mode::Shape_polar_duplicate: options_shape_polar_duplicate_mode_(); break; - case Mode::Shape_section: options_shape_section_mode_(); break; + case Mode::Shape_cross_section: options_shape_cross_section_mode_(); break; // Sketch related modes: case Mode::Sketch_inspection_mode: options_sketch_inspection_mode_(); break; @@ -757,11 +757,11 @@ void GUI::options_shape_polar_duplicate_mode_() options_orthographic_projection_(); } -void GUI::options_shape_section_mode_() +void GUI::options_shape_cross_section_mode_() { - EZY_ASSERT(get_mode() == Mode::Shape_section); + EZY_ASSERT(get_mode() == Mode::Shape_cross_section); - Shp_section& section = m_view->shp_section(); + Shp_cross_section& section = m_view->shp_cross_section(); int plane = static_cast(section.get_plane()); double offset = section.get_offset_display(); @@ -776,21 +776,21 @@ void GUI::options_shape_section_mode_() ImGui::Separator(); ImGui::TextUnformatted("Section plane"); - if (ImGui::RadioButton("Local XY", &plane, static_cast(Section_plane::XY))) + if (ImGui::RadioButton("Local XY", &plane, static_cast(Cross_section_plane::XY))) { - section.set_plane(Section_plane::XY); + section.set_plane(Cross_section_plane::XY); update_preview(); } ImGui::SameLine(); - if (ImGui::RadioButton("Local XZ", &plane, static_cast(Section_plane::XZ))) + if (ImGui::RadioButton("Local XZ", &plane, static_cast(Cross_section_plane::XZ))) { - section.set_plane(Section_plane::XZ); + section.set_plane(Cross_section_plane::XZ); update_preview(); } ImGui::SameLine(); - if (ImGui::RadioButton("Local YZ", &plane, static_cast(Section_plane::YZ))) + if (ImGui::RadioButton("Local YZ", &plane, static_cast(Cross_section_plane::YZ))) { - section.set_plane(Section_plane::YZ); + section.set_plane(Cross_section_plane::YZ); update_preview(); } diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index d09f0da..e71f0b7 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -95,7 +95,7 @@ Occt_view::Occt_view(GUI& gui) , m_shp_common(*this) , m_shp_polar_dup(*this) , m_shp_extrude(*this) - , m_shp_section(*this) + , m_shp_cross_section(*this) { } @@ -574,8 +574,8 @@ void Occt_view::cancel(Set_parent_mode set_parent_mode) gui().set_mode(Mode::Normal); break; - case Mode::Shape_section: - shp_section().clear(); + case Mode::Shape_cross_section: + shp_cross_section().clear(); gui().set_mode(Mode::Normal); break; @@ -2859,8 +2859,8 @@ void Occt_view::on_mode() DBG_MSG(c_mode_strs[int(get_mode())]); shp_polar_dup().reset(); - if (get_mode() != Mode::Shape_section) - shp_section().clear(); + if (get_mode() != Mode::Shape_cross_section) + shp_cross_section().clear(); for (Sketch_ptr& s : m_sketches) s->on_mode(); @@ -2922,7 +2922,7 @@ void Occt_view::on_mode() case Mode::Move: set_shp_selection_mode(TopAbs_COMPOUND); break; case Mode::Rotate: set_shp_selection_mode(TopAbs_COMPOUND); break; case Mode::Scale: set_shp_selection_mode(TopAbs_COMPOUND); break; - case Mode::Shape_section: set_shp_selection_mode(TopAbs_COMPOUND); break; + case Mode::Shape_cross_section: set_shp_selection_mode(TopAbs_COMPOUND); break; default: if(m_modes_selection_mode_map.count(get_mode())) set_shp_selection_mode(m_modes_selection_mode_map.at(get_mode())); @@ -3165,7 +3165,7 @@ Shp_fuse& Occt_view::shp_fuse() { return m_shp_fuse; } Shp_common& Occt_view::shp_common() { return m_shp_common; } Shp_polar_dup& Occt_view::shp_polar_dup() { return m_shp_polar_dup; } Shp_extrude& Occt_view::shp_extrude() { return m_shp_extrude; } -Shp_section& Occt_view::shp_section() { return m_shp_section; } +Shp_cross_section& Occt_view::shp_cross_section() { return m_shp_cross_section; } // clang-format on // --------------------------------------------------------------------------- diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h index 220c2ca..b9cf990 100644 --- a/src/gui_occt_view.h +++ b/src/gui_occt_view.h @@ -26,7 +26,7 @@ #include "shp_polar_dup.h" #include "shp_rotate.h" #include "shp_scale.h" -#include "shp_section.h" +#include "shp_cross_section.h" #include "utl_types.h" #include "utl_asset_store.h" #include "utl_geom.h" @@ -230,7 +230,7 @@ class Occt_view : protected AIS_ViewController Shp_common& shp_common(); Shp_polar_dup& shp_polar_dup(); Shp_extrude& shp_extrude(); - Shp_section& shp_section(); + Shp_cross_section& shp_cross_section(); // Revolve related void revolve_selected(const double angle); @@ -515,7 +515,7 @@ class Occt_view : protected AIS_ViewController Shp_common m_shp_common; Shp_polar_dup m_shp_polar_dup; Shp_extrude m_shp_extrude; - Shp_section m_shp_section; + Shp_cross_section m_shp_cross_section; // -------------------------------------------------------------------- // Selection related std::map m_modes_selection_mode_map; diff --git a/src/mode.h b/src/mode.h index 86f4303..1857232 100644 --- a/src/mode.h +++ b/src/mode.h @@ -29,7 +29,7 @@ X(Sketch_add_circle_3_pts) \ X(Sketch_add_slot) \ X(Sketch_dim_anno) \ - X(Shape_section) + X(Shape_cross_section) enum class Mode { diff --git a/src/shp_section.cpp b/src/shp_cross_section.cpp similarity index 89% rename from src/shp_section.cpp rename to src/shp_cross_section.cpp index ed4e08d..04e7f66 100644 --- a/src/shp_section.cpp +++ b/src/shp_cross_section.cpp @@ -1,4 +1,4 @@ -#include "shp_section.h" +#include "shp_cross_section.h" #include "gui_occt_view.h" #include "utl_occt.h" @@ -32,8 +32,8 @@ namespace { -gp_Pln section_plane_(const gp_Ax3& frame, Section_plane plane, double offset); -void count_curve_type_(const TopoDS_Edge& edge, Section_geometry& result); +gp_Pln cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, double offset); +void count_curve_type_(const TopoDS_Edge& edge, Cross_section_geometry& result); bool contains_solid_(const TopoDS_Shape& shape); TopoDS_Shape shape_world_(const Shp& shp); gp_Ax3 frame_world_(const Shp& shp); @@ -42,20 +42,20 @@ std::array bbox_corners_(const Bnd_Box& bounds); void project_bbox_offsets_(const Bnd_Box& bounds, const gp_Pnt& origin, const gp_Dir& normal, double& out_min, double& out_max); void add_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, TopoDS_Compound& lines); -Result section_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane); +Result cross_section_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane); } // namespace -Result section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Section_plane plane, double offset) +Result cross_section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Cross_section_plane plane, double offset) { - return section_shape_on_plane_(shape, section_plane_(frame, plane, offset)); + return cross_section_shape_on_plane_(shape, cross_section_plane_(frame, plane, offset)); } -Shp_section::Shp_section(Occt_view& view) +Shp_cross_section::Shp_cross_section(Occt_view& view) : Shp_operation_base(view) { } -Status Shp_section::preview_selected() +Status Shp_cross_section::preview_selected() { clear(); if (!std::isfinite(m_offset_display)) @@ -97,7 +97,7 @@ Status Shp_section::preview_selected() shared_axes.SetLocation(gp_Pnt((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, (z_min + z_max) * 0.5)); const double offset_model = view().to_model(m_offset_display); - const gp_Pln shared_plane = section_plane_(shared_axes, m_plane, offset_model); + const gp_Pln shared_plane = cross_section_plane_(shared_axes, m_plane, offset_model); TopoDS_Compound compound; TopoDS_Compound plane_fill; @@ -107,11 +107,11 @@ Status Shp_section::preview_selected() builder.MakeCompound(plane_fill); builder.MakeCompound(plane_lines); - Section_geometry totals; + Cross_section_geometry totals; size_t missed = 0; for (size_t i = 0; i < selected.size(); ++i) { - Result result = section_shape_on_plane_(world_shapes[i], shared_plane); + Result result = cross_section_shape_on_plane_(world_shapes[i], shared_plane); if (!result.has_value()) { // A miss on one solid should not kill the shared preview for the others. @@ -124,7 +124,7 @@ Status Shp_section::preview_selected() return Status(result.status(), selected[i]->get_name() + ": " + result.message()); } - const Section_geometry& geometry = *result; + const Cross_section_geometry& geometry = *result; builder.Add(compound, geometry.shape); totals.edge_count += geometry.edge_count; totals.line_count += geometry.line_count; @@ -177,7 +177,7 @@ Status Shp_section::preview_selected() return Status::ok(msg.str()); } -void Shp_section::clear() +void Shp_cross_section::clear() { if (!m_preview.IsNull()) ctx().Remove(m_preview, false); @@ -194,7 +194,7 @@ void Shp_section::clear() ctx().UpdateCurrentViewer(); } -bool Shp_section::try_get_offset_range_display(double& out_min, double& out_max) +bool Shp_cross_section::try_get_offset_range_display(double& out_min, double& out_max) { const std::vector selected = get_selected_shps_(); if (selected.empty()) @@ -224,7 +224,7 @@ bool Shp_section::try_get_offset_range_display(double& out_min, double& out_max) combined_bounds.Get(x_min, y_min, z_min, x_max, y_max, z_max); shared_axes.SetLocation(gp_Pnt((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, (z_min + z_max) * 0.5)); - const gp_Pln plane = section_plane_(shared_axes, m_plane, 0.0); + const gp_Pln plane = cross_section_plane_(shared_axes, m_plane, 0.0); double model_min; double model_max; project_bbox_offsets_(combined_bounds, plane.Location(), plane.Axis().Direction(), model_min, model_max); @@ -241,21 +241,21 @@ bool Shp_section::try_get_offset_range_display(double& out_min, double& out_max) namespace { -gp_Pln section_plane_(const gp_Ax3& frame, Section_plane plane, double offset) +gp_Pln cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, double offset) { gp_Dir normal; gp_Dir x_dir; switch (plane) { - case Section_plane::XY: + case Cross_section_plane::XY: normal = frame.Direction(); x_dir = frame.XDirection(); break; - case Section_plane::XZ: + case Cross_section_plane::XZ: normal = frame.YDirection(); x_dir = frame.XDirection(); break; - case Section_plane::YZ: + case Cross_section_plane::YZ: normal = frame.XDirection(); x_dir = frame.YDirection(); break; @@ -265,7 +265,7 @@ gp_Pln section_plane_(const gp_Ax3& frame, Section_plane plane, double offset) return gp_Pln(gp_Ax3(location, normal, x_dir)); } -Result section_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane) +Result cross_section_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane) { if (shape.IsNull()) return {Result_status::User_error, "Cannot section a null shape."}; @@ -281,7 +281,7 @@ Result section_shape_on_plane_(const TopoDS_Shape& shape, cons if (!section.IsDone()) return {Result_status::Topo_error, "Open CASCADE could not compute the section."}; - Section_geometry result; + Cross_section_geometry result; result.shape = section.Shape(); for (TopExp_Explorer it(result.shape, TopAbs_EDGE); it.More(); it.Next()) { @@ -300,7 +300,7 @@ Result section_shape_on_plane_(const TopoDS_Shape& shape, cons } } -void count_curve_type_(const TopoDS_Edge& edge, Section_geometry& result) +void count_curve_type_(const TopoDS_Edge& edge, Cross_section_geometry& result) { switch (BRepAdaptor_Curve(edge).GetType()) { diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h new file mode 100644 index 0000000..010ac7d --- /dev/null +++ b/src/shp_cross_section.h @@ -0,0 +1,57 @@ +#pragma once + +#include "shp_operation.h" + +#include +#include + +#include + +enum class Cross_section_plane +{ + XY, + XZ, + YZ +}; + +struct Cross_section_geometry +{ + TopoDS_Shape shape; + std::size_t edge_count{0}; + std::size_t line_count{0}; + std::size_t circle_count{0}; + std::size_t ellipse_count{0}; + std::size_t bspline_count{0}; + std::size_t other_curve_count{0}; +}; + +/// Compute a cross-section with a plane from \a frame local coordinates. Offset is in +/// model units along the selected local plane normal. Interactive preview uses one +/// shared plane for the whole selection (first-shape axes, selection bbox center). +Result cross_section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Cross_section_plane plane, + double offset); + +class Shp_cross_section : private Shp_operation_base +{ +public: + explicit Shp_cross_section(Occt_view& view); + + [[nodiscard]] Status preview_selected(); + void clear(); + + Cross_section_plane get_plane() const { return m_plane; } + void set_plane(Cross_section_plane plane) { m_plane = plane; } + double get_offset_display() const { return m_offset_display; } + void set_offset_display(double offset) { m_offset_display = offset; } + bool has_preview() const { return !m_preview.IsNull(); } + /// Projected extent of the current selection along the shared cross-section plane normal, + /// in display units. False when nothing solid is selected or bounds are empty. + [[nodiscard]] bool try_get_offset_range_display(double& out_min, double& out_max); + +private: + Cross_section_plane m_plane{Cross_section_plane::XY}; + double m_offset_display{0.0}; + AIS_Shape_ptr m_preview; + AIS_Shape_ptr m_plane_fill; + AIS_Shape_ptr m_plane_lines; +}; diff --git a/src/shp_section.h b/src/shp_section.h deleted file mode 100644 index 9465805..0000000 --- a/src/shp_section.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include "shp_operation.h" - -#include -#include - -#include - -enum class Section_plane -{ - XY, - XZ, - YZ -}; - -struct Section_geometry -{ - TopoDS_Shape shape; - std::size_t edge_count{0}; - std::size_t line_count{0}; - std::size_t circle_count{0}; - std::size_t ellipse_count{0}; - std::size_t bspline_count{0}; - std::size_t other_curve_count{0}; -}; - -/// Compute a section with a plane from \a frame local coordinates. Offset is in -/// model units along the selected local plane normal. Interactive preview uses one -/// shared plane for the whole selection (first-shape axes, selection bbox center). -Result section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Section_plane plane, double offset); - -class Shp_section : private Shp_operation_base -{ -public: - explicit Shp_section(Occt_view& view); - - [[nodiscard]] Status preview_selected(); - void clear(); - - Section_plane get_plane() const { return m_plane; } - void set_plane(Section_plane plane) { m_plane = plane; } - double get_offset_display() const { return m_offset_display; } - void set_offset_display(double offset) { m_offset_display = offset; } - bool has_preview() const { return !m_preview.IsNull(); } - /// Projected extent of the current selection along the shared section-plane normal, - /// in display units. False when nothing solid is selected or bounds are empty. - [[nodiscard]] bool try_get_offset_range_display(double& out_min, double& out_max); - -private: - Section_plane m_plane{Section_plane::XY}; - double m_offset_display{0.0}; - AIS_Shape_ptr m_preview; - AIS_Shape_ptr m_plane_fill; - AIS_Shape_ptr m_plane_lines; -}; diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index aad1000..0a4675f 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -19,7 +19,7 @@ #include "shp.h" #include "shp_create.h" #include "shp_info.h" -#include "shp_section.h" +#include "shp_cross_section.h" #include "skt_op_recorder.h" #include "utl.h" @@ -174,38 +174,38 @@ TEST(Shp_create, Pyramid_solid_non_null) // Cross-section geometry (no viewer) // --------------------------------------------------------------------------- -TEST(Shp_section, Box_midplane_is_four_lines) +TEST(Shp_cross_section, Box_midplane_is_four_lines) { const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 4, 6, 8); const gp_Ax3 frame = gp_Ax3(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); - const Result result = section_shape(box, frame, Section_plane::XY, 0.0); + const Result result = cross_section_shape(box, frame, Cross_section_plane::XY, 0.0); ASSERT_TRUE(result.has_value()) << result.message(); EXPECT_EQ((*result).edge_count, 4u); EXPECT_EQ((*result).line_count, 4u); } -TEST(Shp_section, Cylinder_midplane_contains_circle) +TEST(Shp_cross_section, Cylinder_midplane_contains_circle) { const TopoDS_Shape cylinder = shp_create::create_cylinder(2.0, 6.0); const gp_Ax3 frame(gp::Origin(), gp::DZ(), gp::DX()); - const Result result = section_shape(cylinder, frame, Section_plane::XY, 0.0); + const Result result = cross_section_shape(cylinder, frame, Cross_section_plane::XY, 0.0); ASSERT_TRUE(result.has_value()) << result.message(); EXPECT_GE((*result).edge_count, 1u); EXPECT_GE((*result).circle_count, 1u); } -TEST(Shp_section, Offset_outside_box_fails_closed) +TEST(Shp_cross_section, Offset_outside_box_fails_closed) { const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 4, 6, 8); const gp_Ax3 frame = gp_Ax3(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); - const Result result = section_shape(box, frame, Section_plane::XY, 5.0); + const Result result = cross_section_shape(box, frame, Cross_section_plane::XY, 5.0); EXPECT_FALSE(result.has_value()); } -TEST(Shp_section, Compound_with_solid_is_supported) +TEST(Shp_cross_section, Compound_with_solid_is_supported) { TopoDS_Compound compound; BRep_Builder builder; @@ -213,21 +213,21 @@ TEST(Shp_section, Compound_with_solid_is_supported) builder.Add(compound, shp_create::create_box(0, 0, 0, 4, 6, 8)); const gp_Ax3 frame(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); - const Result result = section_shape(compound, frame, Section_plane::XY, 0.0); + const Result result = cross_section_shape(compound, frame, Cross_section_plane::XY, 0.0); ASSERT_TRUE(result.has_value()) << result.message(); EXPECT_EQ((*result).line_count, 4u); } -TEST(Shp_section, Empty_compound_is_rejected) +TEST(Shp_cross_section, Empty_compound_is_rejected) { TopoDS_Compound compound; BRep_Builder().MakeCompound(compound); - const Result result = section_shape(compound, gp_Ax3(), Section_plane::XY, 0.0); + const Result result = cross_section_shape(compound, gp_Ax3(), Cross_section_plane::XY, 0.0); EXPECT_FALSE(result.has_value()); } -TEST(Shp_section, Face_only_compound_is_rejected) +TEST(Shp_cross_section, Face_only_compound_is_rejected) { const TopoDS_Shape face = BRepBuilderAPI_MakeFace(gp_Pln(gp::Origin(), gp::DZ()), -1.0, 1.0, -1.0, 1.0).Face(); TopoDS_Compound compound; @@ -235,27 +235,27 @@ TEST(Shp_section, Face_only_compound_is_rejected) builder.MakeCompound(compound); builder.Add(compound, face); - const Result result = section_shape(compound, gp_Ax3(), Section_plane::XY, 0.0); + const Result result = cross_section_shape(compound, gp_Ax3(), Cross_section_plane::XY, 0.0); EXPECT_FALSE(result.has_value()); } -TEST(Shp_section, Box_local_xz_and_yz_midplanes_are_four_lines) +TEST(Shp_cross_section, Box_local_xz_and_yz_midplanes_are_four_lines) { const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 4, 6, 8); const gp_Ax3 frame = gp_Ax3(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); - const Result xz = section_shape(box, frame, Section_plane::XZ, 0.0); + const Result xz = cross_section_shape(box, frame, Cross_section_plane::XZ, 0.0); ASSERT_TRUE(xz.has_value()) << xz.message(); EXPECT_EQ((*xz).edge_count, 4u); EXPECT_EQ((*xz).line_count, 4u); - const Result yz = section_shape(box, frame, Section_plane::YZ, 0.0); + const Result yz = cross_section_shape(box, frame, Cross_section_plane::YZ, 0.0); ASSERT_TRUE(yz.has_value()) << yz.message(); EXPECT_EQ((*yz).edge_count, 4u); EXPECT_EQ((*yz).line_count, 4u); } -TEST(Shp_section, Rotated_frame_midplane_is_four_lines) +TEST(Shp_cross_section, Rotated_frame_midplane_is_four_lines) { const TopoDS_Shape box = shp_create::create_box(0, 0, 0, 4, 6, 8); gp_Ax3 frame(gp_Pnt(2, 3, 4), gp::DZ(), gp::DX()); @@ -263,13 +263,13 @@ TEST(Shp_section, Rotated_frame_midplane_is_four_lines) rotate.SetRotation(gp_Ax1(frame.Location(), gp::DX()), std::numbers::pi / 2.0); frame.Transform(rotate); - const Result result = section_shape(box, frame, Section_plane::XY, 0.0); + const Result result = cross_section_shape(box, frame, Cross_section_plane::XY, 0.0); ASSERT_TRUE(result.has_value()) << result.message(); EXPECT_EQ((*result).edge_count, 4u); EXPECT_EQ((*result).line_count, 4u); } -TEST(Shp_section, Fused_boxes_midplane_returns_extra_line_edges) +TEST(Shp_cross_section, Fused_boxes_midplane_returns_extra_line_edges) { const TopoDS_Shape a = shp_create::create_box(0, 0, 0, 10, 10, 10); const TopoDS_Shape b = shp_create::create_box(5, 0, 0, 10, 10, 10); @@ -278,7 +278,7 @@ TEST(Shp_section, Fused_boxes_midplane_returns_extra_line_edges) ASSERT_TRUE(fuse.IsDone()); const gp_Ax3 frame(gp_Pnt(7.5, 5, 5), gp::DZ(), gp::DX()); - const Result result = section_shape(fuse.Shape(), frame, Section_plane::XY, 0.0); + const Result result = cross_section_shape(fuse.Shape(), frame, Cross_section_plane::XY, 0.0); ASSERT_TRUE(result.has_value()) << result.message(); // Ideal outer section is a 15x10 rectangle (4 lines), but BRepAlgoAPI_Section currently // returns 8 line edges here. Keep this expectation as discovery evidence for sketch import. @@ -287,15 +287,15 @@ TEST(Shp_section, Fused_boxes_midplane_returns_extra_line_edges) EXPECT_EQ((*result).other_curve_count, 0u); } -TEST(Shp_section, Shared_plane_cuts_two_separated_boxes) +TEST(Shp_cross_section, Shared_plane_cuts_two_separated_boxes) { // Mimic multi-select: one plane through the combined bbox center must hit both solids. const TopoDS_Shape left = shp_create::create_box(0, 0, 0, 4, 4, 4); const TopoDS_Shape right = shp_create::create_box(6, 0, 0, 4, 4, 4); const gp_Ax3 shared_frame(gp_Pnt(5, 2, 2), gp::DZ(), gp::DX()); - const Result left_result = section_shape(left, shared_frame, Section_plane::XY, 0.0); - const Result right_result = section_shape(right, shared_frame, Section_plane::XY, 0.0); + const Result left_result = cross_section_shape(left, shared_frame, Cross_section_plane::XY, 0.0); + const Result right_result = cross_section_shape(right, shared_frame, Cross_section_plane::XY, 0.0); ASSERT_TRUE(left_result.has_value()) << left_result.message(); ASSERT_TRUE(right_result.has_value()) << right_result.message(); EXPECT_EQ((*left_result).line_count, 4u); From c2b640b66f2b4a0dde3fa72a5ba996880a53f6a9 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Tue, 21 Jul 2026 18:03:12 -0600 Subject: [PATCH 04/17] Improve --- CHANGELOG.md | 2 +- docs/usage.md | 2 +- src/doc/gui.md | 2 +- src/doc/shape.md | 2 +- src/gui_occt_view.cpp | 23 ++++++++++++++++++++++- src/shp_cross_section.cpp | 6 +++++- src/shp_cross_section.h | 2 ++ tests/shp_tests.cpp | 12 ++++++++++++ 8 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da21612..06dfacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Cross-section -- Added a temporary **Shape cross-section** preview tool for selected solids. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center) and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. Status reports curve counts and any missed solids. +- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center) and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. Status reports curve counts and any missed solids. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. ### Shape List diff --git a/docs/usage.md b/docs/usage.md index cf65a09..1540332 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -602,7 +602,7 @@ The polar duplicate tool allows you to create multiple copies of selected shapes Use the cross-section tool to inspect where a plane cuts one or more selected solids. The result is a temporary cyan wire preview. A translucent yellow rectangle outlines the cutting plane, with an arrow showing its positive normal direction. These annotations do not modify the solids or create a sketch. 1. Select one or more solids. -2. Click **Shape cross-section** in the toolbar. +2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. 3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. 4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit, and the preview updates as you change it. 5. If you change the selection, click **Update preview**. Use **Clear** or leave the tool to remove the preview. diff --git a/src/doc/gui.md b/src/doc/gui.md index affe67b..c0acee4 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -235,7 +235,7 @@ Toolbar buttons hold `std::variant`. `Command` (`Shape_cut`, `Sha Mode buttons call `set_mode`. Active state tracks `m_mode`. -The cross-section toolbar button enters `Mode::Shape_cross_section`. Its Options controls call `Shp_cross_section::preview_selected`; the temporary AIS wire result is cleared when the mode is left, when the user presses **Clear**, or before a failed/updated preview. +The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. Options controls call `preview_selected`. The temporary AIS wire result is cleared when the mode is left, when the user presses **Clear**, or before a failed/updated preview. ## Typical developer usage diff --git a/src/doc/shape.md b/src/doc/shape.md index 5c13302..5d667b5 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -167,7 +167,7 @@ Protected helpers used by all operation classes: | `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 | -| `Mode::Shape_cross_section` | -- | -- | Options **Update preview** | Preview cleared | +| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter if selection; Options **Update preview** | Preview cleared | | Fuse / cut / common (toolbar) | -- | `selected_fuse` / `selected_cut` / `selected_common` (one-shot) | -- | -- | | Primitives (menu / script) | -- | `Occt_view::add_box`, `add_sphere`, ... | -- | -- | | Revolve (sketch Options) | -- | `Occt_view::revolve_selected` -> `add_shp_` | -- | -- | diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index e71f0b7..8a7c4a2 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -2858,6 +2858,10 @@ void Occt_view::on_mode() { DBG_MSG(c_mode_strs[int(get_mode())]); + // Snapshot before selection-mode / faint redisplay Erase drops AIS selection. + const std::vector cross_section_enter_selection = + get_mode() == Mode::Shape_cross_section ? get_selected_shps() : std::vector{}; + shp_polar_dup().reset(); if (get_mode() != Mode::Shape_cross_section) shp_cross_section().clear(); @@ -2922,7 +2926,7 @@ void Occt_view::on_mode() case Mode::Move: set_shp_selection_mode(TopAbs_COMPOUND); break; case Mode::Rotate: set_shp_selection_mode(TopAbs_COMPOUND); break; case Mode::Scale: set_shp_selection_mode(TopAbs_COMPOUND); break; - case Mode::Shape_cross_section: set_shp_selection_mode(TopAbs_COMPOUND); break; + case Mode::Shape_cross_section: set_shp_selection_mode(TopAbs_COMPOUND); break; default: if(m_modes_selection_mode_map.count(get_mode())) set_shp_selection_mode(m_modes_selection_mode_map.at(get_mode())); @@ -2934,6 +2938,23 @@ void Occt_view::on_mode() sync_sketch_shape_faint_style(); apply_sketch_dimensions_visibility(); + + if (get_mode() == Mode::Shape_cross_section && !cross_section_enter_selection.empty()) + { + // Restore after faint sync (set_sketch_faint Erase/redisplays and clears AIS selection). + if (!m_ctx.IsNull()) + { + m_ctx->ClearSelected(false); + for (const Shp_ptr& shp : cross_section_enter_selection) + if (!shp.IsNull()) + m_ctx->AddOrRemoveSelected(shp, false); + m_ctx->HilightSelected(false); + m_ctx->UpdateCurrentViewer(); + } + + const Status status = shp_cross_section().preview(cross_section_enter_selection); + gui().show_message(status.message()); + } } void Occt_view::sync_sketch_shape_faint_style() diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index 04e7f66..ebdc329 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -56,12 +56,16 @@ Shp_cross_section::Shp_cross_section(Occt_view& view) } Status Shp_cross_section::preview_selected() +{ + return preview(get_selected_shps_()); +} + +Status Shp_cross_section::preview(const std::vector& selected) { clear(); if (!std::isfinite(m_offset_display)) return Status::user_error("Section offset must be a finite number."); - const std::vector selected = get_selected_shps_(); if (selected.empty()) return Status::user_error("Select one or more solid shapes."); diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h index 010ac7d..6842d35 100644 --- a/src/shp_cross_section.h +++ b/src/shp_cross_section.h @@ -37,6 +37,8 @@ class Shp_cross_section : private Shp_operation_base explicit Shp_cross_section(Occt_view& view); [[nodiscard]] Status preview_selected(); + /// Same as preview_selected, but uses \a shapes instead of the current AIS selection. + [[nodiscard]] Status preview(const std::vector& shapes); void clear(); Cross_section_plane get_plane() const { return m_plane; } diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 0a4675f..9b8408d 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -302,6 +302,18 @@ TEST(Shp_cross_section, Shared_plane_cuts_two_separated_boxes) EXPECT_EQ((*right_result).line_count, 4u); } +TEST_F(Shp_test, Cross_section_previews_on_mode_enter_with_selection) +{ + view().add_box(0, 0, 0, 10, 10, 10); + select_shapes(view(), {view().get_shapes().back()}); + EXPECT_FALSE(view().shp_cross_section().has_preview()); + + gui().set_mode(Mode::Shape_cross_section); + + EXPECT_TRUE(view().shp_cross_section().has_preview()); + EXPECT_EQ(gui().get_mode(), Mode::Shape_cross_section); +} + // --------------------------------------------------------------------------- // shp_info // --------------------------------------------------------------------------- From b21c94bfc001ce76551bd5238ff37f2be495eacf Mon Sep 17 00:00:00 2001 From: Trailcode Date: Tue, 21 Jul 2026 18:10:20 -0600 Subject: [PATCH 05/17] Improve --- CHANGELOG.md | 2 +- docs/usage-settings.md | 2 +- docs/usage.md | 2 +- src/doc/gui.md | 2 +- src/doc/shape.md | 2 +- src/gui_mode.cpp | 34 +++++++++++++++++++++++++++++++--- src/shp_cross_section.cpp | 21 +++++++++++++++++++++ src/shp_cross_section.h | 18 +++++++++++++----- tests/shp_tests.cpp | 27 +++++++++++++++++++++++++++ 9 files changed, 97 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06dfacc..d390875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Cross-section -- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center) and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. Status reports curve counts and any missed solids. +- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center) and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. Status reports curve counts and any missed solids. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. ### Shape List diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 67497e2..0a1d38b 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -104,7 +104,7 @@ Under **Material**: - Document preset for new solids that do not inherit from a clicked shape (toolbar **Box**, **polar duplicate** output, and similar). To change material on an existing solid, use the [Shape List](usage.md#shape-list). -For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, a bbox-ranged **Offset** slider (Ctrl+click to type), **Update preview**, and **Clear** controls; see [Shape cross-section tool](usage.md#shape-cross-section-tool). +For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, a bbox-ranged **Offset** slider (Ctrl+click to type), automatic preview updates when the selection changes, **Update preview**, and **Clear** controls; see [Shape cross-section tool](usage.md#shape-cross-section-tool). ### Sketch tools diff --git a/docs/usage.md b/docs/usage.md index 1540332..c0d2f31 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -605,7 +605,7 @@ Use the cross-section tool to inspect where a plane cuts one or more selected so 2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. 3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. 4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit, and the preview updates as you change it. -5. If you change the selection, click **Update preview**. Use **Clear** or leave the tool to remove the preview. +5. Changing the selection (in the 3D view or Shape List) updates the preview automatically. Use **Update preview** to force a refresh, or **Clear** / leave the tool to remove the preview. Each solid has a local frame used for orientation. New solids start with a world-aligned frame at the center of their bounding box; moving, rotating, or scaling a solid updates that frame. With multiple selected solids, all of them share one cutting plane: orientation from the first selected solid's local axes, and origin at the selection bounding-box center. Offset moves that shared plane along its normal. diff --git a/src/doc/gui.md b/src/doc/gui.md index c0acee4..fc373d9 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -235,7 +235,7 @@ Toolbar buttons hold `std::variant`. `Command` (`Shape_cut`, `Sha Mode buttons call `set_mode`. Active state tracks `m_mode`. -The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. Options controls call `preview_selected`. The temporary AIS wire result is cleared when the mode is left, when the user presses **Clear**, or before a failed/updated preview. +The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. While the mode is active, Options polls `selection_stale` and rebuilds (or clears) the preview when the AIS selection ids change. Plane/offset controls and **Update preview** also call `preview_selected`. **Clear** removes the AIS preview and acknowledges the current selection so it does not rebuild until the selection changes again. The temporary AIS wire result is also cleared when the mode is left, or before a failed/updated preview. ## Typical developer usage diff --git a/src/doc/shape.md b/src/doc/shape.md index 5d667b5..9d939d5 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -167,7 +167,7 @@ Protected helpers used by all operation classes: | `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 | -| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter if selection; Options **Update preview** | Preview cleared | +| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter / selection change; Options **Update preview** | Preview cleared | | Fuse / cut / common (toolbar) | -- | `selected_fuse` / `selected_cut` / `selected_common` (one-shot) | -- | -- | | Primitives (menu / script) | -- | `Occt_view::add_box`, `add_sphere`, ... | -- | -- | | Revolve (sketch Options) | -- | `Occt_view::revolve_selected` -> `add_shp_` | -- | -- | diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index e58cbf4..273164d 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -762,8 +762,8 @@ void GUI::options_shape_cross_section_mode_() EZY_ASSERT(get_mode() == Mode::Shape_cross_section); Shp_cross_section& section = m_view->shp_cross_section(); - int plane = static_cast(section.get_plane()); - double offset = section.get_offset_display(); + int plane = static_cast(section.get_plane()); + double offset = section.get_offset_display(); auto update_preview = [&]() { @@ -771,6 +771,30 @@ void GUI::options_shape_cross_section_mode_() show_message(status.message()); }; + // Viewer / shape-list / script selection changes: rebuild once when the id set changes. + if (section.selection_stale()) + { + if (m_view->get_selected_shps().empty()) + { + section.clear(); + section.acknowledge_current_selection(); + } + else + { + double range_min = 0.0; + double range_max = 0.0; + if (section.try_get_offset_range_display(range_min, range_max)) + { + if (offset < range_min) + section.set_offset_display(range_min); + else if (offset > range_max) + section.set_offset_display(range_max); + offset = section.get_offset_display(); + } + update_preview(); + } + } + ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -840,13 +864,17 @@ void GUI::options_shape_cross_section_mode_() { ImGui::SameLine(); if (ImGui::Button("Clear")) + { section.clear(); + section.acknowledge_current_selection(); + } } ImGui::TextWrapped( "All selected solids share one cutting plane. Orientation follows the first selected solid's local axes; the " "plane is centered on the selection bounding box. Offset slides across that box along the plane normal " - "(Ctrl+click the slider to type a value). The yellow plane and arrow show the cut plane and positive normal."); + "(Ctrl+click the slider to type a value). The yellow plane and arrow show the cut plane and positive normal. " + "Changing the selection updates the preview automatically."); options_orthographic_projection_(); } diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index ebdc329..a794649 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -63,6 +63,7 @@ Status Shp_cross_section::preview_selected() Status Shp_cross_section::preview(const std::vector& selected) { clear(); + m_acked_selection_ids = selection_ids_(selected); if (!std::isfinite(m_offset_display)) return Status::user_error("Section offset must be a finite number."); @@ -198,6 +199,26 @@ void Shp_cross_section::clear() ctx().UpdateCurrentViewer(); } +std::vector Shp_cross_section::selection_ids_(const std::vector& shapes) +{ + std::vector ids; + ids.reserve(shapes.size()); + for (const Shp_ptr& shp : shapes) + if (!shp.IsNull()) + ids.push_back(shp->get_id()); + return ids; +} + +bool Shp_cross_section::selection_stale() const +{ + return selection_ids_(get_selected_shps_()) != m_acked_selection_ids; +} + +void Shp_cross_section::acknowledge_current_selection() +{ + m_acked_selection_ids = selection_ids_(get_selected_shps_()); +} + bool Shp_cross_section::try_get_offset_range_display(double& out_min, double& out_max) { const std::vector selected = get_selected_shps_(); diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h index 6842d35..33dd578 100644 --- a/src/shp_cross_section.h +++ b/src/shp_cross_section.h @@ -6,6 +6,7 @@ #include #include +#include enum class Cross_section_plane { @@ -46,14 +47,21 @@ class Shp_cross_section : private Shp_operation_base double get_offset_display() const { return m_offset_display; } void set_offset_display(double offset) { m_offset_display = offset; } bool has_preview() const { return !m_preview.IsNull(); } + /// True when the current AIS selection ids differ from the last acknowledged set. + [[nodiscard]] bool selection_stale() const; + /// Record the current AIS selection as acknowledged without rebuilding the preview. + void acknowledge_current_selection(); /// Projected extent of the current selection along the shared cross-section plane normal, /// in display units. False when nothing solid is selected or bounds are empty. [[nodiscard]] bool try_get_offset_range_display(double& out_min, double& out_max); private: - Cross_section_plane m_plane{Cross_section_plane::XY}; - double m_offset_display{0.0}; - AIS_Shape_ptr m_preview; - AIS_Shape_ptr m_plane_fill; - AIS_Shape_ptr m_plane_lines; + static std::vector selection_ids_(const std::vector& shapes); + + Cross_section_plane m_plane{Cross_section_plane::XY}; + double m_offset_display{0.0}; + std::vector m_acked_selection_ids; + AIS_Shape_ptr m_preview; + AIS_Shape_ptr m_plane_fill; + AIS_Shape_ptr m_plane_lines; }; diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 9b8408d..0a88b5b 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -312,8 +312,35 @@ TEST_F(Shp_test, Cross_section_previews_on_mode_enter_with_selection) EXPECT_TRUE(view().shp_cross_section().has_preview()); EXPECT_EQ(gui().get_mode(), Mode::Shape_cross_section); + EXPECT_FALSE(view().shp_cross_section().selection_stale()); } +TEST_F(Shp_test, Cross_section_selection_stale_after_selection_change) +{ + view().add_box(0, 0, 0, 10, 10, 10); + view().add_box(20, 0, 0, 10, 10, 10); + const std::vector boxes(view().get_shapes().begin(), view().get_shapes().end()); + ASSERT_EQ(boxes.size(), 2u); + + select_shapes(view(), {boxes[0]}); + gui().set_mode(Mode::Shape_cross_section); + ASSERT_TRUE(view().shp_cross_section().has_preview()); + EXPECT_FALSE(view().shp_cross_section().selection_stale()); + + select_shapes(view(), {boxes[1]}); + EXPECT_TRUE(view().shp_cross_section().selection_stale()); + + ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); + EXPECT_TRUE(view().shp_cross_section().has_preview()); + EXPECT_FALSE(view().shp_cross_section().selection_stale()); + + view().shp_cross_section().clear(); + view().shp_cross_section().acknowledge_current_selection(); + EXPECT_FALSE(view().shp_cross_section().has_preview()); + EXPECT_FALSE(view().shp_cross_section().selection_stale()); +} + + // --------------------------------------------------------------------------- // shp_info // --------------------------------------------------------------------------- From dc66a97ed690f03f89a58843bad1dffcdb5d731d Mon Sep 17 00:00:00 2001 From: Trailcode Date: Tue, 21 Jul 2026 18:12:45 -0600 Subject: [PATCH 06/17] Improve --- docs/usage.md | 2 +- src/gui_mode.cpp | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index c0d2f31..cfa67f9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -605,7 +605,7 @@ Use the cross-section tool to inspect where a plane cuts one or more selected so 2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. 3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. 4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit, and the preview updates as you change it. -5. Changing the selection (in the 3D view or Shape List) updates the preview automatically. Use **Update preview** to force a refresh, or **Clear** / leave the tool to remove the preview. +5. Changing the selection (in the 3D view or Shape List) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. Use **Update preview** to force a refresh, or **Clear** / leave the tool to remove the preview. Each solid has a local frame used for orientation. New solids start with a world-aligned frame at the center of their bounding box; moving, rotating, or scaling a solid updates that frame. With multiple selected solids, all of them share one cutting plane: orientation from the first selected solid's local axes, and origin at the selection bounding-box center. Offset moves that shared plane along its normal. diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 273164d..768b6ce 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -798,6 +798,18 @@ void GUI::options_shape_cross_section_mode_() ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); + + const bool have_selection = !m_view->get_selected_shps().empty(); + if (!have_selection) + { + // No dedicated bold font is loaded; offset a second draw for a bold look. + const char* msg = "Select one or more shapes."; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImU32 col = ImGui::GetColorU32(ImGuiCol_Text); + ImGui::GetWindowDrawList()->AddText(ImVec2(pos.x + 1.0f, pos.y), col, msg); + ImGui::TextUnformatted(msg); + } + ImGui::TextUnformatted("Section plane"); if (ImGui::RadioButton("Local XY", &plane, static_cast(Cross_section_plane::XY))) @@ -855,8 +867,8 @@ void GUI::options_shape_cross_section_mode_() ImGui::EndDisabled(); ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::TextUnformatted(m_view->project_unit_suffix()); - if (!have_range) - ImGui::TextDisabled("Select solids to enable the offset slider."); + if (have_selection && !have_range) + ImGui::TextDisabled("Select solid shapes to enable the offset slider."); if (ImGui::Button("Update preview")) update_preview(); From dfa547c7ca9060a5e519cc8913590646d47e6d7f Mon Sep 17 00:00:00 2001 From: Trailcode Date: Tue, 21 Jul 2026 18:19:18 -0600 Subject: [PATCH 07/17] Improve --- docs/usage-settings.md | 2 +- docs/usage.md | 2 +- src/doc/gui.md | 4 +-- src/doc/shape.md | 2 +- src/gui_mode.cpp | 74 ++++++++------------------------------- src/shp_cross_section.cpp | 16 +++++++-- src/shp_cross_section.h | 7 +++- tests/shp_tests.cpp | 9 ++++- 8 files changed, 48 insertions(+), 68 deletions(-) diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 0a1d38b..e3a281b 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -104,7 +104,7 @@ Under **Material**: - Document preset for new solids that do not inherit from a clicked shape (toolbar **Box**, **polar duplicate** output, and similar). To change material on an existing solid, use the [Shape List](usage.md#shape-list). -For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, a bbox-ranged **Offset** slider (Ctrl+click to type), automatic preview updates when the selection changes, **Update preview**, and **Clear** controls; see [Shape cross-section tool](usage.md#shape-cross-section-tool). +For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, a bbox-ranged **Offset** slider (Ctrl+click to type), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). ### Sketch tools diff --git a/docs/usage.md b/docs/usage.md index cfa67f9..17a5414 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -605,7 +605,7 @@ Use the cross-section tool to inspect where a plane cuts one or more selected so 2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. 3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. 4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit, and the preview updates as you change it. -5. Changing the selection (in the 3D view or Shape List) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. Use **Update preview** to force a refresh, or **Clear** / leave the tool to remove the preview. +5. Changing the selection or **Section plane** (or dragging **Offset**) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. Leave the tool (or clear the selection) to remove the preview. Each solid has a local frame used for orientation. New solids start with a world-aligned frame at the center of their bounding box; moving, rotating, or scaling a solid updates that frame. With multiple selected solids, all of them share one cutting plane: orientation from the first selected solid's local axes, and origin at the selection bounding-box center. Offset moves that shared plane along its normal. diff --git a/src/doc/gui.md b/src/doc/gui.md index fc373d9..66bd8f8 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -186,7 +186,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | -| `Shape_cross_section` | local XY/XZ/YZ plane, bbox-ranged offset slider (auto preview), **Update preview**, clear | +| `Shape_cross_section` | local XY/XZ/YZ plane, bbox-ranged offset slider (auto preview on selection / plane / offset) | | `Sketch_inspection_mode` | `options_sketch_common_` | | Each sketch tool mode | Matching `options_sketch_*_mode_` | | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | @@ -235,7 +235,7 @@ Toolbar buttons hold `std::variant`. `Command` (`Shape_cut`, `Sha Mode buttons call `set_mode`. Active state tracks `m_mode`. -The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. While the mode is active, Options polls `selection_stale` and rebuilds (or clears) the preview when the AIS selection ids change. Plane/offset controls and **Update preview** also call `preview_selected`. **Clear** removes the AIS preview and acknowledges the current selection so it does not rebuild until the selection changes again. The temporary AIS wire result is also cleared when the mode is left, or before a failed/updated preview. +The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. While the mode is active, Options applies plane/offset widgets then polls `preview_inputs_stale` (selection ids, plane, or offset) and rebuilds or clears the preview. The temporary AIS wire result is cleared when the mode is left, when the selection becomes empty, or before a failed/updated preview. ## Typical developer usage diff --git a/src/doc/shape.md b/src/doc/shape.md index 9d939d5..9fc60a1 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -167,7 +167,7 @@ Protected helpers used by all operation classes: | `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 | -| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter / selection change; Options **Update preview** | Preview cleared | +| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter / selection / Options plane & offset | Preview cleared | | Fuse / cut / common (toolbar) | -- | `selected_fuse` / `selected_cut` / `selected_common` (one-shot) | -- | -- | | Primitives (menu / script) | -- | `Occt_view::add_box`, `add_sphere`, ... | -- | -- | | Revolve (sketch Options) | -- | `Occt_view::revolve_selected` -> `add_shp_` | -- | -- | diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 768b6ce..497549c 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -771,30 +771,6 @@ void GUI::options_shape_cross_section_mode_() show_message(status.message()); }; - // Viewer / shape-list / script selection changes: rebuild once when the id set changes. - if (section.selection_stale()) - { - if (m_view->get_selected_shps().empty()) - { - section.clear(); - section.acknowledge_current_selection(); - } - else - { - double range_min = 0.0; - double range_max = 0.0; - if (section.try_get_offset_range_display(range_min, range_max)) - { - if (offset < range_min) - section.set_offset_display(range_min); - else if (offset > range_max) - section.set_offset_display(range_max); - offset = section.get_offset_display(); - } - update_preview(); - } - } - ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -803,32 +779,21 @@ void GUI::options_shape_cross_section_mode_() if (!have_selection) { // No dedicated bold font is loaded; offset a second draw for a bold look. - const char* msg = "Select one or more shapes."; - const ImVec2 pos = ImGui::GetCursorScreenPos(); - const ImU32 col = ImGui::GetColorU32(ImGuiCol_Text); + const char* msg = "Select one or more shapes."; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImU32 col = ImGui::GetColorU32(ImGuiCol_Text); ImGui::GetWindowDrawList()->AddText(ImVec2(pos.x + 1.0f, pos.y), col, msg); ImGui::TextUnformatted(msg); } ImGui::TextUnformatted("Section plane"); - if (ImGui::RadioButton("Local XY", &plane, static_cast(Cross_section_plane::XY))) - { - section.set_plane(Cross_section_plane::XY); - update_preview(); - } + ImGui::RadioButton("Local XY", &plane, static_cast(Cross_section_plane::XY)); ImGui::SameLine(); - if (ImGui::RadioButton("Local XZ", &plane, static_cast(Cross_section_plane::XZ))) - { - section.set_plane(Cross_section_plane::XZ); - update_preview(); - } + ImGui::RadioButton("Local XZ", &plane, static_cast(Cross_section_plane::XZ)); ImGui::SameLine(); - if (ImGui::RadioButton("Local YZ", &plane, static_cast(Cross_section_plane::YZ))) - { - section.set_plane(Cross_section_plane::YZ); - update_preview(); - } + ImGui::RadioButton("Local YZ", &plane, static_cast(Cross_section_plane::YZ)); + section.set_plane(static_cast(plane)); double offset_min = -1.0; double offset_max = 1.0; @@ -836,15 +801,9 @@ void GUI::options_shape_cross_section_mode_() if (have_range) { if (offset < offset_min) - { offset = offset_min; - section.set_offset_display(offset); - } else if (offset > offset_max) - { offset = offset_max; - section.set_offset_display(offset); - } } else { @@ -859,34 +818,31 @@ void GUI::options_shape_cross_section_mode_() ImGui::SetNextItemWidth(180.0f); ImGui::BeginDisabled(!have_range); - if (ImGui::SliderScalar("Offset", ImGuiDataType_Double, &offset, &offset_min, &offset_max, "%.6g")) - { - section.set_offset_display(offset); - update_preview(); - } + ImGui::SliderScalar("Offset", ImGuiDataType_Double, &offset, &offset_min, &offset_max, "%.6g"); ImGui::EndDisabled(); + section.set_offset_display(offset); ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::TextUnformatted(m_view->project_unit_suffix()); if (have_selection && !have_range) ImGui::TextDisabled("Select solid shapes to enable the offset slider."); - if (ImGui::Button("Update preview")) - update_preview(); - if (section.has_preview()) + // Refresh after plane/offset widgets so a plane change is never missed. + if (section.preview_inputs_stale()) { - ImGui::SameLine(); - if (ImGui::Button("Clear")) + if (m_view->get_selected_shps().empty()) { section.clear(); section.acknowledge_current_selection(); } + else + update_preview(); } ImGui::TextWrapped( "All selected solids share one cutting plane. Orientation follows the first selected solid's local axes; the " "plane is centered on the selection bounding box. Offset slides across that box along the plane normal " "(Ctrl+click the slider to type a value). The yellow plane and arrow show the cut plane and positive normal. " - "Changing the selection updates the preview automatically."); + "Changing the selection or section plane updates the preview automatically."); options_orthographic_projection_(); } diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index a794649..bf32d7b 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -63,7 +63,7 @@ Status Shp_cross_section::preview_selected() Status Shp_cross_section::preview(const std::vector& selected) { clear(); - m_acked_selection_ids = selection_ids_(selected); + acknowledge_inputs_(selected); if (!std::isfinite(m_offset_display)) return Status::user_error("Section offset must be a finite number."); @@ -214,9 +214,21 @@ bool Shp_cross_section::selection_stale() const return selection_ids_(get_selected_shps_()) != m_acked_selection_ids; } +bool Shp_cross_section::preview_inputs_stale() const +{ + return selection_stale() || m_acked_plane != m_plane || m_acked_offset_display != m_offset_display; +} + +void Shp_cross_section::acknowledge_inputs_(const std::vector& shapes) +{ + m_acked_selection_ids = selection_ids_(shapes); + m_acked_plane = m_plane; + m_acked_offset_display = m_offset_display; +} + void Shp_cross_section::acknowledge_current_selection() { - m_acked_selection_ids = selection_ids_(get_selected_shps_()); + acknowledge_inputs_(get_selected_shps_()); } bool Shp_cross_section::try_get_offset_range_display(double& out_min, double& out_max) diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h index 33dd578..627ead8 100644 --- a/src/shp_cross_section.h +++ b/src/shp_cross_section.h @@ -49,7 +49,9 @@ class Shp_cross_section : private Shp_operation_base bool has_preview() const { return !m_preview.IsNull(); } /// True when the current AIS selection ids differ from the last acknowledged set. [[nodiscard]] bool selection_stale() const; - /// Record the current AIS selection as acknowledged without rebuilding the preview. + /// True when selection, section plane, or offset differ from the last preview/acknowledge. + [[nodiscard]] bool preview_inputs_stale() const; + /// Record the current AIS selection, plane, and offset as acknowledged without rebuilding. void acknowledge_current_selection(); /// Projected extent of the current selection along the shared cross-section plane normal, /// in display units. False when nothing solid is selected or bounds are empty. @@ -57,9 +59,12 @@ class Shp_cross_section : private Shp_operation_base private: static std::vector selection_ids_(const std::vector& shapes); + void acknowledge_inputs_(const std::vector& shapes); Cross_section_plane m_plane{Cross_section_plane::XY}; double m_offset_display{0.0}; + Cross_section_plane m_acked_plane{Cross_section_plane::XY}; + double m_acked_offset_display{0.0}; std::vector m_acked_selection_ids; AIS_Shape_ptr m_preview; AIS_Shape_ptr m_plane_fill; diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 0a88b5b..4e4e59d 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -326,18 +326,25 @@ TEST_F(Shp_test, Cross_section_selection_stale_after_selection_change) gui().set_mode(Mode::Shape_cross_section); ASSERT_TRUE(view().shp_cross_section().has_preview()); EXPECT_FALSE(view().shp_cross_section().selection_stale()); + EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); select_shapes(view(), {boxes[1]}); EXPECT_TRUE(view().shp_cross_section().selection_stale()); + EXPECT_TRUE(view().shp_cross_section().preview_inputs_stale()); ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); EXPECT_TRUE(view().shp_cross_section().has_preview()); EXPECT_FALSE(view().shp_cross_section().selection_stale()); + view().shp_cross_section().set_plane(Cross_section_plane::XZ); + EXPECT_TRUE(view().shp_cross_section().preview_inputs_stale()); + ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); + EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); + view().shp_cross_section().clear(); view().shp_cross_section().acknowledge_current_selection(); EXPECT_FALSE(view().shp_cross_section().has_preview()); - EXPECT_FALSE(view().shp_cross_section().selection_stale()); + EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); } From 702d28cd8be32444eaa6d5c40b888f9dfb64cf4b Mon Sep 17 00:00:00 2001 From: Trailcode Date: Tue, 21 Jul 2026 18:21:11 -0600 Subject: [PATCH 08/17] Add GitHub draft notes for cross-section issue #218 and PR #219. Co-authored-by: Cursor --- .../active/gh-218-cross-section-tool.md | 36 +++++++++++++++++++ .../prs/active/gh-219-cross-section-tool.md | 32 +++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 agents/drafts/issues/active/gh-218-cross-section-tool.md create mode 100644 agents/drafts/prs/active/gh-219-cross-section-tool.md diff --git a/agents/drafts/issues/active/gh-218-cross-section-tool.md b/agents/drafts/issues/active/gh-218-cross-section-tool.md new file mode 100644 index 0000000..50918e8 --- /dev/null +++ b/agents/drafts/issues/active/gh-218-cross-section-tool.md @@ -0,0 +1,36 @@ +--- +github_issue: 218 +github_pr: 219 +status: active +paired_draft: ../prs/active/gh-219-cross-section-tool.md +--- + +# Add temporary shape cross-section preview tool + +**Suggested labels:** `enhancement`, `ui`, `feature-request` + +--- + +## Title (GitHub) + +Add temporary shape cross-section preview tool + +## Body (GitHub) + +See https://github.com/trailcode/EzyCad/issues/218 + +### Summary + +Interactive **Shape cross-section** tool: shared local plane cut of selected solids with temporary AIS preview (no sketch commit in v0). + +### Acceptance criteria + +- [ ] Auto-preview on enter / selection / plane / offset +- [ ] Bold empty-selection prompt; leave mode or clear selection removes preview +- [ ] Focused tests `Shp_cross_section.*` / `Shp_test.Cross_section*` +- [ ] Docs + CHANGELOG updated + +### Related + +- PR: https://github.com/trailcode/EzyCad/pull/219 +- Plan: `agents/plans/cross-section-tool.md` diff --git a/agents/drafts/prs/active/gh-219-cross-section-tool.md b/agents/drafts/prs/active/gh-219-cross-section-tool.md new file mode 100644 index 0000000..153fecf --- /dev/null +++ b/agents/drafts/prs/active/gh-219-cross-section-tool.md @@ -0,0 +1,32 @@ +--- +github_issue: 218 +github_pr: 219 +status: active +paired_draft: ../issues/active/gh-218-cross-section-tool.md +--- + +# PR - Trailcode/cross-section + +## Title + +Add temporary shape cross-section preview tool + +## Summary + +- Temporary shape cross-section preview tool (local XY/XZ/YZ + offset). +- Shape local frames for plane orientation / persistence. +- Auto-update preview from selection, plane, and offset; Options empty-selection prompt. + +## Related + +- Issue: https://github.com/trailcode/EzyCad/issues/218 +- PR: https://github.com/trailcode/EzyCad/pull/219 +- Branch: `Trailcode/cross-section` +- Plan: `agents/plans/cross-section-tool.md` + +## Test Plan + +- [ ] Desktop Release build +- [ ] `EzyCad_tests --gtest_filter=Shp_cross_section.*:Shp_test.Cross_section*` +- [ ] Manual enter / selection / plane / offset / Esc +- [ ] Docs sync checklist From 70e57f4586834fc6b0fd2227b4b3284da2d6f89b Mon Sep 17 00:00:00 2001 From: Trailcode Date: Tue, 21 Jul 2026 19:00:22 -0600 Subject: [PATCH 09/17] Improve --- CHANGELOG.md | 2 +- docs/usage-settings.md | 2 +- docs/usage.md | 2 +- src/doc/gui.md | 2 +- src/gui_mode.cpp | 12 ++++++++++-- src/shp_cross_section.cpp | 32 +++++++++++++++++++++++--------- src/shp_cross_section.h | 9 +++++++-- tests/shp_tests.cpp | 13 +++++++++++++ 8 files changed, 57 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d390875..ccb00d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Cross-section -- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center) and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. Status reports curve counts and any missed solids. +- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. ### Shape List diff --git a/docs/usage-settings.md b/docs/usage-settings.md index e3a281b..a3dc9cb 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -104,7 +104,7 @@ Under **Material**: - Document preset for new solids that do not inherit from a clicked shape (toolbar **Box**, **polar duplicate** output, and similar). To change material on an existing solid, use the [Shape List](usage.md#shape-list). -For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, a bbox-ranged **Offset** slider (Ctrl+click to type), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). +For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Invert normal**, a bbox-ranged **Offset** slider (Ctrl+click to type), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). ### Sketch tools diff --git a/docs/usage.md b/docs/usage.md index 17a5414..cea9f15 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -603,7 +603,7 @@ Use the cross-section tool to inspect where a plane cuts one or more selected so 1. Select one or more solids. 2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. -3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. +3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. 4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit, and the preview updates as you change it. 5. Changing the selection or **Section plane** (or dragging **Offset**) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. Leave the tool (or clear the selection) to remove the preview. diff --git a/src/doc/gui.md b/src/doc/gui.md index 66bd8f8..b8f938d 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -186,7 +186,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | -| `Shape_cross_section` | local XY/XZ/YZ plane, bbox-ranged offset slider (auto preview on selection / plane / offset) | +| `Shape_cross_section` | local XY/XZ/YZ, invert normal, bbox-ranged offset slider (auto preview) | | `Sketch_inspection_mode` | `options_sketch_common_` | | Each sketch tool mode | Matching `options_sketch_*_mode_` | | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 497549c..eaf994c 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -768,7 +768,9 @@ void GUI::options_shape_cross_section_mode_() auto update_preview = [&]() { const Status status = section.preview_selected(); - show_message(status.message()); + // Always rebuild above; only toast failures. Success edge-count spam on Offset drag is noise. + if (!status.is_ok()) + show_message(status.message()); }; ImGui::TextUnformatted(current_mode_description_()); @@ -795,9 +797,14 @@ void GUI::options_shape_cross_section_mode_() ImGui::RadioButton("Local YZ", &plane, static_cast(Cross_section_plane::YZ)); section.set_plane(static_cast(plane)); + bool invert_normal = section.get_invert_normal(); + if (ImGui::Checkbox("Invert normal", &invert_normal)) + section.set_invert_normal(invert_normal); + double offset_min = -1.0; double offset_max = 1.0; const bool have_range = section.try_get_offset_range_display(offset_min, offset_max); + offset = section.get_offset_display(); if (have_range) { if (offset < offset_min) @@ -841,7 +848,8 @@ void GUI::options_shape_cross_section_mode_() ImGui::TextWrapped( "All selected solids share one cutting plane. Orientation follows the first selected solid's local axes; the " "plane is centered on the selection bounding box. Offset slides across that box along the plane normal " - "(Ctrl+click the slider to type a value). The yellow plane and arrow show the cut plane and positive normal. " + "(Ctrl+click the slider to type a value). The yellow plane and arrow show the cut plane and positive normal; " + "use Invert normal to flip the arrow (offset is negated so the plane stays in place). " "Changing the selection or section plane updates the preview automatically."); options_orthographic_projection_(); } diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index bf32d7b..1224625 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -32,7 +32,7 @@ namespace { -gp_Pln cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, double offset); +gp_Pln cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, double offset, bool invert_normal); void count_curve_type_(const TopoDS_Edge& edge, Cross_section_geometry& result); bool contains_solid_(const TopoDS_Shape& shape); TopoDS_Shape shape_world_(const Shp& shp); @@ -47,7 +47,7 @@ Result cross_section_shape_on_plane_(const TopoDS_Shape& Result cross_section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Cross_section_plane plane, double offset) { - return cross_section_shape_on_plane_(shape, cross_section_plane_(frame, plane, offset)); + return cross_section_shape_on_plane_(shape, cross_section_plane_(frame, plane, offset, false)); } Shp_cross_section::Shp_cross_section(Occt_view& view) @@ -102,7 +102,7 @@ Status Shp_cross_section::preview(const std::vector& selected) shared_axes.SetLocation(gp_Pnt((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, (z_min + z_max) * 0.5)); const double offset_model = view().to_model(m_offset_display); - const gp_Pln shared_plane = cross_section_plane_(shared_axes, m_plane, offset_model); + const gp_Pln shared_plane = cross_section_plane_(shared_axes, m_plane, offset_model, m_invert_normal); TopoDS_Compound compound; TopoDS_Compound plane_fill; @@ -216,14 +216,25 @@ bool Shp_cross_section::selection_stale() const bool Shp_cross_section::preview_inputs_stale() const { - return selection_stale() || m_acked_plane != m_plane || m_acked_offset_display != m_offset_display; + return selection_stale() || m_acked_plane != m_plane || m_acked_offset_display != m_offset_display || + m_acked_invert_normal != m_invert_normal; +} + +void Shp_cross_section::set_invert_normal(bool invert) +{ + if (m_invert_normal == invert) + return; + + m_invert_normal = invert; + m_offset_display = -m_offset_display; } void Shp_cross_section::acknowledge_inputs_(const std::vector& shapes) { - m_acked_selection_ids = selection_ids_(shapes); - m_acked_plane = m_plane; - m_acked_offset_display = m_offset_display; + m_acked_selection_ids = selection_ids_(shapes); + m_acked_plane = m_plane; + m_acked_offset_display = m_offset_display; + m_acked_invert_normal = m_invert_normal; } void Shp_cross_section::acknowledge_current_selection() @@ -261,7 +272,7 @@ bool Shp_cross_section::try_get_offset_range_display(double& out_min, double& ou combined_bounds.Get(x_min, y_min, z_min, x_max, y_max, z_max); shared_axes.SetLocation(gp_Pnt((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, (z_min + z_max) * 0.5)); - const gp_Pln plane = cross_section_plane_(shared_axes, m_plane, 0.0); + const gp_Pln plane = cross_section_plane_(shared_axes, m_plane, 0.0, m_invert_normal); double model_min; double model_max; project_bbox_offsets_(combined_bounds, plane.Location(), plane.Axis().Direction(), model_min, model_max); @@ -278,7 +289,7 @@ bool Shp_cross_section::try_get_offset_range_display(double& out_min, double& ou namespace { -gp_Pln cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, double offset) +gp_Pln cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, double offset, bool invert_normal) { gp_Dir normal; gp_Dir x_dir; @@ -298,6 +309,9 @@ gp_Pln cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, doub break; } + if (invert_normal) + normal.Reverse(); + const gp_Pnt location = frame.Location().Translated(gp_Vec(normal) * offset); return gp_Pln(gp_Ax3(location, normal, x_dir)); } diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h index 627ead8..b01e33c 100644 --- a/src/shp_cross_section.h +++ b/src/shp_cross_section.h @@ -46,12 +46,15 @@ class Shp_cross_section : private Shp_operation_base void set_plane(Cross_section_plane plane) { m_plane = plane; } double get_offset_display() const { return m_offset_display; } void set_offset_display(double offset) { m_offset_display = offset; } + bool get_invert_normal() const { return m_invert_normal; } + /// Flips the cutting-plane normal (and annotation arrow). Negates offset so the plane stays put. + void set_invert_normal(bool invert); bool has_preview() const { return !m_preview.IsNull(); } /// True when the current AIS selection ids differ from the last acknowledged set. [[nodiscard]] bool selection_stale() const; - /// True when selection, section plane, or offset differ from the last preview/acknowledge. + /// True when selection, section plane, offset, or normal invert differ from the last preview/acknowledge. [[nodiscard]] bool preview_inputs_stale() const; - /// Record the current AIS selection, plane, and offset as acknowledged without rebuilding. + /// Record the current AIS selection, plane, offset, and invert as acknowledged without rebuilding. void acknowledge_current_selection(); /// Projected extent of the current selection along the shared cross-section plane normal, /// in display units. False when nothing solid is selected or bounds are empty. @@ -63,8 +66,10 @@ class Shp_cross_section : private Shp_operation_base Cross_section_plane m_plane{Cross_section_plane::XY}; double m_offset_display{0.0}; + bool m_invert_normal{false}; Cross_section_plane m_acked_plane{Cross_section_plane::XY}; double m_acked_offset_display{0.0}; + bool m_acked_invert_normal{false}; std::vector m_acked_selection_ids; AIS_Shape_ptr m_preview; AIS_Shape_ptr m_plane_fill; diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 4e4e59d..f080bfa 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -341,6 +341,19 @@ TEST_F(Shp_test, Cross_section_selection_stale_after_selection_change) ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); + double offset_min = 0.0; + double offset_max = 0.0; + ASSERT_TRUE(view().shp_cross_section().try_get_offset_range_display(offset_min, offset_max)); + const double sample_offset = (offset_min + offset_max) * 0.25; + view().shp_cross_section().set_offset_display(sample_offset); + ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); + view().shp_cross_section().set_invert_normal(true); + EXPECT_TRUE(view().shp_cross_section().get_invert_normal()); + EXPECT_NEAR(view().shp_cross_section().get_offset_display(), -sample_offset, 1e-9); + EXPECT_TRUE(view().shp_cross_section().preview_inputs_stale()); + ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); + EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); + view().shp_cross_section().clear(); view().shp_cross_section().acknowledge_current_selection(); EXPECT_FALSE(view().shp_cross_section().has_preview()); From cdc0ac96e0c12023a71ddb1edad405afa5a90229 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Wed, 22 Jul 2026 18:05:43 -0600 Subject: [PATCH 10/17] WASM fix. --- CHANGELOG.md | 5 +++++ agents/conventions/occt-wasm-dual-version.md | 2 ++ src/gui.cpp | 3 +++ src/gui_occt_view.cpp | 12 +++++++++--- src/gui_occt_view.h | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb00d9..d5e574b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- WASM (OCCT 7.9.3): stop forcing a near-white AIS `SetColor` after every material apply. That OCCT 8 GLES workaround had been running on all Emscripten builds and made steel, gold, and other presets look identical (glass still showed transparency). +- WASM: clear AIS `OwnColor` when applying a Shape List material so presets are not stuck on a forced color. Prefer the **OCCT 7.9.3** wasm kit — OCCT 8.x GLES still has known shading regressions (`docs/building-occt.md`). + ### Cross-section - Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. diff --git a/agents/conventions/occt-wasm-dual-version.md b/agents/conventions/occt-wasm-dual-version.md index cb15bb7..4bc6262 100644 --- a/agents/conventions/occt-wasm-dual-version.md +++ b/agents/conventions/occt-wasm-dual-version.md @@ -10,6 +10,8 @@ | WASM (Emscripten) | **7.9.3** (`V7_9_3`) recommended | `scripts/build-occt-793-wasm.ps1` | | WASM (compare) | 8.0.0.p1 (`V8_0_0_p1`) experimental | Shading regressions; not for demos/release | +Do **not** apply OCCT 8 GLES workarounds unconditionally under `__EMSCRIPTEN__`. Example: `Occt_view::refresh_shape_shading_` must not force a fixed AIS `SetColor` on 7.9.3 (washes out material presets). Gate with `OCC_VERSION_HEX >= 0x080000`, and when a color is required on OCCT 8 prefer the material's own color. + Details: [docs/building-occt.md](../../docs/building-occt.md), [workflows/local-dev.md](../workflows/local-dev.md). ## Shared `src/` must compile on both diff --git a/src/gui.cpp b/src/gui.cpp index 701f18d..218cec7 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -2571,6 +2571,9 @@ void GUI::shape_list_() if (is_group || i < 0 || i >= nmat) return; + // OwnColor (e.g. from older wasm SetColor workarounds) overrides material presets. + if (shape->HasColor()) + shape->UnsetColor(); shape->SetMaterial(Graphic3d_MaterialAspect(static_cast(i))); m_view->refresh_shape_shading_(shape); m_view->ctx().Redisplay(shape, true); diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 8a7c4a2..722e64b 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -926,8 +927,14 @@ void Occt_view::refresh_shape_shading_(const Shp_ptr& shp) return; #ifdef __EMSCRIPTEN__ - // OCCT 8 GLES: Phong needs explicit color; UNLIT fallback was removed in 8.0. - shp->SetColor(Quantity_Color(0.78, 0.78, 0.80, Quantity_TOC_RGB)); +#if OCC_VERSION_HEX >= 0x080000 + // OCCT 8 GLES: AIS OwnColor overrides Graphic3d material presets, so Shape List + // picks (Copper, Gold, ...) never change the shaded look. Clear any forced color and + // let SetMaterial drive appearance. Broader OCCT 8 wasm shading regressions remain — + // recommended kit is still 7.9.3 (docs/building-occt.md). + if (shp->HasColor()) + shp->UnsetColor(); +#endif const Prs3d_Drawer_ptr& drawer = shp->Attributes(); if (!drawer.IsNull()) @@ -947,7 +954,6 @@ void Occt_view::refresh_shape_shading_(const Shp_ptr& shp) } #endif } - void Occt_view::add_shp_(Shp_ptr& shp, bool use_current_group) { if (shp->get_id() == 0) diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h index b9cf990..2ae24bf 100644 --- a/src/gui_occt_view.h +++ b/src/gui_occt_view.h @@ -343,7 +343,7 @@ class Occt_view : protected AIS_ViewController // Material related const Graphic3d_MaterialAspect& get_default_material() const; void set_default_material(const Graphic3d_MaterialAspect& mat); - /// Apply default material/color and wasm GLES presentation fixes; call before Redisplay. + /// Apply wasm presentation fixes after SetMaterial (OCCT 8 GLES color/back-face); call before Redisplay. void refresh_shape_shading_(const Shp_ptr& shp); // View presentation (background gradient) and grid colors (0-1 RGB) From b94bdeecefa8c134930ff1f3a285d87fc9f64814 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Wed, 22 Jul 2026 18:31:10 -0600 Subject: [PATCH 11/17] Improve. --- CHANGELOG.md | 2 +- docs/usage-settings.md | 2 +- docs/usage.md | 4 +-- src/doc/gui.md | 4 +-- src/doc/shape.md | 6 ++--- src/gui_mode.cpp | 10 +++----- src/shp_cross_section.cpp | 52 +++++++++++++++++++++++++++++++++++---- src/shp_cross_section.h | 34 ++++++++++++++++--------- src/utl_types.h | 2 ++ tests/shp_tests.cpp | 9 +++++++ 10 files changed, 93 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e574b..ee29e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Cross-section -- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. +- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), **Hide back side** (clips selected solids so geometry opposite the yellow arrow is not drawn), and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. ### Shape List diff --git a/docs/usage-settings.md b/docs/usage-settings.md index a3dc9cb..5529cd1 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -104,7 +104,7 @@ Under **Material**: - Document preset for new solids that do not inherit from a clicked shape (toolbar **Box**, **polar duplicate** output, and similar). To change material on an existing solid, use the [Shape List](usage.md#shape-list). -For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Invert normal**, a bbox-ranged **Offset** slider (Ctrl+click to type), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). +For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Invert normal**, **Hide back side**, a bbox-ranged **Offset** slider (Ctrl+click to type), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). ### Sketch tools diff --git a/docs/usage.md b/docs/usage.md index cea9f15..31b4d5f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -603,9 +603,9 @@ Use the cross-section tool to inspect where a plane cuts one or more selected so 1. Select one or more solids. 2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. -3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. +3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. Enable **Hide back side** to clip the selected solids so geometry on the negative-normal side of the plane is not drawn (the side opposite the yellow arrow). 4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit, and the preview updates as you change it. -5. Changing the selection or **Section plane** (or dragging **Offset**) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. Leave the tool (or clear the selection) to remove the preview. +5. Changing the selection or **Section plane** (or dragging **Offset**) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. Leave the tool (or clear the selection) to remove the preview and any back-side clipping. Each solid has a local frame used for orientation. New solids start with a world-aligned frame at the center of their bounding box; moving, rotating, or scaling a solid updates that frame. With multiple selected solids, all of them share one cutting plane: orientation from the first selected solid's local axes, and origin at the selection bounding-box center. Offset moves that shared plane along its normal. diff --git a/src/doc/gui.md b/src/doc/gui.md index b8f938d..5902cb8 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -186,7 +186,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | -| `Shape_cross_section` | local XY/XZ/YZ, invert normal, bbox-ranged offset slider (auto preview) | +| `Shape_cross_section` | local XY/XZ/YZ, invert normal, hide back side, bbox-ranged offset slider (auto preview) | | `Sketch_inspection_mode` | `options_sketch_common_` | | Each sketch tool mode | Matching `options_sketch_*_mode_` | | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | @@ -235,7 +235,7 @@ Toolbar buttons hold `std::variant`. `Command` (`Shape_cut`, `Sha Mode buttons call `set_mode`. Active state tracks `m_mode`. -The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. While the mode is active, Options applies plane/offset widgets then polls `preview_inputs_stale` (selection ids, plane, or offset) and rebuilds or clears the preview. The temporary AIS wire result is cleared when the mode is left, when the selection becomes empty, or before a failed/updated preview. +The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. While the mode is active, Options applies plane/offset/hide-back widgets then polls `preview_inputs_stale` (selection ids, plane, offset, invert, or hide-back) and rebuilds or clears the preview. The temporary AIS wire result and any per-shape `Graphic3d_ClipPlane` attachments are cleared when the mode is left, when the selection becomes empty, or before a failed/updated preview. ## Typical developer usage diff --git a/src/doc/shape.md b/src/doc/shape.md index 9fc60a1..ccdb438 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -23,7 +23,7 @@ Typical uses: - Extrude a sketch face into a solid; revolve sketch geometry (revolve lives in `skt_operations.cpp`, returns `Shp_rslt`). - Boolean fuse/cut/common on selected shapes. - Interactive move, rotate, and scale with preview transforms. -- Preview cross-sections on a shape-local XY, XZ, or YZ plane. +- Preview cross-sections on a shape-local XY, XZ, or YZ plane (optional hide of the back half). - Fillet/chamfer by shape, face, wire, or edge pick mode. - Polar duplicate selected shapes about an arm on the current sketch plane. @@ -151,7 +151,7 @@ Protected helpers used by all operation classes: | `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. | -| `shp_cross_section.h` | `Shp_cross_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation. | +| `shp_cross_section.h` | `Shp_cross_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation; optional per-shape `Graphic3d_ClipPlane` to hide the negative-normal half. | | `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | ## Input routing (from UI / `Occt_view`) @@ -167,7 +167,7 @@ Protected helpers used by all operation classes: | `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 | -| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter / selection / Options plane & offset | Preview cleared | +| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter / selection / Options plane, offset, hide-back | Preview and clips cleared | | Fuse / cut / common (toolbar) | -- | `selected_fuse` / `selected_cut` / `selected_common` (one-shot) | -- | -- | | Primitives (menu / script) | -- | `Occt_view::add_box`, `add_sphere`, ... | -- | -- | | Revolve (sketch Options) | -- | `Occt_view::revolve_selected` -> `add_shp_` | -- | -- | diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index eaf994c..09f60da 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -801,6 +801,10 @@ void GUI::options_shape_cross_section_mode_() if (ImGui::Checkbox("Invert normal", &invert_normal)) section.set_invert_normal(invert_normal); + bool hide_back_side = section.get_hide_back_side(); + if (ImGui::Checkbox("Hide back side", &hide_back_side)) + section.set_hide_back_side(hide_back_side); + double offset_min = -1.0; double offset_max = 1.0; const bool have_range = section.try_get_offset_range_display(offset_min, offset_max); @@ -845,12 +849,6 @@ void GUI::options_shape_cross_section_mode_() update_preview(); } - ImGui::TextWrapped( - "All selected solids share one cutting plane. Orientation follows the first selected solid's local axes; the " - "plane is centered on the selection bounding box. Offset slides across that box along the plane normal " - "(Ctrl+click the slider to type a value). The yellow plane and arrow show the cut plane and positive normal; " - "use Invert normal to flip the arrow (offset is negated so the plane stays in place). " - "Changing the selection or section plane updates the preview automatically."); options_orthographic_projection_(); } diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index 1224625..602d431 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -142,6 +143,9 @@ Status Shp_cross_section::preview(const std::vector& selected) if (totals.edge_count == 0) return Status::user_error("The section plane does not intersect the selection."); + if (m_hide_back_side) + apply_clips_(selected, shared_plane); + add_plane_annotation_(combined_bounds, shared_plane, builder, plane_fill, plane_lines); m_preview = new AIS_Shape(compound); @@ -184,6 +188,8 @@ Status Shp_cross_section::preview(const std::vector& selected) void Shp_cross_section::clear() { + clear_clips_(); + if (!m_preview.IsNull()) ctx().Remove(m_preview, false); @@ -199,6 +205,41 @@ void Shp_cross_section::clear() ctx().UpdateCurrentViewer(); } +void Shp_cross_section::clear_clips_() +{ + if (!m_clip_plane.IsNull()) + { + for (const Shp_ptr& shp : m_clipped_shapes) + { + if (shp.IsNull()) + continue; + + shp->RemoveClipPlane(m_clip_plane); + ctx().Redisplay(shp, false); + } + } + + m_clipped_shapes.clear(); + m_clip_plane.Nullify(); +} + +void Shp_cross_section::apply_clips_(const std::vector& shapes, const gp_Pln& plane) +{ + clear_clips_(); + m_clip_plane = new Graphic3d_ClipPlane(plane); + m_clip_plane->SetOn(true); + m_clipped_shapes.reserve(shapes.size()); + for (const Shp_ptr& shp : shapes) + { + if (shp.IsNull() || shp->is_group()) + continue; + + shp->AddClipPlane(m_clip_plane); + ctx().Redisplay(shp, false); + m_clipped_shapes.push_back(shp); + } +} + std::vector Shp_cross_section::selection_ids_(const std::vector& shapes) { std::vector ids; @@ -217,7 +258,7 @@ bool Shp_cross_section::selection_stale() const bool Shp_cross_section::preview_inputs_stale() const { return selection_stale() || m_acked_plane != m_plane || m_acked_offset_display != m_offset_display || - m_acked_invert_normal != m_invert_normal; + m_acked_invert_normal != m_invert_normal || m_acked_hide_back_side != m_hide_back_side; } void Shp_cross_section::set_invert_normal(bool invert) @@ -231,10 +272,11 @@ void Shp_cross_section::set_invert_normal(bool invert) void Shp_cross_section::acknowledge_inputs_(const std::vector& shapes) { - m_acked_selection_ids = selection_ids_(shapes); - m_acked_plane = m_plane; - m_acked_offset_display = m_offset_display; - m_acked_invert_normal = m_invert_normal; + m_acked_selection_ids = selection_ids_(shapes); + m_acked_plane = m_plane; + m_acked_offset_display = m_offset_display; + m_acked_invert_normal = m_invert_normal; + m_acked_hide_back_side = m_hide_back_side; } void Shp_cross_section::acknowledge_current_selection() diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h index b01e33c..eaeb364 100644 --- a/src/shp_cross_section.h +++ b/src/shp_cross_section.h @@ -3,6 +3,7 @@ #include "shp_operation.h" #include +#include #include #include @@ -49,12 +50,15 @@ class Shp_cross_section : private Shp_operation_base bool get_invert_normal() const { return m_invert_normal; } /// Flips the cutting-plane normal (and annotation arrow). Negates offset so the plane stays put. void set_invert_normal(bool invert); + bool get_hide_back_side() const { return m_hide_back_side; } + /// When true, AIS clip planes hide selected-solid geometry on the negative-normal side. + void set_hide_back_side(bool hide) { m_hide_back_side = hide; } bool has_preview() const { return !m_preview.IsNull(); } /// True when the current AIS selection ids differ from the last acknowledged set. [[nodiscard]] bool selection_stale() const; - /// True when selection, section plane, offset, or normal invert differ from the last preview/acknowledge. + /// True when selection, section plane, offset, normal invert, or hide-back differ from the last preview/acknowledge. [[nodiscard]] bool preview_inputs_stale() const; - /// Record the current AIS selection, plane, offset, and invert as acknowledged without rebuilding. + /// Record the current AIS selection, plane, offset, invert, and hide-back as acknowledged without rebuilding. void acknowledge_current_selection(); /// Projected extent of the current selection along the shared cross-section plane normal, /// in display units. False when nothing solid is selected or bounds are empty. @@ -63,15 +67,21 @@ class Shp_cross_section : private Shp_operation_base private: static std::vector selection_ids_(const std::vector& shapes); void acknowledge_inputs_(const std::vector& shapes); + void clear_clips_(); + void apply_clips_(const std::vector& shapes, const gp_Pln& plane); - Cross_section_plane m_plane{Cross_section_plane::XY}; - double m_offset_display{0.0}; - bool m_invert_normal{false}; - Cross_section_plane m_acked_plane{Cross_section_plane::XY}; - double m_acked_offset_display{0.0}; - bool m_acked_invert_normal{false}; - std::vector m_acked_selection_ids; - AIS_Shape_ptr m_preview; - AIS_Shape_ptr m_plane_fill; - AIS_Shape_ptr m_plane_lines; + Cross_section_plane m_plane{Cross_section_plane::XY}; + double m_offset_display{0.0}; + bool m_invert_normal{false}; + bool m_hide_back_side{false}; + Cross_section_plane m_acked_plane{Cross_section_plane::XY}; + double m_acked_offset_display{0.0}; + bool m_acked_invert_normal{false}; + bool m_acked_hide_back_side{false}; + std::vector m_acked_selection_ids; + AIS_Shape_ptr m_preview; + AIS_Shape_ptr m_plane_fill; + AIS_Shape_ptr m_plane_lines; + Graphic3d_ClipPlane_ptr m_clip_plane; + std::vector m_clipped_shapes; }; diff --git a/src/utl_types.h b/src/utl_types.h index a91de87..f88c63a 100644 --- a/src/utl_types.h +++ b/src/utl_types.h @@ -31,6 +31,7 @@ class Graphic3d_AspectFillArea3d; class Graphic3d_AspectText3d; class Graphic3d_Camera; class Graphic3d_CLight; +class Graphic3d_ClipPlane; class Image_PixMap; class Occt_glfw_win; class OpenGl_GraphicDriver; @@ -77,6 +78,7 @@ using Graphic3d_AspectFillArea3d_ptr = opencascade::handle; using Graphic3d_Camera_ptr = opencascade::handle; using Graphic3d_CLight_ptr = opencascade::handle; +using Graphic3d_ClipPlane_ptr = opencascade::handle; using Image_PixMap_ptr = opencascade::handle; using Occt_glfw_win_ptr = opencascade::handle; using OpenGl_GraphicDriver_ptr = opencascade::handle; diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index f080bfa..f7d92fd 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -354,10 +354,19 @@ TEST_F(Shp_test, Cross_section_selection_stale_after_selection_change) ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); + view().shp_cross_section().set_hide_back_side(true); + EXPECT_TRUE(view().shp_cross_section().get_hide_back_side()); + EXPECT_TRUE(view().shp_cross_section().preview_inputs_stale()); + ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); + EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); + EXPECT_FALSE(boxes[1]->ClipPlanes().IsNull()); + EXPECT_EQ(boxes[1]->ClipPlanes()->Size(), 1); + view().shp_cross_section().clear(); view().shp_cross_section().acknowledge_current_selection(); EXPECT_FALSE(view().shp_cross_section().has_preview()); EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); + EXPECT_TRUE(boxes[1]->ClipPlanes().IsNull() || boxes[1]->ClipPlanes()->IsEmpty()); } From 578bda81358b907b0628f7dfbdc8b6dd08278175 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Wed, 22 Jul 2026 18:41:49 -0600 Subject: [PATCH 12/17] Clip button --- CHANGELOG.md | 2 +- docs/usage-settings.md | 2 +- docs/usage.md | 5 +- src/doc/gui.md | 4 +- src/doc/shape.md | 6 +- src/gui_mode.cpp | 8 ++ src/shp_cross_section.cpp | 277 ++++++++++++++++++++++++++++---------- src/shp_cross_section.h | 25 +++- tests/shp_tests.cpp | 15 ++- 9 files changed, 257 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee29e7e..54e2ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Cross-section -- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), **Hide back side** (clips selected solids so geometry opposite the yellow arrow is not drawn), and a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units; a translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. +- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), **Hide back side** (display-only AIS clip opposite the yellow arrow), a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units, and **Clip** (half-space cut: keeps the positive-normal side, deletes the originals, adds clipped replacements; undoable). A translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. ### Shape List diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 5529cd1..9a127b4 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -104,7 +104,7 @@ Under **Material**: - Document preset for new solids that do not inherit from a clicked shape (toolbar **Box**, **polar duplicate** output, and similar). To change material on an existing solid, use the [Shape List](usage.md#shape-list). -For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Invert normal**, **Hide back side**, a bbox-ranged **Offset** slider (Ctrl+click to type), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). +For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Invert normal**, **Hide back side**, a bbox-ranged **Offset** slider (Ctrl+click to type), **Clip** (half-space cut, replaces selection), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). ### Sketch tools diff --git a/docs/usage.md b/docs/usage.md index 31b4d5f..9007b46 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -603,9 +603,10 @@ Use the cross-section tool to inspect where a plane cuts one or more selected so 1. Select one or more solids. 2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. -3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. Enable **Hide back side** to clip the selected solids so geometry on the negative-normal side of the plane is not drawn (the side opposite the yellow arrow). +3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. Enable **Hide back side** to preview-clip the selected solids so geometry on the negative-normal side of the plane is not drawn (opposite the yellow arrow). 4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit, and the preview updates as you change it. -5. Changing the selection or **Section plane** (or dragging **Offset**) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. Leave the tool (or clear the selection) to remove the preview and any back-side clipping. +5. Click **Clip** to keep the positive-normal half of each selected solid as a new shape and delete the originals (undoable). Leave the tool (or clear the selection) to remove the preview and any hide-back display clip. +6. Changing the selection or **Section plane** (or dragging **Offset**) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. Each solid has a local frame used for orientation. New solids start with a world-aligned frame at the center of their bounding box; moving, rotating, or scaling a solid updates that frame. With multiple selected solids, all of them share one cutting plane: orientation from the first selected solid's local axes, and origin at the selection bounding-box center. Offset moves that shared plane along its normal. diff --git a/src/doc/gui.md b/src/doc/gui.md index 5902cb8..f66f53f 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -186,7 +186,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | -| `Shape_cross_section` | local XY/XZ/YZ, invert normal, hide back side, bbox-ranged offset slider (auto preview) | +| `Shape_cross_section` | local XY/XZ/YZ, invert normal, hide back side, bbox-ranged offset, Clip (replace) | | `Sketch_inspection_mode` | `options_sketch_common_` | | Each sketch tool mode | Matching `options_sketch_*_mode_` | | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | @@ -235,7 +235,7 @@ Toolbar buttons hold `std::variant`. `Command` (`Shape_cut`, `Sha Mode buttons call `set_mode`. Active state tracks `m_mode`. -The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. While the mode is active, Options applies plane/offset/hide-back widgets then polls `preview_inputs_stale` (selection ids, plane, offset, invert, or hide-back) and rebuilds or clears the preview. The temporary AIS wire result and any per-shape `Graphic3d_ClipPlane` attachments are cleared when the mode is left, when the selection becomes empty, or before a failed/updated preview. +The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. While the mode is active, Options applies plane/offset/hide-back widgets then polls `preview_inputs_stale` (selection ids, plane, offset, invert, or hide-back) and rebuilds or clears the preview. **Hide back side** attaches a temporary per-shape `Graphic3d_ClipPlane` for display only. **Clip** runs a half-space `BRepAlgoAPI_Common`, deletes the input solids, and adds clipped replacements (`Shape_replace_delta`). The temporary AIS wire result and any display clip planes are cleared when the mode is left, when the selection becomes empty, after a successful **Clip**, or before a failed/updated preview. ## Typical developer usage diff --git a/src/doc/shape.md b/src/doc/shape.md index ccdb438..83f2041 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -23,7 +23,7 @@ Typical uses: - Extrude a sketch face into a solid; revolve sketch geometry (revolve lives in `skt_operations.cpp`, returns `Shp_rslt`). - Boolean fuse/cut/common on selected shapes. - Interactive move, rotate, and scale with preview transforms. -- Preview cross-sections on a shape-local XY, XZ, or YZ plane (optional hide of the back half). +- Preview cross-sections on a shape-local XY, XZ, or YZ plane (optional hide-back preview; **Clip** commits a half-space cut). - Fillet/chamfer by shape, face, wire, or edge pick mode. - Polar duplicate selected shapes about an arm on the current sketch plane. @@ -151,7 +151,7 @@ Protected helpers used by all operation classes: | `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. | -| `shp_cross_section.h` | `Shp_cross_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation; optional per-shape `Graphic3d_ClipPlane` to hide the negative-normal half. | +| `shp_cross_section.h` | `Shp_cross_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation; optional AIS `Graphic3d_ClipPlane` (**Hide back side**); **Clip** half-space-commons each solid and replaces the inputs. | | `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | ## Input routing (from UI / `Occt_view`) @@ -167,7 +167,7 @@ Protected helpers used by all operation classes: | `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 | -| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter / selection / Options plane, offset, hide-back | Preview and clips cleared | +| `Mode::Shape_cross_section` | -- | -- | Auto-preview on enter / selection / Options; Clip replaces solids | Preview cleared; Clip commits | | Fuse / cut / common (toolbar) | -- | `selected_fuse` / `selected_cut` / `selected_common` (one-shot) | -- | -- | | Primitives (menu / script) | -- | `Occt_view::add_box`, `add_sphere`, ... | -- | -- | | Revolve (sketch Options) | -- | `Occt_view::revolve_selected` -> `add_shp_` | -- | -- | diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 09f60da..3b75d03 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -849,6 +849,14 @@ void GUI::options_shape_cross_section_mode_() update_preview(); } + ImGui::BeginDisabled(!have_selection); + if (ImGui::Button("Clip")) + { + const Status status = section.clip_selected(); + show_message(status.message()); + } + ImGui::EndDisabled(); + options_orthographic_projection_(); } diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index 602d431..b7cdccc 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -1,15 +1,18 @@ #include "shp_cross_section.h" #include "gui_occt_view.h" +#include "shp_delta.h" #include "utl_occt.h" #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -21,6 +24,8 @@ #include #include #include +#include +#include #include #include @@ -41,12 +46,16 @@ gp_Ax3 frame_world_(const Shp& shp); bool append_bounds_(const TopoDS_Shape& shape, Bnd_Box& bounds); std::array bbox_corners_(const Bnd_Box& bounds); void project_bbox_offsets_(const Bnd_Box& bounds, const gp_Pnt& origin, const gp_Dir& normal, double& out_min, double& out_max); +void project_bbox_uv_(const Bnd_Box& bounds, const gp_Pln& plane, double& u_min, double& u_max, double& v_min, double& v_max); void add_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, TopoDS_Compound& lines); Result cross_section_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane); +Result keep_half_space_(const gp_Pln& plane, const Bnd_Box& bounds); +Result clip_solid_to_half_space_(const TopoDS_Shape& world_shape, const TopoDS_Solid& half_space); } // namespace -Result cross_section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Cross_section_plane plane, double offset) +Result cross_section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Cross_section_plane plane, + double offset) { return cross_section_shape_on_plane_(shape, cross_section_plane_(frame, plane, offset, false)); } @@ -56,35 +65,33 @@ Shp_cross_section::Shp_cross_section(Occt_view& view) { } -Status Shp_cross_section::preview_selected() -{ - return preview(get_selected_shps_()); -} +Status Shp_cross_section::preview_selected() { return preview(get_selected_shps_()); } -Status Shp_cross_section::preview(const std::vector& selected) +Result Shp_cross_section::build_shared_plane_(const std::vector& shapes) { - clear(); - acknowledge_inputs_(selected); if (!std::isfinite(m_offset_display)) - return Status::user_error("Section offset must be a finite number."); + return {Result_status::User_error, "Section offset must be a finite number."}; - if (selected.empty()) - return Status::user_error("Select one or more solid shapes."); + if (shapes.empty()) + return {Result_status::User_error, "Select one or more solid shapes."}; - std::vector world_shapes; - world_shapes.reserve(selected.size()); - Bnd_Box combined_bounds; - gp_Ax3 shared_axes; - bool have_axes = false; + Shared_plane shared; + shared.shapes.reserve(shapes.size()); + shared.world_shapes.reserve(shapes.size()); + gp_Ax3 shared_axes; + bool have_axes = false; - for (const Shp_ptr& shp : selected) + for (const Shp_ptr& shp : shapes) { + if (shp.IsNull() || shp->is_group()) + continue; + TopoDS_Shape world_shape = shape_world_(*shp); if (!contains_solid_(world_shape)) - return Status::user_error(shp->get_name() + ": Cross-section supports solid shapes only."); + return {Result_status::User_error, shp->get_name() + ": Cross-section supports solid shapes only."}; - if (!append_bounds_(world_shape, combined_bounds)) - return Status::user_error(shp->get_name() + ": Shape has empty bounds."); + if (!append_bounds_(world_shape, shared.bounds)) + return {Result_status::User_error, shp->get_name() + ": Shape has empty bounds."}; if (!have_axes) { @@ -92,18 +99,33 @@ Status Shp_cross_section::preview(const std::vector& selected) have_axes = true; } - world_shapes.push_back(std::move(world_shape)); + shared.shapes.push_back(shp); + shared.world_shapes.push_back(std::move(world_shape)); } - if (combined_bounds.IsVoid() || !have_axes) - return Status::user_error("Select one or more solid shapes."); + if (shared.shapes.empty() || shared.bounds.IsVoid() || !have_axes) + return {Result_status::User_error, "Select one or more solid shapes."}; double x_min, y_min, z_min, x_max, y_max, z_max; - combined_bounds.Get(x_min, y_min, z_min, x_max, y_max, z_max); + shared.bounds.Get(x_min, y_min, z_min, x_max, y_max, z_max); shared_axes.SetLocation(gp_Pnt((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, (z_min + z_max) * 0.5)); const double offset_model = view().to_model(m_offset_display); - const gp_Pln shared_plane = cross_section_plane_(shared_axes, m_plane, offset_model, m_invert_normal); + shared.plane = cross_section_plane_(shared_axes, m_plane, offset_model, m_invert_normal); + return shared; +} + +Status Shp_cross_section::preview(const std::vector& selected) +{ + clear_preview_(); + clear_ais_clips_(); + acknowledge_inputs_(selected); + + Result shared = build_shared_plane_(selected); + if (!shared.has_value()) + return Status(shared.status(), shared.message()); + + const Shared_plane& plane_ctx = *shared; TopoDS_Compound compound; TopoDS_Compound plane_fill; @@ -114,10 +136,10 @@ Status Shp_cross_section::preview(const std::vector& selected) builder.MakeCompound(plane_lines); Cross_section_geometry totals; - size_t missed = 0; - for (size_t i = 0; i < selected.size(); ++i) + size_t missed = 0; + for (size_t i = 0; i < plane_ctx.shapes.size(); ++i) { - Result result = cross_section_shape_on_plane_(world_shapes[i], shared_plane); + Result result = cross_section_shape_on_plane_(plane_ctx.world_shapes[i], plane_ctx.plane); if (!result.has_value()) { // A miss on one solid should not kill the shared preview for the others. @@ -127,7 +149,7 @@ Status Shp_cross_section::preview(const std::vector& selected) continue; } - return Status(result.status(), selected[i]->get_name() + ": " + result.message()); + return Status(result.status(), plane_ctx.shapes[i]->get_name() + ": " + result.message()); } const Cross_section_geometry& geometry = *result; @@ -144,9 +166,9 @@ Status Shp_cross_section::preview(const std::vector& selected) return Status::user_error("The section plane does not intersect the selection."); if (m_hide_back_side) - apply_clips_(selected, shared_plane); + apply_ais_clips_(plane_ctx.shapes, plane_ctx.plane); - add_plane_annotation_(combined_bounds, shared_plane, builder, plane_fill, plane_lines); + add_plane_annotation_(plane_ctx.bounds, plane_ctx.plane, builder, plane_fill, plane_lines); m_preview = new AIS_Shape(compound); m_preview->SetColor(Quantity_NOC_CYAN); @@ -186,10 +208,66 @@ Status Shp_cross_section::preview(const std::vector& selected) return Status::ok(msg.str()); } +Status Shp_cross_section::clip_selected() { return clip(get_selected_shps_()); } + +Status Shp_cross_section::clip(const std::vector& shapes) +{ + Result shared = build_shared_plane_(shapes); + if (!shared.has_value()) + return Status(shared.status(), shared.message()); + + const Shared_plane& plane_ctx = *shared; + Result half = keep_half_space_(plane_ctx.plane, plane_ctx.bounds); + if (!half.has_value()) + return Status(half.status(), half.message()); + + std::vector clipped_geoms; + clipped_geoms.reserve(plane_ctx.shapes.size()); + for (size_t i = 0; i < plane_ctx.shapes.size(); ++i) + { + Result clipped = clip_solid_to_half_space_(plane_ctx.world_shapes[i], *half); + if (!clipped.has_value()) + return Status(clipped.status(), plane_ctx.shapes[i]->get_name() + ": " + clipped.message()); + + clipped_geoms.push_back(std::move(*clipped)); + } + + std::vector removed; + removed.reserve(plane_ctx.shapes.size()); + for (const Shp_ptr& shp : plane_ctx.shapes) + removed.push_back(capture_shape_rec(*shp)); + + std::vector added; + added.reserve(plane_ctx.shapes.size()); + m_shps = plane_ctx.shapes; + + for (size_t i = 0; i < plane_ctx.shapes.size(); ++i) + { + const Shp_ptr& old_shp = plane_ctx.shapes[i]; + Shp_ptr new_shp = new Shp(ctx(), clipped_geoms[i]); + new_shp->set_name(old_shp->get_name()); + new_shp->set_frame(frame_world_(*old_shp)); + assign_result_parent_(new_shp, std::vector{old_shp}); + add_shp_(new_shp); + copy_shape_material_from_(new_shp, old_shp); + added.push_back(capture_shape_rec(*new_shp)); + } + + delete_operation_shps_(); + view().push_undo_delta(std::make_unique(std::move(removed), std::move(added))); + clear(); + return Status::ok("Clipped " + std::to_string(added.size()) + (added.size() == 1 ? " shape." : " shapes.")); +} + void Shp_cross_section::clear() { - clear_clips_(); + clear_preview_(); + clear_ais_clips_(); + ctx().UpdateCurrentViewer(); +} +void Shp_cross_section::clear_preview_() +{ if (!m_preview.IsNull()) ctx().Remove(m_preview, false); @@ -202,41 +280,40 @@ void Shp_cross_section::clear() m_preview.Nullify(); m_plane_fill.Nullify(); m_plane_lines.Nullify(); - ctx().UpdateCurrentViewer(); } -void Shp_cross_section::clear_clips_() +void Shp_cross_section::clear_ais_clips_() { - if (!m_clip_plane.IsNull()) + if (!m_ais_clip_plane.IsNull()) { - for (const Shp_ptr& shp : m_clipped_shapes) + for (const Shp_ptr& shp : m_ais_clipped_shapes) { if (shp.IsNull()) continue; - shp->RemoveClipPlane(m_clip_plane); + shp->RemoveClipPlane(m_ais_clip_plane); ctx().Redisplay(shp, false); } } - m_clipped_shapes.clear(); - m_clip_plane.Nullify(); + m_ais_clipped_shapes.clear(); + m_ais_clip_plane.Nullify(); } -void Shp_cross_section::apply_clips_(const std::vector& shapes, const gp_Pln& plane) +void Shp_cross_section::apply_ais_clips_(const std::vector& shapes, const gp_Pln& plane) { - clear_clips_(); - m_clip_plane = new Graphic3d_ClipPlane(plane); - m_clip_plane->SetOn(true); - m_clipped_shapes.reserve(shapes.size()); + clear_ais_clips_(); + m_ais_clip_plane = new Graphic3d_ClipPlane(plane); + m_ais_clip_plane->SetOn(true); + m_ais_clipped_shapes.reserve(shapes.size()); for (const Shp_ptr& shp : shapes) { if (shp.IsNull() || shp->is_group()) continue; - shp->AddClipPlane(m_clip_plane); + shp->AddClipPlane(m_ais_clip_plane); ctx().Redisplay(shp, false); - m_clipped_shapes.push_back(shp); + m_ais_clipped_shapes.push_back(shp); } } @@ -250,10 +327,7 @@ std::vector Shp_cross_section::selection_ids_(const std::vector& shapes) { - m_acked_selection_ids = selection_ids_(shapes); - m_acked_plane = m_plane; - m_acked_offset_display = m_offset_display; - m_acked_invert_normal = m_invert_normal; - m_acked_hide_back_side = m_hide_back_side; + m_acked_selection_ids = selection_ids_(shapes); + m_acked_plane = m_plane; + m_acked_offset_display = m_offset_display; + m_acked_invert_normal = m_invert_normal; + m_acked_hide_back_side = m_hide_back_side; } -void Shp_cross_section::acknowledge_current_selection() -{ - acknowledge_inputs_(get_selected_shps_()); -} +void Shp_cross_section::acknowledge_current_selection() { acknowledge_inputs_(get_selected_shps_()); } bool Shp_cross_section::try_get_offset_range_display(double& out_min, double& out_max) { @@ -474,19 +545,15 @@ void project_bbox_offsets_(const Bnd_Box& bounds, const gp_Pnt& origin, const gp } } -void add_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, - TopoDS_Compound& lines) +void project_bbox_uv_(const Bnd_Box& bounds, const gp_Pln& plane, double& u_min, double& u_max, double& v_min, double& v_max) { - if (bounds.IsVoid()) - return; - const gp_Pnt origin = plane.Location(); const gp_Vec x_axis(plane.XAxis().Direction()); const gp_Vec y_axis(plane.YAxis().Direction()); - double u_min = std::numeric_limits::max(); - double u_max = std::numeric_limits::lowest(); - double v_min = std::numeric_limits::max(); - double v_max = std::numeric_limits::lowest(); + u_min = std::numeric_limits::max(); + u_max = std::numeric_limits::lowest(); + v_min = std::numeric_limits::max(); + v_max = std::numeric_limits::lowest(); for (const gp_Pnt& corner : bbox_corners_(bounds)) { const gp_Vec relative(origin, corner); @@ -497,6 +564,76 @@ void add_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane, BRep_Buil v_min = std::min(v_min, v); v_max = std::max(v_max, v); } +} + +Result keep_half_space_(const gp_Pln& plane, const Bnd_Box& bounds) +{ + if (bounds.IsVoid()) + return {Result_status::User_error, "Cannot build a clipping half-space from empty bounds."}; + + double u_min, u_max, v_min, v_max; + project_bbox_uv_(bounds, plane, u_min, u_max, v_min, v_max); + const double width = u_max - u_min; + const double height = v_max - v_min; + const double margin = std::max(1.0, std::max(width, height) * 0.5); + u_min -= margin; + u_max += margin; + v_min -= margin; + v_max += margin; + + try + { + const TopoDS_Face face = BRepBuilderAPI_MakeFace(plane, u_min, u_max, v_min, v_max).Face(); + if (face.IsNull()) + return {Result_status::Topo_error, "Could not build the clipping plane face."}; + + // Reference point on the positive-normal side (kept half, same as Hide back side / AIS clip). + const gp_Pnt ref = plane.Location().Translated(gp_Vec(plane.Axis().Direction())); + BRepPrimAPI_MakeHalfSpace half(face, ref); + const TopoDS_Solid solid = half.Solid(); + if (solid.IsNull()) + return {Result_status::Topo_error, "Could not build the clipping half-space."}; + + return solid; + } + catch (const Standard_Failure& e) + { + return {Result_status::Topo_error, std::string("Half-space failed: ") + standard_failure_message(e)}; + } +} + +Result clip_solid_to_half_space_(const TopoDS_Shape& world_shape, const TopoDS_Solid& half_space) +{ + try + { + BRepAlgoAPI_Common common(world_shape, half_space); + common.Build(); + if (!common.IsDone()) + return {Result_status::Topo_error, "Open CASCADE could not clip the solid."}; + + const TopoDS_Shape& result = common.Shape(); + if (result.IsNull() || !contains_solid_(result)) + return {Result_status::User_error, "Clip removed the entire solid (nothing left on the kept side)."}; + + return result; + } + catch (const Standard_Failure& e) + { + return {Result_status::Topo_error, std::string("Clip failed: ") + standard_failure_message(e)}; + } +} + +void add_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, + TopoDS_Compound& lines) +{ + if (bounds.IsVoid()) + return; + + const gp_Pnt origin = plane.Location(); + const gp_Vec x_axis(plane.XAxis().Direction()); + const gp_Vec y_axis(plane.YAxis().Direction()); + double u_min, u_max, v_min, v_max; + project_bbox_uv_(bounds, plane, u_min, u_max, v_min, v_max); const double width = u_max - u_min; const double height = v_max - v_min; diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h index eaeb364..e741725 100644 --- a/src/shp_cross_section.h +++ b/src/shp_cross_section.h @@ -2,6 +2,7 @@ #include "shp_operation.h" +#include #include #include #include @@ -43,6 +44,12 @@ class Shp_cross_section : private Shp_operation_base [[nodiscard]] Status preview(const std::vector& shapes); void clear(); + /// Half-space clip: keep the positive-normal side of each selected solid, delete the inputs, + /// and add the clipped results (undoable). + [[nodiscard]] Status clip_selected(); + /// Same as clip_selected, but uses \a shapes instead of the current AIS selection. + [[nodiscard]] Status clip(const std::vector& shapes); + Cross_section_plane get_plane() const { return m_plane; } void set_plane(Cross_section_plane plane) { m_plane = plane; } double get_offset_display() const { return m_offset_display; } @@ -65,10 +72,20 @@ class Shp_cross_section : private Shp_operation_base [[nodiscard]] bool try_get_offset_range_display(double& out_min, double& out_max); private: + struct Shared_plane + { + std::vector shapes; + std::vector world_shapes; + Bnd_Box bounds; + gp_Pln plane; + }; + static std::vector selection_ids_(const std::vector& shapes); void acknowledge_inputs_(const std::vector& shapes); - void clear_clips_(); - void apply_clips_(const std::vector& shapes, const gp_Pln& plane); + void clear_preview_(); + void clear_ais_clips_(); + void apply_ais_clips_(const std::vector& shapes, const gp_Pln& plane); + [[nodiscard]] Result build_shared_plane_(const std::vector& shapes); Cross_section_plane m_plane{Cross_section_plane::XY}; double m_offset_display{0.0}; @@ -82,6 +99,6 @@ class Shp_cross_section : private Shp_operation_base AIS_Shape_ptr m_preview; AIS_Shape_ptr m_plane_fill; AIS_Shape_ptr m_plane_lines; - Graphic3d_ClipPlane_ptr m_clip_plane; - std::vector m_clipped_shapes; + Graphic3d_ClipPlane_ptr m_ais_clip_plane; + std::vector m_ais_clipped_shapes; }; diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index f7d92fd..b95bff8 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,11 @@ void get_bbox(const TopoDS_Shape& shape, double& xmin, double& ymin, double& zmi bbox.Get(xmin, ymin, zmin, xmax, ymax, zmax); } +bool contains_solid_like(const TopoDS_Shape& shape) +{ + return !shape.IsNull() && (shape.ShapeType() == TopAbs_SOLID || TopExp_Explorer(shape, TopAbs_SOLID).More()); +} + std::string line_value(const std::vector& lines, const char* label) { for (const auto& line : lines) @@ -362,11 +368,12 @@ TEST_F(Shp_test, Cross_section_selection_stale_after_selection_change) EXPECT_FALSE(boxes[1]->ClipPlanes().IsNull()); EXPECT_EQ(boxes[1]->ClipPlanes()->Size(), 1); - view().shp_cross_section().clear(); - view().shp_cross_section().acknowledge_current_selection(); + const Shape_id clipped_id = boxes[1]->get_id(); + ASSERT_TRUE(view().shp_cross_section().clip_selected().is_ok()); EXPECT_FALSE(view().shp_cross_section().has_preview()); - EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); - EXPECT_TRUE(boxes[1]->ClipPlanes().IsNull() || boxes[1]->ClipPlanes()->IsEmpty()); + EXPECT_TRUE(view().find_shape_by_id(clipped_id).IsNull()); + ASSERT_FALSE(view().get_shapes().empty()); + EXPECT_TRUE(contains_solid_like(view().get_shapes().back()->Shape())); } From fa6a45959e18b91941c2e1076517d48325477546 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Wed, 22 Jul 2026 18:53:46 -0600 Subject: [PATCH 13/17] Multi threading. --- CHANGELOG.md | 1 + src/doc/shape.md | 2 +- src/shp_cross_section.cpp | 91 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e2ec5..ae1fb7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Cross-section - Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), **Hide back side** (display-only AIS clip opposite the yellow arrow), a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units, and **Clip** (half-space cut: keeps the positive-normal side, deletes the originals, adds clipped replacements; undoable). A translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. +- Desktop preview sections multiple selected solids in parallel (worker pool); WASM remains single-threaded. Bounding-box culling skips solids that cannot meet the plane. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. ### Shape List diff --git a/src/doc/shape.md b/src/doc/shape.md index 83f2041..a235a75 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -151,7 +151,7 @@ Protected helpers used by all operation classes: | `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. | -| `shp_cross_section.h` | `Shp_cross_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation; optional AIS `Graphic3d_ClipPlane` (**Hide back side**); **Clip** half-space-commons each solid and replaces the inputs. | +| `shp_cross_section.h` | `Shp_cross_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation; optional AIS `Graphic3d_ClipPlane` (**Hide back side**); **Clip** half-space-commons each solid and replaces the inputs. Desktop preview sections solids on a worker pool (WASM stays single-threaded); bbox cull skips obvious misses. | | `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | ## Input routing (from UI / `Occt_view`) diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index b7cdccc..1323a99 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -31,11 +31,17 @@ #include #include +#include #include +#include #include #include #include +#ifndef __EMSCRIPTEN__ +# include +#endif + namespace { gp_Pln cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, double offset, bool invert_normal); @@ -47,11 +53,15 @@ bool append_bounds_(const TopoDS_Shape& shape, Bnd_Box& bounds) std::array bbox_corners_(const Bnd_Box& bounds); void project_bbox_offsets_(const Bnd_Box& bounds, const gp_Pnt& origin, const gp_Dir& normal, double& out_min, double& out_max); void project_bbox_uv_(const Bnd_Box& bounds, const gp_Pln& plane, double& u_min, double& u_max, double& v_min, double& v_max); +bool bbox_misses_plane_(const Bnd_Box& bounds, const gp_Pln& plane); void add_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, TopoDS_Compound& lines); Result cross_section_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane); Result keep_half_space_(const gp_Pln& plane, const Bnd_Box& bounds); Result clip_solid_to_half_space_(const TopoDS_Shape& world_shape, const TopoDS_Solid& half_space); +void for_each_index_(size_t count, const std::function& fn); +std::vector> section_shapes_on_plane_(const std::vector& world_shapes, + const gp_Pln& plane); } // namespace Result cross_section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Cross_section_plane plane, @@ -127,6 +137,10 @@ Status Shp_cross_section::preview(const std::vector& selected) const Shared_plane& plane_ctx = *shared; + // Desktop: section each solid on a worker pool. WASM stays serial (no pthreads). + const std::vector> section_results = + section_shapes_on_plane_(plane_ctx.world_shapes, plane_ctx.plane); + TopoDS_Compound compound; TopoDS_Compound plane_fill; TopoDS_Compound plane_lines; @@ -137,9 +151,9 @@ Status Shp_cross_section::preview(const std::vector& selected) Cross_section_geometry totals; size_t missed = 0; - for (size_t i = 0; i < plane_ctx.shapes.size(); ++i) + for (size_t i = 0; i < section_results.size(); ++i) { - Result result = cross_section_shape_on_plane_(plane_ctx.world_shapes[i], plane_ctx.plane); + const Result& result = section_results[i]; if (!result.has_value()) { // A miss on one solid should not kill the shared preview for the others. @@ -545,6 +559,79 @@ void project_bbox_offsets_(const Bnd_Box& bounds, const gp_Pnt& origin, const gp } } +bool bbox_misses_plane_(const Bnd_Box& bounds, const gp_Pln& plane) +{ + if (bounds.IsVoid()) + return true; + + double d_min = 0.0; + double d_max = 0.0; + project_bbox_offsets_(bounds, plane.Location(), plane.Axis().Direction(), d_min, d_max); + // Strict same-side means Section cannot hit; keep a tiny tolerance for near-tangency. + constexpr double tol = 1.0e-7; + return d_max < -tol || d_min > tol; +} + +void for_each_index_(size_t count, const std::function& fn) +{ + if (count == 0) + return; + +#ifdef __EMSCRIPTEN__ + for (size_t i = 0; i < count; ++i) + fn(i); +#else + if (count == 1) + { + fn(0); + return; + } + + const unsigned hw = std::thread::hardware_concurrency(); + const size_t workers = + std::min(count, static_cast(hw == 0 ? 2u : hw)); + std::atomic next{0}; + std::vector threads; + threads.reserve(workers); + for (size_t w = 0; w < workers; ++w) + { + threads.emplace_back( + [&]() + { + for (;;) + { + const size_t i = next.fetch_add(1, std::memory_order_relaxed); + if (i >= count) + break; + fn(i); + } + }); + } + for (std::thread& t : threads) + t.join(); +#endif +} + +std::vector> section_shapes_on_plane_(const std::vector& world_shapes, + const gp_Pln& plane) +{ + std::vector> results(world_shapes.size()); + for_each_index_(world_shapes.size(), + [&](size_t i) + { + Bnd_Box bounds; + BRepBndLib::Add(world_shapes[i], bounds); + if (bbox_misses_plane_(bounds, plane)) + { + results[i] = {Result_status::User_error, "The section plane does not intersect the shape."}; + return; + } + + results[i] = cross_section_shape_on_plane_(world_shapes[i], plane); + }); + return results; +} + void project_bbox_uv_(const Bnd_Box& bounds, const gp_Pln& plane, double& u_min, double& u_max, double& v_min, double& v_max) { const gp_Pnt origin = plane.Location(); From 4183f64c3ea794aef1801d95aad0cea855f19869 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Wed, 22 Jul 2026 19:11:15 -0600 Subject: [PATCH 14/17] Improve --- CHANGELOG.md | 2 +- agents/plans/README.md | 1 + agents/plans/cross-section-tool.md | 1 + agents/plans/wasm-multithreading.md | 110 +++++++ docs/usage.md | 2 +- src/doc/gui.md | 2 +- src/doc/shape.md | 2 +- src/gui_mode.cpp | 22 +- src/shp_cross_section.cpp | 453 ++++++++++++++++++++++------ src/shp_cross_section.h | 87 +++++- 10 files changed, 560 insertions(+), 122 deletions(-) create mode 100644 agents/plans/wasm-multithreading.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ae1fb7d..087c437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Cross-section - Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), **Hide back side** (display-only AIS clip opposite the yellow arrow), a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units, and **Clip** (half-space cut: keeps the positive-normal side, deletes the originals, adds clipped replacements; undoable). A translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. -- Desktop preview sections multiple selected solids in parallel (worker pool); WASM remains single-threaded. Bounding-box culling skips solids that cannot meet the plane. +- Desktop preview sections multiple selected solids in parallel (worker pool); WASM remains single-threaded for the per-solid pool. Bounding-box culling skips solids that cannot meet the plane. Offset/plane dragging updates the yellow plane immediately and runs section wires asynchronously (cancel + latest-pending) so the Options slider stays responsive. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. ### Shape List diff --git a/agents/plans/README.md b/agents/plans/README.md index 44d20a5..c125baf 100644 --- a/agents/plans/README.md +++ b/agents/plans/README.md @@ -20,3 +20,4 @@ sketch-mode-shape-faint (parallel UX: ghost/wire shapes while sketching) | [sketch-from-shape-section.md](sketch-from-shape-section.md) | "sketch from shape", section → editable sketch (end-state; deferred) | | [sketch-mode-shape-faint.md](sketch-mode-shape-faint.md) | faint/ghost/wireframe shapes in sketch mode, alpha blending solids | | [shape-list-hierarchy-phase3.md](shape-list-hierarchy-phase3.md) | parent transform inheritance, Parts/planes, Boolean history (#214) | +| [wasm-multithreading.md](wasm-multithreading.md) | WASM/Emscripten pthreads, SharedArrayBuffer, parallel OCCT on web | diff --git a/agents/plans/cross-section-tool.md b/agents/plans/cross-section-tool.md index 0a373b1..7be0d49 100644 --- a/agents/plans/cross-section-tool.md +++ b/agents/plans/cross-section-tool.md @@ -67,3 +67,4 @@ Import into `Sketch_edges`; undoable new sketch; script binding (unless useful f - The first curve evidence supports line and circle handling. Ellipse/B-spline/other counts are exposed for further manual experiments before sketch import rules are chosen. - Boolean solids are not yet sketch-ready: a fused two-box mid-plane yields 8 line edges (likely unmerged/overlapping segments) instead of one 4-edge outer loop. - Desktop Release build and focused section tests pass with OCCT 8.0.0. The Emscripten Release build also passes with OCCT 7.9.3. +- Desktop multi-solid preview sections solids on a `std::thread` worker pool (`for_each_index_` / `section_shapes_on_plane_`); WASM remains single-threaded for that pool. Interactive Options use `request_preview` + `poll`: plane AIS updates immediately; section wires run async with cancel + latest-pending (desktop `std::async`; WASM one solid per poll). WASM pthread enablement is tracked in [wasm-multithreading.md](wasm-multithreading.md). diff --git a/agents/plans/wasm-multithreading.md b/agents/plans/wasm-multithreading.md new file mode 100644 index 0000000..f88ca8c --- /dev/null +++ b/agents/plans/wasm-multithreading.md @@ -0,0 +1,110 @@ +--- +status: planned +topic: wasm-multithreading +depends_on: [] +blocks: [] +--- + +# WASM multithreading (Emscripten pthreads) + +**Load only when** the prompt is about WASM/Emscripten threads, pthreads, SharedArrayBuffer, parallel OCCT on the web, or enabling the cross-section (or similar) worker pool under `__EMSCRIPTEN__`. Skip otherwise ([token-lean](../conventions/token-lean.md)). Index: [plans/README.md](README.md). + +## Context (what already exists) + +Desktop cross-section preview already parallelizes per-solid `BRepAlgoAPI_Section` via a small worker pool in `shp_cross_section.cpp` (`for_each_index_` / `section_shapes_on_plane_`). WASM is intentionally serial: + +```cpp +#ifdef __EMSCRIPTEN__ + // single-threaded loop +#else + // std::thread pool sized to hardware_concurrency +#endif +``` + +AIS / viewer updates stay on the main thread after results are collected. BBox cull skips obvious plane misses on both targets. + +Related: [cross-section-tool.md](cross-section-tool.md), [occt-wasm-dual-version](../conventions/occt-wasm-dual-version.md) (desktop OCCT 8 vs wasm 7.9.3). + +## Why WASM needs its own plan + +Emscripten does not get free `std::thread` parallelism. Real threads require: + +1. Compile/link with **`-pthread`** (app + preferably OCCT wasm kit). +2. Browser **SharedArrayBuffer**, which needs **cross-origin isolation** (`Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` or equivalent) on the hosting page and assets. +3. Larger binary, pthread pool sizing (`PTHREAD_POOL_SIZE` / `PROXY_TO_PTHREAD` choices), and careful main-thread vs worker ownership of OpenGL/AIS. + +Until that stack exists, keep the `#ifdef __EMSCRIPTEN__` serial path. Do not pretend `SetRunParallel(true)` alone enables multi-core on current single-threaded wasm builds. + +## Goal + +Enable the **same parallel map pattern** used by cross-section preview on WASM when isolation + pthread builds are available, without regressing: + +- Non-isolated hosts (fallback to serial). +- Desktop behavior. +- OCCT 7.9.3 wasm kit compatibility ([occt-wasm-dual-version](../conventions/occt-wasm-dual-version.md)). + +Cross-section is the **first consumer / proof**; later ops (multi-solid Clip half-space, other embarrassingly parallel BREP maps) should reuse one helper. + +## Approach (phased) + +### Phase 0 — document + gate (no behavior change) + +- [ ] Record current desktop pool API shape (`for_each_index_` or a shared `utl_parallel.h` helper). +- [ ] Keep WASM serial by default. +- [ ] Note hosting constraints in [docs/building-occt.md](../../docs/building-occt.md) / wasm run notes when work starts. + +### Phase 1 — Emscripten pthread build option + +- [ ] CMake/Emscripten option (e.g. `EZYCAD_WASM_PTHREADS=ON`) adding `-pthread` to compile and link. +- [ ] Set sensible defaults: `PTHREAD_POOL_SIZE` (start small, e.g. 4), memory growth still allowed. +- [ ] Rebuild or document whether the **OCCT 7.9.3 wasm kit** must also be built with pthreads (likely yes for allocator / internal parallel safety). +- [ ] CI or local smoke: Release wasm with option off (current) and on (new). + +### Phase 2 — Hosting / COOP+COEP + +- [ ] Serve demo/pages with COOP/COEP (or document required headers for GitHub Pages / custom host). +- [ ] Runtime detect: if `crossOriginIsolated` is false, **force serial** even in a pthread build (avoid SAB failures). +- [ ] Shell HTML / deploy notes updated so agents and maintainers know why threads are off. + +### Phase 3 — Wire shared parallel helper on WASM + +- [ ] Lift `for_each_index_` (or equivalent) to a shared utility used by `shp_cross_section` and future callers. +- [ ] Under `__EMSCRIPTEN__` + pthread + `crossOriginIsolated`: use the same worker-pool logic as desktop. +- [ ] Otherwise: serial loop (today’s behavior). +- [ ] Still: **no AIS/OCCT viewer calls from workers**; only BREP compute, then main-thread Display. + +### Phase 4 — Hardening + +- [ ] Stress test: many selected solids in cross-section preview (Offset drag) on Chrome/Firefox/Safari as available. +- [ ] Watch for OCCT allocator / intermittent section crashes under concurrent `BRepAlgoAPI_Section` (known historical risk); fall back to serial if unstable on 7.9.3. +- [ ] Optional: cancel/stale-token so an old preview does not finish after a newer one (helps UI more than threads alone). +- [ ] Binary size / startup cost note in CHANGELOG when enabling by default. + +## Non-goals (for this plan) + +- Web Worker with a second full OCCT instance (heavier architecture; revisit only if pthreads prove insufficient). +- Making all OCCT booleans internally `SetRunParallel` on wasm without measuring. +- Requiring pthreads for every wasm deploy (isolated hosting may stay optional). + +## Success + +- With pthread build + cross-origin isolation, multi-solid cross-section preview uses multiple cores on wasm similarly to desktop. +- Without isolation or with pthreads off, behavior matches today’s single-threaded wasm (no crash, no SAB error). +- One reusable parallel-for helper; cross-section remains the reference caller. + +## Likely touch points + +| Area | Notes | +| ---------------------------- | --------------------------------------------------------------------- | +| `src/shp_cross_section.cpp` | First consumer; drop hard serial-only `#ifdef` once helper exists | +| New `utl_parallel.*` (maybe) | Shared `for_each_index` / pool; desktop + wasm gated | +| `CMakeLists.txt` | `EZYCAD_WASM_PTHREADS`, link flags, pool size | +| `scripts/build-occt-*-wasm*` | Rebuild OCCT kit with `-pthread` if required | +| `web/EzyCad.html` / host | COOP/COEP or meta documentation | +| Docs | `building-occt.md`, maybe short wasm run section | + +## Open questions + +1. Must the OCCT wasm kit itself be pthread-linked, or is app-only `-pthread` enough for independent `BRepAlgoAPI_Section` instances? +2. Default ON for demo builds, or opt-in forever for smaller non-isolated deploys? +3. Safari / iOS SharedArrayBuffer support matrix for our supported browsers? diff --git a/docs/usage.md b/docs/usage.md index 9007b46..11972f2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -604,7 +604,7 @@ Use the cross-section tool to inspect where a plane cuts one or more selected so 1. Select one or more solids. 2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. 3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. Enable **Hide back side** to preview-clip the selected solids so geometry on the negative-normal side of the plane is not drawn (opposite the yellow arrow). -4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit, and the preview updates as you change it. +4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit. The yellow plane updates immediately while you drag; cyan section wires catch up asynchronously so the control stays responsive. 5. Click **Clip** to keep the positive-normal half of each selected solid as a new shape and delete the originals (undoable). Leave the tool (or clear the selection) to remove the preview and any hide-back display clip. 6. Changing the selection or **Section plane** (or dragging **Offset**) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. diff --git a/src/doc/gui.md b/src/doc/gui.md index f66f53f..7dc5bc7 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -235,7 +235,7 @@ Toolbar buttons hold `std::variant`. `Command` (`Shape_cut`, `Sha Mode buttons call `set_mode`. Active state tracks `m_mode`. -The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` with the snapshot. While the mode is active, Options applies plane/offset/hide-back widgets then polls `preview_inputs_stale` (selection ids, plane, offset, invert, or hide-back) and rebuilds or clears the preview. **Hide back side** attaches a temporary per-shape `Graphic3d_ClipPlane` for display only. **Clip** runs a half-space `BRepAlgoAPI_Common`, deletes the input solids, and adds clipped replacements (`Shape_replace_delta`). The temporary AIS wire result and any display clip planes are cleared when the mode is left, when the selection becomes empty, after a successful **Clip**, or before a failed/updated preview. +The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` (blocking) with the snapshot. While the mode is active, Options updates the yellow plane annotation immediately on plane/offset/hide-back changes (`request_preview`), then `poll`s a background section job (desktop `std::async`; WASM chunks one solid per frame). At most one running job plus one pending (latest only); moving the slider cancels/coalesces work so the UI stays responsive. **Hide back side** attaches a temporary per-shape `Graphic3d_ClipPlane` for display only. **Clip** runs a half-space `BRepAlgoAPI_Common`, deletes the input solids, and adds clipped replacements (`Shape_replace_delta`). Temporary AIS and jobs are cleared when the mode is left, when the selection becomes empty, after a successful **Clip**, or on `clear()`. ## Typical developer usage diff --git a/src/doc/shape.md b/src/doc/shape.md index a235a75..957eb10 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -151,7 +151,7 @@ Protected helpers used by all operation classes: | `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. | -| `shp_cross_section.h` | `Shp_cross_section` | Shared `BRepAlgoAPI_Section` preview for the selection (first-shape local XY/XZ/YZ orientation, selection-bbox center + offset); temporary cyan section wires plus one translucent yellow plane/normal annotation; optional AIS `Graphic3d_ClipPlane` (**Hide back side**); **Clip** half-space-commons each solid and replaces the inputs. Desktop preview sections solids on a worker pool (WASM stays single-threaded); bbox cull skips obvious misses. | +| `shp_cross_section.h` | `Shp_cross_section` | Shared cutting-plane preview: immediate yellow plane AIS; cyan section wires via async job (desktop `std::async` + per-solid pool; WASM one-solid-per-`poll` chunks); running+latest-pending cancel/coalesce; optional hide-back AIS clip; **Clip** half-space-commons and replaces inputs. | | `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | ## Input routing (from UI / `Occt_view`) diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 3b75d03..a49235a 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -765,14 +766,6 @@ void GUI::options_shape_cross_section_mode_() int plane = static_cast(section.get_plane()); double offset = section.get_offset_display(); - auto update_preview = [&]() - { - const Status status = section.preview_selected(); - // Always rebuild above; only toast failures. Success edge-count spam on Offset drag is noise. - if (!status.is_ok()) - show_message(status.message()); - }; - ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); ImGui::Separator(); @@ -837,7 +830,7 @@ void GUI::options_shape_cross_section_mode_() if (have_selection && !have_range) ImGui::TextDisabled("Select solid shapes to enable the offset slider."); - // Refresh after plane/offset widgets so a plane change is never missed. + // Plane annotation updates immediately; section wires run async (poll below). if (section.preview_inputs_stale()) { if (m_view->get_selected_shps().empty()) @@ -845,8 +838,15 @@ void GUI::options_shape_cross_section_mode_() section.clear(); section.acknowledge_current_selection(); } - else - update_preview(); + else if (const Status status = section.request_preview_selected(); !status.is_ok()) + show_message(status.message()); + } + + if (std::optional finished = section.poll()) + { + // Toast failures only; success edge-count spam on Offset drag is noise. + if (!finished->is_ok()) + show_message(finished->message()); } ImGui::BeginDisabled(!have_selection); diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index 1323a99..163093b 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -35,15 +35,25 @@ #include #include #include +#include #include +#include #include #ifndef __EMSCRIPTEN__ +# include +# include # include #endif namespace { +struct Assembled_section +{ + Status status{Result_status::Success}; + TopoDS_Shape compound; +}; + gp_Pln cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, double offset, bool invert_normal); void count_curve_type_(const TopoDS_Edge& edge, Cross_section_geometry& result); bool contains_solid_(const TopoDS_Shape& shape); @@ -56,12 +66,15 @@ void project_bbox_uv_(const Bnd_Box& bounds, const gp_Pln& plane, double& u_min, bool bbox_misses_plane_(const Bnd_Box& bounds, const gp_Pln& plane); void add_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane, BRep_Builder& builder, TopoDS_Compound& fill, TopoDS_Compound& lines); +Result section_one_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane); Result cross_section_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane); Result keep_half_space_(const gp_Pln& plane, const Bnd_Box& bounds); Result clip_solid_to_half_space_(const TopoDS_Shape& world_shape, const TopoDS_Solid& half_space); -void for_each_index_(size_t count, const std::function& fn); +void for_each_index_(size_t count, const std::function& fn, std::atomic* cancel); std::vector> section_shapes_on_plane_(const std::vector& world_shapes, - const gp_Pln& plane); + const gp_Pln& plane, std::atomic* cancel); +Assembled_section assemble_section_geometries_(const std::vector>& section_results, + const std::vector& shape_names); } // namespace Result cross_section_shape(const TopoDS_Shape& shape, const gp_Ax3& frame, Cross_section_plane plane, @@ -75,8 +88,17 @@ Shp_cross_section::Shp_cross_section(Occt_view& view) { } +Shp_cross_section::~Shp_cross_section() +{ + cancel_section_jobs_(); + clear_preview_ais_(); + clear_ais_clips_(); +} + Status Shp_cross_section::preview_selected() { return preview(get_selected_shps_()); } +Status Shp_cross_section::request_preview_selected() { return request_preview(get_selected_shps_()); } + Result Shp_cross_section::build_shared_plane_(const std::vector& shapes) { if (!std::isfinite(m_offset_display)) @@ -88,6 +110,7 @@ Result Shp_cross_section::build_shared_plane_(c Shared_plane shared; shared.shapes.reserve(shapes.size()); shared.world_shapes.reserve(shapes.size()); + shared.shape_names.reserve(shapes.size()); gp_Ax3 shared_axes; bool have_axes = false; @@ -111,6 +134,7 @@ Result Shp_cross_section::build_shared_plane_(c shared.shapes.push_back(shp); shared.world_shapes.push_back(std::move(world_shape)); + shared.shape_names.push_back(shp->get_name()); } if (shared.shapes.empty() || shared.bounds.IsVoid() || !have_axes) @@ -125,71 +149,231 @@ Result Shp_cross_section::build_shared_plane_(c return shared; } -Status Shp_cross_section::preview(const std::vector& selected) +Status Shp_cross_section::request_preview(const std::vector& shapes) { - clear_preview_(); - clear_ais_clips_(); - acknowledge_inputs_(selected); + acknowledge_inputs_(shapes); - Result shared = build_shared_plane_(selected); + Result shared = build_shared_plane_(shapes); if (!shared.has_value()) - return Status(shared.status(), shared.message()); + { + cancel_section_jobs_(); + clear_preview_ais_(); + clear_ais_clips_(); + m_last_section_status = Status(shared.status(), shared.message()); + ctx().UpdateCurrentViewer(); + return m_last_section_status; + } const Shared_plane& plane_ctx = *shared; + if (m_hide_back_side) + apply_ais_clips_(plane_ctx.shapes, plane_ctx.plane); + else + clear_ais_clips_(); + + display_plane_annotation_(plane_ctx.bounds, plane_ctx.plane); + // Keep last cyan wires until the new section job finishes (feels less flickery while dragging). + enqueue_section_(plane_ctx); + ctx().UpdateCurrentViewer(); + return Status::ok(); +} + +Status Shp_cross_section::preview(const std::vector& shapes) +{ + const Status requested = request_preview(shapes); + if (!requested.is_ok()) + return requested; + return wait_section_(); +} + +void Shp_cross_section::enqueue_section_(const Shared_plane& plane_ctx) +{ + m_cancel.store(true); + const std::uint64_t gen = m_generation.fetch_add(1) + 1; + + Section_request req; + req.generation = gen; + req.world_shapes = plane_ctx.world_shapes; + req.shape_names = plane_ctx.shape_names; + req.plane = plane_ctx.plane; + +#ifndef __EMSCRIPTEN__ + const bool running = m_running_active; +#else + const bool running = m_chunked.has_value(); +#endif + + if (running) + m_pending = std::move(req); + else + { + m_pending.reset(); + start_section_job_(std::move(req)); + } +} - // Desktop: section each solid on a worker pool. WASM stays serial (no pthreads). +void Shp_cross_section::start_section_job_(Section_request req) +{ + m_cancel.store(false); + +#ifndef __EMSCRIPTEN__ + m_running = std::async(std::launch::async, + [req = std::move(req), cancel = &m_cancel]() mutable + { return compute_section_result_(std::move(req), cancel); }); + m_running_active = true; +#else + Chunked_job job; + job.request = std::move(req); + job.next = 0; + job.results.assign(job.request.world_shapes.size(), Result{}); + m_chunked = std::move(job); +#endif +} + +Shp_cross_section::Section_result Shp_cross_section::compute_section_result_(Section_request req, std::atomic* cancel) +{ const std::vector> section_results = - section_shapes_on_plane_(plane_ctx.world_shapes, plane_ctx.plane); + section_shapes_on_plane_(req.world_shapes, req.plane, cancel); - TopoDS_Compound compound; - TopoDS_Compound plane_fill; - TopoDS_Compound plane_lines; - BRep_Builder builder; - builder.MakeCompound(compound); - builder.MakeCompound(plane_fill); - builder.MakeCompound(plane_lines); + Section_result out; + out.generation = req.generation; + if (cancel && cancel->load()) + { + out.status = Status::user_error("Section cancelled."); + return out; + } - Cross_section_geometry totals; - size_t missed = 0; - for (size_t i = 0; i < section_results.size(); ++i) + const Assembled_section assembled = assemble_section_geometries_(section_results, req.shape_names); + out.status = assembled.status; + out.compound = assembled.compound; + return out; +} + +std::optional Shp_cross_section::finish_section_result_(Section_result result) +{ + if (result.generation != m_generation.load()) + return std::nullopt; + + m_last_section_status = result.status; + if (result.status.is_ok() && !result.compound.IsNull()) + display_section_wires_(result.compound); + else + clear_section_wires_(); + + ctx().UpdateCurrentViewer(); + return result.status; +} + +std::optional Shp_cross_section::poll() +{ + std::optional finished; + +#ifndef __EMSCRIPTEN__ + if (m_running_active && m_running.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { - const Result& result = section_results[i]; - if (!result.has_value()) + finished = m_running.get(); + m_running_active = false; + } +#else + if (m_chunked.has_value()) + { + Chunked_job& job = *m_chunked; + if (m_cancel.load() || job.request.generation != m_generation.load()) { - // A miss on one solid should not kill the shared preview for the others. - if (result.status() == Result_status::User_error) - { - ++missed; - continue; - } - - return Status(result.status(), plane_ctx.shapes[i]->get_name() + ": " + result.message()); + m_chunked.reset(); + } + else if (job.next < job.request.world_shapes.size()) + { + job.results[job.next] = section_one_shape_on_plane_(job.request.world_shapes[job.next], job.request.plane); + ++job.next; } + else + { + const Assembled_section assembled = assemble_section_geometries_(job.results, job.request.shape_names); + Section_result result; + result.generation = job.request.generation; + result.status = assembled.status; + result.compound = assembled.compound; + finished = std::move(result); + m_chunked.reset(); + } + } +#endif - const Cross_section_geometry& geometry = *result; - builder.Add(compound, geometry.shape); - totals.edge_count += geometry.edge_count; - totals.line_count += geometry.line_count; - totals.circle_count += geometry.circle_count; - totals.ellipse_count += geometry.ellipse_count; - totals.bspline_count += geometry.bspline_count; - totals.other_curve_count += geometry.other_curve_count; + std::optional out; + if (finished.has_value()) + out = finish_section_result_(std::move(*finished)); + +#ifndef __EMSCRIPTEN__ + const bool running = m_running_active; +#else + const bool running = m_chunked.has_value(); +#endif + + if (!running && m_pending.has_value()) + { + Section_request req = std::move(*m_pending); + m_pending.reset(); + start_section_job_(std::move(req)); } - if (totals.edge_count == 0) - return Status::user_error("The section plane does not intersect the selection."); + return out; +} - if (m_hide_back_side) - apply_ais_clips_(plane_ctx.shapes, plane_ctx.plane); +bool Shp_cross_section::section_busy() const +{ +#ifndef __EMSCRIPTEN__ + return m_running_active || m_pending.has_value(); +#else + return m_chunked.has_value() || m_pending.has_value(); +#endif +} - add_plane_annotation_(plane_ctx.bounds, plane_ctx.plane, builder, plane_fill, plane_lines); +Status Shp_cross_section::wait_section_() +{ + while (section_busy()) + { + if (std::optional status = poll()) + m_last_section_status = *status; - m_preview = new AIS_Shape(compound); - m_preview->SetColor(Quantity_NOC_CYAN); - m_preview->SetWidth(3.0); - m_preview->SetZLayer(Graphic3d_ZLayerId_Topmost); - ctx().Display(m_preview, AIS_WireFrame, -1, false); - ctx().Deactivate(m_preview); +#ifndef __EMSCRIPTEN__ + std::this_thread::sleep_for(std::chrono::milliseconds(1)); +#endif + } + + if (std::optional status = poll()) + m_last_section_status = *status; + + return m_last_section_status; +} + +void Shp_cross_section::cancel_section_jobs_() +{ + m_cancel.store(true); + m_generation.fetch_add(1); + m_pending.reset(); + +#ifndef __EMSCRIPTEN__ + if (m_running_active) + { + m_running.wait(); + (void)m_running.get(); + m_running_active = false; + } +#else + m_chunked.reset(); +#endif +} + +void Shp_cross_section::display_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane) +{ + clear_plane_annotation_(); + + TopoDS_Compound plane_fill; + TopoDS_Compound plane_lines; + BRep_Builder builder; + builder.MakeCompound(plane_fill); + builder.MakeCompound(plane_lines); + add_plane_annotation_(bounds, plane, builder, plane_fill, plane_lines); m_plane_fill = new AIS_Shape(plane_fill); m_plane_fill->SetColor(Quantity_NOC_YELLOW); @@ -203,29 +387,52 @@ Status Shp_cross_section::preview(const std::vector& selected) m_plane_lines->SetZLayer(Graphic3d_ZLayerId_Topmost); ctx().Display(m_plane_lines, AIS_WireFrame, -1, false); ctx().Deactivate(m_plane_lines); +} - ctx().UpdateCurrentViewer(); +void Shp_cross_section::display_section_wires_(const TopoDS_Shape& compound) +{ + clear_section_wires_(); - std::ostringstream msg; - msg << "Section preview: " << totals.edge_count << " edge"; - if (totals.edge_count != 1) - msg << "s"; - msg << " (" << totals.line_count << " line, " << totals.circle_count << " circle, " << totals.ellipse_count << " ellipse, " - << totals.bspline_count << " B-spline, " << totals.other_curve_count << " other)"; - if (missed > 0) - { - msg << "; missed " << missed << " solid"; - if (missed != 1) - msg << "s"; - } - msg << "."; - return Status::ok(msg.str()); + m_preview = new AIS_Shape(compound); + m_preview->SetColor(Quantity_NOC_CYAN); + m_preview->SetWidth(3.0); + m_preview->SetZLayer(Graphic3d_ZLayerId_Topmost); + ctx().Display(m_preview, AIS_WireFrame, -1, false); + ctx().Deactivate(m_preview); +} + +void Shp_cross_section::clear_section_wires_() +{ + if (!m_preview.IsNull()) + ctx().Remove(m_preview, false); + + m_preview.Nullify(); +} + +void Shp_cross_section::clear_plane_annotation_() +{ + if (!m_plane_fill.IsNull()) + ctx().Remove(m_plane_fill, false); + + if (!m_plane_lines.IsNull()) + ctx().Remove(m_plane_lines, false); + + m_plane_fill.Nullify(); + m_plane_lines.Nullify(); +} + +void Shp_cross_section::clear_preview_ais_() +{ + clear_section_wires_(); + clear_plane_annotation_(); } Status Shp_cross_section::clip_selected() { return clip(get_selected_shps_()); } Status Shp_cross_section::clip(const std::vector& shapes) { + cancel_section_jobs_(); + Result shared = build_shared_plane_(shapes); if (!shared.has_value()) return Status(shared.status(), shared.message()); @@ -275,27 +482,12 @@ Status Shp_cross_section::clip(const std::vector& shapes) void Shp_cross_section::clear() { - clear_preview_(); + cancel_section_jobs_(); + clear_preview_ais_(); clear_ais_clips_(); ctx().UpdateCurrentViewer(); } -void Shp_cross_section::clear_preview_() -{ - if (!m_preview.IsNull()) - ctx().Remove(m_preview, false); - - if (!m_plane_fill.IsNull()) - ctx().Remove(m_plane_fill, false); - - if (!m_plane_lines.IsNull()) - ctx().Remove(m_plane_lines, false); - - m_preview.Nullify(); - m_plane_fill.Nullify(); - m_plane_lines.Nullify(); -} - void Shp_cross_section::clear_ais_clips_() { if (!m_ais_clip_plane.IsNull()) @@ -478,6 +670,16 @@ Result cross_section_shape_on_plane_(const TopoDS_Shape& } } +Result section_one_shape_on_plane_(const TopoDS_Shape& shape, const gp_Pln& plane) +{ + Bnd_Box bounds; + BRepBndLib::Add(shape, bounds); + if (bbox_misses_plane_(bounds, plane)) + return {Result_status::User_error, "The section plane does not intersect the shape."}; + + return cross_section_shape_on_plane_(shape, plane); +} + void count_curve_type_(const TopoDS_Edge& edge, Cross_section_geometry& result) { switch (BRepAdaptor_Curve(edge).GetType()) @@ -572,18 +774,24 @@ bool bbox_misses_plane_(const Bnd_Box& bounds, const gp_Pln& plane) return d_max < -tol || d_min > tol; } -void for_each_index_(size_t count, const std::function& fn) +void for_each_index_(size_t count, const std::function& fn, std::atomic* cancel) { if (count == 0) return; #ifdef __EMSCRIPTEN__ for (size_t i = 0; i < count; ++i) + { + if (cancel && cancel->load()) + break; + fn(i); + } #else if (count == 1) { - fn(0); + if (!(cancel && cancel->load())) + fn(0); return; } @@ -600,6 +808,9 @@ void for_each_index_(size_t count, const std::function& fn) { for (;;) { + if (cancel && cancel->load()) + break; + const size_t i = next.fetch_add(1, std::memory_order_relaxed); if (i >= count) break; @@ -613,25 +824,83 @@ void for_each_index_(size_t count, const std::function& fn) } std::vector> section_shapes_on_plane_(const std::vector& world_shapes, - const gp_Pln& plane) + const gp_Pln& plane, std::atomic* cancel) { std::vector> results(world_shapes.size()); for_each_index_(world_shapes.size(), [&](size_t i) { - Bnd_Box bounds; - BRepBndLib::Add(world_shapes[i], bounds); - if (bbox_misses_plane_(bounds, plane)) - { - results[i] = {Result_status::User_error, "The section plane does not intersect the shape."}; + if (cancel && cancel->load()) return; - } - results[i] = cross_section_shape_on_plane_(world_shapes[i], plane); - }); + results[i] = section_one_shape_on_plane_(world_shapes[i], plane); + }, + cancel); return results; } +Assembled_section assemble_section_geometries_(const std::vector>& section_results, + const std::vector& shape_names) +{ + Assembled_section out; + TopoDS_Compound compound; + BRep_Builder builder; + builder.MakeCompound(compound); + + Cross_section_geometry totals; + size_t missed = 0; + for (size_t i = 0; i < section_results.size(); ++i) + { + const Result& result = section_results[i]; + if (!result.has_value()) + { + // A miss on one solid should not kill the shared preview for the others. + if (result.status() == Result_status::User_error) + { + ++missed; + continue; + } + + const std::string name = i < shape_names.size() ? shape_names[i] : "Shape"; + out.status = Status(result.status(), name + ": " + result.message()); + return out; + } + + const Cross_section_geometry& geometry = *result; + builder.Add(compound, geometry.shape); + totals.edge_count += geometry.edge_count; + totals.line_count += geometry.line_count; + totals.circle_count += geometry.circle_count; + totals.ellipse_count += geometry.ellipse_count; + totals.bspline_count += geometry.bspline_count; + totals.other_curve_count += geometry.other_curve_count; + } + + if (totals.edge_count == 0) + { + out.status = Status::user_error("The section plane does not intersect the selection."); + return out; + } + + out.compound = compound; + + std::ostringstream msg; + msg << "Section preview: " << totals.edge_count << " edge"; + if (totals.edge_count != 1) + msg << "s"; + msg << " (" << totals.line_count << " line, " << totals.circle_count << " circle, " << totals.ellipse_count << " ellipse, " + << totals.bspline_count << " B-spline, " << totals.other_curve_count << " other)"; + if (missed > 0) + { + msg << "; missed " << missed << " solid"; + if (missed != 1) + msg << "s"; + } + msg << "."; + out.status = Status::ok(msg.str()); + return out; +} + void project_bbox_uv_(const Bnd_Box& bounds, const gp_Pln& plane, double& u_min, double& u_max, double& v_min, double& v_max) { const gp_Pnt origin = plane.Location(); diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h index e741725..befebe7 100644 --- a/src/shp_cross_section.h +++ b/src/shp_cross_section.h @@ -7,9 +7,17 @@ #include #include +#include #include +#include +#include +#include #include +#ifndef __EMSCRIPTEN__ +# include +#endif + enum class Cross_section_plane { XY, @@ -38,16 +46,26 @@ class Shp_cross_section : private Shp_operation_base { public: explicit Shp_cross_section(Occt_view& view); + ~Shp_cross_section(); + /// Blocking preview (plane + section). Used on mode enter and in tests. [[nodiscard]] Status preview_selected(); - /// Same as preview_selected, but uses \a shapes instead of the current AIS selection. [[nodiscard]] Status preview(const std::vector& shapes); - void clear(); + + /// Non-blocking: update plane annotation now and enqueue section compute (running + latest pending). + /// Returns immediately with a plane-build failure, or ok when the section job was enqueued. + [[nodiscard]] Status request_preview_selected(); + [[nodiscard]] Status request_preview(const std::vector& shapes); + + /// Drain finished section jobs / advance WASM chunks; start pending. Call each Options frame. + /// Returns a status when a job for the current generation completes (success or failure toast). + [[nodiscard]] std::optional poll(); + + void clear(); /// Half-space clip: keep the positive-normal side of each selected solid, delete the inputs, /// and add the clipped results (undoable). [[nodiscard]] Status clip_selected(); - /// Same as clip_selected, but uses \a shapes instead of the current AIS selection. [[nodiscard]] Status clip(const std::vector& shapes); Cross_section_plane get_plane() const { return m_plane; } @@ -58,34 +76,55 @@ class Shp_cross_section : private Shp_operation_base /// Flips the cutting-plane normal (and annotation arrow). Negates offset so the plane stays put. void set_invert_normal(bool invert); bool get_hide_back_side() const { return m_hide_back_side; } - /// When true, AIS clip planes hide selected-solid geometry on the negative-normal side. void set_hide_back_side(bool hide) { m_hide_back_side = hide; } bool has_preview() const { return !m_preview.IsNull(); } - /// True when the current AIS selection ids differ from the last acknowledged set. - [[nodiscard]] bool selection_stale() const; - /// True when selection, section plane, offset, normal invert, or hide-back differ from the last preview/acknowledge. - [[nodiscard]] bool preview_inputs_stale() const; - /// Record the current AIS selection, plane, offset, invert, and hide-back as acknowledged without rebuilding. - void acknowledge_current_selection(); - /// Projected extent of the current selection along the shared cross-section plane normal, - /// in display units. False when nothing solid is selected or bounds are empty. - [[nodiscard]] bool try_get_offset_range_display(double& out_min, double& out_max); + bool section_busy() const; + [[nodiscard]] bool selection_stale() const; + [[nodiscard]] bool preview_inputs_stale() const; + void acknowledge_current_selection(); + [[nodiscard]] bool try_get_offset_range_display(double& out_min, double& out_max); private: struct Shared_plane { std::vector shapes; std::vector world_shapes; + std::vector shape_names; Bnd_Box bounds; gp_Pln plane; }; + struct Section_request + { + std::uint64_t generation{0}; + std::vector world_shapes; + std::vector shape_names; + gp_Pln plane; + }; + + struct Section_result + { + std::uint64_t generation{0}; + Status status{Result_status::Success}; + TopoDS_Shape compound; + }; + static std::vector selection_ids_(const std::vector& shapes); void acknowledge_inputs_(const std::vector& shapes); - void clear_preview_(); + void clear_section_wires_(); + void clear_plane_annotation_(); + void clear_preview_ais_(); void clear_ais_clips_(); void apply_ais_clips_(const std::vector& shapes, const gp_Pln& plane); - [[nodiscard]] Result build_shared_plane_(const std::vector& shapes); + void display_plane_annotation_(const Bnd_Box& bounds, const gp_Pln& plane); + void display_section_wires_(const TopoDS_Shape& compound); + void cancel_section_jobs_(); + void enqueue_section_(const Shared_plane& plane_ctx); + void start_section_job_(Section_request req); + [[nodiscard]] static Section_result compute_section_result_(Section_request req, std::atomic* cancel); + [[nodiscard]] std::optional finish_section_result_(Section_result result); + [[nodiscard]] Result build_shared_plane_(const std::vector& shapes); + [[nodiscard]] Status wait_section_(); Cross_section_plane m_plane{Cross_section_plane::XY}; double m_offset_display{0.0}; @@ -101,4 +140,22 @@ class Shp_cross_section : private Shp_operation_base AIS_Shape_ptr m_plane_lines; Graphic3d_ClipPlane_ptr m_ais_clip_plane; std::vector m_ais_clipped_shapes; + + std::atomic m_generation{0}; + std::atomic m_cancel{false}; + std::optional m_pending; + Status m_last_section_status{Result_status::Success}; + +#ifndef __EMSCRIPTEN__ + std::future m_running; + bool m_running_active{false}; +#else + struct Chunked_job + { + Section_request request; + std::size_t next{0}; + std::vector> results; + }; + std::optional m_chunked; +#endif }; From c82c23ac5d7d2c9207f4191bd73b989b47d14c52 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Wed, 22 Jul 2026 19:27:43 -0600 Subject: [PATCH 15/17] Agent work --- CHANGELOG.md | 2 +- .../gh-220-wasm-alt-drag-multiselect.md | 37 ++++++++++ agents/plans/README.md | 1 + agents/plans/wasm-alt-drag-multiselect.md | 73 +++++++++++++++++++ docs/usage.md | 2 +- src/shp_cross_section.h | 4 +- 6 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 agents/drafts/issues/active/gh-220-wasm-alt-drag-multiselect.md create mode 100644 agents/plans/wasm-alt-drag-multiselect.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 087c437..dd36333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Cross-section -- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), **Hide back side** (display-only AIS clip opposite the yellow arrow), a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units, and **Clip** (half-space cut: keeps the positive-normal side, deletes the originals, adds clipped replacements; undoable). A translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. +- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), **Hide back side** (on by default; display-only AIS clip opposite the yellow arrow), a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units, and **Clip** (half-space cut: keeps the positive-normal side, deletes the originals, adds clipped replacements; undoable). A translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. - Desktop preview sections multiple selected solids in parallel (worker pool); WASM remains single-threaded for the per-solid pool. Bounding-box culling skips solids that cannot meet the plane. Offset/plane dragging updates the yellow plane immediately and runs section wires asynchronously (cancel + latest-pending) so the Options slider stays responsive. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. diff --git a/agents/drafts/issues/active/gh-220-wasm-alt-drag-multiselect.md b/agents/drafts/issues/active/gh-220-wasm-alt-drag-multiselect.md new file mode 100644 index 0000000..0d3bbef --- /dev/null +++ b/agents/drafts/issues/active/gh-220-wasm-alt-drag-multiselect.md @@ -0,0 +1,37 @@ +--- +github_issue: 220 +status: active +--- + +# WASM: Alt + LMB drag does not multi-select shapes + +**Suggested labels:** `bug` + +**Opened:** https://github.com/trailcode/EzyCad/issues/220 + +--- + +## Title (GitHub) + +WASM: Alt + LMB drag does not multi-select shapes + +## Body (GitHub) + +See https://github.com/trailcode/EzyCad/issues/220 + +### Summary + +Desktop: **Alt + LMB drag** rubber-band multi-selects shapes via OCCT `AIS_ViewController`. WASM: same gesture does not select multiple shapes. + +### Acceptance criteria + +- [ ] WASM Alt + LMB drag multi-selects like desktop +- [ ] Desktop unchanged +- [ ] Docs note Alt+drag (and any Web caveats) +- [ ] Plan updated: `agents/plans/wasm-alt-drag-multiselect.md` + +### Related + +- Issue: https://github.com/trailcode/EzyCad/issues/220 +- Plan: `agents/plans/wasm-alt-drag-multiselect.md` +- Related: #93 / `gh-93-emscripten-web-hotkeys-followup.md` diff --git a/agents/plans/README.md b/agents/plans/README.md index c125baf..8a87285 100644 --- a/agents/plans/README.md +++ b/agents/plans/README.md @@ -21,3 +21,4 @@ sketch-mode-shape-faint (parallel UX: ghost/wire shapes while sketching) | [sketch-mode-shape-faint.md](sketch-mode-shape-faint.md) | faint/ghost/wireframe shapes in sketch mode, alpha blending solids | | [shape-list-hierarchy-phase3.md](shape-list-hierarchy-phase3.md) | parent transform inheritance, Parts/planes, Boolean history (#214) | | [wasm-multithreading.md](wasm-multithreading.md) | WASM/Emscripten pthreads, SharedArrayBuffer, parallel OCCT on web | +| [wasm-alt-drag-multiselect.md](wasm-alt-drag-multiselect.md) | WASM Alt+LMB drag rectangle multi-select broken (#220) | diff --git a/agents/plans/wasm-alt-drag-multiselect.md b/agents/plans/wasm-alt-drag-multiselect.md new file mode 100644 index 0000000..4dffebe --- /dev/null +++ b/agents/plans/wasm-alt-drag-multiselect.md @@ -0,0 +1,73 @@ +--- +status: planned +topic: wasm-alt-drag-multiselect +depends_on: [] +blocks: [] +github_issue: 220 +--- + +# WASM Alt + LMB drag multi-select + +**Load only when** the prompt is about WASM/Emscripten Alt+drag, rectangle / rubber-band selection, or multi-select failing on web. Skip otherwise ([token-lean](../conventions/token-lean.md)). Index: [plans/README.md](README.md). + +**Issue:** https://github.com/trailcode/EzyCad/issues/220 + +## Context + +On desktop, holding **Alt** and **left-dragging** in the 3D view activates OCCT `AIS_ViewController` rectangle selection (`SelectRectangle` / rubber band) and multi-selects shapes under the box. + +On the WASM build the same gesture does **not** multi-select. + +EzyCad already maps modifiers into OCCT flags: + +- `Occt_view::key_flags_from_glfw_` — `GLFW_MOD_ALT` → `Aspect_VKeyFlags_ALT` +- Mouse press/release: `PressMouseButton` / `ReleaseMouseButton` with `theMods` +- Mouse move: `UpdateMousePosition(..., key_flags_from_glfw_window_(), ...)` which polls `glfwGetKey(... LEFT/RIGHT_ALT ...)` + +Related input/hotkey tracking: [#93](https://github.com/trailcode/EzyCad/issues/93), draft `agents/drafts/issues/active/gh-93-emscripten-web-hotkeys-followup.md`. OCCT desktop vs wasm kit: [occt-wasm-dual-version](../conventions/occt-wasm-dual-version.md). + +## Goal + +Make **Alt + LMB drag** multi-select on WASM match desktop, without changing desktop gesture mapping or selection schemes. + +## Likely causes (investigate in order) + +1. **Alt not present on pointer events under Emscripten/GLFW** — browser menu focus, `preventDefault`, or incomplete `mods` on `glfwSetMouseButtonCallback`. +2. **`glfwGetKey(ALT)` false during drag** — rubber-band gesture needs Alt on move updates (`key_flags_from_glfw_window_`), not only on button press. +3. **ImGui / focus** — canvas loses keyboard focus when Alt is pressed; Alt never reaches GLFW. +4. **OCCT 7.9.3 wasm kit** — confirm default `MouseGestureMap` still binds Alt+LMB to select-rectangle (unlikely difference, but verify if input looks correct). + +## Approach + +### Phase 0 — reproduce and instrument + +- [ ] Confirm desktop Alt+LMB rubber band still works. +- [ ] On WASM, log (temporary) `mods` on LMB press/release and Alt from `key_flags_from_glfw_window_` during drag. +- [ ] Note browser (Chrome/Firefox/Edge) and whether the OS/browser steals Alt. + +### Phase 1 — fix input path + +- [ ] Ensure Alt is forwarded for press, move, and release while the gesture is active (Emscripten/GLFW and/or synthetic modifier from `event.altKey` if GLFW is incomplete). +- [ ] Avoid focusing browser chrome on Alt when the canvas has focus (as far as the platform allows). +- [ ] Keep `GUI::on_mouse_button` / sketch `mods == 0` click paths unchanged for unmodified LMB. + +### Phase 2 — docs and parity + +- [ ] Document Alt+drag multi-select in `docs/usage.md` and/or `docs/usage-occt-view.md`; call out any remaining Web limitation. +- [ ] Manual check: Ctrl+click multi-select still works on WASM if it already does; Alt+drag matches desktop. +- [ ] Close #220 when done; update this plan status. + +## Out of scope + +- WASM pthreads / parallel OCCT ([wasm-multithreading.md](wasm-multithreading.md)). +- Broader #93 hotkey parity beyond what this gesture needs. +- Changing default OCCT mouse gesture map on desktop. + +## Related code + +| Area | Path | +| ---------------------------- | ----------------------------------------- | +| Modifier → OCCT flags | `src/gui_occt_view.cpp` (`key_flags_*`) | +| Mouse → view controller | `src/gui_occt_view.cpp` (`on_mouse_*`) | +| GLFW → GUI | `src/main.cpp`, `src/gui.cpp` | +| Draft | `agents/drafts/issues/active/gh-220-*.md` | diff --git a/docs/usage.md b/docs/usage.md index 11972f2..dce4527 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -603,7 +603,7 @@ Use the cross-section tool to inspect where a plane cuts one or more selected so 1. Select one or more solids. 2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. -3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. Enable **Hide back side** to preview-clip the selected solids so geometry on the negative-normal side of the plane is not drawn (opposite the yellow arrow). +3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. **Hide back side** is on by default and preview-clips the selected solids so geometry on the negative-normal side of the plane is not drawn (opposite the yellow arrow); turn it off to show the full solids. 4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit. The yellow plane updates immediately while you drag; cyan section wires catch up asynchronously so the control stays responsive. 5. Click **Clip** to keep the positive-normal half of each selected solid as a new shape and delete the originals (undoable). Leave the tool (or clear the selection) to remove the preview and any hide-back display clip. 6. Changing the selection or **Section plane** (or dragging **Offset**) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h index befebe7..0bfb87f 100644 --- a/src/shp_cross_section.h +++ b/src/shp_cross_section.h @@ -129,11 +129,11 @@ class Shp_cross_section : private Shp_operation_base Cross_section_plane m_plane{Cross_section_plane::XY}; double m_offset_display{0.0}; bool m_invert_normal{false}; - bool m_hide_back_side{false}; + bool m_hide_back_side{true}; Cross_section_plane m_acked_plane{Cross_section_plane::XY}; double m_acked_offset_display{0.0}; bool m_acked_invert_normal{false}; - bool m_acked_hide_back_side{false}; + bool m_acked_hide_back_side{true}; std::vector m_acked_selection_ids; AIS_Shape_ptr m_preview; AIS_Shape_ptr m_plane_fill; From f98c0260bb30bfcb5ccbec56c2fa2e5e6331c881 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Wed, 22 Jul 2026 19:37:51 -0600 Subject: [PATCH 16/17] Fix --- .../issues/active/gh-218-cross-section-tool.md | 12 +++++++----- .../active/gh-220-wasm-alt-drag-multiselect.md | 3 ++- .../drafts/prs/active/gh-219-cross-section-tool.md | 6 +++--- src/gui_occt_view.cpp | 2 +- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/agents/drafts/issues/active/gh-218-cross-section-tool.md b/agents/drafts/issues/active/gh-218-cross-section-tool.md index 50918e8..2d0d7ca 100644 --- a/agents/drafts/issues/active/gh-218-cross-section-tool.md +++ b/agents/drafts/issues/active/gh-218-cross-section-tool.md @@ -21,16 +21,18 @@ See https://github.com/trailcode/EzyCad/issues/218 ### Summary -Interactive **Shape cross-section** tool: shared local plane cut of selected solids with temporary AIS preview (no sketch commit in v0). +Interactive **Shape cross-section** tool: shared local plane cut of selected solids with temporary AIS preview; Invert normal, Hide back side, Clip; async wires + desktop parallel pool (no sketch commit in v0). ### Acceptance criteria -- [ ] Auto-preview on enter / selection / plane / offset -- [ ] Bold empty-selection prompt; leave mode or clear selection removes preview -- [ ] Focused tests `Shp_cross_section.*` / `Shp_test.Cross_section*` -- [ ] Docs + CHANGELOG updated +- [x] Auto-preview on enter / selection / plane / offset / Invert +- [x] Hide back side (default on); Clip undoable half-space +- [x] Bold empty-selection prompt; leave mode or clear selection removes preview +- [x] Focused tests `Shp_cross_section.*` / `Shp_test.Cross_section*` +- [x] Docs + CHANGELOG updated ### Related - PR: https://github.com/trailcode/EzyCad/pull/219 - Plan: `agents/plans/cross-section-tool.md` +- Related tracking: #220 diff --git a/agents/drafts/issues/active/gh-220-wasm-alt-drag-multiselect.md b/agents/drafts/issues/active/gh-220-wasm-alt-drag-multiselect.md index 0d3bbef..d49da5d 100644 --- a/agents/drafts/issues/active/gh-220-wasm-alt-drag-multiselect.md +++ b/agents/drafts/issues/active/gh-220-wasm-alt-drag-multiselect.md @@ -33,5 +33,6 @@ Desktop: **Alt + LMB drag** rubber-band multi-selects shapes via OCCT `AIS_ViewC ### Related - Issue: https://github.com/trailcode/EzyCad/issues/220 -- Plan: `agents/plans/wasm-alt-drag-multiselect.md` +- Plan: `agents/plans/wasm-alt-drag-multiselect.md` (tracking on PR #219; bug not fixed there) - Related: #93 / `gh-93-emscripten-web-hotkeys-followup.md` +- PR that added tracking: https://github.com/trailcode/EzyCad/pull/219 diff --git a/agents/drafts/prs/active/gh-219-cross-section-tool.md b/agents/drafts/prs/active/gh-219-cross-section-tool.md index 153fecf..6ad9b80 100644 --- a/agents/drafts/prs/active/gh-219-cross-section-tool.md +++ b/agents/drafts/prs/active/gh-219-cross-section-tool.md @@ -13,9 +13,9 @@ Add temporary shape cross-section preview tool ## Summary -- Temporary shape cross-section preview tool (local XY/XZ/YZ + offset). +- Temporary shape cross-section preview (local XY/XZ/YZ + offset, Invert normal, Hide back side, Clip). +- Async section wires + desktop parallel pool; WASM serial (see `wasm-multithreading.md`). - Shape local frames for plane orientation / persistence. -- Auto-update preview from selection, plane, and offset; Options empty-selection prompt. ## Related @@ -28,5 +28,5 @@ Add temporary shape cross-section preview tool - [ ] Desktop Release build - [ ] `EzyCad_tests --gtest_filter=Shp_cross_section.*:Shp_test.Cross_section*` -- [ ] Manual enter / selection / plane / offset / Esc +- [ ] Manual enter / selection / plane / offset / Hide back / Clip / Esc - [ ] Docs sync checklist diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 722e64b..d16bf40 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -930,7 +930,7 @@ void Occt_view::refresh_shape_shading_(const Shp_ptr& shp) #if OCC_VERSION_HEX >= 0x080000 // OCCT 8 GLES: AIS OwnColor overrides Graphic3d material presets, so Shape List // picks (Copper, Gold, ...) never change the shaded look. Clear any forced color and - // let SetMaterial drive appearance. Broader OCCT 8 wasm shading regressions remain — + // let SetMaterial drive appearance. Broader OCCT 8 wasm shading regressions remain - // recommended kit is still 7.9.3 (docs/building-occt.md). if (shp->HasColor()) shp->UnsetColor(); From 5aed6d2f2607ca60de91317f1b385564f15e71a5 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Wed, 22 Jul 2026 19:49:40 -0600 Subject: [PATCH 17/17] Unit test fix. --- tests/shp_tests.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index b95bff8..17f3765 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -360,8 +360,18 @@ TEST_F(Shp_test, Cross_section_selection_stale_after_selection_change) ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); - view().shp_cross_section().set_hide_back_side(true); + // Hide back side defaults on (already acked by prior preview); toggle to exercise stale + clip apply. EXPECT_TRUE(view().shp_cross_section().get_hide_back_side()); + EXPECT_FALSE(boxes[1]->ClipPlanes().IsNull()); + EXPECT_EQ(boxes[1]->ClipPlanes()->Size(), 1); + + view().shp_cross_section().set_hide_back_side(false); + EXPECT_TRUE(view().shp_cross_section().preview_inputs_stale()); + ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); + EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); + EXPECT_TRUE(boxes[1]->ClipPlanes().IsNull() || boxes[1]->ClipPlanes()->IsEmpty()); + + view().shp_cross_section().set_hide_back_side(true); EXPECT_TRUE(view().shp_cross_section().preview_inputs_stale()); ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale());