diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c7341..dd36333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ 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), **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. + ### 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/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/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..2d0d7ca --- /dev/null +++ b/agents/drafts/issues/active/gh-218-cross-section-tool.md @@ -0,0 +1,38 @@ +--- +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; Invert normal, Hide back side, Clip; async wires + desktop parallel pool (no sketch commit in v0). + +### Acceptance criteria + +- [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 new file mode 100644 index 0000000..d49da5d --- /dev/null +++ b/agents/drafts/issues/active/gh-220-wasm-alt-drag-multiselect.md @@ -0,0 +1,38 @@ +--- +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` (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 new file mode 100644 index 0000000..6ad9b80 --- /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 (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. + +## 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 / Hide back / Clip / Esc +- [ ] Docs sync checklist diff --git a/agents/plans/README.md b/agents/plans/README.md index 44d20a5..8a87285 100644 --- a/agents/plans/README.md +++ b/agents/plans/README.md @@ -20,3 +20,5 @@ 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 | +| [wasm-alt-drag-multiselect.md](wasm-alt-drag-multiselect.md) | WASM Alt+LMB drag rectangle multi-select broken (#220) | diff --git a/agents/plans/cross-section-tool.md b/agents/plans/cross-section-tool.md index 2284778..7be0d49 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,13 @@ 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_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. +- 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-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/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-settings.md b/docs/usage-settings.md index 8877bf0..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 (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**, **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 @@ -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..dce4527 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,23 @@ 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. 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. **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. + +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, 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 - Create chamfers (C) diff --git a/src/doc/gui.md b/src/doc/gui.md index ee4cffe..7dc5bc7 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_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 | @@ -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_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 ### 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..957eb10 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 (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. @@ -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_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) @@ -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_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`) @@ -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_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/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..218cec7 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_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}, @@ -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) @@ -2572,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); @@ -2764,15 +2766,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 +2790,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 +2809,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 +2832,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 +3471,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 +3548,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 +3574,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 +4057,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 +4271,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 +4321,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..07a73ee 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_cross_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..a49235a 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -2,11 +2,13 @@ #include +#include #include #include #include #include #include +#include #include #include #include @@ -62,6 +64,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_cross_section, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-cross-section-tool"}, // clang-format on }; @@ -130,6 +133,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_cross_section, Mode::Normal}, // clang-format on }; @@ -392,6 +396,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_cross_section: options_shape_cross_section_mode_(); break; // Sketch related modes: case Mode::Sketch_inspection_mode: options_sketch_inspection_mode_(); break; @@ -753,6 +758,108 @@ void GUI::options_shape_polar_duplicate_mode_() options_orthographic_projection_(); } +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(); + + 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"); + + ImGui::RadioButton("Local XY", &plane, static_cast(Cross_section_plane::XY)); + ImGui::SameLine(); + ImGui::RadioButton("Local XZ", &plane, static_cast(Cross_section_plane::XZ)); + ImGui::SameLine(); + 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); + + 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); + offset = section.get_offset_display(); + if (have_range) + { + if (offset < offset_min) + offset = offset_min; + else if (offset > offset_max) + offset = offset_max; + } + 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); + 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."); + + // Plane annotation updates immediately; section wires run async (poll below). + if (section.preview_inputs_stale()) + { + if (m_view->get_selected_shps().empty()) + { + section.clear(); + section.acknowledge_current_selection(); + } + 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); + if (ImGui::Button("Clip")) + { + const Status status = section.clip_selected(); + show_message(status.message()); + } + ImGui::EndDisabled(); + + 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..d16bf40 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -95,6 +96,7 @@ Occt_view::Occt_view(GUI& gui) , m_shp_common(*this) , m_shp_polar_dup(*this) , m_shp_extrude(*this) + , m_shp_cross_section(*this) { } @@ -519,6 +521,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 +575,11 @@ void Occt_view::cancel(Set_parent_mode set_parent_mode) gui().set_mode(Mode::Normal); break; + case Mode::Shape_cross_section: + shp_cross_section().clear(); + gui().set_mode(Mode::Normal); + break; + default: operation_canceled |= cancel_sketch_extrude_(); operation_canceled |= curr_sketch().cancel_elm(); @@ -918,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()) @@ -939,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) @@ -1056,7 +1070,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 +1207,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 +1319,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 +1333,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 +1365,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 +1706,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; } @@ -2850,7 +2864,13 @@ 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(); for (Sketch_ptr& s : m_sketches) s->on_mode(); @@ -2912,6 +2932,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; default: if(m_modes_selection_mode_map.count(get_mode())) set_shp_selection_mode(m_modes_selection_mode_map.at(get_mode())); @@ -2923,6 +2944,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() @@ -2955,7 +2993,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 +3192,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_cross_section& Occt_view::shp_cross_section() { return m_shp_cross_section; } // clang-format on // --------------------------------------------------------------------------- @@ -3276,7 +3315,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 +3349,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 +3440,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 +3752,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 +3825,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..2ae24bf 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_cross_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_cross_section& shp_cross_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); @@ -341,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) @@ -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_cross_section m_shp_cross_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..1857232 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_cross_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_cross_section.cpp b/src/shp_cross_section.cpp new file mode 100644 index 0000000..163093b --- /dev/null +++ b/src/shp_cross_section.cpp @@ -0,0 +1,1031 @@ +#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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#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); +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 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 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, std::atomic* cancel); +std::vector> section_shapes_on_plane_(const std::vector& world_shapes, + 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, + double offset) +{ + return cross_section_shape_on_plane_(shape, cross_section_plane_(frame, plane, offset, false)); +} + +Shp_cross_section::Shp_cross_section(Occt_view& view) + : Shp_operation_base(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)) + return {Result_status::User_error, "Section offset must be a finite number."}; + + if (shapes.empty()) + return {Result_status::User_error, "Select one or more solid shapes."}; + + 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; + + 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 {Result_status::User_error, shp->get_name() + ": Cross-section supports solid shapes only."}; + + if (!append_bounds_(world_shape, shared.bounds)) + return {Result_status::User_error, shp->get_name() + ": Shape has empty bounds."}; + + if (!have_axes) + { + shared_axes = frame_world_(*shp); + have_axes = true; + } + + 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) + return {Result_status::User_error, "Select one or more solid shapes."}; + + double 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); + shared.plane = cross_section_plane_(shared_axes, m_plane, offset_model, m_invert_normal); + return shared; +} + +Status Shp_cross_section::request_preview(const std::vector& shapes) +{ + acknowledge_inputs_(shapes); + + Result shared = build_shared_plane_(shapes); + if (!shared.has_value()) + { + 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)); + } +} + +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_(req.world_shapes, req.plane, cancel); + + Section_result out; + out.generation = req.generation; + if (cancel && cancel->load()) + { + out.status = Status::user_error("Section cancelled."); + return out; + } + + 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) + { + 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()) + { + 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 + + 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)); + } + + return out; +} + +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 +} + +Status Shp_cross_section::wait_section_() +{ + while (section_busy()) + { + if (std::optional status = poll()) + m_last_section_status = *status; + +#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); + 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); +} + +void Shp_cross_section::display_section_wires_(const TopoDS_Shape& compound) +{ + clear_section_wires_(); + + 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()); + + 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() +{ + cancel_section_jobs_(); + clear_preview_ais_(); + clear_ais_clips_(); + ctx().UpdateCurrentViewer(); +} + +void Shp_cross_section::clear_ais_clips_() +{ + if (!m_ais_clip_plane.IsNull()) + { + for (const Shp_ptr& shp : m_ais_clipped_shapes) + { + if (shp.IsNull()) + continue; + + shp->RemoveClipPlane(m_ais_clip_plane); + ctx().Redisplay(shp, false); + } + } + + m_ais_clipped_shapes.clear(); + m_ais_clip_plane.Nullify(); +} + +void Shp_cross_section::apply_ais_clips_(const std::vector& shapes, const gp_Pln& plane) +{ + 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_ais_clip_plane); + ctx().Redisplay(shp, false); + m_ais_clipped_shapes.push_back(shp); + } +} + +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; } + +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_hide_back_side != m_hide_back_side; +} + +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_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_()); } + +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()) + 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 = 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); + 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 cross_section_plane_(const gp_Ax3& frame, Cross_section_plane plane, double offset, bool invert_normal) +{ + gp_Dir normal; + gp_Dir x_dir; + switch (plane) + { + case Cross_section_plane::XY: + normal = frame.Direction(); + x_dir = frame.XDirection(); + break; + case Cross_section_plane::XZ: + normal = frame.YDirection(); + x_dir = frame.XDirection(); + break; + case Cross_section_plane::YZ: + normal = frame.XDirection(); + x_dir = frame.YDirection(); + 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)); +} + +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."}; + + 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."}; + + Cross_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)}; + } +} + +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()) + { + 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()); +} + +TopoDS_Shape shape_world_(const Shp& shp) +{ + 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); + 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); + } +} + +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, 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) + { + if (!(cancel && cancel->load())) + 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 (;;) + { + if (cancel && cancel->load()) + break; + + 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::atomic* cancel) +{ + std::vector> results(world_shapes.size()); + for_each_index_(world_shapes.size(), + [&](size_t i) + { + if (cancel && cancel->load()) + return; + + 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(); + const gp_Vec x_axis(plane.XAxis().Direction()); + const gp_Vec y_axis(plane.YAxis().Direction()); + 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); + 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); + } +} + +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; + 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_cross_section.h b/src/shp_cross_section.h new file mode 100644 index 0000000..0bfb87f --- /dev/null +++ b/src/shp_cross_section.h @@ -0,0 +1,161 @@ +#pragma once + +#include "shp_operation.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#ifndef __EMSCRIPTEN__ +# include +#endif + +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); + ~Shp_cross_section(); + + /// Blocking preview (plane + section). Used on mode enter and in tests. + [[nodiscard]] Status preview_selected(); + [[nodiscard]] Status preview(const std::vector& shapes); + + /// 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(); + [[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; } + 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 get_hide_back_side() const { return m_hide_back_side; } + void set_hide_back_side(bool hide) { m_hide_back_side = hide; } + bool has_preview() const { return !m_preview.IsNull(); } + 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_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); + 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}; + bool m_invert_normal{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{true}; + 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_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 +}; 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/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/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 11993f5..17f3765 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -1,17 +1,26 @@ #include "skt_test_fixture.h" #include +#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_cross_section.h" #include "skt_op_recorder.h" #include "utl.h" @@ -32,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) @@ -79,8 +93,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 +176,217 @@ TEST(Shp_create, Pyramid_solid_non_null) EXPECT_NEAR(zmax, side / 2.0, 1e-6); } +// --------------------------------------------------------------------------- +// Cross-section geometry (no viewer) +// --------------------------------------------------------------------------- + +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 = 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_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 = 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_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 = cross_section_shape(box, frame, Cross_section_plane::XY, 5.0); + EXPECT_FALSE(result.has_value()); +} + +TEST(Shp_cross_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 = 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_cross_section, Empty_compound_is_rejected) +{ + TopoDS_Compound compound; + BRep_Builder().MakeCompound(compound); + + const Result result = cross_section_shape(compound, gp_Ax3(), Cross_section_plane::XY, 0.0); + EXPECT_FALSE(result.has_value()); +} + +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; + BRep_Builder builder; + builder.MakeCompound(compound); + builder.Add(compound, face); + + const Result result = cross_section_shape(compound, gp_Ax3(), Cross_section_plane::XY, 0.0); + EXPECT_FALSE(result.has_value()); +} + +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 = 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 = 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_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()); + gp_Trsf rotate; + rotate.SetRotation(gp_Ax1(frame.Location(), gp::DX()), std::numbers::pi / 2.0); + frame.Transform(rotate); + + 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_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); + 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 = 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. + EXPECT_EQ((*result).edge_count, 8u); + EXPECT_EQ((*result).line_count, 8u); + EXPECT_EQ((*result).other_curve_count, 0u); +} + +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 = 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); + 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); + 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()); + 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()); + + 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()); + + // 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()); + EXPECT_FALSE(boxes[1]->ClipPlanes().IsNull()); + EXPECT_EQ(boxes[1]->ClipPlanes()->Size(), 1); + + 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_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())); +} + + // --------------------------------------------------------------------------- // shp_info // --------------------------------------------------------------------------- @@ -174,7 +399,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 +430,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 +654,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 +779,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()) {