diff --git a/CHANGELOG.md b/CHANGELOG.md index 04955a2b..62add7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Extrude Both sides**: Options **Both sides** defaults to on (session sticky; not a Settings key). + +### Fixed + +- **Wasm configure/link**: Detect Emscripten via `EMSCRIPTEN` / `CMAKE_SYSTEM_NAME` (modern emsdk reports Clang). Link the FreeType package from the OCCT wasm install so `wasm-ld` finds `libfreetype.a` instead of bare `-lfreetype`. + ### Added +- **Add bone** sketch tool (Shift+U): click the two circle centers, then radius 1, radius 2, and waist width (like other sketch tools). Waist is the minimum neck thickness (offset from mid when end radii differ). Live length dim only for center-to-center; radii / holes / waist use the geometry preview (Tab still enters exact values). Options: **Add center nodes** (default on) and **Holes** (**None** / **One radius** / **Two radii**). Waist cutters are tangent to both end circles. Commits the trimmed outline (outer end arcs and inner waist arcs), optional hole circles, and optional permanent **Bone A** / **Bone B** center nodes, then exits to **Sketch inspection** (does not stay in Add bone for another). Remappable as **`mode.add_bone`**. + - **Shape local frame**: Shape List right-click on a solid toggles **Show axes** / **Show plane** / **Show up**, **Reset frame to bbox**, **Set from planar/cylindrical face**, and **Flip up** / **Flip axis (Z)**. Frame display flags persist in `.ezy` as `frameDisplay`. Face picks use `Mode::Shape_set_frame` (no toolbar hotkey). - **Shape List Zoom to**: right-click a shape or group name (or the **M** button on a solid) and choose **Zoom to** to frame that solid, or all descendant solids of a group, in the 3D view while keeping the current camera orientation. diff --git a/CMakeLists.txt b/CMakeLists.txt index c741f5f7..7daae7fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,8 +14,10 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # Add custom CMake modules path set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_SOURCE_DIR}/cmake/") -# Detect Emscripten early (for optional branching) -if(CMAKE_CXX_COMPILER MATCHES "em\\+\\+") +# Detect Emscripten early (for optional branching). +# Modern emsdk toolchains force CMAKE_CXX_COMPILER_ID to Clang and set EMSCRIPTEN=1 / +# CMAKE_SYSTEM_NAME=Emscripten; do not rely on matching "em++" in the compiler path. +if(EMSCRIPTEN OR CMAKE_SYSTEM_NAME STREQUAL "Emscripten" OR CMAKE_CXX_COMPILER MATCHES "em\\+\\+") set(EMSCRIPTEN_BUILD TRUE) endif() @@ -84,8 +86,9 @@ target_compile_features(lua PUBLIC c_std_99) # Combines CMAKE_BINARY_DIR and project-specific binary dir for flexibility set(BINARY_DIR ${${PROJECT_NAME}_BINARY_DIR}) -# Detect if we're using Emscripten compiler (for WebAssembly builds) -if(CMAKE_CXX_COMPILER MATCHES "/em\\+\\+(-[a-zA-Z0-9.])?(\.bat)?$") +# EzyCad branches on CMAKE_CXX_COMPILER_ID STREQUAL "Emscripten" (wasm link flags, +# TKOpenGles, no gtest/GLFW nuget). Override the Clang ID that emsdk forces. +if(EMSCRIPTEN_BUILD) message(" * C++ compiler: Emscripten") set(CMAKE_CXX_COMPILER_ID "Emscripten") else() @@ -200,6 +203,24 @@ else() set(OCCT_BIN_DIR) endif() +# Wasm OCCT (scripts/build-occt-*-wasm.ps1) installs FreeType under install/freetype. +# OCCT INTERFACE libs use $; without this package CMake emits bare -lfreetype. +if(EMSCRIPTEN_BUILD AND OpenCASCADE_FOUND) + get_filename_component(_ezy_occt_cmake "${OpenCASCADE_DIR}" ABSOLUTE) + get_filename_component(_ezy_occt_lib_cmake "${_ezy_occt_cmake}" DIRECTORY) + get_filename_component(_ezy_occt_lib "${_ezy_occt_lib_cmake}" DIRECTORY) + get_filename_component(_ezy_occt_prefix "${_ezy_occt_lib}" DIRECTORY) + set(freetype_DIR "${_ezy_occt_prefix}/freetype/lib/cmake/freetype" CACHE PATH + "FreeType CMake package from OCCT wasm install" FORCE) + if(NOT EXISTS "${freetype_DIR}/freetype-config.cmake") + message(FATAL_ERROR + "Wasm FreeType CMake package not found at ${freetype_DIR}. " + "Build OCCT with scripts/build-occt-793-wasm.ps1 (or build-occt-v8-wasm.ps1).") + endif() + find_package(freetype CONFIG REQUIRED) + message(STATUS "Using FreeType (wasm) from \"${freetype_DIR}\"") +endif() + # OCCT 3rd-party DLL path must not contain backticks — a common mistake is pasting # -DOCCT_3RD_PARTY_DIR=... from Markdown and including the closing ` of `...`. if(DEFINED OCCT_3RD_PARTY_DIR) @@ -390,7 +411,8 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "Emscripten") target_link_libraries( ${PROJECT_NAME} lua - ${OpenCASCADE_LIBS}) + ${OpenCASCADE_LIBS} + freetype) else() diff --git a/README.md b/README.md index 0862aa24..1257c338 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,8 @@ Full guide: **[docs/building-occt.md](docs/building-occt.md)** (Windows prebuilt - **OCCT 7.9.3 for wasm (recommended):** `scripts\build-occt-793-wasm.ps1` (or `.cmd`) after `emsdk_env` — see [docs/building-occt.md](docs/building-occt.md#webassembly-emscripten). OCCT 8.x has a GLES shading regression on wasm (see [docs/bugs.md](docs/bugs.md)). - **OCCT 8.0.0.p1 for wasm:** `scripts\build-occt-v8-wasm.ps1` for regression testing against upstream. - Configure the EzyCad project with Emscripten (Ninja recommended): - - `emcmake cmake -S . -B build-em-7-9-3 -Wno-dev -G Ninja -DOpenCASCADE_DIR=C:/Users/you/occt-wasm-build/V7_9_3/install/lib/cmake/opencascade -DCMAKE_BUILD_TYPE=Release` - - Add **-Wno-dev** to suppress any remaining CMake developer warnings. + - `emcmake cmake -S . -B build-em-7-9-3 -Wno-dev -G Ninja "-DOpenCASCADE_DIR=%USERPROFILE%/occt-wasm-build/V7_9_3/install/lib/cmake/opencascade" -DCMAKE_BUILD_TYPE=Release` + - Replace `%USERPROFILE%` with your home if the shell does not expand it (path must contain `OpenCASCADEConfig.cmake`). Add **-Wno-dev** to suppress remaining CMake developer warnings. - If configure **freezes** after that warning, the hang is often in `find_package(OpenCASCADE)` or Emscripten compiler detection. Run with `--debug-output` to see where it stops. - Build: - `ninja -C build-em-7-9-3` (or `emmake cmake --build . --config Release`) diff --git a/agents.md b/agents.md index 3ea081a7..e6812116 100644 --- a/agents.md +++ b/agents.md @@ -18,6 +18,7 @@ Pointer for AI coding assistants. Details live in [agents/README.md](agents/READ - UI/settings/docs changes: [agents/conventions/user-docs-sync.md](agents/conventions/user-docs-sync.md) - **New `Mode` / toolbar tool / one-shot `Command`:** update remappable hotkeys — checklist in [src/doc/gui.md](src/doc/gui.md#new-mode-or-toolbar-command-hotkeys) +- **Sketch tool Options pane:** tool-specific **Options** above **Sketch options** — [src/doc/gui.md](src/doc/gui.md#options-panel-layout-sketch-tools) - Markdown tables (align for source + preview): [agents/conventions/markdown-tables.md](agents/conventions/markdown-tables.md) - Sketch subsystem: [src/doc/sketch.md](src/doc/sketch.md) (read; update when API or architecture changes) - Shape module: [src/doc/shape.md](src/doc/shape.md) (read; update when API or operation patterns change) diff --git a/agents/README.md b/agents/README.md index cea81aea..692ca669 100644 --- a/agents/README.md +++ b/agents/README.md @@ -6,24 +6,24 @@ Root markers: [AGENTS.md](../AGENTS.md) / [agents.md](../agents.md). ## Quick index -| Need | File | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ASCII / `src/` edits | [conventions/ascii-source.md](conventions/ascii-source.md) — after edits: `python scripts/agent_check.py ` | -| C++ style (full) | [docs/ezycad_code_style.md](../docs/ezycad_code_style.md) — optional local: `python scripts/code_style_check.py` (not CI) | -| User docs when UI changes | [conventions/user-docs-sync.md](conventions/user-docs-sync.md) | -| Sketch module (dev doc) | [src/doc/sketch.md](../src/doc/sketch.md) — read when editing sketch code; update if API/architecture changes | -| Shape module (dev doc) | [src/doc/shape.md](../src/doc/shape.md) — read when editing `shp_*` code; update if API/operations change | -| GUI module (dev doc) | [src/doc/gui.md](../src/doc/gui.md) — read when editing `gui_*` / viewer shell; update if routing or settings change; **new Mode/Command → hotkeys checklist** | -| Script module (dev doc) | [src/doc/script.md](../src/doc/script.md) — read when editing `scr_*`; update if bindings change | -| Utility module (dev doc) | [src/doc/utility.md](../src/doc/utility.md) — read when editing `utl_*`; update if shared helpers or I/O change | -| Build / test / wasm | [workflows/local-dev.md](workflows/local-dev.md) | -| OCCT desktop 8 vs wasm 7.9.3 | [conventions/occt-wasm-dual-version.md](conventions/occt-wasm-dual-version.md) — until wasm works on OCCT 8 | -| OCCT handles (`*_ptr`) | [conventions/occt-handles.md](conventions/occt-handles.md) — prefer aliases over `Handle()` for clang-format | -| Release | [workflows/release.md](workflows/release.md) | -| Issue/PR drafts | [drafts/](drafts/) — [github-drafts.md](conventions/github-drafts.md) | -| Feature plans (opt-in) | [plans/](plans/) — load **only** when the prompt matches that feature ([token-lean](conventions/token-lean.md)) | -| Token-saving rules | [conventions/token-lean.md](conventions/token-lean.md) | -| Markdown tables | [conventions/markdown-tables.md](conventions/markdown-tables.md) — align GFM pipes for source + preview | -| Outreach (optional) | [outreach/discoverability.md](outreach/discoverability.md) | +| Need | File | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ASCII / `src/` edits | [conventions/ascii-source.md](conventions/ascii-source.md) — after edits: `python scripts/agent_check.py ` | +| C++ style (full) | [docs/ezycad_code_style.md](../docs/ezycad_code_style.md) — optional local: `python scripts/code_style_check.py` (not CI) | +| User docs when UI changes | [conventions/user-docs-sync.md](conventions/user-docs-sync.md) | +| Sketch module (dev doc) | [src/doc/sketch.md](../src/doc/sketch.md) — read when editing sketch code; update if API/architecture changes | +| Shape module (dev doc) | [src/doc/shape.md](../src/doc/shape.md) — read when editing `shp_*` code; update if API/operations change | +| GUI module (dev doc) | [src/doc/gui.md](../src/doc/gui.md) — read when editing `gui_*` / viewer shell; update if routing or settings change; **new Mode/Command → hotkeys checklist**; **sketch Options: tool Options above Sketch options** | +| Script module (dev doc) | [src/doc/script.md](../src/doc/script.md) — read when editing `scr_*`; update if bindings change | +| Utility module (dev doc) | [src/doc/utility.md](../src/doc/utility.md) — read when editing `utl_*`; update if shared helpers or I/O change | +| Build / test / wasm | [workflows/local-dev.md](workflows/local-dev.md) | +| OCCT desktop 8 vs wasm 7.9.3 | [conventions/occt-wasm-dual-version.md](conventions/occt-wasm-dual-version.md) — until wasm works on OCCT 8 | +| OCCT handles (`*_ptr`) | [conventions/occt-handles.md](conventions/occt-handles.md) — prefer aliases over `Handle()` for clang-format | +| Release | [workflows/release.md](workflows/release.md) | +| Issue/PR drafts | [drafts/](drafts/) — [github-drafts.md](conventions/github-drafts.md) | +| Feature plans (opt-in) | [plans/](plans/) — load **only** when the prompt matches that feature ([token-lean](conventions/token-lean.md)) | +| Token-saving rules | [conventions/token-lean.md](conventions/token-lean.md) | +| Markdown tables | [conventions/markdown-tables.md](conventions/markdown-tables.md) — align GFM pipes for source + preview | +| Outreach (optional) | [outreach/discoverability.md](outreach/discoverability.md) | Full user-doc style: [docs/ezycad_doc_style.md](../docs/ezycad_doc_style.md). OCCT build: [docs/building-occt.md](../docs/building-occt.md). diff --git a/agents/conventions/token-lean.md b/agents/conventions/token-lean.md index f157a2e7..c6f2a777 100644 --- a/agents/conventions/token-lean.md +++ b/agents/conventions/token-lean.md @@ -13,23 +13,23 @@ Goal: give assistants **only what they need** for the task at hand. Full style g ## Load on demand -| Task | Read | -| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| Build / test | [workflows/local-dev.md](../workflows/local-dev.md) — or root [README.md](../../README.md#building-instructions) | -| OCCT / WASM (shared `src/` APIs) | [occt-wasm-dual-version.md](occt-wasm-dual-version.md) — desktop 8 vs wasm 7.9.3 until wasm works on OCCT 8 | -| OCCT handles | [occt-handles.md](occt-handles.md) — `*_ptr` / `opencascade::handle` over `Handle()` (clang-format) | -| User-visible UI/settings | [user-docs-sync.md](user-docs-sync.md) + target `docs/usage-*.md` only | -| Sketch subsystem (`src/skt*`, `tests/skt_*`, sketch behavior in `occt_view` / `gui`) | [src/doc/sketch.md](../../src/doc/sketch.md) — read before editing; update when API, invariants, module layout, or workflows change | -| Shape module (`src/shp*`, shape ops in `occt_view` / `gui`) | [src/doc/shape.md](../../src/doc/shape.md) — read before editing; update when API, operation patterns, or registration/undo change | -| GUI / viewer shell (`src/gui*`, input routing, settings panes) | [src/doc/gui.md](../../src/doc/gui.md) — read before editing; update when modes, Options, hotkeys, or settings keys change | -| Script consoles (`src/scr*`, bindings) | [src/doc/script.md](../../src/doc/script.md) — read before editing; update when `ezy`/`view` API or console UI changes | -| Utilities (`src/utl*`, results, I/O, geometry) | [src/doc/utility.md](../../src/doc/utility.md) — read before editing; update when shared helper contracts change | -| Docs build | [workflows/docs-build.md](../workflows/docs-build.md) | -| Editing Markdown tables | [markdown-tables.md](markdown-tables.md) — align GFM pipes; after edits: `python scripts/agent_check.py ` | -| Release | [workflows/release.md](../workflows/release.md) | -| Specific issue/PR | One file under `drafts/issues/active/` or `drafts/prs/active/` | -| Feature plan under `plans/` | **Only** the matching file when the prompt is clearly about that feature (see [plans/README.md](../plans/README.md)); never bulk-load `plans/` | -| Forum posts | [outreach/discoverability.md](../outreach/discoverability.md) | +| Task | Read | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Build / test | [workflows/local-dev.md](../workflows/local-dev.md) — or root [README.md](../../README.md#building-instructions) | +| OCCT / WASM (shared `src/` APIs) | [occt-wasm-dual-version.md](occt-wasm-dual-version.md) — desktop 8 vs wasm 7.9.3 until wasm works on OCCT 8 | +| OCCT handles | [occt-handles.md](occt-handles.md) — `*_ptr` / `opencascade::handle` over `Handle()` (clang-format) | +| User-visible UI/settings | [user-docs-sync.md](user-docs-sync.md) + target `docs/usage-*.md` only | +| Sketch subsystem (`src/skt*`, `tests/skt_*`, sketch behavior in `occt_view` / `gui`) | [src/doc/sketch.md](../../src/doc/sketch.md) — read before editing; update when API, invariants, module layout, or workflows change | +| Shape module (`src/shp*`, shape ops in `occt_view` / `gui`) | [src/doc/shape.md](../../src/doc/shape.md) — read before editing; update when API, operation patterns, or registration/undo change | +| GUI / viewer shell (`src/gui*`, input routing, settings panes) | [src/doc/gui.md](../../src/doc/gui.md) — read before editing; update when modes, Options, hotkeys, or settings keys change; sketch tool Options: tool-specific block **above** **Sketch options** | +| Script consoles (`src/scr*`, bindings) | [src/doc/script.md](../../src/doc/script.md) — read before editing; update when `ezy`/`view` API or console UI changes | +| Utilities (`src/utl*`, results, I/O, geometry) | [src/doc/utility.md](../../src/doc/utility.md) — read before editing; update when shared helper contracts change | +| Docs build | [workflows/docs-build.md](../workflows/docs-build.md) | +| Editing Markdown tables | [markdown-tables.md](markdown-tables.md) — align GFM pipes; after edits: `python scripts/agent_check.py ` | +| Release | [workflows/release.md](../workflows/release.md) | +| Specific issue/PR | One file under `drafts/issues/active/` or `drafts/prs/active/` | +| Feature plan under `plans/` | **Only** the matching file when the prompt is clearly about that feature (see [plans/README.md](../plans/README.md)); never bulk-load `plans/` | +| Forum posts | [outreach/discoverability.md](../outreach/discoverability.md) | ## Draft hygiene (saves tokens long-term) diff --git a/docs/building-occt.md b/docs/building-occt.md index cc81c26a..04301478 100644 --- a/docs/building-occt.md +++ b/docs/building-occt.md @@ -136,15 +136,17 @@ FreeType wasm configure also disables optional zlib/png/harfbuzz finds to simpli ### Wasm troubleshooting -| Symptom | Fix | -| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `running scripts is disabled` | `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` **or** use `build-occt-793-wasm.cmd` / `build-occt-v8-wasm.cmd` **or** `powershell -ExecutionPolicy Bypass -File ...` | -| `Can't initialize filter; xz` | Script uses `.tar.gz` for FreeType; delete stale `*.tar.xz` under `src/` and re-run | -| `source directory .../build/freetype does not contain CMakeLists.txt` | Fixed: do not name a PowerShell function parameter `$Args` (shadows automatic `$Args`) | -| `emcc` not found | Run `emsdk_env.bat` / `emsdk_env.ps1` in the same shell | -| EzyCad configure hangs on `find_package(OpenCASCADE)` | `emcmake cmake ... --debug-output`; verify `OpenCASCADE_DIR` path | -| OCCT 8 + ghosted dimension labels | Retest `gui.edge_dim_text_render_mode` (Z-layer Topmost); grid compositing changed in 8.0 | -| Shaded faces missing / wireframe-only solids (wasm, OCCT 8.x) | Use **7.9.3** (`build-occt-793-wasm.ps1`); see [bugs.md](bugs.md) | +| Symptom | Fix | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `running scripts is disabled` | `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` **or** use `build-occt-793-wasm.cmd` / `build-occt-v8-wasm.cmd` **or** `powershell -ExecutionPolicy Bypass -File ...` | +| `Can't initialize filter; xz` | Script uses `.tar.gz` for FreeType; delete stale `*.tar.xz` under `src/` and re-run | +| `source directory .../build/freetype does not contain CMakeLists.txt` | Fixed: do not name a PowerShell function parameter `$Args` (shadows automatic `$Args`) | +| `emcc` not found | Run `emsdk_env.bat` / `emsdk_env.ps1` in the same shell | +| EzyCad configure hangs on `find_package(OpenCASCADE)` | `emcmake cmake ... --debug-output`; verify `OpenCASCADE_DIR` path | +| `GLFW3_LIBRARY-NOTFOUND` / `* C++ compiler: Clang` under `emcmake` | Stale/broken Emscripten detect: wipe `build-em-*` and reconfigure. CMake must print `* C++ compiler: Emscripten` (uses `EMSCRIPTEN` / `CMAKE_SYSTEM_NAME`, not path regex). | +| `unable to find library -lfreetype` at wasm link | FreeType must come from the same OCCT wasm install (`...\install\freetype\lib\cmake\freetype`). Reconfigure after `build-occt-793-wasm.ps1`; CMake should print `Using FreeType (wasm)`. | +| OCCT 8 + ghosted dimension labels | Retest `gui.edge_dim_text_render_mode` (Z-layer Topmost); grid compositing changed in 8.0 | +| Shaded faces missing / wireframe-only solids (wasm, OCCT 8.x) | Use **7.9.3** (`build-occt-793-wasm.ps1`); see [bugs.md](bugs.md) | --- diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 8c548dd2..0f903ea3 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -115,9 +115,10 @@ For other non-sketch Options content, see the matching tool section in the usage Sketch-related preferences are edited in the **Options** panel while you use a sketch tool, not in the **Settings** pane: - **Sketch options** (all sketch tools): **Snap guide mode** (*Traditional*, *Fullscreen*, *Both*, *None*), then **Snap dist** and **All co-axial nodes** (global co-axial grid vs closest-per-axis only; both hidden when mode is *None*), and **Faint shapes** (show document solids as ghost/wire while sketching; style and **Shape Faint Strength** in **Settings -> Sketch -> Appearance**). *None* disables snap-to-node and snap guides. See [How sketch snap works](usage-sketch.md#sketch-snapping). **Snap guide color (node)**, **Snap guide color (axis)**, **Snap guide line width**, **Snap guide mode**, and **All co-axial nodes** are also in **Settings -> Sketch -> Snap** (persisted in `gui.*` keys below). -- **Extrude sketch face**: under **Extrude**, **Both sides**, **Twist**, and **Material** for the new solid (same document preset as **Normal** mode Options **Material**). With **Twist** on, the first drag sets height, then a second drag sets twist angle about the face center; Tab / Shift+Tab enter height / angle. Fast drag preview for dense faces is configured in **Settings -> Sketch -> Appearance** (**Extrude fast preview**); with **Twist**, face copies rotate as well as translate. Other modes that still show **Material** in Options use that same preset when relevant (for example **Sketch from planar face**). +- **Extrude sketch face**: under **Extrude**, **Both sides** (default on), **Twist**, and **Material** for the new solid (same document preset as **Normal** mode Options **Material**). With **Twist** on, the first drag sets height, then a second drag sets twist angle about the face center; Tab / Shift+Tab enter height / angle. Fast drag preview for dense faces is configured in **Settings -> Sketch -> Appearance** (**Extrude fast preview**); with **Twist**, face copies rotate as well as translate. Other modes that still show **Material** in Options use that same preset when relevant (for example **Sketch from planar face**). - **Add edge** / **Add node** (and similar): a **Shortcuts** line documents TAB / Shift+TAB typing behavior. - **Add line edge**: under **Options**, **Add midpoint nodes** ([usage-sketch.md#line-edge-option-add-midpoint-nodes](usage-sketch.md#line-edge-option-add-midpoint-nodes)) and **Place from center** ([usage-sketch.md#line-edge-option-place-from-center](usage-sketch.md#line-edge-option-place-from-center)); each has a **?** link. See also [Line edge Options](usage-sketch.md#line-edge-options). The midpoint setting is also in **Settings -> Sketch -> Nodes** (persisted). +- **Add bone**: under **Options**, **Add center nodes** (default on; permanent **Bone A** / **Bone B**) and **Holes** (**None**, **One radius**, **Two radii**). Persisted as **`gui.bone_add_center_nodes`** and **`gui.bone_holes`**. See [Bone Creation Tool](usage-sketch.md#bone-creation-tool). - **Sketch operation** (mirror / revolve axis): mirror, revolve, angle, and clear-axis actions (see [usage-sketch.md](usage-sketch.md#operation-axis-tool)). Global length-dimension style (line width, arrows, color, text) is in **Settings -> Sketch -> Dimensions**. Per-dimension visibility, name, and offset remain in **Sketch List -> Dimensions** (saved in the project `.ezy` file). @@ -166,14 +167,14 @@ A **?** at the top of the section (when [UI verbosity](#settings-pane) is high e **Reserved (cannot remap)** -| Keys | Role | -| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| Esc, Enter, Tab / Shift+Tab | Cancel, confirm, distance / angle input | -| Delete, Backspace | Always delete selection (fixed aliases) | -| Selection filter digits (19) | Normal-mode shape filter | -| View zoom / orbit / roll | See [usage.md -> View navigation](usage.md#view-navigation) | -| Unmodified X / Y / Z | Move axis constraints; Rotate axis pick | -| Ctrl+Shift+Z | Fixed redo alias (alongside remappable Ctrl+Y) | +| Keys | Role | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Esc, Enter, Tab / Shift+Tab | Cancel, confirm, distance / angle input | +| Delete, Backspace | Always delete selection (fixed aliases) | +| Selection filter digits (19) | Normal-mode shape filter | +| View zoom / orbit / roll | See [usage.md -> View navigation](usage.md#view-navigation) | +| Unmodified X / Y / Z | Move axis constraints; Rotate axis pick | +| Ctrl+Shift+Z | Fixed redo alias (alongside remappable Ctrl+Y) | Two actions cannot share the same chord. Reserved, unsupported, and duplicate chords show an inline message under the table and a status toast with the reason. @@ -211,62 +212,64 @@ If saved layout text has no `[Docking]` section (older installs), a default dock ### `gui` -| Key | Type | Meaning | -| ------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dark_mode` | boolean | Light/dark theme. | -| `show_options` | boolean | Options panel visible. | -| `show_sketch_list` | boolean | Sketch List pane visible. | -| `show_shape_list` | boolean | Shape List pane visible. | -| `log_window_visible` | boolean | Log window visible. | -| `show_settings_dialog` | boolean | Whether the Settings pane was open when last saved (usually false). | -| `show_lua_console` | boolean | Lua console pane visible (default **false**). | -| `show_python_console` | boolean | Python console pane visible (native builds with Python; default **false**). | -| `show_dbg` | boolean | Debug pane visible (debug builds only). | -| `inspection_orthographic` | boolean | Non-sketch modes Options: orthographic camera when true (default false). Sketch modes always use orthographic. | -| `edge_dim_label_h` | integer | Length dimension label placement: **0** near first point, **1** near second, **2** center, **3** automatic. | -| `edge_dim_line_width` | number | Sketch length dimension line width (**0.5** to **8.0**; desktop only; ineffective in WebAssembly). | -| `edge_dim_arrow_size` | number | Arrow head length (**1.0** to **24.0**). | -| `edge_dim_color` | array of 3 numbers | Dimension line, arrow, and text RGB (**0** to **1** per channel; default olive **0.54**, **0.54**, **0.21**). | -| `edge_dim_text_scale` | number | Label height multiplier (**0.5** to **3.0**; default **1.0**). | -| `edge_dim_text_render_mode` | integer | **0** opaque 2D, **1** SetCommonColor, **2** 2D screen, **3** 3D text, **4** Z Top, **5** Z Topmost (default). | -| `edge_dim_arrow_style` | integer | **0** standard, **1** sharp, **2** wide, **3** 3D shaded. | -| `edge_dim_arrow_orientation` | integer | **0** automatic, **1** internal, **2** external. | -| `show_sketch_dimensions` | boolean | When false, hides length dimensions on all sketches. | -| `permanent_node_anno_scale` | number | Scale for permanent **+** markers: the sketch **Origin** and user-placed Add node points ([Sketch origin](usage-sketch.md#sketch-origin); **0.25** to **3.0**; default **1.0**). | -| `origin_marker_color` | array of 3 numbers | RGB color for the **active** sketch's Origin marker (+ with circle; **0** to **1** per channel; default cyan **0.0**, **0.75**, **1.0**). | -| `sketch_edge_color` | array of 4 numbers | Current-sketch edge RGBA (**0** to **1**; alpha = opacity; default green **0**, **1**, **0**, **1**). | -| `sketch_edge_selection_color` | array of 4 numbers | Selected sketch-edge RGBA (default orange **1**, **0.545**, **0**, **1**). Falls back to `sketch_edge_highlight_color` when absent. | -| `sketch_edge_highlight_color` | array of 4 numbers | Mouse-over (dynamic) highlight RGBA for sketch edges (default yellow **1**, **1**, **0**, **1**). | -| `sketch_edge_line_width` | number | Current-sketch edge line width (**0.5** to **8.0**; default **1.0**; desktop only; ineffective in WebAssembly). | -| `sketch_face_color` | array of 4 numbers | Current-sketch face fill RGBA (default mauve **0.301**, **0.245**, **0.321**, **0.297**). | -| `sketch_face_selection_color` | array of 4 numbers | Selected sketch-face **fill** RGBA (default magenta **0.799**, **0.187**, **0.591**, **1**). Falls back to `sketch_face_highlight_color` when absent. | -| `sketch_face_highlight_color` | array of 4 numbers | Mouse-over (dynamic) sketch-face **fill** RGBA (default violet **0.823**, **0**, **1**, **1**). | -| `snap_guide_color_node` | array of 3 numbers | RGB for snap guides when both axes lock to the same node (float **0** to **1**; default lavender **0.82**, **0.55**, **0.95**). Legacy `snap_guide_color` loads here when `snap_guide_color_node` is absent. | -| `snap_guide_color_axis` | array of 3 numbers | RGB for snap guides when aligned on X or Y only (float **0** to **1**; default magenta **0.96**, **0.06**, **0.54**). Legacy `snap_guide_color` sets both node and axis colors. | -| `snap_guide_mode` | integer | **0** *Traditional* (local markers), **1** *Fullscreen* (view-spanning axis lines), **2** *Both* (default **2**), **3** *None* (disable snap-to-node and snap guides). | -| `snap_guide_line_width` | number | Open CASCADE line width for snap guides (axis lines, markers, co-axial overlay; **0.5** to **8.0**; default **1.0**; desktop only; ineffective in WebAssembly). | -| `annotate_all_coaxial_nodes` | boolean | When true (default), show axis guides and markers for *all* co-axial nodes (current sketch plus other visible sketches). When false, only the closest node per active axis is annotated. Also in sketch **Options**. | -| `imgui_style_dark` | object | ImGui layout for **dark mode** (see keys below). | -| `imgui_style_light` | object | ImGui layout for **light mode** (same keys). | -| `settings_headers` | object | Which Settings pane collapsing sections are expanded (booleans; see keys below). | -| `imgui_rounding_general` | number | **Legacy:** copied into both theme objects on load when `imgui_style_*` is absent. | -| `imgui_rounding_scroll` | number | **Legacy:** same. | -| `imgui_rounding_tabs` | number | **Legacy:** same. | -| `underlay_highlight_color` | array of 3 numbers | Default underlay tint (float RGB **0** to **1** per channel; default **0.64**, **0.56**, **0.31**). | -| `elm_list_hover_color` | array of 4 numbers | RGBA highlight for rows hovered in the **Shape List** or **Sketch List** dimensions table (float **0** to **1** per channel; default purple **0.40**, **0.10**, **0.47**, **1**). | -| `shape_selection_color` | array of 4 numbers | Selected 3D shape highlight RGBA (AIS SelectionStyle; float **0** to **1**; alpha = opacity; default purple **0.75**, **0.07**, **0.85**, **1**). | -| `sketch_shape_faint_style` | integer | How 3D shapes appear in sketch mode when enabled: **0** Off (hide), **1** Ghost (default), **2** Wire. See [usage-sketch.md](usage-sketch.md#sketching-2d). | -| `sketch_shape_faint_opacity` | number | Shape Faint Strength as opacity (**0.05** to **0.85**; default **0.14**; Settings shows **5%**–**85%**). Ghost and Wire. OCCT transparency is **1 - opacity**. | -| `sketch_shape_faint_enabled` | boolean | Master switch for faint solids in all sketch tools (default **true**). Also **Options -> Sketch options -> Faint shapes**. When false, solids are hidden in sketch modes. | -| `extrude_fast_preview` | boolean | When **true** (default), extrude uses a face-copy drag preview for faces with more edges than `extrude_fast_preview_edge_threshold`. Settings -> Sketch -> Appearance. See [Extrude](usage.md#extrude-sketch-face-tool-e). | -| `extrude_fast_preview_edge_threshold` | integer | Edge-count threshold for extrude fast preview (**4** to **256**; default **24**). | -| `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_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. | +| Key | Type | Meaning | +| ------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dark_mode` | boolean | Light/dark theme. | +| `show_options` | boolean | Options panel visible. | +| `show_sketch_list` | boolean | Sketch List pane visible. | +| `show_shape_list` | boolean | Shape List pane visible. | +| `log_window_visible` | boolean | Log window visible. | +| `show_settings_dialog` | boolean | Whether the Settings pane was open when last saved (usually false). | +| `show_lua_console` | boolean | Lua console pane visible (default **false**). | +| `show_python_console` | boolean | Python console pane visible (native builds with Python; default **false**). | +| `show_dbg` | boolean | Debug pane visible (debug builds only). | +| `inspection_orthographic` | boolean | Non-sketch modes Options: orthographic camera when true (default false). Sketch modes always use orthographic. | +| `edge_dim_label_h` | integer | Length dimension label placement: **0** near first point, **1** near second, **2** center, **3** automatic. | +| `edge_dim_line_width` | number | Sketch length dimension line width (**0.5** to **8.0**; desktop only; ineffective in WebAssembly). | +| `edge_dim_arrow_size` | number | Arrow head length (**1.0** to **24.0**). | +| `edge_dim_color` | array of 3 numbers | Dimension line, arrow, and text RGB (**0** to **1** per channel; default olive **0.54**, **0.54**, **0.21**). | +| `edge_dim_text_scale` | number | Label height multiplier (**0.5** to **3.0**; default **1.0**). | +| `edge_dim_text_render_mode` | integer | **0** opaque 2D, **1** SetCommonColor, **2** 2D screen, **3** 3D text, **4** Z Top, **5** Z Topmost (default). | +| `edge_dim_arrow_style` | integer | **0** standard, **1** sharp, **2** wide, **3** 3D shaded. | +| `edge_dim_arrow_orientation` | integer | **0** automatic, **1** internal, **2** external. | +| `show_sketch_dimensions` | boolean | When false, hides length dimensions on all sketches. | +| `permanent_node_anno_scale` | number | Scale for permanent **+** markers: the sketch **Origin** and user-placed Add node points ([Sketch origin](usage-sketch.md#sketch-origin); **0.25** to **3.0**; default **1.0**). | +| `bone_add_center_nodes` | boolean | Add-bone Options: commit permanent **Bone A** / **Bone B** (default **true**). | +| `bone_holes` | integer | Add-bone Options holes mode: **0** None, **1** One radius, **2** Two radii (default **0**). | +| `origin_marker_color` | array of 3 numbers | RGB color for the **active** sketch's Origin marker (+ with circle; **0** to **1** per channel; default cyan **0.0**, **0.75**, **1.0**). | +| `sketch_edge_color` | array of 4 numbers | Current-sketch edge RGBA (**0** to **1**; alpha = opacity; default green **0**, **1**, **0**, **1**). | +| `sketch_edge_selection_color` | array of 4 numbers | Selected sketch-edge RGBA (default orange **1**, **0.545**, **0**, **1**). Falls back to `sketch_edge_highlight_color` when absent. | +| `sketch_edge_highlight_color` | array of 4 numbers | Mouse-over (dynamic) highlight RGBA for sketch edges (default yellow **1**, **1**, **0**, **1**). | +| `sketch_edge_line_width` | number | Current-sketch edge line width (**0.5** to **8.0**; default **1.0**; desktop only; ineffective in WebAssembly). | +| `sketch_face_color` | array of 4 numbers | Current-sketch face fill RGBA (default mauve **0.301**, **0.245**, **0.321**, **0.297**). | +| `sketch_face_selection_color` | array of 4 numbers | Selected sketch-face **fill** RGBA (default magenta **0.799**, **0.187**, **0.591**, **1**). Falls back to `sketch_face_highlight_color` when absent. | +| `sketch_face_highlight_color` | array of 4 numbers | Mouse-over (dynamic) sketch-face **fill** RGBA (default violet **0.823**, **0**, **1**, **1**). | +| `snap_guide_color_node` | array of 3 numbers | RGB for snap guides when both axes lock to the same node (float **0** to **1**; default lavender **0.82**, **0.55**, **0.95**). Legacy `snap_guide_color` loads here when `snap_guide_color_node` is absent. | +| `snap_guide_color_axis` | array of 3 numbers | RGB for snap guides when aligned on X or Y only (float **0** to **1**; default magenta **0.96**, **0.06**, **0.54**). Legacy `snap_guide_color` sets both node and axis colors. | +| `snap_guide_mode` | integer | **0** *Traditional* (local markers), **1** *Fullscreen* (view-spanning axis lines), **2** *Both* (default **2**), **3** *None* (disable snap-to-node and snap guides). | +| `snap_guide_line_width` | number | Open CASCADE line width for snap guides (axis lines, markers, co-axial overlay; **0.5** to **8.0**; default **1.0**; desktop only; ineffective in WebAssembly). | +| `annotate_all_coaxial_nodes` | boolean | When true (default), show axis guides and markers for *all* co-axial nodes (current sketch plus other visible sketches). When false, only the closest node per active axis is annotated. Also in sketch **Options**. | +| `imgui_style_dark` | object | ImGui layout for **dark mode** (see keys below). | +| `imgui_style_light` | object | ImGui layout for **light mode** (same keys). | +| `settings_headers` | object | Which Settings pane collapsing sections are expanded (booleans; see keys below). | +| `imgui_rounding_general` | number | **Legacy:** copied into both theme objects on load when `imgui_style_*` is absent. | +| `imgui_rounding_scroll` | number | **Legacy:** same. | +| `imgui_rounding_tabs` | number | **Legacy:** same. | +| `underlay_highlight_color` | array of 3 numbers | Default underlay tint (float RGB **0** to **1** per channel; default **0.64**, **0.56**, **0.31**). | +| `elm_list_hover_color` | array of 4 numbers | RGBA highlight for rows hovered in the **Shape List** or **Sketch List** dimensions table (float **0** to **1** per channel; default purple **0.40**, **0.10**, **0.47**, **1**). | +| `shape_selection_color` | array of 4 numbers | Selected 3D shape highlight RGBA (AIS SelectionStyle; float **0** to **1**; alpha = opacity; default purple **0.75**, **0.07**, **0.85**, **1**). | +| `sketch_shape_faint_style` | integer | How 3D shapes appear in sketch mode when enabled: **0** Off (hide), **1** Ghost (default), **2** Wire. See [usage-sketch.md](usage-sketch.md#sketching-2d). | +| `sketch_shape_faint_opacity` | number | Shape Faint Strength as opacity (**0.05** to **0.85**; default **0.14**; Settings shows **5%**–**85%**). Ghost and Wire. OCCT transparency is **1 - opacity**. | +| `sketch_shape_faint_enabled` | boolean | Master switch for faint solids in all sketch tools (default **true**). Also **Options -> Sketch options -> Faint shapes**. When false, solids are hidden in sketch modes. | +| `extrude_fast_preview` | boolean | When **true** (default), extrude uses a face-copy drag preview for faces with more edges than `extrude_fast_preview_edge_threshold`. Settings -> Sketch -> Appearance. See [Extrude](usage.md#extrude-sketch-face-tool-e). | +| `extrude_fast_preview_edge_threshold` | integer | Edge-count threshold for extrude fast preview (**4** to **256**; default **24**). | +| `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_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. | | `hotkeys` | object | Remappable keyboard shortcuts: action id string keys to human-readable chord strings (for example `"mode.move": "G"`, `"mode.add_edge": "L"`, `"cmd.shape_cut": "Ctrl+Shift+C"`, `"edit.delete": "Shift+D"`, `"edit.copy": "Ctrl+C"`, `"edit.paste": "Ctrl+V"`). Missing keys merge to built-in defaults. See [Keyboard shortcuts](#keyboard-shortcuts) and [usage.md#hotkeys](usage.md#hotkeys). | Each **`imgui_style_dark`** / **`imgui_style_light`** object may contain: diff --git a/docs/usage-sketch.md b/docs/usage-sketch.md index 81b83430..0cf4188e 100644 --- a/docs/usage-sketch.md +++ b/docs/usage-sketch.md @@ -14,11 +14,12 @@ This guide covers all 2D sketching tools and operations in EzyCad. For the main 9. [Arc Segment Creation Tool](#arc-segment-creation-tool) 10. [Rectangle and Square Creation Tools](#rectangle-and-square-creation-tools) 11. [Slot Creation Tool](#slot-creation-tool) -12. [Operation Axis Tool](#operation-axis-tool) -13. [Dimension Tool](#dimension-tool) -14. [Add Node Tool](#add-node-tool) -15. [Create Sketch from Planar Face Tool](#create-sketch-from-planar-face-tool) -16. [Image underlay](#image-underlay) +12. [Bone Creation Tool](#bone-creation-tool) +13. [Operation Axis Tool](#operation-axis-tool) +14. [Dimension Tool](#dimension-tool) +15. [Add Node Tool](#add-node-tool) +16. [Create Sketch from Planar Face Tool](#create-sketch-from-planar-face-tool) +17. [Image underlay](#image-underlay) --- @@ -31,6 +32,7 @@ This guide covers all 2D sketching tools and operations in EzyCad. For the main - ![Circle Tool](res/icons/Sketcher_CreateCircle.png) [Create circles](#circle-creation-tools) - ![Rectangle Tool](res/icons/Sketcher_CreateRectangle.png) ![Square Tool](res/icons/Sketcher_CreateSquare.png) [Draw rectangles and squares](#rectangle-and-square-creation-tools) - ![Slot Tool](res/icons/Sketcher_CreateSlot.png) [Add slots](#slot-creation-tool) + - ![Bone Tool](res/icons/Sketcher_CreateBone.png) [Add bone](#bone-creation-tool) - ![Dimension Tool](res/icons/TechDraw_LengthDimension.png) [Dimension tool](#dimension-tool) - ![Add Node Tool](res/icons/Sketcher_CreatePoint.png) [Add nodes](#add-node-tool) @@ -109,6 +111,7 @@ Common keyboard shortcuts (hotkeys) while working in 2D sketch mode or with sket | Q / B / Shift+B | Add square / rectangle / rectangle from center (defaults; remappable) | | O / Shift+O | Add circle / three-point circle (defaults; remappable) | | U | Add slot (default; remappable) | +| Shift+U | Add bone (default; remappable) | | P | Sketch from planar face (default; remappable) | | D | Activate the Dimension tool (default; remappable) | | Shift+D / Delete / Backspace | Delete the selected sketch element(s) or dimension | @@ -705,6 +708,46 @@ The slot tool allows you to create an oblong or oval-shaped slot with rounded en - Creating rounded-end cutouts in parts - Designing slots for sliding mechanisms +(bone-creation-tool)= +## Bone Creation Tool + +![Bone Tool](res/icons/Sketcher_CreateBone.png) + +The bone tool builds a connecting-rod outline: the outer arcs of the two end circles and the inward waist arcs where the cutters touch those circles. The result is one closed face you can extrude. Permanent nodes **Bone A** and **Bone B** stay at the two centers for later holes, dimensions, or an operation axis. + +**How to use:** + +1. Select **Add bone** on the toolbar (default Shift+U). +2. Click the first circle center (normal sketch snap). +3. Click the second circle center. Tab sets the center-to-center distance; Shift+Tab sets the bone-axis angle. +4. Click to set **radius 1** (distance from the first center). Tab enters an exact radius. +5. Click to set **radius 2** (distance from the second center). Tab enters an exact radius. +6. Click a point to set **waist width** (minimum remaining thickness between the waist arcs, perpendicular to the center line). When the end radii differ, the neck is offset toward the smaller end, not at the center midpoint. Tab enters an exact waist. If **Holes** is **None**, the tool commits after this click. +7. If **Holes** is **One radius**, click once from the first center to set both hole radii. If **Two radii**, click hole radius at end A, then at end B. Each hole must be smaller than that end's outer radius. + +After a successful commit, the mode switches to **Sketch inspection** (ready to select the face or Extrude). Unlike circle or slot, Add bone does not stay active for another bone. + +A live length dimension is shown only while placing the two centers. End radii, holes, and waist use the geometry preview (circle / bone outline); Tab still enters exact values. + +**Options** (while Add bone is active): + +| Option | Default | Meaning | +| -------------------- | ------- | ------------------------------------------------------------------------------------------- | +| **Add center nodes** | On | Commit permanent **Bone A** / **Bone B** nodes at the two centers | +| **Holes** | None | **None** (outline only), **One radius** (both holes match), or **Two radii** (set each end) | + +Waist cutters are tangent to both end circles (cut radius is solved from the waist). Geometry fails when the two centers coincide, one circle sits inside the other, or the waist cannot be realized. + +The sketch keeps only that trimmed outline (four arcs). Extrude it with the normal **Extrude sketch face** tool. + +**Tips:** + +- Snap the two centers to existing nodes, or to the sketch origin. +- After Add with no holes, use **Add circle** snapped to **Bone A** / **Bone B** if you want holes later. +- For waist, click on either side of the bone axis; the distance from the axis to that point is half the waist. +- If a hole radius is greater than or equal to that end's outer radius, a toast explains the hole must be smaller; the click is ignored. +- Preferences **Add center nodes** and **Holes** are saved as `gui.bone_add_center_nodes` and `gui.bone_holes`. + ## Operation Axis Tool ![Operation Axis Tool](res/icons/Sketcher_MirrorSketch.png) @@ -870,12 +913,12 @@ Edge dimension tool creates/removes **length dimensions between two sketch nodes **Technical Details:** -| | | -| -------------------: | ------------------------------------------------------------------------------ | -| **Dimension source** | Calculated from the two referenced nodes | +| | | +| -------------------: | ---------------------------------------------------------------------------------- | +| **Dimension source** | Calculated from the two referenced nodes | | **Unit system** | **File -> Project units** (Inches or Millimeters); dims and length entry follow it | -| **Auto-update** | Dimensions update automatically when geometry is modified | -| **View-only** | Does not affect the underlying geometry | +| **Auto-update** | Dimensions update automatically when geometry is modified | +| **View-only** | Does not affect the underlying geometry | ## Add Node Tool diff --git a/docs/usage.md b/docs/usage.md index 666df3a9..c373b4cf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -772,6 +772,7 @@ Mode, file, and edit chords in the **General Operations** and **Modeling Shortcu | O | Add circle | | Shift+O | Add circle (three points) | | U | Add slot | +| Shift+U | Add bone | | Shift+P | Polar duplicate | | Shift+X | Cross-section | | Ctrl+Shift+C | Shape cut | @@ -928,6 +929,7 @@ Contributors should follow **[ezycad_code_style.md](ezycad_code_style.md)** for - ![Sketcher_CreateCircle](res/icons/Sketcher_CreateCircle.png) - Add circle (center and radius) - ![Sketcher_Create3PointCircle](res/icons/Sketcher_Create3PointCircle.png) - Add circle from three points *(planned feature)* - ![Sketcher_CreateSlot](res/icons/Sketcher_CreateSlot.png) - Add slot +- ![Sketcher_CreateBone](res/icons/Sketcher_CreateBone.png) - Add bone (Shift+U) - ![TechDraw_LengthDimension](res/icons/TechDraw_LengthDimension.png) - Dimension tool (D) ### 3D Operations diff --git a/res/ezycad_settings.json b/res/ezycad_settings.json index 7741f053..804b799c 100644 --- a/res/ezycad_settings.json +++ b/res/ezycad_settings.json @@ -3,6 +3,8 @@ "add_mid_pt_edges": false, "add_mid_pt_rect_edges": true, "add_mid_pt_slot_edges": true, + "bone_add_center_nodes": true, + "bone_holes": 0, "annotate_all_coaxial_nodes": true, "dark_mode": true, "edge_dim_arrow_orientation": 0, @@ -53,6 +55,7 @@ "mode.add_circle": "O", "mode.add_circle_3_pts": "Shift+O", "mode.add_slot": "U", + "mode.add_bone": "Shift+U", "mode.polar_duplicate": "Shift+P", "mode.cross_section": "Shift+X", "mode.cyl_align": "J", diff --git a/res/icons/Sketcher_CreateBone.png b/res/icons/Sketcher_CreateBone.png new file mode 100644 index 00000000..03c0a9e5 Binary files /dev/null and b/res/icons/Sketcher_CreateBone.png differ diff --git a/res/icons/Sketcher_CreateBone.svg b/res/icons/Sketcher_CreateBone.svg new file mode 100644 index 00000000..17f7e225 --- /dev/null +++ b/res/icons/Sketcher_CreateBone.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/_gen_bone_icon.py b/scripts/_gen_bone_icon.py new file mode 100644 index 00000000..c8109dea --- /dev/null +++ b/scripts/_gen_bone_icon.py @@ -0,0 +1,170 @@ +"""Create Sketcher_CreateBone.svg + .png matching FreeCAD sketcher icon style.""" +from __future__ import annotations + +import math +from pathlib import Path + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +OUT_DIR = ROOT / "res" / "icons" + +# Geometry in 64x64 icon space (same as FreeCAD SVGs). +c1 = (18.0, 32.0) +c2 = (46.0, 32.0) +r1, r2 = 11.0, 15.5 +cut_r = 22.0 + +dist = c2[0] - c1[0] +R1, R2 = r1 + cut_r, r2 + cut_r +a = (R1 * R1 - R2 * R2 + dist * dist) / (2 * dist) +h = math.sqrt(max(0.0, R1 * R1 - a * a)) +mx = c1[0] + a +cut_plus = (mx, c1[1] - h) +cut_minus = (mx, c1[1] + h) + + +def ang(c: tuple[float, float], p: tuple[float, float]) -> float: + return math.atan2(p[1] - c[1], p[0] - c[0]) + + +def pt_on(c: tuple[float, float], r: float, a0: float) -> tuple[float, float]: + return (c[0] + r * math.cos(a0), c[1] + r * math.sin(a0)) + + +def contact(c: tuple[float, float], r: float, cut: tuple[float, float]) -> tuple[float, float]: + dx, dy = cut[0] - c[0], cut[1] - c[1] + d = math.hypot(dx, dy) + return (c[0] + dx * r / d, c[1] + dy * r / d) + + +c1p = contact(c1, r1, cut_plus) +c1m = contact(c1, r1, cut_minus) +c2p = contact(c2, r2, cut_plus) +c2m = contact(c2, r2, cut_minus) +c1o = (c1[0] - r1, c1[1]) +c2o = (c2[0] + r2, c2[1]) + + +def sample_arc( + cx: float, cy: float, r: float, a0: float, a1: float, sweep_ccw: bool, n: int = 48 +) -> list[tuple[float, float]]: + if sweep_ccw: + while a1 < a0: + a1 += 2 * math.pi + return [pt_on((cx, cy), r, a0 + (a1 - a0) * i / n) for i in range(n + 1)] + while a1 > a0: + a1 -= 2 * math.pi + return [pt_on((cx, cy), r, a0 + (a1 - a0) * i / n) for i in range(n + 1)] + + +def poly_outline() -> list[tuple[float, float]]: + pts: list[tuple[float, float]] = [] + # left outer c1o -> c1p CCW + pts += sample_arc(c1[0], c1[1], r1, ang(c1, c1o), ang(c1, c1p), True)[:-1] + # upper waist c1p -> c2p CW around cut_plus + pts += sample_arc(cut_plus[0], cut_plus[1], cut_r, ang(cut_plus, c1p), ang(cut_plus, c2p), False)[:-1] + # right outer c2p -> c2m CCW + pts += sample_arc(c2[0], c2[1], r2, ang(c2, c2p), ang(c2, c2m), True)[:-1] + # lower waist c2m -> c1m CW around cut_minus + pts += sample_arc(cut_minus[0], cut_minus[1], cut_r, ang(cut_minus, c2m), ang(cut_minus, c1m), False)[:-1] + # left bottom c1m -> c1o CCW + pts += sample_arc(c1[0], c1[1], r1, ang(c1, c1m), ang(c1, c1o), True) + return pts + + +def svg_arc(r: float, p_end: tuple[float, float], a0: float, a1: float, sweep_ccw: bool) -> str: + da = (a1 - a0) if sweep_ccw else (a0 - a1) + while da <= 0: + da += 2 * math.pi + large = 1 if da > math.pi else 0 + sweep = 1 if sweep_ccw else 0 + return f"A {r:.3f},{r:.3f} 0 {large},{sweep} {p_end[0]:.3f},{p_end[1]:.3f}" + + +def path_d() -> str: + parts = [f"M {c1o[0]:.3f},{c1o[1]:.3f}"] + parts.append(svg_arc(r1, c1p, ang(c1, c1o), ang(c1, c1p), True)) + parts.append(svg_arc(cut_r, c2p, ang(cut_plus, c1p), ang(cut_plus, c2p), False)) + parts.append(svg_arc(r2, c2m, ang(c2, c2p), ang(c2, c2m), True)) + parts.append(svg_arc(cut_r, c1m, ang(cut_minus, c2m), ang(cut_minus, c1m), False)) + parts.append(svg_arc(r1, c1o, ang(c1, c1m), ang(c1, c1o), True)) + parts.append("Z") + return " ".join(parts) + + +def write_svg(d: str) -> None: + svg = f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + (OUT_DIR / "Sketcher_CreateBone.svg").write_text(svg, encoding="utf-8") + + +def draw_icon(size: int, pts: list[tuple[float, float]]) -> Image.Image: + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + s = size / 64.0 + poly = [(p[0] * s, p[1] * s) for p in pts] + + for width, color in [ + (8 * s, (21, 24, 25, 255)), + (4 * s, (211, 215, 207, 255)), + (2.2 * s, (255, 255, 255, 230)), + ]: + layer = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + dr = ImageDraw.Draw(layer) + dr.line(poly + [poly[0]], fill=color, width=max(1, int(round(width))), joint="curve") + img = Image.alpha_composite(img, layer) + + dr = ImageDraw.Draw(img) + for c in (c1, c2): + cx, cy = c[0] * s, c[1] * s + R = 5.5 * s + r = 3.8 * s + dr.ellipse([cx - R, cy - R, cx + R, cy + R], outline=(46, 0, 0, 255), width=max(1, int(1.5 * s))) + dr.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(239, 41, 41, 255), outline=(239, 41, 41, 255)) + return img + + +def main() -> None: + d = path_d() + write_svg(d) + pts = poly_outline() + draw_icon(48, pts).save(OUT_DIR / "Sketcher_CreateBone.png") + print("wrote", OUT_DIR / "Sketcher_CreateBone.svg") + print("wrote", OUT_DIR / "Sketcher_CreateBone.png") + + +if __name__ == "__main__": + main() diff --git a/src/doc/gui.md b/src/doc/gui.md index baf8bb8b..84b4d5d9 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -166,7 +166,7 @@ Remappable chords live in `Gui_hotkeys` (`gui_hotkeys.h` / `.cpp`), owned by `GU | Remappable chord | `m_hotkeys` hit | `dispatch_hotkey_action_` (`Gui_action`: sketch/shape modes, booleans, delete, copy/paste, file, undo/redo) | | Move-mode keys | `Mode::Move` | `on_key_move_mode_` (axis constraints X/Y/Z); hardcoded | | Rotate-mode keys | `Mode::Rotate` | `on_key_rotate_mode_` (axis pick, Tab angle); hardcoded | -| Align-shafts keys | `Mode::Shape_shaft_align` | `on_key_cyl_align_mode_` (Tab depth, Shift+Tab clock/angle, Enter finalize); hardcoded | +| Align-shafts keys | `Mode::Shape_shaft_align` | `on_key_cyl_align_mode_` (Tab depth, Shift+Tab clock/angle, Enter finalize); hardcoded | Default remappable chords include G/R/S/J/E/C/F/D shape tools; sketch tools N/L/A/Q/B/O/U/I/P and Shift variants; Shift+P polar, Shift+X cross-section; Ctrl+Shift+C/F/M booleans; Shift+D delete; Ctrl+C / Ctrl+V copy/paste (in-app shape clipboard); Ctrl+N/O/S; Ctrl+Z / Ctrl+Y. Unmodified X/Y/Z are reserved for Move/Rotate axis toggles (`is_reserved_chord`); Shift+X remains free for cross-section. Remappable keys must pass `is_bindable_key` (letters, digits, Space, and named keys that round-trip in settings JSON); punctuation such as `,` / `.` and numpad keys are rejected. Settings **Keyboard shortcuts** has a `?` to `doc_urls::k_hotkeys` ([usage-settings.md#keyboard-shortcuts](../../docs/usage-settings.md#keyboard-shortcuts)). @@ -176,16 +176,16 @@ See also [`src/doc/sketch.md`](sketch.md) and [`src/doc/shape.md`](shape.md) for ### Mouse move (`GUI::on_mouse_pos`) -| `Mode` | Delegate | -| --------------------------------------------------- | ------------------------------------------- | -| `Move` | `shp_move().move_selected` | -| `Rotate` | `shp_rotate().rotate_selected` | -| `Scale` | `shp_scale().scale_selected` | -| `Shape_shaft_align` | `shp_cyl_align().drag_depth` / `drag_twist` | -| `Shape_set_frame` | `options_shape_set_frame_mode_` (Shape List only; no toolbar/hotkey) | -| `Shape_polar_duplicate` | `shp_polar_dup().move_point` | -| Sketch tool modes (line, arc, rect, dim, axis, ...) | `curr_sketch().sketch_pt_move` | -| `Sketch_face_extrude` | `sketch_face_extrude(..., true)` | +| `Mode` | Delegate | +| --------------------------------------------------------- | -------------------------------------------------------------------- | +| `Move` | `shp_move().move_selected` | +| `Rotate` | `shp_rotate().rotate_selected` | +| `Scale` | `shp_scale().scale_selected` | +| `Shape_shaft_align` | `shp_cyl_align().drag_depth` / `drag_twist` | +| `Shape_set_frame` | `options_shape_set_frame_mode_` (Shape List only; no toolbar/hotkey) | +| `Shape_polar_duplicate` | `shp_polar_dup().move_point` | +| Sketch tool modes (line, arc, rect, dim, axis, bone, ...) | `curr_sketch().sketch_pt_move` | +| `Sketch_face_extrude` | `sketch_face_extrude(..., true)` | Always calls `m_view->on_mouse_move(screen_coords)` first. @@ -215,7 +215,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | -------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `Normal` | `options_normal_mode_` (selection filter, orthographic) | | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | -| `Shape_shaft_align` | `options_shape_shaft_align_mode_` (Flip direction, Clock rotation; pick / depth / clock help) | +| `Shape_shaft_align` | `options_shape_shaft_align_mode_` (Flip direction, Clock rotation; pick / depth / clock help) | | `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, show section outline, bbox-ranged offset, Clip, Cross section sketch | @@ -224,7 +224,18 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | | `Sketch_face_extrude` | Both sides, Twist, material; help mentions Settings fast preview | -Shared sketch controls (snap, midpoint nodes, place-from-center) live in `options_sketch_common_` and helpers in `gui_mode.cpp`. +### Options panel layout (sketch tools) + +Vertical order for sketch tool Options panes: + +1. Mode title + doc **?** (`options_sketch_mode_header_`) +2. Tool-specific **Options** (checkboxes, combos, tool help) — **above** shared sketch controls +3. Separator, then **Sketch options** (`options_sketch_shared_controls_`: snap guide mode, snap dist, faint shapes, …) +4. Optional **Shortcuts** / other footers (`options_sketch_len_angle_hotkeys_`, …) + +`options_sketch_common_()` is header + shared controls only (no tool-specific block). Prefer composing header → tool Options → shared when a tool has its own controls (see `options_sketch_add_bone_mode_`). Do not put tool-specific controls below **Sketch options**. + +Shared sketch controls (snap, faint shapes) live in `options_sketch_shared_controls_`. Midpoint / place-from-center helpers still append after `options_sketch_common_` in older tools — new work should use the order above. ## ImGui frame order (`render_gui`) diff --git a/src/doc/sketch.md b/src/doc/sketch.md index 79ce21c1..2945edab 100644 --- a/src/doc/sketch.md +++ b/src/doc/sketch.md @@ -12,7 +12,7 @@ The class is a **coordinator**: it holds shared state (plane, viewer context, vi Typical uses: -- Interactive creation and editing via sketch tools (line, arc, rectangle, slot, add-node, dimension, operation axis). +- Interactive creation and editing via sketch tools (line, arc, rectangle, slot, bone, add-node, dimension, operation axis). - Face extraction for extrusion and revolve into 3D solids. - Mirror selected edges about an operation axis. - JSON save/load and undo/redo through stable sketch and node identity. @@ -66,7 +66,7 @@ Sketch (coordinator: skt.cpp, skt.h) +-- Sketch_edges persistent edge list; add, split, remove, pick +-- Sketch_topo planar graph -> closed faces, edge splitting +-- Sketch_dims length dimensions, typed distance/angle input - +-- Sketch_tools interactive drawing session (tmp state) + +-- Sketch_tools interactive drawing session (tmp state; bone in skt_tools_bone.cpp) +-- Sketch_underlay raster image on the sketch plane Supporting (not owned sub-objects): @@ -203,7 +203,8 @@ Prefer these visitors in JSON/delta/topo code over iterating `std::list`, `CHK_RET`, `clear_all`, textures, image decode, name uniquification | -| [`utl_types.h`](../utl_types.h) | OCCT/AIS handle typedefs, `ScreenCoords`, `Export_format`, `Export_unit`, `DECL_PTR`, `SafeType` | -| [`utl_geom.h`](../utl_geom.h) / [`.cpp`](../utl_geom.cpp) | 2D/3D geometry, wires, dimensions, Boost polygon tests, plane projection | -| [`utl_geom_boost.inl`](../utl_geom_boost.inl) | `ezy_geom` Boost.Geometry aliases | -| [`utl_occt.h`](../utl_occt.h) / [`.cpp`](../utl_occt.cpp) | `TopAbs` name table, `try_make_solid`, `append_cad_import_bodies`, `standard_failure_message` | -| [`utl_json.h`](../utl_json.h) / [`.cpp`](../utl_json.cpp) | JSON serializers for `gp_Pnt`, `gp_Pln`, etc. | -| [`utl_io.h`](../utl_io.h) / [`.cpp`](../utl_io.cpp) | `.ezy` zip v3 pack/unpack, format sniff, base64 | -| [`utl_asset_store.h`](../utl_asset_store.h) / [`.cpp`](../utl_asset_store.cpp) | Content-addressed RGBA blobs for sketch underlay assets | -| [`utl_settings.h`](../utl_settings.h) / [`.cpp`](../utl_settings.cpp) | User settings file paths, startup project blob I/O | -| [`utl_ply_io.h`](../utl_ply_io.h) / [`.cpp`](../utl_ply_io.cpp) | PLY import/export for mesh shapes | -| [`utl_cad_file_info.h`](../utl_cad_file_info.h) / [`.cpp`](../utl_cad_file_info.cpp) | Read-only STEP/IGES/STL/PLY metadata for **File -> Import** (no document mutation until Import) | -| [`utl_log.h`](../utl_log.h) / [`.cpp`](../utl_log.cpp) | `Log_strm` redirecting stdout/stderr to `GUI::log_message` | -| [`utl_dbg.h`](../utl_dbg.h) | `EZY_ASSERT`, `DBG_MSG`, debug break macros | +| File | Responsibility | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| [`utl.h`](../utl.h) / [`utl.cpp`](../utl.cpp) | `Status`, `Result`, `CHK_RET`, `clear_all`, textures, image decode, name uniquification | +| [`utl_types.h`](../utl_types.h) | OCCT/AIS handle typedefs, `ScreenCoords`, `Export_format`, `Export_unit`, `DECL_PTR`, `SafeType` | +| [`utl_geom.h`](../utl_geom.h) / [`.cpp`](../utl_geom.cpp) | 2D/3D geometry, wires, dimensions, Boost polygon tests, plane projection | +| [`utl_geom_boost.inl`](../utl_geom_boost.inl) | `ezy_geom` Boost.Geometry aliases | +| [`utl_occt.h`](../utl_occt.h) / [`.cpp`](../utl_occt.cpp) | `TopAbs` name table, `try_make_solid`, `append_cad_import_bodies`, `standard_failure_message` | +| [`utl_json.h`](../utl_json.h) / [`.cpp`](../utl_json.cpp) | JSON serializers for `gp_Pnt`, `gp_Pln`, etc. | +| [`utl_io.h`](../utl_io.h) / [`.cpp`](../utl_io.cpp) | `.ezy` zip v3 pack/unpack, format sniff, base64 | +| [`utl_asset_store.h`](../utl_asset_store.h) / [`.cpp`](../utl_asset_store.cpp) | Content-addressed RGBA blobs for sketch underlay assets | +| [`utl_settings.h`](../utl_settings.h) / [`.cpp`](../utl_settings.cpp) | User settings file paths, startup project blob I/O | +| [`utl_ply_io.h`](../utl_ply_io.h) / [`.cpp`](../utl_ply_io.cpp) | PLY import/export for mesh shapes | +| [`utl_cad_file_info.h`](../utl_cad_file_info.h) / [`.cpp`](../utl_cad_file_info.cpp) | Read-only STEP/IGES/STL/PLY metadata for **File -> Import** (no document mutation until Import) | +| [`utl_log.h`](../utl_log.h) / [`.cpp`](../utl_log.cpp) | `Log_strm` redirecting stdout/stderr to `GUI::log_message` | +| [`utl_dbg.h`](../utl_dbg.h) | `EZY_ASSERT`, `DBG_MSG`, debug break macros | CMake IDE group: `src\utl` (pattern `^utl(_|\.)`). @@ -48,13 +48,13 @@ CMake IDE group: `src\utl` (pattern `^utl(_|\.)`). ### General helpers -| API | Purpose | -| ---------------------------------------- | ------------------------------------------------ | +| API | Purpose | +| ---------------------------------------- | ------------------------------------------------------------------------------------- | | `clear_all(...)` | Reset optional/containers/arithmetic/handles (`Nullify`)/enums/aggregates in one call | -| `unique_sequential_name(base, existing)` | `Name`, `Name.001`, ... for sketches/shapes | -| `load_texture(path)` | Toolbar icon loading | -| `decode_image_bytes(bytes)` | stb_image -> RGBA for underlay import | -| `safe_cstr_copy` | ImGui fixed-buffer copies (MSVC-safe) | +| `unique_sequential_name(base, existing)` | `Name`, `Name.001`, ... for sketches/shapes | +| `load_texture(path)` | Toolbar icon loading | +| `decode_image_bytes(bytes)` | stb_image -> RGBA for underlay import | +| `safe_cstr_copy` | ImGui fixed-buffer copies (MSVC-safe) | ## Geometry (`utl_geom`) @@ -63,7 +63,7 @@ Large module; grouped by concern: | Area | Examples | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Plane / 2D | `to_2d`, `to_3d`, `xy_plane`, `sketch_reference_plane`, `Plane_side` | -| Profile wires | `make_square_wire`, `make_circle_wire`, `make_slot_wire`, `create_wire_box` | +| Profile wires | `make_square_wire`, `make_circle_wire`, `make_slot_wire`, `make_bone_wire`, `create_wire_box` | | Sketch dimensions | `Length_dimension_style`, `create_distance_annotation`, `create_angle_annotation`, `apply_length_dimension_style`, `apply_angle_dimension_style` | | Analysis | `to_boost` (polygon), `to_boost_ls` (edge `linestring_2d`), `get_shape_bbox_center`, `plane_from_face`, `side_of_plane` | | Tests / debug | `to_wkt_string` (linestring / ring / polygon), `ezy_geom::area`, `is_valid`; Geometry Watch in `scripts/ezycad_graphical_debugging.xml` (`ring_2d` inherits vector as Ring; `linestring_2d` uses named `points` as Linestring). Re-select the XML path in Options after editing it. | @@ -74,10 +74,10 @@ Includes [`utl_geom_boost.inl`](../utl_geom_boost.inl) for `ezy_geom` polygon / ### `.ezy` v3 zip layout -| Path in archive | Content | -| ------------------ | ------------------------------------------------------------- | +| Path in archive | Content | +| ------------------ | ----------------------------------------------------------------------------------------- | | `manifest.json` | Document JSON (`ezyFormat`, `projectUnit`, sketches, shapes, view, mode, `ui.sketchList`) | -| `assets/.rgba` | Raw RGBA pixels for underlay `"asset"` references | +| `assets/.rgba` | Raw RGBA pixels for underlay `"asset"` references | | Function | Role | | ------------------------------ | --------------------------------------------------------------- | @@ -127,13 +127,13 @@ Raw PLY parse/write only (no unit conversion). `Occt_view::import_ply` / `export Used by **File -> Import**. Reads file bytes only until the user confirms import; does not add shapes by itself. -| API | Role | -| --------------------------- | ----------------------------------------------------------------- | -| `detect(path, bytes)` | Format from extension, then content sniff | -| `can_import(fmt)` | True for STEP and PLY | -| `collect(path, bytes)` | Label/value rows (size, roots/shapes, mesh header, bbox, etc.) | -| `read_step_named_bodies` | STEPCAF/XCAF bodies + product names (flat; falls back to plain reader) | -| `read_step_named_tree` | STEPCAF/XCAF assembly tree as group/leaf `Named_node`s (falls back flat) | +| API | Role | +| ------------------------ | ------------------------------------------------------------------------ | +| `detect(path, bytes)` | Format from extension, then content sniff | +| `can_import(fmt)` | True for STEP and PLY | +| `collect(path, bytes)` | Label/value rows (size, roots/shapes, mesh header, bbox, etc.) | +| `read_step_named_bodies` | STEPCAF/XCAF bodies + product names (flat; falls back to plain reader) | +| `read_step_named_tree` | STEPCAF/XCAF assembly tree as group/leaf `Named_node`s (falls back flat) | `Occt_view::import_step` takes `Step_import_mode` (`utl_types.h`): preserve hierarchy (default), flat root leaves, or union. Heavy work splits into `prepare_step_import` (thread-safe geometry) + `commit_step_import` (UI thread). STEP Transfer accepts optional `Atomic_progress_indicator` / `Message_ProgressRange` (`utl_occt_progress.h`). `collect` remains available for tooling but is not shown in the Import dialog. diff --git a/src/gui.cpp b/src/gui.cpp index dcb05141..ffdc5deb 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -197,6 +197,7 @@ void GUI::initialize_toolbar_() {load_texture("res/icons/Sketcher_CreateCircle.png"), false, "Add circle", Mode::Sketch_add_circle}, {load_texture("res/icons/Sketcher_Create3PointCircle.png"), false, "Add circle from three points", Mode::Sketch_add_circle_3_pts}, {load_texture("res/icons/Sketcher_CreateSlot.png"), false, "Add slot", Mode::Sketch_add_slot}, + {load_texture("res/icons/Sketcher_CreateBone.png"), false, "Add bone", Mode::Sketch_add_bone}, {load_texture("res/icons/TechDraw_LengthDimension.png"), false, "Length dimension", Mode::Sketch_dim_anno}, {load_texture("res/icons/Design456_Extrude.png"), false, "Extrude sketch face", Mode::Sketch_face_extrude}, {load_texture("res/icons/PartDesign_Chamfer.png"), false, "Chamfer", Mode::Shape_chamfer}, @@ -262,6 +263,7 @@ void GUI::sync_toolbar_hotkey_tooltips_() tip_mode(Mode::Sketch_add_circle, "Add circle", Gui_action::Mode_add_circle); tip_mode(Mode::Sketch_add_circle_3_pts, "Add circle from three points", Gui_action::Mode_add_circle_3_pts); tip_mode(Mode::Sketch_add_slot, "Add slot", Gui_action::Mode_add_slot); + tip_mode(Mode::Sketch_add_bone, "Add bone", Gui_action::Mode_add_bone); tip_mode(Mode::Shape_polar_duplicate, "Shape polar duplicate", Gui_action::Mode_polar_duplicate); tip_mode(Mode::Shape_cross_section, "Shape cross-section", Gui_action::Mode_cross_section); tip_cmd(Command::Shape_cut, "Shape cut", Gui_action::Cmd_shape_cut); @@ -4020,6 +4022,7 @@ void GUI::on_mouse_pos(const ScreenCoords& screen_coords) case Mode::Sketch_add_rectangle_center_pt: case Mode::Sketch_add_circle: case Mode::Sketch_add_slot: + case Mode::Sketch_add_bone: case Mode::Sketch_add_seg_circle_arc: case Mode::Sketch_dim_anno: m_view->curr_sketch().sketch_pt_move(screen_coords); break; case Mode::Sketch_face_extrude: m_view->sketch_face_extrude(screen_coords, true); break; @@ -4061,6 +4064,7 @@ void GUI::on_left_click_(const ScreenCoords& screen_coords) case Mode::Sketch_add_rectangle_center_pt: case Mode::Sketch_add_circle: case Mode::Sketch_add_slot: + case Mode::Sketch_add_bone: hide_dist_edit(); m_view->curr_sketch().add_sketch_pt(screen_coords); break; diff --git a/src/gui.h b/src/gui.h index e7949fa2..a7ce18dd 100644 --- a/src/gui.h +++ b/src/gui.h @@ -215,6 +215,7 @@ inline constexpr const char* k_line_edge_place_from_center = "https://ezycad.re inline constexpr const char* k_revolve_solid_conversion = "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#revolve-solid-conversion"; inline constexpr const char* k_shape_selection_filter = "https://ezycad.readthedocs.io/en/latest/usage.html#shape-selection-filter-normal-mode-only"; inline constexpr const char* k_add_node_tool = "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#add-node-tool"; +inline constexpr const char* k_bone_creation_tool = "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#bone-creation-tool"; inline constexpr const char* k_image_underlay = "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#image-underlay"; inline constexpr const char* k_usage_settings_options = "https://ezycad.readthedocs.io/en/latest/usage-settings.html#options-panel"; inline constexpr const char* k_occt_view = "https://ezycad.readthedocs.io/en/latest/usage-occt-view.html"; @@ -304,6 +305,10 @@ class GUI 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; } + /// Add-bone Options: permanent Bone A / Bone B nodes (`gui.bone_add_center_nodes`). + bool get_bone_add_center_nodes() const { return m_bone_add_center_nodes; } + /// Add-bone Options: hole clicks after waist (`gui.bone_holes`). + Bone_holes get_bone_holes() const { return m_bone_holes; } 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; } @@ -472,6 +477,7 @@ class GUI void options_sketch_add_circle_mode_(); void options_sketch_add_circle_three_pts_mode_(); void options_sketch_add_slot_mode_(); + void options_sketch_add_bone_mode_(); const char* current_mode_description_() const; @@ -480,6 +486,10 @@ class GUI void doc_help_button_(const char* scope, int line, const char* tooltip, const char* doc_url, bool trailing_same_line = false); void options_orthographic_projection_(); void options_sketch_common_(); + /// Mode title + doc ? + separator (call before tool-specific Options). + void options_sketch_mode_header_(); + /// Shared **Sketch options** block (snap, faint shapes). Goes below tool-specific Options. + void options_sketch_shared_controls_(); void options_sketch_len_angle_hotkeys_(); void sync_sketch_add_mid_pt_edges_if_applicable_(); bool add_mid_pt_edges_for_mode_(Mode mode) const; @@ -650,6 +660,8 @@ class GUI 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_bone_add_center_nodes = true; + Bone_holes m_bone_holes = Bone_holes::None; 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; diff --git a/src/gui_add.cpp b/src/gui_add.cpp index 82cd61c4..a78947eb 100644 --- a/src/gui_add.cpp +++ b/src/gui_add.cpp @@ -1,6 +1,7 @@ #include "gui.h" #include "utl_geom.h" #include "gui_occt_view.h" +#include "skt.h" namespace { diff --git a/src/gui_hotkeys.cpp b/src/gui_hotkeys.cpp index 3d5ac885..5f589f47 100644 --- a/src/gui_hotkeys.cpp +++ b/src/gui_hotkeys.cpp @@ -42,6 +42,7 @@ constexpr Action_meta c_actions[] = { {Gui_action::Mode_add_circle, "mode.add_circle", "Add circle", {GLFW_KEY_O, 0}}, {Gui_action::Mode_add_circle_3_pts, "mode.add_circle_3_pts", "Add circle (3 pts)", {GLFW_KEY_O, GLFW_MOD_SHIFT}}, {Gui_action::Mode_add_slot, "mode.add_slot", "Add slot", {GLFW_KEY_U, 0}}, + {Gui_action::Mode_add_bone, "mode.add_bone", "Add bone", {GLFW_KEY_U, GLFW_MOD_SHIFT}}, {Gui_action::Mode_polar_duplicate, "mode.polar_duplicate", "Polar duplicate", {GLFW_KEY_P, GLFW_MOD_SHIFT}}, {Gui_action::Mode_cross_section, "mode.cross_section", "Cross-section", {GLFW_KEY_X, GLFW_MOD_SHIFT}}, {Gui_action::Mode_cyl_align, "mode.cyl_align", "Align shafts", {GLFW_KEY_J, 0}}, diff --git a/src/gui_hotkeys.h b/src/gui_hotkeys.h index 2bdd992c..06eeed21 100644 --- a/src/gui_hotkeys.h +++ b/src/gui_hotkeys.h @@ -30,6 +30,7 @@ enum class Gui_action Mode_add_circle, Mode_add_circle_3_pts, Mode_add_slot, + Mode_add_bone, Mode_polar_duplicate, Mode_cross_section, Mode_cyl_align, diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 74faaca4..ed0b3c68 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -64,6 +64,7 @@ std::string GUI::get_doc_url_for_mode(Mode mode) {Mode::Sketch_add_circle, "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#circle-creation-tools"}, {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_add_bone, "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#bone-creation-tool"}, {Mode::Sketch_dim_anno, "https://ezycad.readthedocs.io/en/latest/usage-sketch.html#dimension-tool"}, {Mode::Shape_cross_section, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-cross-section-tool"}, {Mode::Shape_set_frame, "https://ezycad.readthedocs.io/en/latest/usage.html#shape-list"}, @@ -148,6 +149,7 @@ Mode GUI::parent_mode_of(Mode mode) {Mode::Sketch_add_circle, Mode::Sketch_inspection_mode}, {Mode::Sketch_add_circle_3_pts, Mode::Sketch_inspection_mode}, {Mode::Sketch_add_slot, Mode::Sketch_inspection_mode}, + {Mode::Sketch_add_bone, Mode::Sketch_inspection_mode}, {Mode::Sketch_dim_anno, Mode::Sketch_inspection_mode}, {Mode::Shape_cross_section, Mode::Normal}, {Mode::Shape_set_frame, Mode::Normal}, @@ -402,6 +404,7 @@ void GUI::dispatch_hotkey_action_(Gui_action action) case Gui_action::Mode_add_circle: set_mode(Mode::Sketch_add_circle); break; case Gui_action::Mode_add_circle_3_pts: set_mode(Mode::Sketch_add_circle_3_pts); break; case Gui_action::Mode_add_slot: set_mode(Mode::Sketch_add_slot); break; + case Gui_action::Mode_add_bone: set_mode(Mode::Sketch_add_bone); break; case Gui_action::Mode_polar_duplicate: set_mode(Mode::Shape_polar_duplicate); break; case Gui_action::Mode_cross_section: set_mode(Mode::Shape_cross_section); break; case Gui_action::Cmd_shape_cut: @@ -536,6 +539,7 @@ void GUI::options_() case Mode::Sketch_add_circle: options_sketch_add_circle_mode_(); break; case Mode::Sketch_add_circle_3_pts: options_sketch_add_circle_three_pts_mode_(); break; case Mode::Sketch_add_slot: options_sketch_add_slot_mode_(); break; + case Mode::Sketch_add_bone: options_sketch_add_bone_mode_(); break; default: EZY_ASSERT_MSG(false, "Options panel: unhandled mode"); break; @@ -1252,6 +1256,51 @@ void GUI::options_sketch_add_slot_mode_() options_sketch_add_midpoint_nodes_(m_add_mid_pt_slot_edges); } +void GUI::options_sketch_add_bone_mode_() +{ + EZY_ASSERT(get_mode() == Mode::Sketch_add_bone); + + // Tool-specific Options above shared Sketch options (see src/doc/gui.md Options panel layout). + options_sketch_mode_header_(); + + ImGui::TextUnformatted("Options"); + + bool add_centers = m_bone_add_center_nodes; + if (ImGui::Checkbox("Add center nodes", &add_centers)) + { + m_bone_add_center_nodes = add_centers; + save_occt_view_settings(); + } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + GUI_DOC_HELP_("When on, commits permanent Bone A and Bone B nodes at the end centers. " + "Click ? to open the user guide.", + doc_urls::k_bone_creation_tool); + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Holes"); + ImGui::SameLine(); + int holes = static_cast(m_bone_holes); + if (ImGui::Combo("##bone_holes", &holes, c_bone_holes_strs.data(), static_cast(Bone_holes::_count))) + { + if (holes >= 0 && holes < static_cast(Bone_holes::_count)) + { + m_bone_holes = static_cast(holes); + save_occt_view_settings(); + } + } + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + GUI_DOC_HELP_("None: outline only. One radius: one click sets both holes. Two radii: set each end. " + "Hole radius must be smaller than that end's outer radius. Click ? to open the user guide.", + doc_urls::k_bone_creation_tool); + + ImGui::TextWrapped( + "Click center A, center B, radius 1, radius 2, waist, then holes if enabled."); + + ImGui::Separator(); + options_sketch_shared_controls_(); + options_sketch_len_angle_hotkeys_(); +} + void GUI::options_orthographic_projection_() { ImGui::Separator(); @@ -1271,12 +1320,20 @@ void GUI::options_orthographic_projection_() } void GUI::options_sketch_common_() +{ + options_sketch_mode_header_(); + options_sketch_shared_controls_(); +} + +void GUI::options_sketch_mode_header_() { ImGui::TextUnformatted(current_mode_description_()); options_doc_help_button_(); - ImGui::Separator(); +} +void GUI::options_sketch_shared_controls_() +{ ImGui::TextUnformatted("Sketch options"); if (ImGui::BeginTable("options_sketch_sketch", 2, k_options_table_flags)) { diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index aabbb966..fba7aef2 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -64,6 +64,8 @@ std::string GUI::occt_view_settings_json() const {"add_mid_pt_edges", m_add_mid_pt_line_edges}, {"add_mid_pt_rect_edges", m_add_mid_pt_rect_edges}, {"add_mid_pt_slot_edges", m_add_mid_pt_slot_edges}, + {"bone_add_center_nodes", m_bone_add_center_nodes}, + {"bone_holes", static_cast(m_bone_holes)}, {"view_roll_step_deg", m_view_roll_step_deg}, {"view_zoom_scroll_scale", m_view_zoom_scroll_scale}, {"default_2d_view_width", m_default_2d_view_width}, @@ -156,6 +158,8 @@ void GUI::save_occt_view_settings() {"add_mid_pt_edges", m_add_mid_pt_line_edges}, {"add_mid_pt_rect_edges", m_add_mid_pt_rect_edges}, {"add_mid_pt_slot_edges", m_add_mid_pt_slot_edges}, + {"bone_add_center_nodes", m_bone_add_center_nodes}, + {"bone_holes", static_cast(m_bone_holes)}, {"load_last_opened_on_startup", m_load_last_opened_on_startup}, {"last_opened_project_path", m_last_opened_project_path}, {"imgui_style_dark", imgui_style_to_json_(m_imgui_style_dark)}, @@ -462,6 +466,14 @@ void GUI::parse_gui_panes_settings_(const std::string& content) m_add_mid_pt_rect_edges = b("add_mid_pt_rect_edges", true); m_add_mid_pt_slot_edges = b("add_mid_pt_slot_edges", false); + m_bone_add_center_nodes = b("bone_add_center_nodes", true); + m_bone_holes = Bone_holes::None; + if (g.contains("bone_holes") && g["bone_holes"].is_number_integer()) + { + const int holes = g["bone_holes"].get(); + if (holes >= 0 && holes < static_cast(Bone_holes::_count)) + m_bone_holes = static_cast(holes); + } m_load_last_opened_on_startup = b("load_last_opened_on_startup", b("load_last_saved_on_startup", false)); if (g.contains("last_opened_project_path") && g["last_opened_project_path"].is_string()) diff --git a/src/mode.cpp b/src/mode.cpp index 50ad9dee..dd489652 100644 --- a/src/mode.cpp +++ b/src/mode.cpp @@ -24,6 +24,7 @@ bool is_sketch_mode(const Mode mode) case Mode::Sketch_add_circle: case Mode::Sketch_add_circle_3_pts: case Mode::Sketch_add_slot: + case Mode::Sketch_add_bone: case Mode::Sketch_operation_axis: case Mode::Sketch_dim_anno: case Mode::Sketch_face_extrude: diff --git a/src/mode.h b/src/mode.h index 6d1ff60e..0c4fba63 100644 --- a/src/mode.h +++ b/src/mode.h @@ -28,6 +28,7 @@ X(Sketch_add_circle) \ X(Sketch_add_circle_3_pts) \ X(Sketch_add_slot) \ + X(Sketch_add_bone) /* two centers, then r1, r2, waist clicks */ \ X(Sketch_dim_anno) \ X(Shape_cross_section) \ X(Shape_shaft_align) \ @@ -92,6 +93,23 @@ static_assert(c_fillet_mode_strs.size() == static_cast(Fillet_mode: #undef EZY_CHAMFER_FILLET_MODE_LIST +/// Add-bone Options: optional end holes after the waist click. +enum class Bone_holes +{ + None, // outline only + One_radius, // one click sets both hole radii + Two_radii, // hole at end A, then hole at end B + _count +}; + +constexpr std::array(Bone_holes::_count)> c_bone_holes_strs = { + "None", + "One radius", + "Two radii", +}; + +static_assert(c_bone_holes_strs.size() == static_cast(Bone_holes::_count)); + bool is_sketch_mode(const Mode mode); /// Return Mode for a name (e.g. "Normal", "Sketch_add_edge"). Returns Normal if not found. diff --git a/src/shp_extrude.h b/src/shp_extrude.h index d880d441..a1331021 100644 --- a/src/shp_extrude.h +++ b/src/shp_extrude.h @@ -74,7 +74,7 @@ class Shp_extrude : private Shp_operation_base PrsDim_LengthDimension_ptr m_tmp_dim; PrsDim_AngleDimension_ptr m_tmp_angle_dim; Plane_side m_extrude_side; - bool m_extrude_both_sides{false}; + bool m_extrude_both_sides{true}; bool m_twist_enabled{false}; Phase m_phase{Phase::Height}; double m_twist_angle{0.0}; // radians, CCW about face centroid / plane normal diff --git a/src/skt.cpp b/src/skt.cpp index 15926fd8..794fd65c 100644 --- a/src/skt.cpp +++ b/src/skt.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -151,6 +152,70 @@ void Sketch::add_arc_circle(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_mid, const void Sketch::rebuild_faces() { update_faces_(); } +void Sketch::add_bone(const gp_Pnt2d& c1, const gp_Pnt2d& c2, double r1, double r2, double waist, + bool add_center_nodes, std::optional hole_r1, std::optional hole_r2) +{ + Bone_params params; + params.c1 = c1; + params.c2 = c2; + params.r1 = r1; + params.r2 = r2; + params.waist = waist; + params.drive = Bone_drive::Waist; + const std::optional g = compute_bone_geom(params); + if (!g) + return; + + auto hole_ok = [](double outer_r, const std::optional& hole) -> bool + { + if (!hole) + return true; + return *hole > Precision::Confusion() && *hole + Precision::Confusion() < outer_r; + }; + if (!hole_ok(r1, hole_r1) || !hole_ok(r2, hole_r2)) + return; + + Sketch_op_recorder rec(m_view, *this); + { + auto mark_center = [&](const gp_Pnt2d& c, const char* name) + { + const size_t idx = m_nodes.get_node_exact(c, true); + Sketch_nodes::Node& n = m_nodes[idx]; + n.permanent = true; + n.name = name; + rec.note_curr_node(idx); + }; + + auto add_hole_circle = [&](const gp_Pnt2d& center, double radius) + { + const gp_Pnt2d rim(center.X() + radius, center.Y()); + const std::array pts = xy_stencil_pnts(center, rim); + add_arc_circle_(pts[0], pts[2], pts[1], rec); + add_arc_circle_(pts[0], pts[3], pts[1], rec); + }; + + const Bone_profile p = get_bone_profile(*g); + if (add_center_nodes) + { + mark_center(g->c1, "Bone A"); + mark_center(g->c2, "Bone B"); + } + add_arc_circle_(p.c1_minus, p.c1_outer, p.c1_plus, rec); + add_arc_circle_(p.c1_plus, p.waist_plus, p.c2_plus, rec); + add_arc_circle_(p.c2_plus, p.c2_outer, p.c2_minus, rec); + add_arc_circle_(p.c2_minus, p.waist_minus, p.c1_minus, rec); + if (hole_r1) + add_hole_circle(g->c1, *hole_r1); + if (hole_r2) + add_hole_circle(g->c2, *hole_r2); + rec.commit(); + } + + m_nodes.finalize(); + m_node_marks.sync(); + update_faces_(); +} + void Sketch::add_edge_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, Sketch_op_recorder& rec) { m_edges.add_edge(pt_a, pt_b, rec); diff --git a/src/skt.h b/src/skt.h index 391f2aff..98feff52 100644 --- a/src/skt.h +++ b/src/skt.h @@ -167,6 +167,13 @@ class Sketch void add_arc_circle(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_mid, const gp_Pnt2d& pt_c); /// Rebuild closed-face topology after bulk edge import. void rebuild_faces(); + /// Add a bone outline (outer end arcs and inner waist arcs). + /// Waist cutters are tangent to both end circles; cut radius is solved from \\a waist. + /// Optional permanent **Bone A** / **Bone B** centers; optional hole circles (radii must be + /// positive and smaller than the matching end radii). + void add_bone(const gp_Pnt2d& c1, const gp_Pnt2d& c2, double r1, double r2, double waist, + bool add_center_nodes = true, std::optional hole_r1 = std::nullopt, + std::optional hole_r2 = std::nullopt); private: friend class Sketch_json; diff --git a/src/skt_dims.cpp b/src/skt_dims.cpp index 55fe3553..a2059f4b 100644 --- a/src/skt_dims.cpp +++ b/src/skt_dims.cpp @@ -66,7 +66,13 @@ void Sketch_dims::on_finalize_elm_start() clear_tmp_dim_anno(); } -void Sketch_dims::on_clear_tmps() { clear_all(m_entered_edge_len, m_show_dim_input, m_entered_edge_angle, m_show_angle_input); } +void Sketch_dims::on_clear_tmps() +{ + clear_all(m_entered_edge_len, m_show_dim_input, m_entered_edge_angle, m_show_angle_input); + m_sketch.m_view.gui().hide_dist_edit(false); + m_sketch.m_view.gui().hide_angle_edit(); + clear_tmp_dim_anno(); +} void Sketch_dims::clear_typed_constraints() { diff --git a/src/skt_op_recorder.cpp b/src/skt_op_recorder.cpp index 94430599..84c1a3da 100644 --- a/src/skt_op_recorder.cpp +++ b/src/skt_op_recorder.cpp @@ -56,8 +56,9 @@ struct Sketch_op_data struct Curr_node_record { - gp_Pnt2d pt; - bool permanent{false}; + gp_Pnt2d pt; + bool permanent{false}; + std::string name; }; Sketch* m_sketch{nullptr}; @@ -129,7 +130,12 @@ class Sketch_op_recorder::Impl Sketch_op_recorder* m_owner{nullptr}; bool m_active{true}; bool m_committed{false}; - std::vector m_live_node_pts_at_start; + struct Live_node_at_start + { + gp_Pnt2d pt; + bool permanent{false}; + }; + std::vector m_live_nodes_at_start; std::vector m_linear_edges_at_start; Sketch_op_data m_data; @@ -222,7 +228,7 @@ Sketch_op_recorder::Impl::Impl(Occt_view& view, Sketch& sketch) { for (size_t i = 0, n = sketch.m_nodes.size(); i < n; ++i) if (!sketch.m_nodes[i].deleted) - m_live_node_pts_at_start.push_back(sketch.m_nodes[i]); + m_live_nodes_at_start.push_back({sketch.m_nodes[i], sketch.m_nodes[i].permanent}); Sketch_op_data::capture_linear_edges_at_start_(sketch, m_linear_edges_at_start); m_data.m_sketch = &sketch; @@ -307,17 +313,27 @@ void Sketch_op_recorder::Impl::note_curr_node(size_t node_idx) if (!m_active) return; - const gp_Pnt2d pt = m_sketch.m_nodes[node_idx]; + const gp_Pnt2d pt = m_sketch.m_nodes[node_idx]; + const bool now_permanent = m_sketch.m_nodes[node_idx].permanent; + + for (const Live_node_at_start& live : m_live_nodes_at_start) + { + if (!pts_equal_(live.pt, pt)) + continue; - for (const gp_Pnt2d& live : m_live_node_pts_at_start) - if (pts_equal_(live, pt)) + // Pre-existing permanent nodes are not owned by this op. Pre-existing non-permanent + // nodes stay owned by topology unless this op promotes them to permanent (e.g. bone centers). + if (live.permanent || !now_permanent) return; + break; + } + for (const Sketch_op_data::Curr_node_record& x : m_data.curr_nodes) if (pts_equal_(x.pt, pt)) return; - m_data.curr_nodes.push_back({pt, m_sketch.m_nodes[node_idx].permanent}); + m_data.curr_nodes.push_back({pt, now_permanent, m_sketch.m_nodes[node_idx].name}); } void Sketch_op_recorder::Impl::note_prev_length_dim(size_t lo, size_t hi, bool visible, std::optional flyout, @@ -463,6 +479,7 @@ void Sketch_op_data::apply_reverse_(Occt_view& view) const for (const Curr_node_record& node : curr_nodes) tombstone_node_at_pt_(*sketch, node.pt); + sketch->m_node_marks.sync(); sketch->m_nodes.hide_snap_annos(); sketch->update_faces_(); } @@ -615,6 +632,9 @@ void Sketch_op_data::restore_curr_node_at_pt_(Sketch& sketch, const Curr_node_re } const size_t node_idx = sketch.m_nodes.get_node_exact(rec.pt, rec.permanent); + if (!rec.name.empty()) + sketch.m_nodes[node_idx].name = rec.name; + if (is_arc_bulge) return; diff --git a/src/skt_tools.cpp b/src/skt_tools.cpp index 73c1686f..b2340b3b 100644 --- a/src/skt_tools.cpp +++ b/src/skt_tools.cpp @@ -9,6 +9,9 @@ #include #include #include +#include +#include +#include #include "gui.h" #include "mode.h" @@ -20,6 +23,8 @@ #include "utl_occt.h" #include "utl.h" +#include "skt_tools.inl" + using namespace glm; namespace @@ -55,6 +60,7 @@ void Sketch_tools::on_click(const ScreenCoords& screen_coords) case Mode::Sketch_add_node: add_node_pt_ (screen_coords); break; case Mode::Sketch_add_edge: add_line_string_pt_ (screen_coords, Linestring_type::Single); break; case Mode::Sketch_add_slot: add_line_string_pt_ (screen_coords, Linestring_type::Two); break; + case Mode::Sketch_add_bone: add_bone_pt_ (screen_coords); break; case Mode::Sketch_add_multi_edges: add_line_string_pt_ (screen_coords, Linestring_type::Multiple); break; case Mode::Sketch_add_seg_circle_arc: add_arc_circle_pt_ (screen_coords); break; case Mode::Sketch_operation_axis: add_operation_axis_pt_(screen_coords); break; @@ -74,6 +80,7 @@ void Sketch_tools::on_move(const ScreenCoords& screen_coords) case Mode::Sketch_add_square: move_square_pt_ (screen_coords); break; case Mode::Sketch_add_circle: move_circle_pt_ (screen_coords); break; case Mode::Sketch_add_slot: move_slot_pt_ (screen_coords); break; + case Mode::Sketch_add_bone: move_bone_pt_ (screen_coords); break; case Mode::Sketch_add_edge: case Mode::Sketch_operation_axis: @@ -112,6 +119,10 @@ void Sketch_tools::on_enter() m_sketch.m_dims.check_dimension_seg_(static_cast(Linestring_type::Two)); break; + case Mode::Sketch_add_bone: + bone_on_enter_(); + break; + case Mode::Sketch_add_multi_edges: m_sketch.m_dims.check_dimension_seg_(static_cast(Linestring_type::Multiple)); break; @@ -144,6 +155,7 @@ void Sketch_tools::finalize() case Mode::Sketch_add_circle: finalize_circle_(rec); break; case Mode::Sketch_add_node: finalize_add_node_elm_cleanup_(); break; case Mode::Sketch_add_slot: finalize_slot_(rec); break; + case Mode::Sketch_add_bone: finalize_bone_(); break; case Mode::Sketch_operation_axis: finalize_operation_axis_(rec); break; // clang-format on default: @@ -911,56 +923,19 @@ bool Sketch_tools::clear_tmps() m_tmp_shp = nullptr; } - const bool operation_canceled = !m_tmp_edges.empty(); + const bool operation_canceled = !m_tmp_edges.empty() || m_bone_centers.has_value(); + m_bone_centers.reset(); + m_bone_r1.reset(); + m_bone_r2.reset(); + m_bone_waist.reset(); + m_bone_hole_r1.reset(); + m_bone_hole_r2.reset(); clear_all(m_tmp_node_idxs, m_tmp_shp, m_tmp_edges); m_sketch.m_dims.on_clear_tmps(); return operation_canceled; } -// General sketch point related -template -void Sketch_tools::add_sketch_pt_(const ScreenCoords& screen_coords, size_t required_num_pts, Callback&& callback) -{ - auto l = [&](const std::optional& node_idx, const gp_Pnt2d& pt) - { - if (node_idx) - m_tmp_node_idxs.push_back(*node_idx); - else - m_tmp_node_idxs.push_back(m_sketch.m_nodes.add_new_node(pt)); - - if (m_tmp_node_idxs.size() >= required_num_pts) - callback(m_tmp_node_idxs.back()); - }; - - move_sketch_pt_(screen_coords, l); -} - -template void Sketch_tools::move_sketch_pt_(const ScreenCoords& screen_coords, Callback&& callback) -{ - m_last_pt = m_sketch.m_view.pt_on_plane(screen_coords, m_sketch.m_pln); - if (!m_last_pt) - // View plane and sketch plane must be perpendicular. - return; - - std::optional node_idx = m_sketch.m_nodes.try_get_node_idx_snap(*m_last_pt); - - callback(node_idx, *m_last_pt); -} - -/// Invokes callback(e, pt_a, pt_b) with the last tmp edge only when it exists and is non-degenerate. -template void Sketch_tools::if_edge_pt_valid_(Callback&& callback) -{ - if (m_tmp_edges.empty()) - return; - - Sketch_edge& e = m_tmp_edges.back(); - const gp_Pnt2d& pt_a = m_sketch.m_nodes[e.node_idx_a]; - if (m_last_pt.has_value()) - if (unique(pt_a, *m_last_pt)) - callback(e, pt_a, *m_last_pt); -} - void Sketch_tools::finalize_add_node_elm_cleanup_() { if (!m_tmp_edges.empty() && !m_tmp_edges.back().node_idx_b.has_value()) diff --git a/src/skt_tools.h b/src/skt_tools.h index 617d2bc4..f95874ed 100644 --- a/src/skt_tools.h +++ b/src/skt_tools.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include "skt_edge.h" @@ -70,6 +72,21 @@ class Sketch_tools void move_slot_pt_(const ScreenCoords& screen_coords); void finalize_slot_(Sketch_op_recorder& rec); + // Implementations in skt_tools_bone.cpp + void bone_on_enter_(); + void add_bone_pt_(const ScreenCoords& screen_coords); + void move_bone_pt_(const ScreenCoords& screen_coords); + void finalize_bone_(); + void bone_begin_next_edge_from_(const gp_Pnt2d& origin); + void bone_on_centers_ready_(const gp_Pnt2d& c1, const gp_Pnt2d& c2); + [[nodiscard]] bool bone_after_waist_(double waist); + [[nodiscard]] bool bone_after_hole_a_(double hole_r); + [[nodiscard]] bool bone_after_hole_b_(double hole_r); + [[nodiscard]] bool bone_try_commit_(); + void bone_update_preview_(); + [[nodiscard]] std::optional bone_axis_perp_() const; + [[nodiscard]] std::optional bone_waist_from_pt_(const gp_Pnt2d& pt) const; + void add_operation_axis_pt_(const ScreenCoords& screen_coords); void finalize_operation_axis_(Sketch_op_recorder& rec); @@ -86,4 +103,10 @@ class Sketch_tools std::vector m_tmp_node_idxs; std::vector m_tmp_edges; AIS_Shape_ptr m_tmp_shp; + std::optional> m_bone_centers; + std::optional m_bone_r1; + std::optional m_bone_r2; + std::optional m_bone_waist; + std::optional m_bone_hole_r1; + std::optional m_bone_hole_r2; }; diff --git a/src/skt_tools.inl b/src/skt_tools.inl new file mode 100644 index 00000000..8a9a67c4 --- /dev/null +++ b/src/skt_tools.inl @@ -0,0 +1,45 @@ +#pragma once + +// Template helpers for Sketch_tools. Include from skt_tools*.cpp after "skt.h". + +template +void Sketch_tools::add_sketch_pt_(const ScreenCoords& screen_coords, size_t required_num_pts, Callback&& callback) +{ + auto l = [&](const std::optional& node_idx, const gp_Pnt2d& pt) + { + if (node_idx) + m_tmp_node_idxs.push_back(*node_idx); + else + m_tmp_node_idxs.push_back(m_sketch.m_nodes.add_new_node(pt)); + + if (m_tmp_node_idxs.size() >= required_num_pts) + callback(m_tmp_node_idxs.back()); + }; + + move_sketch_pt_(screen_coords, l); +} + +template void Sketch_tools::move_sketch_pt_(const ScreenCoords& screen_coords, Callback&& callback) +{ + m_last_pt = m_sketch.m_view.pt_on_plane(screen_coords, m_sketch.m_pln); + if (!m_last_pt) + // View plane and sketch plane must be perpendicular. + return; + + std::optional node_idx = m_sketch.m_nodes.try_get_node_idx_snap(*m_last_pt); + + callback(node_idx, *m_last_pt); +} + +/// Invokes callback(e, pt_a, pt_b) with the last tmp edge only when it exists and is non-degenerate. +template void Sketch_tools::if_edge_pt_valid_(Callback&& callback) +{ + if (m_tmp_edges.empty()) + return; + + Sketch_edge& e = m_tmp_edges.back(); + const gp_Pnt2d& pt_a = m_sketch.m_nodes[e.node_idx_a]; + if (m_last_pt.has_value()) + if (unique(pt_a, *m_last_pt)) + callback(e, pt_a, *m_last_pt); +} diff --git a/src/skt_tools_bone.cpp b/src/skt_tools_bone.cpp new file mode 100644 index 00000000..732dc65d --- /dev/null +++ b/src/skt_tools_bone.cpp @@ -0,0 +1,521 @@ +#include "skt_tools.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gui.h" +#include "gui_occt_view.h" +#include "mode.h" +#include "skt.h" +#include "utl_geom.h" +#include "utl_occt.h" +#include "utl.h" + +#include "skt_tools.inl" + +namespace +{ +bool bone_hole_radius_ok_(double outer_r, double hole_r) +{ + return hole_r > Precision::Confusion() && hole_r + Precision::Confusion() < outer_r; +} + +bool bone_hole_radius_too_large_(double outer_r, double hole_r) +{ + return hole_r + Precision::Confusion() >= outer_r; +} +} // namespace + +void Sketch_tools::bone_on_enter_() +{ + if (!m_tmp_edges.empty() && !m_bone_centers && m_sketch.m_dims.entered_edge_len().has_value()) + { + Sketch_edge& edge = m_tmp_edges.back(); + const gp_Pnt2d& pt_a = m_sketch.m_nodes[edge.node_idx_a]; + m_last_pt = gp_Pnt2d(pt_a).Translated(gp_Vec2d(m_sketch.m_dims.entered_edge_len()->dir) * + m_sketch.m_dims.entered_edge_len()->len); + if (unique(pt_a, *m_last_pt)) + m_sketch.update_edge_end_pt_(edge, m_sketch.m_nodes.get_node_exact(*m_last_pt)); + + m_sketch.m_dims.clear_typed_constraints(); + } + if (!m_bone_centers && !m_tmp_edges.empty() && m_tmp_edges.back().node_idx_b.has_value()) + { + const Sketch_edge& e = m_tmp_edges.back(); + bone_on_centers_ready_(m_sketch.m_nodes[e.node_idx_a], m_sketch.m_nodes[*e.node_idx_b]); + return; + } + if (!m_bone_centers || !m_sketch.m_dims.entered_edge_len().has_value()) + return; + + const double len = m_sketch.m_dims.entered_edge_len()->len; + if (len <= Precision::Confusion()) + return; + + Sketch_edge& edge = m_tmp_edges.back(); + const gp_Pnt2d& pt_a = m_sketch.m_nodes[edge.node_idx_a]; + m_last_pt = gp_Pnt2d(pt_a).Translated(gp_Vec2d(m_sketch.m_dims.entered_edge_len()->dir) * len); + if (unique(pt_a, *m_last_pt)) + m_sketch.update_edge_end_pt_(edge, m_sketch.m_nodes.get_node_exact(*m_last_pt)); + + m_sketch.m_dims.clear_typed_constraints(); + + if (m_tmp_edges.size() == 2) + { + m_bone_r1 = len; + bone_begin_next_edge_from_(m_bone_centers->second); + } + else if (m_tmp_edges.size() == 3) + { + m_bone_r2 = len; + bone_begin_next_edge_from_(get_midpoint(m_bone_centers->first, m_bone_centers->second)); + } + else if (m_tmp_edges.size() == 4) + (void)bone_after_waist_(len); + else if (m_tmp_edges.size() == 5) + (void)bone_after_hole_a_(len); + else if (m_tmp_edges.size() == 6) + (void)bone_after_hole_b_(len); +} + +void Sketch_tools::add_bone_pt_(const ScreenCoords& screen_coords) +{ + if (m_tmp_edges.empty()) + { + add_line_string_pt_(screen_coords, Linestring_type::Multiple); + return; + } + + if (!m_bone_centers) + { + auto on_second = [&](size_t node_idx) + { + Sketch_edge& last = m_tmp_edges.back(); + if (node_idx == last.node_idx_a) + return; + + m_sketch.update_edge_end_pt_(last, node_idx); + bone_on_centers_ready_(m_sketch.m_nodes[last.node_idx_a], m_sketch.m_nodes[node_idx]); + }; + + if (m_sketch.m_dims.entered_edge_angle().has_value() && !m_tmp_edges.empty()) + { + std::optional pt_opt = m_sketch.m_view.pt_on_plane(screen_coords, m_sketch.m_pln); + if (!pt_opt) + return; + + const gp_Pnt2d& pt_a = m_sketch.m_nodes[m_tmp_edges.back().node_idx_a]; + const double angle_rad = to_radians(*m_sketch.m_dims.entered_edge_angle()); + gp_Dir2d constrained_dir(std::cos(angle_rad), std::sin(angle_rad)); + gp_Vec2d to_click(pt_opt->X() - pt_a.X(), pt_opt->Y() - pt_a.Y()); + const double dist_along = to_click.Dot(gp_Vec2d(constrained_dir)); + gp_Pnt2d final_pt = gp_Pnt2d(pt_a).Translated(gp_Vec2d(constrained_dir) * dist_along); + if (!unique(pt_a, final_pt)) + return; + + const size_t node_idx = m_sketch.m_nodes.get_node_exact(final_pt); + m_tmp_node_idxs.push_back(node_idx); + on_second(node_idx); + return; + } + + add_sketch_pt_(screen_coords, 1, on_second); + return; + } + + auto on_dim = [&](size_t node_idx) + { + Sketch_edge& last = m_tmp_edges.back(); + if (node_idx == last.node_idx_a) + return; + + const gp_Pnt2d& pt_a = m_sketch.m_nodes[last.node_idx_a]; + const gp_Pnt2d& pt_b = m_sketch.m_nodes[node_idx]; + if (!unique(pt_a, pt_b)) + return; + + m_sketch.update_edge_end_pt_(last, node_idx); + const double len = pt_a.Distance(pt_b); + + if (m_tmp_edges.size() == 2) + { + m_bone_r1 = len; + bone_begin_next_edge_from_(m_bone_centers->second); + } + else if (m_tmp_edges.size() == 3) + { + m_bone_r2 = len; + bone_begin_next_edge_from_(get_midpoint(m_bone_centers->first, m_bone_centers->second)); + } + else if (m_tmp_edges.size() == 4) + { + const std::optional waist = bone_waist_from_pt_(pt_b); + if (waist) + (void)bone_after_waist_(*waist); + } + else if (m_tmp_edges.size() == 5) + (void)bone_after_hole_a_(len); + else if (m_tmp_edges.size() == 6) + (void)bone_after_hole_b_(len); + }; + + add_sketch_pt_(screen_coords, 1, on_dim); +} + +void Sketch_tools::move_bone_pt_(const ScreenCoords& screen_coords) +{ + // Only center-to-center shows an AIS length dim. End radii / holes use the circle + // preview; waist uses the bone outline. Tab distance entry still works. + if (!m_bone_centers || m_tmp_edges.size() <= 1) + { + move_line_string_pt_(screen_coords); + return; + } + + if (m_tmp_edges.size() == 4) + { + const std::optional n = bone_axis_perp_(); + if (!n || !m_bone_r1 || !m_bone_r2) + return; + + const gp_Pnt2d& c1 = m_bone_centers->first; + const gp_Pnt2d& c2 = m_bone_centers->second; + const gp_Pnt2d mid = get_midpoint(c1, c2); + + auto l = [&](const std::optional&, const gp_Pnt2d& pt_b) + { + Sketch_edge& edge = m_tmp_edges.back(); + // Waist value is twice the distance from the bone axis (independent of click along-axis). + double half = std::abs(gp_Vec2d(c1, pt_b).Dot(*n)); + if (m_sketch.m_dims.entered_edge_len().has_value()) + half = m_sketch.m_dims.entered_edge_len()->len * 0.5; + + if (half <= Precision::Confusion()) + { + m_sketch.m_dims.clear_tmp_dim_anno(); + m_sketch.m_view.remove(m_tmp_shp); + m_tmp_shp = nullptr; + return; + } + + const double waist = 2.0 * half; + gp_Pnt2d span_a = gp_Pnt2d(mid).Translated(-(*n) * half); + gp_Pnt2d span_b = gp_Pnt2d(mid).Translated((*n) * half); + + Bone_params params; + params.c1 = c1; + params.c2 = c2; + params.r1 = *m_bone_r1; + params.r2 = *m_bone_r2; + params.waist = waist; + params.drive = Bone_drive::Waist; + if (const std::optional g = compute_bone_geom(params)) + { + // Place the live rubber-band on the true neck (offset from mid when r1 != r2). + const Bone_profile pr = get_bone_profile(*g); + span_a = pr.waist_plus; + span_b = pr.waist_minus; + } + + m_last_pt = span_b; + m_sketch.update_edge_shp_(edge, span_a, span_b); + + const double dist = waist / m_sketch.m_view.get_display_to_model_scale(); + m_sketch.m_dims.clear_tmp_dim_anno(); + m_sketch.m_dims.offer_dist_edit_for_segment(span_a, span_b, dist); + bone_update_preview_(); + }; + + move_sketch_pt_(screen_coords, l); + return; + } + + move_line_string_pt_(screen_coords); + m_sketch.m_dims.clear_tmp_dim_anno(); + bone_update_preview_(); +} + +void Sketch_tools::bone_begin_next_edge_from_(const gp_Pnt2d& origin) +{ + m_sketch.m_dims.clear_typed_constraints(); + m_sketch.m_dims.set_show_angle_input(false); + m_sketch.m_view.gui().hide_angle_edit(); + m_sketch.m_view.gui().hide_dist_edit(false); + m_tmp_edges.push_back({m_sketch.m_nodes.get_node_exact(origin)}); +} + +void Sketch_tools::bone_on_centers_ready_(const gp_Pnt2d& c1, const gp_Pnt2d& c2) +{ + if (!unique(c1, c2)) + return; + + m_bone_centers = std::make_pair(c1, c2); + bone_begin_next_edge_from_(c1); +} + +bool Sketch_tools::bone_after_waist_(double waist) +{ + if (!m_bone_centers || !m_bone_r1 || !m_bone_r2) + return false; + + Bone_params params; + params.c1 = m_bone_centers->first; + params.c2 = m_bone_centers->second; + params.r1 = *m_bone_r1; + params.r2 = *m_bone_r2; + params.waist = waist; + params.drive = Bone_drive::Waist; + if (!compute_bone_geom(params)) + return false; + + m_bone_waist = waist; + const Bone_holes holes = m_sketch.m_view.gui().get_bone_holes(); + if (holes == Bone_holes::None) + return bone_try_commit_(); + + bone_begin_next_edge_from_(m_bone_centers->first); + return true; +} + +bool Sketch_tools::bone_after_hole_a_(double hole_r) +{ + if (!m_bone_centers || !m_bone_r1 || !m_bone_r2 || !m_bone_waist) + return false; + + const Bone_holes holes = m_sketch.m_view.gui().get_bone_holes(); + if (holes == Bone_holes::One_radius) + { + if (!bone_hole_radius_ok_(*m_bone_r1, hole_r) || !bone_hole_radius_ok_(*m_bone_r2, hole_r)) + { + if (bone_hole_radius_too_large_(*m_bone_r1, hole_r) || bone_hole_radius_too_large_(*m_bone_r2, hole_r)) + m_sketch.m_view.gui().show_message("Hole radius must be smaller than the end circle."); + return false; + } + m_bone_hole_r1 = hole_r; + m_bone_hole_r2 = hole_r; + return bone_try_commit_(); + } + + if (holes != Bone_holes::Two_radii) + return false; + if (!bone_hole_radius_ok_(*m_bone_r1, hole_r)) + { + if (bone_hole_radius_too_large_(*m_bone_r1, hole_r)) + m_sketch.m_view.gui().show_message("Hole radius must be smaller than the end circle."); + return false; + } + + m_bone_hole_r1 = hole_r; + bone_begin_next_edge_from_(m_bone_centers->second); + return true; +} + +bool Sketch_tools::bone_after_hole_b_(double hole_r) +{ + if (!m_bone_centers || !m_bone_r2 || !m_bone_waist || !m_bone_hole_r1) + return false; + if (!bone_hole_radius_ok_(*m_bone_r2, hole_r)) + { + if (bone_hole_radius_too_large_(*m_bone_r2, hole_r)) + m_sketch.m_view.gui().show_message("Hole radius must be smaller than the end circle."); + return false; + } + + m_bone_hole_r2 = hole_r; + return bone_try_commit_(); +} + +bool Sketch_tools::bone_try_commit_() +{ + if (!m_bone_centers || !m_bone_r1 || !m_bone_r2 || !m_bone_waist) + return false; + + Bone_params params; + params.c1 = m_bone_centers->first; + params.c2 = m_bone_centers->second; + params.r1 = *m_bone_r1; + params.r2 = *m_bone_r2; + params.waist = *m_bone_waist; + params.drive = Bone_drive::Waist; + if (!compute_bone_geom(params)) + return false; + + const bool add_centers = m_sketch.m_view.gui().get_bone_add_center_nodes(); + m_sketch.add_bone(params.c1, params.c2, params.r1, params.r2, *m_bone_waist, add_centers, m_bone_hole_r1, + m_bone_hole_r2); + clear_tmps(); + m_sketch.m_view.gui().set_parent_mode(); + return true; +} + +void Sketch_tools::finalize_bone_() +{ + if (!m_bone_centers || !m_bone_r1 || !m_bone_r2 || !m_last_pt) + return; + + if (m_tmp_edges.size() == 4) + { + if (m_sketch.m_view.gui().get_bone_holes() != Bone_holes::None) + return; + const std::optional waist = bone_waist_from_pt_(*m_last_pt); + if (waist) + (void)bone_after_waist_(*waist); + return; + } + + if (m_tmp_edges.size() == 5 && m_bone_waist) + { + const gp_Pnt2d& c1 = m_bone_centers->first; + (void)bone_after_hole_a_(c1.Distance(*m_last_pt)); + return; + } + + if (m_tmp_edges.size() == 6 && m_bone_waist && m_bone_hole_r1) + { + const gp_Pnt2d& c2 = m_bone_centers->second; + (void)bone_after_hole_b_(c2.Distance(*m_last_pt)); + } +} + +std::optional Sketch_tools::bone_axis_perp_() const +{ + if (!m_bone_centers) + return std::nullopt; + + gp_Vec2d axis(m_bone_centers->first, m_bone_centers->second); + const double dist = axis.Magnitude(); + if (dist <= Precision::Confusion()) + return std::nullopt; + + return gp_Vec2d(axis / dist).Rotated(std::numbers::pi / 2.0); +} + +std::optional Sketch_tools::bone_waist_from_pt_(const gp_Pnt2d& pt) const +{ + const std::optional n = bone_axis_perp_(); + if (!n || !m_bone_centers) + return std::nullopt; + + // Twice the distance from the bone axis (c1->c2); matches min neck when cutters solve. + const double waist = 2.0 * std::abs(gp_Vec2d(m_bone_centers->first, pt).Dot(*n)); + if (waist <= Precision::Confusion()) + return std::nullopt; + + return waist; +} + +void Sketch_tools::bone_update_preview_() +{ + if (!m_bone_centers) + return; + + const gp_Pnt2d& c1 = m_bone_centers->first; + const gp_Pnt2d& c2 = m_bone_centers->second; + + auto circle_at = [&](const gp_Pnt2d& c, double r) -> TopoDS_Wire + { return make_circle_wire(m_sketch.m_pln, c, gp_Pnt2d(c.X() + r, c.Y())); }; + + const double r1 = m_bone_r1.value_or( + (m_tmp_edges.size() == 2 && m_last_pt) ? c1.Distance(*m_last_pt) : 0.0); + const double r2 = m_bone_r2.value_or( + (m_tmp_edges.size() == 3 && m_last_pt) ? c2.Distance(*m_last_pt) : 0.0); + + auto show_compound = [&](const TopoDS_Compound& comp) + { show(m_sketch.m_ctx, m_tmp_shp, comp); }; + + if (m_tmp_edges.size() >= 4 && m_bone_r1 && m_bone_r2) + { + const std::optional waist = + m_bone_waist + ? m_bone_waist + : (m_sketch.m_dims.entered_edge_len().has_value() + ? std::optional(m_sketch.m_dims.entered_edge_len()->len) + : (m_last_pt ? bone_waist_from_pt_(*m_last_pt) : std::nullopt)); + if (waist) + { + Bone_params params; + params.c1 = c1; + params.c2 = c2; + params.r1 = *m_bone_r1; + params.r2 = *m_bone_r2; + params.waist = *waist; + params.drive = Bone_drive::Waist; + if (const std::optional g = compute_bone_geom(params)) + { + TopoDS_Compound comp; + BRep_Builder bb; + bb.MakeCompound(comp); + bb.Add(comp, make_bone_preview_shape(m_sketch.m_pln, *g)); + + auto maybe_hole = [&](const gp_Pnt2d& c, double outer_r, const std::optional& fixed, + bool live_from_center) -> void + { + double hr = 0.0; + if (fixed) + hr = *fixed; + else if (live_from_center && m_last_pt) + hr = c.Distance(*m_last_pt); + if (bone_hole_radius_ok_(outer_r, hr)) + bb.Add(comp, circle_at(c, hr)); + }; + + if (m_tmp_edges.size() >= 5) + { + const Bone_holes holes = m_sketch.m_view.gui().get_bone_holes(); + if (holes == Bone_holes::One_radius) + { + const double hr = m_bone_hole_r1 ? *m_bone_hole_r1 + : (m_last_pt ? c1.Distance(*m_last_pt) : 0.0); + if (bone_hole_radius_ok_(*m_bone_r1, hr)) + bb.Add(comp, circle_at(c1, hr)); + if (bone_hole_radius_ok_(*m_bone_r2, hr)) + bb.Add(comp, circle_at(c2, hr)); + } + else if (holes == Bone_holes::Two_radii) + { + maybe_hole(c1, *m_bone_r1, m_bone_hole_r1, m_tmp_edges.size() == 5); + maybe_hole(c2, *m_bone_r2, m_bone_hole_r2, m_tmp_edges.size() == 6); + } + } + + show_compound(comp); + return; + } + } + + m_sketch.m_view.remove(m_tmp_shp); + m_tmp_shp = nullptr; + return; + } + + TopoDS_Compound comp; + BRep_Builder bb; + bb.MakeCompound(comp); + bool any = false; + if (r1 > Precision::Confusion()) + { + bb.Add(comp, circle_at(c1, r1)); + any = true; + } + if (r2 > Precision::Confusion()) + { + bb.Add(comp, circle_at(c2, r2)); + any = true; + } + + if (!any) + { + m_sketch.m_view.remove(m_tmp_shp); + m_tmp_shp = nullptr; + return; + } + + show_compound(comp); +} diff --git a/src/utl_geom.cpp b/src/utl_geom.cpp index 92f8229e..7fe67af8 100644 --- a/src/utl_geom.cpp +++ b/src/utl_geom.cpp @@ -48,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -368,6 +369,256 @@ TopoDS_Wire make_slot_wire(const gp_Pln& plane, const gp_Pnt2d& pt_a, const gp_P return wire_maker.Wire(); } +namespace +{ +gp_Pnt2d mirror_across_axis_(const gp_Pnt2d& p, const gp_Pnt2d& c1, const gp_Vec2d& axis) +{ + const double len = axis.Magnitude(); + if (len <= Precision::Confusion()) + return p; + + const gp_Vec2d a(axis / len); + gp_Vec2d v(c1, p); + const double along = v.Dot(a); + gp_Vec2d perp = v - a * along; + return gp_Pnt2d(c1).Translated(a * along - perp); +} + +std::optional circle_circle_intersect_pick_side_(const gp_Pnt2d& c1, double ra, const gp_Pnt2d& c2, double rb, + const gp_Pnt2d& mid, const gp_Vec2d& n, bool positive_n_side) +{ + const double eps = Precision::Confusion(); + gp_Vec2d axis(c1, c2); + const double d = axis.Magnitude(); + if (d <= eps || ra <= eps || rb <= eps) + return std::nullopt; + + if (d > ra + rb + eps || d + eps < std::fabs(ra - rb)) + return std::nullopt; + + const double a = (ra * ra - rb * rb + d * d) / (2.0 * d); + const double h2 = ra * ra - a * a; + if (h2 < -eps) + return std::nullopt; + + const double h = std::sqrt(std::max(0.0, h2)); + const gp_Vec2d ad(axis / d); + gp_Pnt2d p2 = gp_Pnt2d(c1).Translated(ad * a); + gp_Vec2d perp(-ad.Y() * h, ad.X() * h); + + const gp_Pnt2d i0 = p2.Translated(perp); + const gp_Pnt2d i1 = p2.Translated(-perp); + + const double d0 = gp_Vec2d(mid, i0).Dot(n); + const double d1 = gp_Vec2d(mid, i1).Dot(n); + if (positive_n_side) + return d0 >= d1 ? i0 : i1; + return d0 <= d1 ? i0 : i1; +} + +double bone_waist_gap_(const gp_Pnt2d& c_top, const gp_Pnt2d& c_bot, double cut_r) +{ + // Min gap between the two cutter circles (along the line of centers). + return c_top.Distance(c_bot) - 2.0 * cut_r; +} + +std::optional bone_geom_with_cut_radius_(const gp_Pnt2d& c1, const gp_Pnt2d& c2, double r1, double r2, + double cut_r, const gp_Vec2d& axis, const gp_Vec2d& n, + const gp_Pnt2d& mid, double vx, double vy, double a, double h) +{ + const double eps = Precision::Confusion(); + if (cut_r <= eps) + return std::nullopt; + + const std::optional c_top = + circle_circle_intersect_pick_side_(c1, cut_r + r1, c2, cut_r + r2, mid, n, true); + if (!c_top) + return std::nullopt; + + Bone_geom g; + g.c1 = c1; + g.c2 = c2; + g.r1 = r1; + g.r2 = r2; + g.cut_radius = cut_r; + g.cut_plus = *c_top; + g.cut_minus = mirror_across_axis_(*c_top, c1, axis); + g.waist = bone_waist_gap_(g.cut_plus, g.cut_minus, cut_r); + + auto tangent_pair = [&](double sign, gp_Pnt2d& t1, gp_Pnt2d& t2) + { + const double nx = vx * a - sign * vy * h; + const double ny = vy * a + sign * vx * h; + t1 = gp_Pnt2d(c1.X() + r1 * nx, c1.Y() + r1 * ny); + t2 = gp_Pnt2d(c2.X() + r2 * nx, c2.Y() + r2 * ny); + }; + + tangent_pair(1.0, g.tan_top_a, g.tan_top_b); + tangent_pair(-1.0, g.tan_bot_a, g.tan_bot_b); + return g; +} + +double bone_waist_for_cut_radius_(const gp_Pnt2d& c1, const gp_Pnt2d& c2, double r1, double r2, double cut_r, + const gp_Vec2d& axis, const gp_Vec2d& n, const gp_Pnt2d& mid, double vx, double vy, + double a, double h) +{ + const std::optional g = bone_geom_with_cut_radius_(c1, c2, r1, r2, cut_r, axis, n, mid, vx, vy, a, h); + if (!g) + return std::numeric_limits::quiet_NaN(); + return g->waist; +} + +std::optional solve_cut_radius_for_waist_(const gp_Pnt2d& c1, const gp_Pnt2d& c2, double r1, double r2, + double target_waist, const gp_Vec2d& axis, const gp_Vec2d& n, + const gp_Pnt2d& mid, double vx, double vy, double a, double h) +{ + const double eps = Precision::Confusion(); + if (target_waist <= eps) + return std::nullopt; + + const double dist = axis.Magnitude(); + double r_lo = (dist - r1 - r2) / 2.0; + if (r_lo <= eps) + r_lo = eps; + + double w_lo = bone_waist_for_cut_radius_(c1, c2, r1, r2, r_lo, axis, n, mid, vx, vy, a, h); + if (!std::isfinite(w_lo)) + return std::nullopt; + + if (w_lo >= target_waist) + return std::nullopt; + + double r_hi = std::max(r_lo + 1.0, r1 + r2); + for (int i = 0; i < 48; ++i) + { + const double w_hi = bone_waist_for_cut_radius_(c1, c2, r1, r2, r_hi, axis, n, mid, vx, vy, a, h); + if (!std::isfinite(w_hi) || w_hi < target_waist) + r_hi *= 2.0; + else + break; + } + + const double w_hi = bone_waist_for_cut_radius_(c1, c2, r1, r2, r_hi, axis, n, mid, vx, vy, a, h); + if (!std::isfinite(w_hi) || w_hi < target_waist) + return std::nullopt; + + for (int i = 0; i < 64; ++i) + { + const double r_mid = (r_lo + r_hi) * 0.5; + const double w_mid = bone_waist_for_cut_radius_(c1, c2, r1, r2, r_mid, axis, n, mid, vx, vy, a, h); + if (!std::isfinite(w_mid)) + return std::nullopt; + + if (w_mid < target_waist) + r_lo = r_mid; + else + r_hi = r_mid; + } + + return (r_lo + r_hi) * 0.5; +} +} // namespace + +std::optional compute_bone_geom(const Bone_params& p) +{ + const double eps = Precision::Confusion(); + if (p.r1 <= eps || p.r2 <= eps) + return std::nullopt; + + if (p.drive == Bone_drive::Cut_radius && p.cut_radius <= eps) + return std::nullopt; + if (p.drive == Bone_drive::Waist && p.waist <= eps) + return std::nullopt; + + const gp_Vec2d axis(p.c1, p.c2); + const double dist = axis.Magnitude(); + if (dist <= eps) + return std::nullopt; + + if (dist + eps < std::fabs(p.r1 - p.r2)) + return std::nullopt; + + const double vx = axis.X() / dist; + const double vy = axis.Y() / dist; + const double a = (p.r1 - p.r2) / dist; + const double h2 = 1.0 - a * a; + if (h2 < -eps) + return std::nullopt; + + const double h = std::sqrt(std::max(0.0, h2)); + const gp_Pnt2d mid = get_midpoint(p.c1, p.c2); + const gp_Vec2d n = gp_Vec2d(vx, vy).Rotated(std::numbers::pi / 2.0); + + double cut_r = p.cut_radius; + if (p.drive == Bone_drive::Waist) + { + const std::optional solved = + solve_cut_radius_for_waist_(p.c1, p.c2, p.r1, p.r2, p.waist, axis, n, mid, vx, vy, a, h); + if (!solved) + return std::nullopt; + cut_r = *solved; + } + + return bone_geom_with_cut_radius_(p.c1, p.c2, p.r1, p.r2, cut_r, axis, n, mid, vx, vy, a, h); +} + +Bone_profile get_bone_profile(const Bone_geom& g) +{ + gp_Vec2d axis(g.c1, g.c2); + const double dist = axis.Magnitude(); + EZY_ASSERT(dist > Precision::Confusion()); + const gp_Vec2d ad(axis / dist); + + auto contact = [](const gp_Pnt2d& c, double r, const gp_Pnt2d& cut) -> gp_Pnt2d + { + gp_Vec2d v(c, cut); + const double d = v.Magnitude(); + EZY_ASSERT(d > Precision::Confusion()); + return gp_Pnt2d(c).Translated(v * (r / d)); + }; + + Bone_profile p; + p.c1_plus = contact(g.c1, g.r1, g.cut_plus); + p.c1_minus = contact(g.c1, g.r1, g.cut_minus); + p.c2_plus = contact(g.c2, g.r2, g.cut_plus); + p.c2_minus = contact(g.c2, g.r2, g.cut_minus); + p.c1_outer = gp_Pnt2d(g.c1).Translated(-ad * g.r1); + p.c2_outer = gp_Pnt2d(g.c2).Translated(ad * g.r2); + + // Skinniest section: points of closest approach between the two cutter circles. + // When r1 != r2 this is offset along the bone axis from the center midpoint. + gp_Vec2d to_minus(g.cut_plus, g.cut_minus); + const double cut_sep = to_minus.Magnitude(); + EZY_ASSERT(cut_sep > Precision::Confusion()); + to_minus /= cut_sep; + p.waist_plus = gp_Pnt2d(g.cut_plus).Translated(to_minus * g.cut_radius); + p.waist_minus = gp_Pnt2d(g.cut_minus).Translated(-to_minus * g.cut_radius); + return p; +} + +TopoDS_Wire make_bone_wire(const gp_Pln& pln, const Bone_geom& g) +{ + const Bone_profile p = get_bone_profile(g); + + auto arc = [&](const gp_Pnt2d& a, const gp_Pnt2d& mid, const gp_Pnt2d& b) -> TopoDS_Edge + { + GC_MakeArcOfCircle maker(to_3d(pln, a), to_3d(pln, mid), to_3d(pln, b)); + return BRepBuilderAPI_MakeEdge(maker.Value()).Edge(); + }; + + BRepBuilderAPI_MakeWire wire; + wire.Add(arc(p.c1_minus, p.c1_outer, p.c1_plus)); + wire.Add(arc(p.c1_plus, p.waist_plus, p.c2_plus)); + wire.Add(arc(p.c2_plus, p.c2_outer, p.c2_minus)); + wire.Add(arc(p.c2_minus, p.waist_minus, p.c1_minus)); + return wire.Wire(); +} + +TopoDS_Shape make_bone_preview_shape(const gp_Pln& pln, const Bone_geom& g) +{ + return make_bone_wire(pln, g); +} + // Function to get the directional vectors at the start and end of a Geom_TrimmedCurve std::pair get_start_end_tangents(const Geom_TrimmedCurve_ptr& curve) { diff --git a/src/utl_geom.h b/src/utl_geom.h index a676fd57..92d2bc37 100644 --- a/src/utl_geom.h +++ b/src/utl_geom.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include // For Pi #include @@ -76,6 +77,65 @@ Slot_pnts get_slot_points(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, const gp_P TopoDS_Wire make_slot_wire(const gp_Pln& plane, const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, const gp_Pnt2d& pt_c); +/// Which dialog value drives the waist cutter radius (the other is derived). +enum class Bone_drive +{ + Cut_radius, + Waist, +}; + +/// Two end circles, two waist cutters, and the capsule external tangents. +struct Bone_params +{ + gp_Pnt2d c1; + gp_Pnt2d c2; + double r1{0}; + double r2{0}; + double cut_radius{0}; + double waist{0}; + Bone_drive drive{Bone_drive::Waist}; +}; + +struct Bone_geom +{ + gp_Pnt2d c1; + gp_Pnt2d c2; + gp_Pnt2d cut_plus; + gp_Pnt2d cut_minus; + double r1{0}; + double r2{0}; + double cut_radius{0}; + double waist{0}; + gp_Pnt2d tan_top_a; + gp_Pnt2d tan_top_b; + gp_Pnt2d tan_bot_a; + gp_Pnt2d tan_bot_b; +}; + +/// Trimmed bone outline: outer end-circle caps and inner waist arcs (no full circles, no capsule tangents). +struct Bone_profile +{ + gp_Pnt2d c1_plus; // contact, end 1 / waist cutter + + gp_Pnt2d c1_outer; // outer pole of end 1 + gp_Pnt2d c1_minus; // contact, end 1 / waist cutter - + gp_Pnt2d c2_plus; + gp_Pnt2d c2_outer; + gp_Pnt2d c2_minus; + gp_Pnt2d waist_plus; // inner bulge of cutter + + gp_Pnt2d waist_minus; +}; + +/// Null when centers coincide, a circle is inside the other, or waist/cut radius cannot be solved. +std::optional compute_bone_geom(const Bone_params& p); + +Bone_profile get_bone_profile(const Bone_geom& g); + +/// Closed wire of the four outline arcs (preview and extrusion profile). +TopoDS_Wire make_bone_wire(const gp_Pln& pln, const Bone_geom& g); + +/// Same as \\a make_bone_wire (AIS preview). +TopoDS_Shape make_bone_preview_shape(const gp_Pln& pln, const Bone_geom& g); + // Function to get the directional vectors at the start and end of a Geom_TrimmedCurve std::pair get_start_end_tangents(const Geom_TrimmedCurve_ptr& curve); diff --git a/tests/skt_tests.cpp b/tests/skt_tests.cpp index 0fa4bef6..50d36ba1 100644 --- a/tests/skt_tests.cpp +++ b/tests/skt_tests.cpp @@ -5,9 +5,12 @@ #include #include #include +#include #include #include +#include + #include "skt_edge.h" #include "skt_json.h" #include "skt_nodes.h" @@ -941,3 +944,186 @@ TEST_F(Sketch_test, ProjectUnit_displayConversionAndJsonRoundTrip) view().new_file(); EXPECT_EQ(view().get_project_unit(), Project_unit::Inch); } + +TEST_F(Sketch_test, BoneGeom_equalRadiiTangentCuttersAndWaistFromCutRadius) +{ + Bone_params p; + p.c1 = gp_Pnt2d(-2.0, 0.0); + p.c2 = gp_Pnt2d(2.0, 0.0); + p.r1 = 1.0; + p.r2 = 1.0; + p.cut_radius = 2.0; + p.drive = Bone_drive::Cut_radius; + + const std::optional g = compute_bone_geom(p); + ASSERT_TRUE(g.has_value()); + EXPECT_NEAR(g->cut_plus.X(), 0.0, 1e-9); + EXPECT_NEAR(g->cut_minus.X(), 0.0, 1e-9); + EXPECT_NEAR(g->cut_plus.Y(), std::sqrt(5.0), 1e-9); + EXPECT_NEAR(g->cut_plus.Y(), -g->cut_minus.Y(), 1e-9); + EXPECT_NEAR(g->tan_top_a.Y(), g->tan_top_b.Y(), 1e-9); + + const double d1 = std::hypot(g->cut_plus.X() - p.c1.X(), g->cut_plus.Y() - p.c1.Y()); + EXPECT_NEAR(d1, g->cut_radius + p.r1, 1e-9); + const double d2 = std::hypot(g->cut_plus.X() - p.c2.X(), g->cut_plus.Y() - p.c2.Y()); + EXPECT_NEAR(d2, g->cut_radius + p.r2, 1e-9); + EXPECT_NEAR(g->waist, 2.0 * std::sqrt(5.0) - 4.0, 1e-9); + + const Bone_profile pr = get_bone_profile(*g); + EXPECT_NEAR(pr.c1_plus.Distance(p.c1), p.r1, 1e-9); + EXPECT_NEAR(pr.c1_plus.Distance(g->cut_plus), g->cut_radius, 1e-9); + EXPECT_NEAR(pr.c2_plus.Distance(p.c2), p.r2, 1e-9); + EXPECT_NEAR(pr.c1_outer.X(), p.c1.X() - p.r1, 1e-9); + + p.c1 = p.c2; + EXPECT_FALSE(compute_bone_geom(p).has_value()); +} + +TEST_F(Sketch_test, BoneGeom_waistDriveSolvesCutRadius) +{ + Bone_params p; + p.c1 = gp_Pnt2d(-2.0, 0.0); + p.c2 = gp_Pnt2d(2.0, 0.0); + p.r1 = 1.0; + p.r2 = 1.0; + p.waist = 0.4; + p.drive = Bone_drive::Waist; + + const std::optional g = compute_bone_geom(p); + ASSERT_TRUE(g.has_value()); + EXPECT_NEAR(g->waist, 0.4, 1e-6); + EXPECT_GT(g->cut_radius, 0.0); +} + +TEST_F(Sketch_test, BoneGeom_unequalRadiiNeckOffsetFromMid) +{ + Bone_params p; + p.c1 = gp_Pnt2d(-2.0, 0.0); + p.c2 = gp_Pnt2d(2.0, 0.0); + p.r1 = 1.0; + p.r2 = 0.5; + p.waist = 0.4; + p.drive = Bone_drive::Waist; + + const std::optional g = compute_bone_geom(p); + ASSERT_TRUE(g.has_value()); + EXPECT_NEAR(g->waist, 0.4, 1e-6); + + const Bone_profile pr = get_bone_profile(*g); + EXPECT_NEAR(pr.waist_plus.Distance(pr.waist_minus), p.waist, 1e-6); + // Neck lies toward the smaller end (c2), not at the center midpoint. + EXPECT_GT(pr.waist_plus.X(), 0.0); + EXPECT_NEAR(pr.waist_plus.X(), pr.waist_minus.X(), 1e-9); + EXPECT_NEAR(pr.waist_plus.X(), g->cut_plus.X(), 1e-9); +} + +TEST_F(Sketch_test, AddBone_createsFacesAndPermanentCenters) +{ + Headless_guard guard(view()); + + gp_Pln default_plane(gp::Origin(), gp::DZ()); + Sketch sketch("BoneSketch", view(), default_plane); + sketch.add_bone(gp_Pnt2d(-2.0, 0.0), gp_Pnt2d(2.0, 0.0), 1.0, 0.5, 0.4); + + EXPECT_EQ(sketch.face_count(), 1u); + EXPECT_EQ(Sketch_access::get_edge_count(sketch), 4u); + + bool found_a = false; + bool found_b = false; + for (size_t i = 0; i < sketch.get_nodes().size(); ++i) + { + const Sketch_nodes::Node& n = sketch.get_nodes()[i]; + if (n.deleted || !n.permanent) + continue; + + if (n.name == "Bone A") + { + found_a = true; + EXPECT_TRUE(n.IsEqual(gp_Pnt2d(-2.0, 0.0), Precision::Confusion())); + } + if (n.name == "Bone B") + { + found_b = true; + EXPECT_TRUE(n.IsEqual(gp_Pnt2d(2.0, 0.0), Precision::Confusion())); + } + } + + EXPECT_TRUE(found_a); + EXPECT_TRUE(found_b); + + const std::vector labels = sketch.inspector_node_labels(); + EXPECT_NE(std::find(labels.begin(), labels.end(), "Bone A"), labels.end()); + EXPECT_NE(std::find(labels.begin(), labels.end(), "Bone B"), labels.end()); +} + +TEST_F(Sketch_test, AddBone_optionalHolesAndNoCenterNodes) +{ + Headless_guard guard(view()); + + gp_Pln default_plane(gp::Origin(), gp::DZ()); + Sketch sketch("BoneHoles", view(), default_plane); + sketch.add_bone(gp_Pnt2d(-2.0, 0.0), gp_Pnt2d(2.0, 0.0), 1.0, 0.8, 0.4, false, 0.3, 0.25); + + // Outer face plus two hole face metas (holes assigned under the outer). + EXPECT_EQ(sketch.face_count(), 3u); + EXPECT_EQ(Sketch_access::get_edge_count(sketch), 8u); + + for (size_t i = 0; i < sketch.get_nodes().size(); ++i) + { + const Sketch_nodes::Node& n = sketch.get_nodes()[i]; + if (n.deleted) + continue; + EXPECT_FALSE(n.permanent && (n.name == "Bone A" || n.name == "Bone B")); + } +} + +TEST_F(Sketch_test, AddBone_undoRemovesPromotedCenters) +{ + Headless_guard guard(view()); + + gp_Pln default_plane(gp::Origin(), gp::DZ()); + Sketch sketch("BoneSketch", view(), default_plane); + + const gp_Pnt2d c1(-2.0, 0.0); + const gp_Pnt2d c2(2.0, 0.0); + // Interactive tool creates non-permanent center nodes before commit; undo must still remove them. + sketch.get_nodes().get_node_exact(c1); + sketch.get_nodes().get_node_exact(c2); + + sketch.add_bone(c1, c2, 1.0, 0.5, 0.4); + EXPECT_EQ(sketch.face_count(), 1u); + EXPECT_EQ(Sketch_access::get_edge_count(sketch), 4u); + EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 3u) << "Origin plus Bone A and Bone B"; + + ASSERT_GT(view().undo_stack_size(), 0u); + EXPECT_TRUE(view().undo()); + EXPECT_EQ(Sketch_access::get_edge_count(sketch), 0u); + EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 1u) << "Undo should remove Bone A/B centers"; + + EXPECT_TRUE(view().redo()); + EXPECT_EQ(Sketch_access::get_edge_count(sketch), 4u); + EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 3u); + + bool found_a = false; + bool found_b = false; + for (size_t i = 0; i < sketch.get_nodes().size(); ++i) + { + const Sketch_nodes::Node& n = sketch.get_nodes()[i]; + if (n.deleted || !n.permanent) + continue; + + if (n.name == "Bone A") + { + found_a = true; + EXPECT_TRUE(n.IsEqual(c1, Precision::Confusion())); + } + if (n.name == "Bone B") + { + found_b = true; + EXPECT_TRUE(n.IsEqual(c2, Precision::Confusion())); + } + } + + EXPECT_TRUE(found_a); + EXPECT_TRUE(found_b); +}